@@ -99,57 +99,135 @@ impl CoverageLoader for RealCoverageLoader {
9999 }
100100}
101101
102- /// Parse LCOV format content into CoverageData.
102+ /// Represents a parsed LCOV line.
103+ #[ derive( Debug , PartialEq ) ]
104+ enum LcovLine {
105+ /// Source file declaration (SF:path)
106+ SourceFile ( std:: path:: PathBuf ) ,
107+ /// Line data (DA:line_number,hit_count)
108+ LineData { line : usize , hits : u64 } ,
109+ /// End of record marker
110+ EndOfRecord ,
111+ /// Unknown or empty line (ignored)
112+ Unknown ,
113+ }
114+
115+ /// Parse a single LCOV line into its structured representation.
103116///
104- /// This is a pure function that parses LCOV content without I/O.
105- fn parse_lcov_content ( content : & str , source_path : & Path ) -> Result < CoverageData , AnalysisError > {
106- let mut data = CoverageData :: new ( ) ;
107- let mut current_file: Option < std:: path:: PathBuf > = None ;
108- let mut current_coverage: Option < FileCoverage > = None ;
117+ /// This is a pure function with no side effects.
118+ fn parse_lcov_line ( line : & str ) -> LcovLine {
119+ let line = line. trim ( ) ;
120+
121+ if let Some ( sf) = line. strip_prefix ( "SF:" ) {
122+ LcovLine :: SourceFile ( sf. into ( ) )
123+ } else if let Some ( da) = line. strip_prefix ( "DA:" ) {
124+ parse_line_data ( da)
125+ } else if line == "end_of_record" {
126+ LcovLine :: EndOfRecord
127+ } else {
128+ LcovLine :: Unknown
129+ }
130+ }
109131
110- for line in content. lines ( ) {
111- let line = line. trim ( ) ;
132+ /// Parse DA (line data) format: "line_number,hit_count".
133+ fn parse_line_data ( da : & str ) -> LcovLine {
134+ let mut parts = da. split ( ',' ) ;
135+
136+ let parsed = parts
137+ . next ( )
138+ . and_then ( |line_str| line_str. parse :: < usize > ( ) . ok ( ) )
139+ . zip (
140+ parts
141+ . next ( )
142+ . and_then ( |hits_str| hits_str. parse :: < u64 > ( ) . ok ( ) ) ,
143+ ) ;
144+
145+ match parsed {
146+ Some ( ( line, hits) ) => LcovLine :: LineData { line, hits } ,
147+ None => LcovLine :: Unknown ,
148+ }
149+ }
150+
151+ /// Parser state for LCOV content processing.
152+ struct LcovParserState {
153+ data : CoverageData ,
154+ current_file : Option < std:: path:: PathBuf > ,
155+ current_coverage : Option < FileCoverage > ,
156+ }
157+
158+ impl LcovParserState {
159+ fn new ( ) -> Self {
160+ Self {
161+ data : CoverageData :: new ( ) ,
162+ current_file : None ,
163+ current_coverage : None ,
164+ }
165+ }
112166
113- if let Some ( sf) = line. strip_prefix ( "SF:" ) {
114- // Source file
115- if let ( Some ( path) , Some ( coverage) ) = ( current_file. take ( ) , current_coverage. take ( ) ) {
116- data. add_file_coverage ( path, coverage) ;
167+ /// Process a single parsed line, returning updated state.
168+ fn process ( mut self , line : LcovLine ) -> Self {
169+ match line {
170+ LcovLine :: SourceFile ( path) => {
171+ self . finalize_current ( ) ;
172+ self . current_file = Some ( path) ;
173+ self . current_coverage = Some ( FileCoverage :: new ( ) ) ;
117174 }
118- current_file = Some ( sf. into ( ) ) ;
119- current_coverage = Some ( FileCoverage :: new ( ) ) ;
120- } else if let Some ( da) = line. strip_prefix ( "DA:" ) {
121- // Line data: DA:line_number,hit_count
122- if let Some ( ref mut coverage) = current_coverage {
123- let parts: Vec < & str > = da. split ( ',' ) . collect ( ) ;
124- if parts. len ( ) >= 2 {
125- if let ( Ok ( line_num) , Ok ( hits) ) =
126- ( parts[ 0 ] . parse :: < usize > ( ) , parts[ 1 ] . parse :: < u64 > ( ) )
127- {
128- coverage. add_line ( line_num, hits) ;
129- }
175+ LcovLine :: LineData { line, hits } => {
176+ if let Some ( ref mut coverage) = self . current_coverage {
177+ coverage. add_line ( line, hits) ;
130178 }
131179 }
132- } else if line == "end_of_record" {
133- // End of file record
134- if let ( Some ( path) , Some ( coverage) ) = ( current_file. take ( ) , current_coverage. take ( ) ) {
135- data. add_file_coverage ( path, coverage) ;
180+ LcovLine :: EndOfRecord => {
181+ self . finalize_current ( ) ;
136182 }
183+ LcovLine :: Unknown => { }
137184 }
185+ self
138186 }
139187
140- // Handle last file if no end_of_record
141- if let ( Some ( path) , Some ( coverage) ) = ( current_file, current_coverage) {
142- data. add_file_coverage ( path, coverage) ;
188+ /// Finalize the current file record if one exists.
189+ fn finalize_current ( & mut self ) {
190+ if let ( Some ( path) , Some ( coverage) ) =
191+ ( self . current_file . take ( ) , self . current_coverage . take ( ) )
192+ {
193+ self . data . add_file_coverage ( path, coverage) ;
194+ }
143195 }
144196
145- // Check if we actually parsed anything
197+ /// Complete parsing and return the final coverage data.
198+ fn finish ( mut self ) -> CoverageData {
199+ self . finalize_current ( ) ;
200+ self . data
201+ }
202+ }
203+
204+ /// Parse LCOV format content into CoverageData.
205+ ///
206+ /// This function uses a functional pipeline to parse LCOV content:
207+ /// 1. Parse each line into a structured representation
208+ /// 2. Fold over lines to accumulate coverage data
209+ fn parse_lcov_content ( content : & str , source_path : & Path ) -> Result < CoverageData , AnalysisError > {
210+ let data = content
211+ . lines ( )
212+ . map ( parse_lcov_line)
213+ . fold ( LcovParserState :: new ( ) , LcovParserState :: process)
214+ . finish ( ) ;
215+
216+ validate_coverage_data ( data, content, source_path)
217+ }
218+
219+ /// Validate that parsed coverage data is not unexpectedly empty.
220+ fn validate_coverage_data (
221+ data : CoverageData ,
222+ content : & str ,
223+ source_path : & Path ,
224+ ) -> Result < CoverageData , AnalysisError > {
146225 if data. files ( ) . next ( ) . is_none ( ) && !content. is_empty ( ) {
147226 return Err ( AnalysisError :: coverage_with_path (
148227 "No coverage data found in LCOV file" ,
149228 source_path,
150229 ) ) ;
151230 }
152-
153231 Ok ( data)
154232}
155233
@@ -336,4 +414,64 @@ end_of_record
336414 cache. invalidate ( "key1" ) . unwrap ( ) ;
337415 cache. clear ( ) . unwrap ( ) ;
338416 }
417+
418+ #[ test]
419+ fn test_parse_lcov_line_source_file ( ) {
420+ let line = parse_lcov_line ( "SF:src/main.rs" ) ;
421+ assert_eq ! ( line, LcovLine :: SourceFile ( "src/main.rs" . into( ) ) ) ;
422+ }
423+
424+ #[ test]
425+ fn test_parse_lcov_line_line_data ( ) {
426+ let line = parse_lcov_line ( "DA:42,5" ) ;
427+ assert_eq ! ( line, LcovLine :: LineData { line: 42 , hits: 5 } ) ;
428+ }
429+
430+ #[ test]
431+ fn test_parse_lcov_line_end_of_record ( ) {
432+ let line = parse_lcov_line ( "end_of_record" ) ;
433+ assert_eq ! ( line, LcovLine :: EndOfRecord ) ;
434+ }
435+
436+ #[ test]
437+ fn test_parse_lcov_line_unknown ( ) {
438+ assert_eq ! ( parse_lcov_line( "" ) , LcovLine :: Unknown ) ;
439+ assert_eq ! ( parse_lcov_line( " " ) , LcovLine :: Unknown ) ;
440+ assert_eq ! ( parse_lcov_line( "# comment" ) , LcovLine :: Unknown ) ;
441+ assert_eq ! ( parse_lcov_line( "TN:test" ) , LcovLine :: Unknown ) ;
442+ }
443+
444+ #[ test]
445+ fn test_parse_lcov_line_whitespace_handling ( ) {
446+ let line = parse_lcov_line ( " SF:src/lib.rs " ) ;
447+ assert_eq ! ( line, LcovLine :: SourceFile ( "src/lib.rs" . into( ) ) ) ;
448+ }
449+
450+ #[ test]
451+ fn test_parse_line_data_invalid ( ) {
452+ // Missing hit count
453+ assert_eq ! ( parse_line_data( "42" ) , LcovLine :: Unknown ) ;
454+ // Non-numeric line
455+ assert_eq ! ( parse_line_data( "abc,5" ) , LcovLine :: Unknown ) ;
456+ // Non-numeric hits
457+ assert_eq ! ( parse_line_data( "42,abc" ) , LcovLine :: Unknown ) ;
458+ // Empty string
459+ assert_eq ! ( parse_line_data( "" ) , LcovLine :: Unknown ) ;
460+ }
461+
462+ #[ test]
463+ fn test_parse_lcov_content_without_end_of_record ( ) {
464+ // Some LCOV generators don't include end_of_record for the last file
465+ let lcov_content = "SF:src/main.rs\n DA:1,5\n DA:2,3" ;
466+ let data = parse_lcov_content ( lcov_content, Path :: new ( "test.lcov" ) ) . unwrap ( ) ;
467+ let coverage = data. get_file_coverage ( Path :: new ( "src/main.rs" ) ) . unwrap ( ) ;
468+ assert ! ( ( coverage - 100.0 ) . abs( ) < 0.1 ) ; // Both lines hit
469+ }
470+
471+ #[ test]
472+ fn test_parse_lcov_content_invalid_format ( ) {
473+ // Non-empty content but no valid LCOV data
474+ let result = parse_lcov_content ( "garbage\n more garbage" , Path :: new ( "test.lcov" ) ) ;
475+ assert ! ( result. is_err( ) ) ;
476+ }
339477}
0 commit comments