1use std::path::Path;
2use std::process::Command;
3
4use color_eyre::eyre::Context;
5use color_eyre::eyre::eyre;
6use ytil_cmd::CmdExt as _;
7
8const PATH_LINE_PREFIX: &str = "diff --git ";
9
10pub fn get_raw(path: Option<&Path>) -> color_eyre::Result<Vec<String>> {
19 let mut args = vec!["diff".into(), "-U0".into()];
20
21 if let Some(path) = path {
22 args.push(path.display().to_string());
23 }
24
25 let output = Command::new("git").args(args).exec()?;
26
27 Ok(ytil_cmd::extract_success_output(&output)?
28 .lines()
29 .map(str::to_string)
30 .collect())
31}
32
33pub fn get_hunks(raw_diff_output: &[String]) -> color_eyre::Result<Vec<(&str, usize)>> {
46 let mut out = vec![];
47
48 for (raw_diff_line_idx, raw_diff_line) in raw_diff_output.iter().enumerate() {
49 let Some(path_line) = raw_diff_line.strip_prefix(PATH_LINE_PREFIX) else {
50 continue;
51 };
52
53 let path_idx = path_line
54 .find(" b/")
55 .ok_or_else(|| {
56 eyre!(
57 "error missing path prefix in path_line | path_line={path_line:?} raw_diff_line_idx={raw_diff_line_idx} raw_diff_line={raw_diff_line:?}"
58 )
59 })?
60 .saturating_add(3);
61
62 let path = path_line.get(path_idx..).ok_or_else(|| {
63 eyre!("error extracting path from path_line | path_idx={path_idx} path_line={path_line:?} raw_diff_line_idx={raw_diff_line_idx} raw_diff_line={raw_diff_line:?}")
64 })?;
65
66 let lnum_lines_start_idx = raw_diff_line_idx.saturating_add(1);
67 let maybe_lnum_lines = raw_diff_output
68 .get(lnum_lines_start_idx..)
69 .ok_or_else(|| eyre!("error extracting lnum_lines from raw_diff_output | lnum_lines_start_idx={lnum_lines_start_idx} raw_diff_line_idx={raw_diff_line_idx}"))?;
70
71 for maybe_lnum_line in maybe_lnum_lines {
72 if maybe_lnum_line.starts_with(PATH_LINE_PREFIX) {
73 break;
74 }
75 if !maybe_lnum_line.starts_with("@@ ") {
76 continue;
77 }
78
79 let lnum = extract_new_lnum_value(maybe_lnum_line)?;
80
81 out.push((path, lnum));
82 }
83 }
84
85 Ok(out)
86}
87
88fn extract_new_lnum_value(lnum_line: &str) -> color_eyre::Result<usize> {
95 let new_lnum = lnum_line
96 .split(' ')
97 .nth(2)
98 .ok_or_else(|| eyre!("error missing new_lnum from lnum_line after split by space | lnum_line={lnum_line:?}"))?;
99
100 let new_lnum_value = new_lnum
101 .split(',')
102 .next()
103 .and_then(|s| {
104 let trimmed = s.trim_start_matches('+');
105 if trimmed.is_empty() { None } else { Some(trimmed) }
106 })
107 .ok_or_else(|| eyre!("error malformed new_lnum in lnum_line | lnum_line={lnum_line:?}"))?;
108
109 new_lnum_value.parse::<usize>().wrap_err_with(|| {
110 eyre!("error parsing new_lnum value as usize | lnum_value={new_lnum_value:?}, lnum_line={lnum_line:?}")
111 })
112}
113
114#[cfg(test)]
115mod tests {
116 use rstest::rstest;
117
118 use super::*;
119
120 #[rstest]
121 #[case::single_file_single_hunk(
122 vec![
123 "diff --git a/src/main.rs b/src/main.rs".to_string(),
124 "index 1234567..abcdef0 100644".to_string(),
125 "--- a/src/main.rs".to_string(),
126 "+++ b/src/main.rs".to_string(),
127 "@@ -42,7 +42,7 @@".to_string(),
128 ],
129 vec![("src/main.rs", 42)]
130 )]
131 #[case::multiple_files(
132 vec![
133 "diff --git a/src/main.rs b/src/main.rs".to_string(),
134 "index 1234567..abcdef0 100644".to_string(),
135 "--- a/src/main.rs".to_string(),
136 "+++ b/src/main.rs".to_string(),
137 "@@ -10,5 +10,5 @@".to_string(),
138 "diff --git a/src/lib.rs b/src/lib.rs".to_string(),
139 "index fedcba9..7654321 100644".to_string(),
140 "--- a/src/lib.rs".to_string(),
141 "+++ b/src/lib.rs".to_string(),
142 "@@ -20,3 +20,3 @@".to_string(),
143 ],
144 vec![("src/main.rs", 10), ("src/lib.rs", 20)]
145 )]
146 #[case::multiple_hunks_same_file(
147 vec![
148 "diff --git a/src/main.rs b/src/main.rs".to_string(),
149 "index 1234567..abcdef0 100644".to_string(),
150 "--- a/src/main.rs".to_string(),
151 "+++ b/src/main.rs".to_string(),
152 "@@ -10,5 +10,5 @@".to_string(),
153 "@@ -50,2 +50,2 @@".to_string(),
154 ],
155 vec![("src/main.rs", 10), ("src/main.rs", 50)]
156 )]
157 #[case::empty_input(vec![], vec![])]
158 #[case::no_hunks(
159 vec!["diff --git a/src/main.rs b/src/main.rs".to_string()],
160 vec![]
161 )]
162 #[case::non_diff_lines_ignored(
163 vec![
164 "index 123..456 789".to_string(),
165 "diff --git a/src/main.rs b/src/main.rs".to_string(),
166 "index 1234567..abcdef0 100644".to_string(),
167 "--- a/src/main.rs".to_string(),
168 "+++ b/src/main.rs".to_string(),
169 "@@ -42,7 +42,7 @@".to_string(),
170 ],
171 vec![("src/main.rs", 42)]
172 )]
173 #[case::multiple_files_with_multiple_hunks(
174 vec![
175 "diff --git a/src/main.rs b/src/main.rs".to_string(),
176 "index 1234567..abcdef0 100644".to_string(),
177 "--- a/src/main.rs".to_string(),
178 "+++ b/src/main.rs".to_string(),
179 "@@ -10,5 +10,5 @@".to_string(),
180 "@@ -50,2 +50,2 @@".to_string(),
181 "diff --git a/src/lib.rs b/src/lib.rs".to_string(),
182 "index fedcba9..7654321 100644".to_string(),
183 "--- a/src/lib.rs".to_string(),
184 "+++ b/src/lib.rs".to_string(),
185 "@@ -20,3 +20,3 @@".to_string(),
186 "@@ -60,1 +60,1 @@".to_string(),
187 ],
188 vec![("src/main.rs", 10), ("src/main.rs", 50), ("src/lib.rs", 20), ("src/lib.rs", 60)]
189 )]
190 fn test_get_hunks_success(#[case] input: Vec<String>, #[case] expected: Vec<(&str, usize)>) {
191 assert2::let_assert!(Ok(result) = get_hunks(&input));
192 pretty_assertions::assert_eq!(result, expected);
193 }
194
195 #[rstest]
196 #[case::missing_b_delimiter(
197 vec!["diff --git a/src/main.rs".to_string()],
198 "error missing path prefix"
199 )]
200 #[case::invalid_lnum(
201 vec![
202 "diff --git a/src/main.rs b/src/main.rs".to_string(),
203 "@@ -abc,5 +abc,5 @@".to_string(),
204 ],
205 "error parsing new_lnum value"
206 )]
207 fn test_get_hunks_error(#[case] input: Vec<String>, #[case] expected_error_contains: &str) {
208 assert2::let_assert!(Err(err) = get_hunks(&input));
209 assert!(err.to_string().contains(expected_error_contains));
210 }
211
212 #[rstest]
213 #[case::standard("@@ -42,7 +42,7 @@", 42)]
214 #[case::without_plus("@@ -42,7 42,7 @@", 42)]
215 #[case::without_comma("@@ -42,7 +42 @@", 42)]
216 #[case::without_plus_or_comma("@@ -42,7 42 @@", 42)]
217 fn extract_new_lnum_value_when_valid_lnum_line_returns_correct_usize(#[case] input: &str, #[case] expected: usize) {
218 assert2::let_assert!(Ok(result) = extract_new_lnum_value(input));
219 pretty_assertions::assert_eq!(result, expected);
220 }
221
222 #[rstest]
223 #[case::missing_new_lnum_part("@@ -42,7", "error missing new_lnum from lnum_line after split by space")]
224 #[case::malformed_lnum("@@ -42,7 +,7 @@", "error malformed new_lnum in lnum_line")]
225 #[case::lnum_value_not_numeric("@@ -42,7 +abc,7 @@", "error parsing new_lnum value as usize")]
226 fn extract_new_lnum_value_error_cases(#[case] input: &str, #[case] expected_error_contains: &str) {
227 assert2::let_assert!(Err(err) = extract_new_lnum_value(input));
228 assert!(err.to_string().contains(expected_error_contains));
229 }
230}