Skip to main content

ytil_git/
diff.rs

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
10/// Retrieves the current `git diff` raw output with `-U0` as a single `String`.
11///
12/// Callers should pass the returned string to [`get_hunks`] which splits into lines internally,
13/// avoiding per-line `String` allocations.
14///
15/// # Errors
16/// - `git diff` command fails.
17pub 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
29/// Extracts file paths and starting line numbers of hunks from raw `git diff` output.
30///
31/// Accepts a `&str` (the full diff output) and splits into lines internally, avoiding
32/// per-line `String` allocations.
33///
34/// # Errors
35/// - Parsing diff output fails.
36pub fn get_hunks(raw_diff_output: &str) -> rootcause::Result<Vec<(&str, usize)>> {
37    let lines: Vec<&str> = raw_diff_output.lines().collect();
38
39    // Pre-allocate with estimated capacity: roughly 1 hunk per 4 diff lines
40    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
84/// Extracts the line number from a `git diff` hunk header line.
85///
86/// # Errors
87/// - If the hunk header line lacks sufficient space-separated parts.
88/// - If the newline number part is malformed (missing comma).
89/// - If the extracted line number value cannot be parsed as a valid [`usize`].
90fn 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}