diffmark 0.1.0

A terminal UI for reviewing uncommitted git diffs and copying inline comments for AI agents.
use crate::diff_parser::{DiffLine, ParsedDiff};

pub fn describe_target(line: &DiffLine) -> String {
    match (line.old_lineno, line.new_lineno) {
        (Some(old), Some(new)) => format!("context line old:{old} new:{new}"),
        (None, Some(new)) => format!("added line new:{new}"),
        (Some(old), None) => format!("deleted line old:{old}"),
        (None, None) => "diff line".to_string(),
    }
}

pub fn build_review_payload(
    parsed: &ParsedDiff,
    target_lines: &[DiffLine],
    comment: &str,
    max_hunk_lines: usize,
) -> String {
    let Some(first_line) = target_lines.first() else {
        return String::new();
    };
    let file_path = first_line.file_path.as_deref().unwrap_or("(unknown file)");
    let mut output = vec![
        format!("File: {file_path}"),
        format!("My comment at {}", describe_target_lines(target_lines)),
        String::new(),
    ];

    if let Some(mut hunk_lines) = format_hunk_window(parsed, target_lines, comment, max_hunk_lines)
    {
        output.append(&mut hunk_lines);
    } else {
        output.push(target_start_marker(target_lines));
        output.extend(target_lines.iter().map(|line| line.raw.clone()));
        if target_lines.len() > 1 {
            output.push(">> target end".to_string());
        }
        output.extend(format_comment(comment));
    }

    output.push(String::new());
    output.join("\n")
}

fn format_hunk_window(
    parsed: &ParsedDiff,
    target_lines: &[DiffLine],
    comment: &str,
    max_hunk_lines: usize,
) -> Option<Vec<String>> {
    let first_line = target_lines.first()?;
    let last_line = target_lines.last()?;
    let hunk_index = first_line.hunk_index?;
    if !target_lines
        .iter()
        .all(|line| line.hunk_index == Some(hunk_index))
    {
        return None;
    }
    let target_start = first_line.hunk_line_index?;
    let target_end = last_line.hunk_line_index?;
    let (target_start, target_end) = normalized_range(target_start, target_end);
    let hunk = parsed.hunks.get(hunk_index)?;
    let hunk_lines = &hunk.line_indices;
    let max_hunk_lines = max_hunk_lines.max(1);
    let target_line_count = target_end - target_start + 1;
    let window_lines = max_hunk_lines.max(target_line_count);

    let (start, end) = if hunk_lines.len() <= window_lines {
        (0, hunk_lines.len())
    } else {
        let spare = window_lines.saturating_sub(target_line_count);
        let before = spare / 2;
        let mut start = target_start.saturating_sub(before);
        let mut end = (start + window_lines).min(hunk_lines.len());
        if end <= target_end {
            end = (target_end + 1).min(hunk_lines.len());
            start = end.saturating_sub(window_lines);
        }
        end = (start + window_lines).min(hunk_lines.len());
        (start, end)
    };

    let mut output = vec![hunk.header.clone()];
    if start > 0 {
        output.push("...".to_string());
    }

    for local_index in start..end {
        let line_index = *hunk_lines.get(local_index)?;
        let current = parsed.lines.get(line_index)?;
        if local_index == target_start {
            output.push(target_start_marker(target_lines));
        }
        if local_index >= target_start && local_index <= target_end {
            output.push(current.raw.clone());
            if local_index == target_end {
                if target_lines.len() > 1 {
                    output.push(">> target end".to_string());
                }
                output.extend(format_comment(comment));
            }
        } else {
            output.push(current.raw.clone());
        }
    }

    Some(output)
}

fn target_start_marker(target_lines: &[DiffLine]) -> String {
    if target_lines.len() > 1 {
        ">> target start".to_string()
    } else {
        ">> target:".to_string()
    }
}

pub fn describe_target_lines(lines: &[DiffLine]) -> String {
    match lines {
        [] => "line unknown".to_string(),
        [line] => format!("line {}", line_number(line)),
        lines => multi_line_label(lines),
    }
}

fn multi_line_label(lines: &[DiffLine]) -> String {
    let old_span = line_span(lines.iter().filter_map(|line| line.old_lineno));
    let new_span = line_span(lines.iter().filter_map(|line| line.new_lineno));
    let all_context = lines
        .iter()
        .all(|line| line.old_lineno.is_some() && line.new_lineno.is_some());

    match (old_span, new_span) {
        (Some(old), Some(new)) if old == new && all_context => format!("lines {old}"),
        (Some(old), Some(new)) => {
            format!(
                "old {} / new {} ({} diff lines)",
                qualified_line_span(&old),
                qualified_line_span(&new),
                lines.len()
            )
        }
        (Some(old), None) => format!("old {}", qualified_line_span(&old)),
        (None, Some(new)) => format!("new {}", qualified_line_span(&new)),
        (None, None) => format!("{} diff lines", lines.len()),
    }
}

fn line_span(values: impl Iterator<Item = usize>) -> Option<String> {
    let mut values = values;
    let first = values.next()?;
    let last = values.last().unwrap_or(first);
    Some(if first == last {
        first.to_string()
    } else {
        format!("{first}-{last}")
    })
}

fn qualified_line_span(span: &str) -> String {
    if span.contains('-') {
        format!("lines {span}")
    } else {
        format!("line {span}")
    }
}

fn line_number(line: &DiffLine) -> String {
    line.new_lineno
        .or(line.old_lineno)
        .map(|value| value.to_string())
        .unwrap_or_else(|| "unknown".to_string())
}

fn normalized_range(a: usize, b: usize) -> (usize, usize) {
    if a <= b { (a, b) } else { (b, a) }
}

fn format_comment(comment: &str) -> Vec<String> {
    let lines: Vec<&str> = comment.trim_end().lines().collect();
    if lines.len() == 1 {
        return vec![format!(">> comment: {}", lines[0])];
    }

    let mut output = vec![">> comment:".to_string()];
    output.extend(lines.into_iter().map(|line| format!(">> {line}")));
    output
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::diff_parser::{LineKind, parse_unified_diff};

    fn make_diff(line_count: usize) -> String {
        let mut lines = vec![
            "diff --git a/big.txt b/big.txt".to_string(),
            "--- a/big.txt".to_string(),
            "+++ b/big.txt".to_string(),
            format!("@@ -1,{line_count} +1,{line_count} @@"),
        ];
        for index in 1..=line_count {
            let prefix = if index == 10 { '+' } else { ' ' };
            lines.push(format!("{prefix}line {index}"));
        }
        lines.join("\n") + "\n"
    }

    #[test]
    fn marks_selected_line_and_comment_insertion() {
        let parsed = parse_unified_diff(&make_diff(20));
        let target = parsed
            .lines
            .iter()
            .find(|line| line.kind == LineKind::Add && line.raw == "+line 10")
            .unwrap();

        let payload = build_review_payload(
            &parsed,
            std::slice::from_ref(target),
            "Please revisit this.\nSecond line.",
            11,
        );

        assert!(payload.contains("File: big.txt"));
        assert!(payload.contains("My comment at line 10"));
        assert!(payload.contains(">> target:\n+line 10"));
        assert!(payload.contains("+line 10"));
        assert!(payload.contains(">> comment:\n>> Please revisit this.\n>> Second line."));
        assert!(payload.contains("Second line."));
        assert!(!payload.contains("DIFFMARK_SELECTED_LINE"));
        assert!(!payload.contains("```"));
    }

    #[test]
    fn trims_long_hunks_around_target() {
        let parsed = parse_unified_diff(&make_diff(200));
        let target = parsed
            .lines
            .iter()
            .find(|line| line.kind == LineKind::Add && line.raw == "+line 10")
            .unwrap();

        let payload = build_review_payload(&parsed, std::slice::from_ref(target), "Trim this.", 11);

        assert!(payload.contains("+line 10"));
        assert!(payload.contains(">> target:\n+line 10"));
        assert!(payload.contains(">> comment: Trim this."));
        assert!(!payload.contains(" line 200"));
    }

    #[test]
    fn keeps_all_selected_lines_in_multi_line_payload() {
        let parsed = parse_unified_diff(&make_diff(200));
        let targets: Vec<_> = parsed
            .lines
            .iter()
            .filter(|line| {
                line.hunk_line_index
                    .is_some_and(|index| (8..=12).contains(&index))
            })
            .cloned()
            .collect();

        let payload = build_review_payload(&parsed, &targets, "Block comment.", 3);

        assert!(payload.contains("My comment at old lines 9-12 / new lines 9-13 (5 diff lines)"));
        assert!(payload.contains(
            ">> target start\n line 9\n+line 10\n line 11\n line 12\n line 13\n>> target end"
        ));
        assert!(payload.contains(">> comment: Block comment."));
        assert!(!payload.contains(" line 200"));
    }

    #[test]
    fn labels_replace_selection_with_old_and_new_lines() {
        let parsed = parse_unified_diff(concat!(
            "diff --git a/src/app.rs b/src/app.rs\n",
            "--- a/src/app.rs\n",
            "+++ b/src/app.rs\n",
            "@@ -1,3 +1,3 @@\n",
            " context\n",
            "-old\n",
            "+new\n",
            " tail\n",
        ));
        let targets: Vec<_> = parsed
            .lines
            .iter()
            .filter(|line| line.raw == "-old" || line.raw == "+new")
            .cloned()
            .collect();

        let payload = build_review_payload(&parsed, &targets, "Interesting.", 11);

        assert!(
            payload.contains("My comment at old line 2 / new line 2 (2 diff lines)"),
            "{payload}"
        );
        assert!(
            payload
                .contains(">> target start\n-old\n+new\n>> target end\n>> comment: Interesting."),
            "{payload}"
        );
    }
}