eazygit 0.5.1

A fast TUI for Git with staging, conflicts, rebase, and palette-first UX
Documentation
use crate::git::parsers::diff::DiffHunk;
use std::collections::HashSet;

const HEADER_PREFIX: &str = "diff --git a/";

pub fn file_header(path: &str) -> String {
    format!("{HEADER_PREFIX}{0} b/{0}\n--- a/{0}\n+++ b/{0}\n", path)
}

pub fn build_hunk_patch(path: &str, hunk: &DiffHunk) -> String {
    let mut buf = String::new();
    buf.push_str(&file_header(path));
    for line in &hunk.lines {
        buf.push_str(&line.content);
        buf.push('\n');
    }
    buf
}

pub fn build_lines_patch(path: &str, raw: &str, selected: &HashSet<usize>) -> Result<String, String> {
    if selected.is_empty() {
        return Err("No selected lines".into());
    }
    let mut records: Vec<(usize, String, Option<i32>, Option<i32>)> = Vec::new();
    let mut old_lineno = 0;
    let mut new_lineno = 0;
    for (idx, line) in raw.lines().enumerate() {
        if line.starts_with("@@") {
            let parts: Vec<&str> = line.split_whitespace().collect();
            if parts.len() >= 3 {
                if let Some(old) = parts.get(1) {
                    let nums: Vec<&str> = old.trim_start_matches('-').split(',').collect();
                    old_lineno = nums.get(0).and_then(|n| n.parse::<i32>().ok()).unwrap_or(0);
                }
                if let Some(newn) = parts.get(2) {
                    let nums: Vec<&str> = newn.trim_start_matches('+').split(',').collect();
                    new_lineno = nums.get(0).and_then(|n| n.parse::<i32>().ok()).unwrap_or(0);
                }
            }
        } else if line.starts_with('+') {
            let ln = new_lineno;
            new_lineno += 1;
            records.push((idx, line.to_string(), None, Some(ln)));
        } else if line.starts_with('-') {
            let ln = old_lineno;
            old_lineno += 1;
            records.push((idx, line.to_string(), Some(ln), None));
        } else if line.starts_with(' ') {
            old_lineno += 1;
            new_lineno += 1;
        }
    }

    let mut selected_records: Vec<(usize, String, Option<i32>, Option<i32>)> = records
        .into_iter()
        .filter(|(idx, _, _, _)| selected.contains(idx))
        .collect();
    selected_records.sort_by_key(|(idx, _, _, _)| *idx);
    if selected_records.is_empty() {
        return Err("Selected lines are not diff lines".into());
    }

    // group contiguous by idx
    let mut buf = String::new();
    buf.push_str(&file_header(path));

    let mut groups: Vec<Vec<(usize, String, Option<i32>, Option<i32>)>> = Vec::new();
    for rec in selected_records {
        // Defensive: check if groups is empty or if last group's last record exists
        let should_start_new_group = groups.is_empty() || {
            groups.last()
                .and_then(|g| g.last())
                .map(|last_rec| rec.0 != last_rec.0 + 1)
                .unwrap_or(true)
        };
        
        if should_start_new_group {
            groups.push(vec![rec]);
        } else if let Some(last) = groups.last_mut() {
            last.push(rec);
        }
    }

    for g in groups {
        let old_start = g.iter().filter_map(|(_, _, o, _)| *o).min().unwrap_or(0);
        let new_start = g.iter().filter_map(|(_, _, _, n)| *n).min().unwrap_or(0);
        let old_count = g.iter().filter(|(_, _, o, _)| o.is_some()).count();
        let new_count = g.iter().filter(|(_, _, _, n)| n.is_some()).count();
        buf.push_str(&format!("@@ -{},{} +{},{} @@\n", old_start, old_count.max(1), new_start, new_count.max(1)));
        for (_, line, _, _) in g {
            buf.push_str(&line);
            buf.push('\n');
        }
    }

    Ok(buf)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::git::parsers::diff::{DiffLine, DiffLineKind};

    #[test]
    fn build_lines_patch_single_group() {
        let raw = "\
@@ -1,3 +1,3 @@
 line1
-line2
+line2-changed
 line3
";
        let mut selected = HashSet::new();
        // Indexing matches raw.lines() enumerate; pick the added line.
        selected.insert(3);
        let patch = build_lines_patch("file.txt", raw, &selected).expect("patch");
        assert!(patch.contains("diff --git a/file.txt b/file.txt"));
        assert!(patch.contains("@@"));
        assert!(patch.contains("+line2-changed"));
    }

    #[test]
    fn build_lines_patch_multiple_groups() {
        let raw = "\
@@ -1,6 +1,6 @@
 line1
-line2
+line2-changed
 line3
-line4
+line4-changed
 line5
";
        let mut selected = HashSet::new();
        // lines indexes in enumerate
        selected.insert(3); // line2-changed
        selected.insert(6); // line4-changed
        let patch = build_lines_patch("file.txt", raw, &selected).expect("patch");
        // Two separate hunks should be present.
        assert!(patch.matches("@@").count() >= 2);
        assert!(patch.contains("+line2-changed"));
        assert!(patch.contains("+line4-changed"));
    }

    #[test]
    fn build_lines_patch_rejects_non_diff_line() {
        let raw = "\
@@ -1,3 +1,3 @@
 line1
-line2
+line2-changed
 line3
";
        let mut selected = HashSet::new();
        // Select a context line index (not recorded as diff line).
        selected.insert(1);
        let err = build_lines_patch("file.txt", raw, &selected).unwrap_err();
        assert!(err.contains("Selected lines are not diff lines"));
    }

    #[test]
    fn build_hunk_patch_contains_hunk_lines() {
        let hunk = DiffHunk {
            header: "@@ -1,1 +1,1 @@".to_string(),
            old_start: 1,
            old_lines: 1,
            new_start: 1,
            new_lines: 1,
            lines: vec![
                DiffLine {
                    kind: DiffLineKind::HunkHeader,
                    content: "@@ -1,1 +1,1 @@".to_string(),
                    old_lineno: None,
                    new_lineno: None,
                },
                DiffLine {
                    kind: DiffLineKind::Delete,
                    content: "-old".to_string(),
                    old_lineno: Some(1),
                    new_lineno: None,
                },
                DiffLine {
                    kind: DiffLineKind::Add,
                    content: "+new".to_string(),
                    old_lineno: None,
                    new_lineno: Some(1),
                },
            ],
        };
        let patch = build_hunk_patch("file.txt", &hunk);
        assert!(patch.contains("diff --git a/file.txt b/file.txt"));
        assert!(patch.contains("@@ -1,1 +1,1 @@"));
        assert!(patch.contains("-old"));
        assert!(patch.contains("+new"));
    }
}