ytil_git/
diff.rs

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
10/// Retrieves the current `git diff` raw output with `-U0` for fine-grained diffs as a [`Vec<String>`].
11///
12/// # Errors
13/// - If the `git diff` command fails to execute or returns a non-zero exit code.
14/// - If extracting the output from the command fails.
15///
16/// # Rationale
17/// Uses `-U0` to produce the most fine-grained line diffs.
18pub 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
33/// Extracts file paths and starting line numbers of hunks from `git diff` output.
34///
35/// # Errors
36/// - Missing path delimiter in the diff line.
37/// - Unable to extract the filepath from the diff line.
38/// - Unable to access subsequent lines for line numbers.
39/// - Missing comma delimiter in the hunk header.
40/// - Unable to extract the line number from the hunk header.
41/// - Line number cannot be parsed as a valid [`usize`].
42///
43/// # Assumptions
44/// Assumes `raw_diff_output` is in standard unified diff format produced by `git diff`.
45pub 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
88/// Extracts the line number from a `git diff` hunk header line.
89///
90/// # Errors
91/// - If the hunk header line lacks sufficient space-separated parts.
92/// - If the newline number part is malformed (missing comma).
93/// - If the extracted line number value cannot be parsed as a valid [`usize`].
94fn 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}