@@ -75,6 +75,88 @@ pub fn get_git_diff(
7575 }
7676}
7777
78+ #[ derive( Debug , Clone , PartialEq , Eq ) ]
79+ pub struct FileDiff {
80+ pub path : String ,
81+ /// (new_start, new_end) inclusive, 1-indexed line ranges touched in the new file.
82+ pub hunks : Vec < ( i64 , i64 ) > ,
83+ pub is_new_file : bool ,
84+ pub is_deleted_file : bool ,
85+ }
86+
87+ /// Minimal unified-diff parser: extracts per-file changed line ranges (new-file side)
88+ /// from `diff --git` / `@@ ... @@` headers. Not a full diff/patch implementation —
89+ /// just enough to overlap against indexed symbol line ranges.
90+ pub fn parse_unified_diff ( diff_text : & str ) -> Vec < FileDiff > {
91+ let mut files: Vec < FileDiff > = Vec :: new ( ) ;
92+ let mut current: Option < FileDiff > = None ;
93+
94+ for line in diff_text. lines ( ) {
95+ if let Some ( rest) = line. strip_prefix ( "diff --git " ) {
96+ if let Some ( f) = current. take ( ) {
97+ files. push ( f) ;
98+ }
99+ current = Some ( FileDiff {
100+ path : parse_diff_git_header ( rest) ,
101+ hunks : Vec :: new ( ) ,
102+ is_new_file : false ,
103+ is_deleted_file : false ,
104+ } ) ;
105+ } else if line. starts_with ( "new file mode" ) {
106+ if let Some ( f) = current. as_mut ( ) {
107+ f. is_new_file = true ;
108+ }
109+ } else if line. starts_with ( "deleted file mode" ) {
110+ if let Some ( f) = current. as_mut ( ) {
111+ f. is_deleted_file = true ;
112+ }
113+ } else if let Some ( rest) = line. strip_prefix ( "+++ " ) {
114+ if let Some ( f) = current. as_mut ( ) {
115+ let rest = rest. trim ( ) ;
116+ if rest != "/dev/null" {
117+ f. path = rest. strip_prefix ( "b/" ) . unwrap_or ( rest) . to_string ( ) ;
118+ }
119+ }
120+ } else if let Some ( range) = line. strip_prefix ( "@@ " ) . and_then ( parse_hunk_header)
121+ && let Some ( f) = current. as_mut ( )
122+ {
123+ f. hunks . push ( range) ;
124+ }
125+ }
126+ if let Some ( f) = current. take ( ) {
127+ files. push ( f) ;
128+ }
129+ files
130+ }
131+
132+ fn parse_diff_git_header ( rest : & str ) -> String {
133+ if let Some ( idx) = rest. find ( " b/" ) {
134+ rest[ idx + 3 ..] . trim ( ) . to_string ( )
135+ } else {
136+ rest. split_whitespace ( )
137+ . last ( )
138+ . map ( |s| s. strip_prefix ( "b/" ) . unwrap_or ( s) . to_string ( ) )
139+ . unwrap_or_default ( )
140+ }
141+ }
142+
143+ /// Parses the new-file `+start,len` range out of a hunk header tail (the part
144+ /// after the leading `"@@ "` has already been stripped by the caller).
145+ fn parse_hunk_header ( rest : & str ) -> Option < ( i64 , i64 ) > {
146+ let close = rest. find ( " @@" ) ?;
147+ let ranges = & rest[ ..close] ;
148+ let new_part = ranges. split ( ' ' ) . find ( |s| s. starts_with ( '+' ) ) ?;
149+ let new_part = & new_part[ 1 ..] ;
150+ let mut parts = new_part. splitn ( 2 , ',' ) ;
151+ let start: i64 = parts. next ( ) ?. parse ( ) . ok ( ) ?;
152+ let len: i64 = parts
153+ . next ( )
154+ . and_then ( |s| s. parse :: < i64 > ( ) . ok ( ) )
155+ . unwrap_or ( 1 ) ;
156+ let end = if len <= 0 { start } else { start + len - 1 } ;
157+ Some ( ( start, end) )
158+ }
159+
78160pub fn is_signature_changed ( signature_range : ( i64 , i64 ) , hunk_ranges : & [ ( i64 , i64 ) ] ) -> bool {
79161 let ( sig_start, sig_end) = signature_range;
80162 hunk_ranges
@@ -227,4 +309,91 @@ mod tests {
227309 assert ! ( diff. is_none( ) ) ;
228310 assert ! ( err. is_some( ) ) ;
229311 }
312+
313+ #[ test]
314+ fn test_parse_unified_diff_single_hunk ( ) {
315+ let diff = "diff --git a/src/foo.rs b/src/foo.rs\n \
316+ index abc..def 100644\n \
317+ --- a/src/foo.rs\n \
318+ +++ b/src/foo.rs\n \
319+ @@ -10,3 +10,4 @@ fn foo() {\n \
320+ context\n \
321+ +new line\n \
322+ context\n ";
323+ let files = parse_unified_diff ( diff) ;
324+ assert_eq ! ( files. len( ) , 1 ) ;
325+ assert_eq ! ( files[ 0 ] . path, "src/foo.rs" ) ;
326+ assert_eq ! ( files[ 0 ] . hunks, vec![ ( 10 , 13 ) ] ) ;
327+ assert ! ( !files[ 0 ] . is_new_file) ;
328+ assert ! ( !files[ 0 ] . is_deleted_file) ;
329+ }
330+
331+ #[ test]
332+ fn test_parse_unified_diff_new_file ( ) {
333+ let diff = "diff --git a/src/new.rs b/src/new.rs\n \
334+ new file mode 100644\n \
335+ index 000..abc\n \
336+ --- /dev/null\n \
337+ +++ b/src/new.rs\n \
338+ @@ -0,0 +1,5 @@\n \
339+ +fn new_fn() {}\n ";
340+ let files = parse_unified_diff ( diff) ;
341+ assert_eq ! ( files. len( ) , 1 ) ;
342+ assert_eq ! ( files[ 0 ] . path, "src/new.rs" ) ;
343+ assert ! ( files[ 0 ] . is_new_file) ;
344+ assert_eq ! ( files[ 0 ] . hunks, vec![ ( 1 , 5 ) ] ) ;
345+ }
346+
347+ #[ test]
348+ fn test_parse_unified_diff_deleted_file ( ) {
349+ let diff = "diff --git a/src/old.rs b/src/old.rs\n \
350+ deleted file mode 100644\n \
351+ index abc..000\n \
352+ --- a/src/old.rs\n \
353+ +++ /dev/null\n \
354+ @@ -1,5 +0,0 @@\n \
355+ -fn old_fn() {}\n ";
356+ let files = parse_unified_diff ( diff) ;
357+ assert_eq ! ( files. len( ) , 1 ) ;
358+ assert_eq ! ( files[ 0 ] . path, "src/old.rs" ) ;
359+ assert ! ( files[ 0 ] . is_deleted_file) ;
360+ }
361+
362+ #[ test]
363+ fn test_parse_unified_diff_rename ( ) {
364+ let diff = "diff --git a/src/old.rs b/src/renamed.rs\n \
365+ similarity index 95%\n \
366+ rename from src/old.rs\n \
367+ rename to src/renamed.rs\n \
368+ --- a/src/old.rs\n \
369+ +++ b/src/renamed.rs\n \
370+ @@ -1,2 +1,3 @@\n \
371+ context\n \
372+ +added\n ";
373+ let files = parse_unified_diff ( diff) ;
374+ assert_eq ! ( files. len( ) , 1 ) ;
375+ assert_eq ! ( files[ 0 ] . path, "src/renamed.rs" ) ;
376+ }
377+
378+ #[ test]
379+ fn test_parse_unified_diff_multiple_files_and_hunks ( ) {
380+ let diff = "diff --git a/a.rs b/a.rs\n \
381+ --- a/a.rs\n \
382+ +++ b/a.rs\n \
383+ @@ -1,2 +1,2 @@\n \
384+ x\n \
385+ @@ -20,1 +20,1 @@\n \
386+ y\n \
387+ diff --git a/b.rs b/b.rs\n \
388+ --- a/b.rs\n \
389+ +++ b/b.rs\n \
390+ @@ -5 +5 @@\n \
391+ z\n ";
392+ let files = parse_unified_diff ( diff) ;
393+ assert_eq ! ( files. len( ) , 2 ) ;
394+ assert_eq ! ( files[ 0 ] . path, "a.rs" ) ;
395+ assert_eq ! ( files[ 0 ] . hunks, vec![ ( 1 , 2 ) , ( 20 , 20 ) ] ) ;
396+ assert_eq ! ( files[ 1 ] . path, "b.rs" ) ;
397+ assert_eq ! ( files[ 1 ] . hunks, vec![ ( 5 , 5 ) ] ) ;
398+ }
230399}
0 commit comments