1use std::path::{Path, PathBuf};
5
6use masterror::AppError;
7
8use super::hunk::{Hunk, HunkLine};
9use crate::error::DiffParseError;
10
11#[derive(Debug, Clone, PartialEq, Eq)]
13pub struct FileDiff {
14 pub path: PathBuf,
16 pub old_path: Option<PathBuf>,
18 pub is_deleted: bool,
20 pub hunks: Vec<Hunk>,
22}
23
24impl FileDiff {
25 pub fn new(path: PathBuf) -> Self {
46 Self {
47 path,
48 old_path: None,
49 is_deleted: false,
50 hunks: Vec::new(),
51 }
52 }
53
54 pub fn total_added(&self) -> usize {
71 self.hunks.iter().map(|h| h.added_count()).sum()
72 }
73
74 pub fn total_removed(&self) -> usize {
91 self.hunks.iter().map(|h| h.removed_count()).sum()
92 }
93
94 pub fn all_added_lines(&self) -> Vec<usize> {
111 self.hunks.iter().flat_map(|h| h.added_lines()).collect()
112 }
113
114 pub fn all_removed_lines(&self) -> Vec<usize> {
131 self.hunks.iter().flat_map(|h| h.removed_lines()).collect()
132 }
133
134 pub fn all_removed_positions_in_new(&self) -> Vec<usize> {
153 self.hunks
154 .iter()
155 .flat_map(|h| h.removed_positions_in_new())
156 .collect()
157 }
158
159 pub fn is_rust_file(&self) -> bool {
179 self.path
180 .extension()
181 .map(|ext| ext == "rs")
182 .unwrap_or(false)
183 }
184}
185
186pub fn parse_diff(input: &str) -> Result<Vec<FileDiff>, AppError> {
219 let mut files = Vec::new();
220 let mut current_file: Option<FileDiff> = None;
221 let mut current_hunk: Option<Hunk> = None;
222 let mut old_line = 0;
223 let mut new_line = 0;
224
225 for line in input.lines() {
226 if line.starts_with("diff --git") {
227 if let Some(mut file) = current_file.take() {
228 if let Some(hunk) = current_hunk.take() {
229 file.hunks.push(hunk);
230 }
231 files.push(file);
232 }
233
234 let path = parse_diff_header(line)?;
235 current_file = Some(FileDiff::new(path));
236 current_hunk = None;
237 } else if line.starts_with("@@") {
238 if let Some(ref mut file) = current_file {
239 if let Some(hunk) = current_hunk.take() {
240 file.hunks.push(hunk);
241 }
242
243 let (old_start, old_count, new_start, new_count) = parse_hunk_header(line)?;
244 current_hunk = Some(Hunk::new(old_start, old_count, new_start, new_count));
245 old_line = old_start;
246 new_line = new_start;
247 }
248 } else if let Some(ref mut hunk) = current_hunk {
249 let mut chars = line.chars();
250 match chars.next() {
251 Some('+') => {
252 hunk.lines
253 .push(HunkLine::added(new_line, chars.as_str().to_string()));
254 new_line += 1;
255 }
256 Some('-') => {
257 hunk.lines
258 .push(HunkLine::removed(old_line, chars.as_str().to_string()));
259 old_line += 1;
260 }
261 Some(' ') | None => {
262 hunk.lines.push(HunkLine::context(
263 old_line,
264 new_line,
265 chars.as_str().to_string(),
266 ));
267 old_line += 1;
268 new_line += 1;
269 }
270 Some(_) => {}
271 }
272 } else if let Some(ref mut file) = current_file {
273 apply_file_metadata(line, file);
274 }
275 }
276
277 if let Some(mut file) = current_file {
278 if let Some(hunk) = current_hunk {
279 file.hunks.push(hunk);
280 }
281 files.push(file);
282 }
283
284 Ok(files)
285}
286
287fn apply_file_metadata(line: &str, file: &mut FileDiff) {
290 if let Some(rest) = line.strip_prefix("+++ ") {
291 let target = extract_path_token(rest);
292 if target == "/dev/null" {
293 file.is_deleted = true;
294 } else {
295 let path = target.strip_prefix("b/").unwrap_or(&target);
296 file.path = PathBuf::from(path);
297 if file.old_path.as_deref() == Some(file.path.as_path()) {
298 file.old_path = None;
299 }
300 }
301 } else if let Some(rest) = line.strip_prefix("--- ") {
302 let target = extract_path_token(rest);
303 if target != "/dev/null" {
304 let path = target.strip_prefix("a/").unwrap_or(&target);
305 if Path::new(path) != file.path {
306 file.old_path = Some(PathBuf::from(path));
307 }
308 }
309 } else if let Some(rest) = line.strip_prefix("rename from ") {
310 file.old_path = Some(PathBuf::from(unquote_git_path(rest.trim_end())));
311 } else if let Some(rest) = line.strip_prefix("rename to ") {
312 file.path = PathBuf::from(unquote_git_path(rest.trim_end()));
313 } else if line.starts_with("deleted file mode") {
314 file.is_deleted = true;
315 }
316}
317
318fn extract_path_token(rest: &str) -> String {
321 if rest.starts_with('"') {
322 unquote_git_path(rest.trim_end())
323 } else {
324 rest.split('\t')
325 .next()
326 .unwrap_or(rest)
327 .trim_end()
328 .to_string()
329 }
330}
331
332fn unquote_git_path(raw: &str) -> String {
338 let inner = match raw.strip_prefix('"').and_then(|s| s.strip_suffix('"')) {
339 Some(inner) => inner,
340 None => return raw.to_string(),
341 };
342
343 let mut bytes = Vec::with_capacity(inner.len());
344 let mut iter = inner.bytes().peekable();
345 while let Some(byte) = iter.next() {
346 if byte != b'\\' {
347 bytes.push(byte);
348 continue;
349 }
350 match iter.next() {
351 Some(b'n') => bytes.push(b'\n'),
352 Some(b't') => bytes.push(b'\t'),
353 Some(b'r') => bytes.push(b'\r'),
354 Some(digit @ b'0'..=b'7') => {
355 let mut value = u32::from(digit - b'0');
356 for _ in 0..2 {
357 match iter.peek().copied() {
358 Some(next @ b'0'..=b'7') => {
359 value = value * 8 + u32::from(next - b'0');
360 iter.next();
361 }
362 _ => break,
363 }
364 }
365 bytes.push(value as u8);
366 }
367 Some(other) => bytes.push(other),
368 None => {}
369 }
370 }
371
372 String::from_utf8_lossy(&bytes).into_owned()
373}
374
375fn parse_diff_header(line: &str) -> Result<PathBuf, AppError> {
376 let invalid_header = || {
377 AppError::from(DiffParseError {
378 message: format!("invalid diff header: {}", line),
379 })
380 };
381
382 let rest = line
383 .strip_prefix("diff --git ")
384 .ok_or_else(invalid_header)?;
385 let b_part = if rest.ends_with('"') {
386 rest.rfind(" \"").map(|pos| &rest[pos + 1..])
387 } else {
388 rest.rfind(" b/").map(|pos| &rest[pos + 1..])
389 };
390 let b_part = b_part
391 .or_else(|| rest.split_whitespace().nth(1))
392 .ok_or_else(invalid_header)?;
393
394 let unquoted = unquote_git_path(b_part);
395 let path = unquoted.strip_prefix("b/").unwrap_or(&unquoted);
396 Ok(PathBuf::from(path))
397}
398
399fn parse_hunk_header(line: &str) -> Result<(usize, usize, usize, usize), AppError> {
400 let invalid_header = || {
401 AppError::from(DiffParseError {
402 message: format!("invalid hunk header: {}", line),
403 })
404 };
405
406 let ranges = line
407 .strip_prefix("@@")
408 .ok_or_else(invalid_header)?
409 .split("@@")
410 .next()
411 .unwrap_or("")
412 .trim();
413
414 let mut parts = ranges.split_whitespace();
415 let old_part = parts.next().ok_or_else(invalid_header)?;
416 let new_part = parts.next().ok_or_else(invalid_header)?;
417
418 let old_range = old_part.strip_prefix('-').ok_or_else(|| {
419 AppError::from(DiffParseError {
420 message: format!("invalid old range: {}", old_part),
421 })
422 })?;
423
424 let new_range = new_part.strip_prefix('+').ok_or_else(|| {
425 AppError::from(DiffParseError {
426 message: format!("invalid new range: {}", new_part),
427 })
428 })?;
429
430 let (old_start, old_count) = parse_range(old_range)?;
431 let (new_start, new_count) = parse_range(new_range)?;
432
433 Ok((old_start, old_count, new_start, new_count))
434}
435
436fn parse_range(range: &str) -> Result<(usize, usize), AppError> {
437 let (start_part, count_part) = match range.split_once(',') {
438 Some((start, count)) => (start, Some(count)),
439 None => (range, None),
440 };
441
442 let start = start_part.parse::<usize>().map_err(|_| {
443 AppError::from(DiffParseError {
444 message: format!("invalid line number: {}", start_part),
445 })
446 })?;
447
448 let count = match count_part {
449 Some(count) => count.parse::<usize>().map_err(|_| {
450 AppError::from(DiffParseError {
451 message: format!("invalid line count: {}", count),
452 })
453 })?,
454 None => 1,
455 };
456
457 Ok((start, count))
458}
459
460#[cfg(test)]
461mod tests {
462 use super::*;
463
464 #[test]
465 fn test_parse_simple_diff() {
466 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
467index 1234567..abcdefg 100644
468--- a/src/lib.rs
469+++ b/src/lib.rs
470@@ -1,3 +1,4 @@
471 fn main() {
472+ println!("Hello");
473 }
474"#;
475
476 let files = parse_diff(diff).expect("parse should succeed");
477 assert_eq!(files.len(), 1);
478 assert_eq!(files[0].path, PathBuf::from("src/lib.rs"));
479 assert_eq!(files[0].hunks.len(), 1);
480 assert_eq!(files[0].total_added(), 1);
481 }
482
483 #[test]
484 fn test_parse_multiple_hunks() {
485 let diff = r#"diff --git a/src/lib.rs b/src/lib.rs
486--- a/src/lib.rs
487+++ b/src/lib.rs
488@@ -1,3 +1,4 @@
489 fn main() {
490+ println!("Hello");
491 }
492@@ -10,2 +11,3 @@
493 fn test() {
494+ assert!(true);
495 }
496"#;
497
498 let files = parse_diff(diff).expect("parse should succeed");
499 assert_eq!(files[0].hunks.len(), 2);
500 assert_eq!(files[0].total_added(), 2);
501 }
502
503 #[test]
504 fn test_parse_multiple_files() {
505 let diff = r#"diff --git a/src/a.rs b/src/a.rs
506--- a/src/a.rs
507+++ b/src/a.rs
508@@ -1,1 +1,2 @@
509 fn a() {}
510+fn a2() {}
511diff --git a/src/b.rs b/src/b.rs
512--- a/src/b.rs
513+++ b/src/b.rs
514@@ -1,1 +1,2 @@
515 fn b() {}
516+fn b2() {}
517"#;
518
519 let files = parse_diff(diff).expect("parse should succeed");
520 assert_eq!(files.len(), 2);
521 assert_eq!(files[0].path, PathBuf::from("src/a.rs"));
522 assert_eq!(files[1].path, PathBuf::from("src/b.rs"));
523 }
524
525 #[test]
526 fn test_is_rust_file() {
527 let rust_diff = FileDiff::new(PathBuf::from("src/lib.rs"));
528 assert!(rust_diff.is_rust_file());
529
530 let md_diff = FileDiff::new(PathBuf::from("README.md"));
531 assert!(!md_diff.is_rust_file());
532 }
533
534 #[test]
535 fn test_parse_multibyte_content_does_not_panic() {
536 let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ \
537 -1,2 +1,2 @@\n контекст\n-старая строка\n+новая строка\nПривет без префикса\n";
538
539 let files = parse_diff(diff).expect("parse should succeed");
540 assert_eq!(files[0].total_added(), 1);
541 assert_eq!(files[0].total_removed(), 1);
542 let hunk = &files[0].hunks[0];
543 assert_eq!(hunk.added_lines(), vec![2]);
544 assert_eq!(hunk.removed_lines(), vec![2]);
545 }
546
547 #[test]
548 fn test_parse_deleted_file() {
549 let diff = "diff --git a/src/old.rs b/src/old.rs\ndeleted file mode 100644\nindex \
550 1234567..0000000\n--- a/src/old.rs\n+++ /dev/null\n@@ -1,2 +0,0 @@\n-fn \
551 gone() {}\n-fn also_gone() {}\n";
552
553 let files = parse_diff(diff).expect("parse should succeed");
554 assert!(files[0].is_deleted);
555 assert_eq!(files[0].path, PathBuf::from("src/old.rs"));
556 assert_eq!(files[0].total_removed(), 2);
557 }
558
559 #[test]
560 fn test_parse_new_file_is_not_deleted() {
561 let diff = "diff --git a/src/new.rs b/src/new.rs\nnew file mode 100644\n--- \
562 /dev/null\n+++ b/src/new.rs\n@@ -0,0 +1,1 @@\n+fn fresh() {}\n";
563
564 let files = parse_diff(diff).expect("parse should succeed");
565 assert!(!files[0].is_deleted);
566 assert!(files[0].old_path.is_none());
567 assert_eq!(files[0].path, PathBuf::from("src/new.rs"));
568 }
569
570 #[test]
571 fn test_parse_renamed_file() {
572 let diff = "diff --git a/src/before.rs b/src/after.rs\nsimilarity index 90%\nrename from \
573 src/before.rs\nrename to src/after.rs\n--- a/src/before.rs\n+++ \
574 b/src/after.rs\n@@ -1,1 +1,2 @@\n fn kept() {}\n+fn added() {}\n";
575
576 let files = parse_diff(diff).expect("parse should succeed");
577 assert_eq!(files[0].path, PathBuf::from("src/after.rs"));
578 assert_eq!(files[0].old_path, Some(PathBuf::from("src/before.rs")));
579 assert!(!files[0].is_deleted);
580 }
581
582 #[test]
583 fn test_parse_path_with_spaces() {
584 let diff = "diff --git a/src/my file.rs b/src/my file.rs\n--- a/src/my file.rs\n+++ \
585 b/src/my file.rs\n@@ -1,1 +1,2 @@\n fn a() {}\n+fn b() {}\n";
586
587 let files = parse_diff(diff).expect("parse should succeed");
588 assert_eq!(files[0].path, PathBuf::from("src/my file.rs"));
589 assert!(files[0].old_path.is_none());
590 }
591
592 #[test]
593 fn test_parse_quoted_path_with_octal_escapes() {
594 let diff = "diff --git \"a/src/\\321\\204.rs\" \"b/src/\\321\\204.rs\"\n--- \
595 \"a/src/\\321\\204.rs\"\n+++ \"b/src/\\321\\204.rs\"\n@@ -1,1 +1,2 @@\n fn \
596 a() {}\n+fn b() {}\n";
597
598 let files = parse_diff(diff).expect("parse should succeed");
599 assert_eq!(files[0].path, PathBuf::from("src/ф.rs"));
600 }
601
602 #[test]
603 fn test_unquote_escapes() {
604 assert_eq!(unquote_git_path("plain/path.rs"), "plain/path.rs");
605 assert_eq!(unquote_git_path("\"a b.rs\""), "a b.rs");
606 assert_eq!(unquote_git_path("\"tab\\there\""), "tab\there");
607 assert_eq!(unquote_git_path("\"q\\\"uote\""), "q\"uote");
608 assert_eq!(unquote_git_path("\"\\321\\204\""), "ф");
609 }
610
611 #[test]
612 fn test_removed_line_numbers_across_hunks() {
613 let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ \
614 -1,3 +1,2 @@\n fn a() {}\n-fn removed_one() {}\n fn c() {}\n@@ -10,3 +9,2 \
615 @@\n fn x() {}\n-fn removed_two() {}\n fn z() {}\n";
616
617 let files = parse_diff(diff).expect("parse should succeed");
618 assert_eq!(files[0].all_removed_lines(), vec![2, 11]);
619 assert!(files[0].all_added_lines().is_empty());
620 }
621
622 #[test]
623 fn test_empty_line_in_hunk_is_empty_context() {
624 let diff = "diff --git a/src/lib.rs b/src/lib.rs\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ \
625 -1,3 +1,4 @@\n fn a() {}\n\n+fn b() {}\n fn c() {}\n";
626
627 let files = parse_diff(diff).expect("parse should succeed");
628 assert_eq!(files[0].all_added_lines(), vec![3]);
629 }
630}