1use std::path::Path;
2use std::process::Command;
3
4use rootcause::prelude::ResultExt;
5use rootcause::report;
6use ytil_cmd::CmdExt as _;
7
8const PATH_LINE_PREFIX: &str = "diff --git ";
9
10pub fn get_raw(path: Option<&Path>) -> rootcause::Result<String> {
18 let mut args = vec!["diff".into(), "-U0".into()];
19
20 if let Some(path) = path {
21 args.push(path.display().to_string());
22 }
23
24 let output = Command::new("git").args(args).exec()?;
25
26 ytil_cmd::extract_success_output(&output)
27}
28
29pub fn get_hunks(raw_diff_output: &str) -> rootcause::Result<Vec<(&str, usize)>> {
37 let lines: Vec<&str> = raw_diff_output.lines().collect();
38
39 let mut out = Vec::with_capacity(lines.len().saturating_div(4).max(1));
41
42 for (raw_diff_line_idx, raw_diff_line) in lines.iter().enumerate() {
43 let Some(path_line) = raw_diff_line.strip_prefix(PATH_LINE_PREFIX) else {
44 continue;
45 };
46
47 let path_idx = path_line
48 .find(" b/")
49 .ok_or_else(|| report!("error missing path prefix in path_line"))
50 .attach_with(|| {
51 format!("path_line={path_line:?} raw_diff_line_idx={raw_diff_line_idx} raw_diff_line={raw_diff_line:?}")
52 })?
53 .saturating_add(3);
54
55 let path = path_line.get(path_idx..)
56 .ok_or_else(|| report!("error extracting path from path_line"))
57 .attach_with(|| format!("path_idx={path_idx} path_line={path_line:?} raw_diff_line_idx={raw_diff_line_idx} raw_diff_line={raw_diff_line:?}"))?;
58
59 let lnum_lines_start_idx = raw_diff_line_idx.saturating_add(1);
60 let maybe_lnum_lines = lines
61 .get(lnum_lines_start_idx..)
62 .ok_or_else(|| report!("error extracting lnum_lines from raw_diff_output"))
63 .attach_with(|| {
64 format!("lnum_lines_start_idx={lnum_lines_start_idx} raw_diff_line_idx={raw_diff_line_idx}")
65 })?;
66
67 for maybe_lnum_line in maybe_lnum_lines {
68 if maybe_lnum_line.starts_with(PATH_LINE_PREFIX) {
69 break;
70 }
71 if !maybe_lnum_line.starts_with("@@ ") {
72 continue;
73 }
74
75 let lnum = extract_new_lnum_value(maybe_lnum_line)?;
76
77 out.push((path, lnum));
78 }
79 }
80
81 Ok(out)
82}
83
84fn extract_new_lnum_value(lnum_line: &str) -> rootcause::Result<usize> {
91 let new_lnum = lnum_line
92 .split(' ')
93 .nth(2)
94 .ok_or_else(|| report!("error missing new_lnum from lnum_line after split by space"))
95 .attach_with(|| format!("lnum_line={lnum_line:?}"))?;
96
97 let new_lnum_value = new_lnum
98 .split(',')
99 .next()
100 .and_then(|s| {
101 let trimmed = s.trim_start_matches('+');
102 if trimmed.is_empty() { None } else { Some(trimmed) }
103 })
104 .ok_or_else(|| report!("error malformed new_lnum in lnum_line"))
105 .attach_with(|| format!("lnum_line={lnum_line:?}"))?;
106
107 Ok(new_lnum_value
108 .parse::<usize>()
109 .context("error parsing new_lnum value as usize")
110 .attach_with(|| format!("lnum_value={new_lnum_value:?} lnum_line={lnum_line:?}"))?)
111}
112
113#[cfg(test)]
114mod tests {
115 use rstest::rstest;
116
117 use super::*;
118
119 #[rstest]
120 #[case::single_file_single_hunk(
121 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -42,7 +42,7 @@",
122 vec![("src/main.rs", 42)]
123 )]
124 #[case::multiple_files(
125 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,5 +10,5 @@\ndiff --git a/src/lib.rs b/src/lib.rs\nindex fedcba9..7654321 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -20,3 +20,3 @@",
126 vec![("src/main.rs", 10), ("src/lib.rs", 20)]
127 )]
128 #[case::multiple_hunks_same_file(
129 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,5 +10,5 @@\n@@ -50,2 +50,2 @@",
130 vec![("src/main.rs", 10), ("src/main.rs", 50)]
131 )]
132 #[case::empty_input("", vec![])]
133 #[case::no_hunks(
134 "diff --git a/src/main.rs b/src/main.rs",
135 vec![]
136 )]
137 #[case::non_diff_lines_ignored(
138 "index 123..456 789\ndiff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -42,7 +42,7 @@",
139 vec![("src/main.rs", 42)]
140 )]
141 #[case::multiple_files_with_multiple_hunks(
142 "diff --git a/src/main.rs b/src/main.rs\nindex 1234567..abcdef0 100644\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -10,5 +10,5 @@\n@@ -50,2 +50,2 @@\ndiff --git a/src/lib.rs b/src/lib.rs\nindex fedcba9..7654321 100644\n--- a/src/lib.rs\n+++ b/src/lib.rs\n@@ -20,3 +20,3 @@\n@@ -60,1 +60,1 @@",
143 vec![("src/main.rs", 10), ("src/main.rs", 50), ("src/lib.rs", 20), ("src/lib.rs", 60)]
144 )]
145 fn test_get_hunks_success(#[case] input: &str, #[case] expected: Vec<(&str, usize)>) {
146 assert2::assert!(let Ok(result) = get_hunks(input));
147 pretty_assertions::assert_eq!(result, expected);
148 }
149
150 #[rstest]
151 #[case::missing_b_delimiter("diff --git a/src/main.rs", "error missing path prefix")]
152 #[case::invalid_lnum(
153 "diff --git a/src/main.rs b/src/main.rs\n@@ -abc,5 +abc,5 @@",
154 "error parsing new_lnum value"
155 )]
156 fn test_get_hunks_error(#[case] input: &str, #[case] expected_error_contains: &str) {
157 assert2::assert!(let Err(err) = get_hunks(input));
158 assert!(err.to_string().contains(expected_error_contains));
159 }
160
161 #[rstest]
162 #[case::standard("@@ -42,7 +42,7 @@", 42)]
163 #[case::without_plus("@@ -42,7 42,7 @@", 42)]
164 #[case::without_comma("@@ -42,7 +42 @@", 42)]
165 #[case::without_plus_or_comma("@@ -42,7 42 @@", 42)]
166 fn extract_new_lnum_value_when_valid_lnum_line_returns_correct_usize(#[case] input: &str, #[case] expected: usize) {
167 assert2::assert!(let Ok(result) = extract_new_lnum_value(input));
168 pretty_assertions::assert_eq!(result, expected);
169 }
170
171 #[rstest]
172 #[case::missing_new_lnum_part("@@ -42,7", "error missing new_lnum from lnum_line after split by space")]
173 #[case::malformed_lnum("@@ -42,7 +,7 @@", "error malformed new_lnum in lnum_line")]
174 #[case::lnum_value_not_numeric("@@ -42,7 +abc,7 @@", "error parsing new_lnum value as usize")]
175 fn extract_new_lnum_value_error_cases(#[case] input: &str, #[case] expected_error_contains: &str) {
176 assert2::assert!(let Err(err) = extract_new_lnum_value(input));
177 assert!(err.to_string().contains(expected_error_contains));
178 }
179}