loom-diff 0.1.0

Line-level diff for loom. Pure-function unified-diff over byte slices and text strings, used by both the loom CLI and the loom-gateway. Layered above weave-sdk; lower levels untouched.
Documentation
//! git-style unified diff rendering. Takes a [`FileDiff`] and produces
//! the canonical `--- a/path` / `+++ b/path` / `@@ -.. +.. @@` output
//! everyone (and every agent's prior) recognizes.

use crate::structured::{DiffLineKind, FileDiff, FileDiffStatus};

/// Options for [`unified_diff_string`].
#[derive(Debug, Clone)]
pub struct UnifiedDiffOptions {
    /// `a/` prefix used in the `--- a/` header (git convention).
    pub a_prefix: String,
    /// `b/` prefix used in the `+++ b/` header.
    pub b_prefix: String,
    /// Whether to colorize with ANSI escape codes (TTY mode).
    pub color: bool,
}

impl Default for UnifiedDiffOptions {
    fn default() -> Self {
        Self {
            a_prefix: "a/".into(),
            b_prefix: "b/".into(),
            color: false,
        }
    }
}

const ANSI_RESET: &str = "\x1b[0m";
const ANSI_BOLD: &str = "\x1b[1m";
const ANSI_RED: &str = "\x1b[31m";
const ANSI_GREEN: &str = "\x1b[32m";
const ANSI_CYAN: &str = "\x1b[36m";

/// Render a [`FileDiff`] as a unified-diff string. Identical layout to
/// `git diff` so agents and humans both find this reading exactly as
/// they expect. Empty string for unchanged files.
pub fn unified_diff_string(diff: &FileDiff, opts: &UnifiedDiffOptions) -> String {
    if matches!(diff.status, FileDiffStatus::Unchanged) {
        return String::new();
    }

    let mut s = String::new();
    let a_path = diff.a_path.as_deref().unwrap_or("/dev/null");
    let b_path = diff.b_path.as_deref().unwrap_or("/dev/null");

    let header_color = if opts.color { ANSI_BOLD } else { "" };
    let hunk_color = if opts.color { ANSI_CYAN } else { "" };
    let add_color = if opts.color { ANSI_GREEN } else { "" };
    let del_color = if opts.color { ANSI_RED } else { "" };
    let reset = if opts.color { ANSI_RESET } else { "" };

    s.push_str(&format!(
        "{}diff --loom {}{}{}{}\n",
        header_color,
        opts.a_prefix,
        a_path,
        opts.b_prefix.is_empty().then(|| "").unwrap_or(""),
        reset,
    ));

    // Emit canonical --- / +++ headers, with /dev/null on the absent side.
    match diff.status {
        FileDiffStatus::Added => {
            s.push_str(&format!("{}--- /dev/null{}\n", header_color, reset));
            s.push_str(&format!(
                "{}+++ {}{}{}\n",
                header_color, opts.b_prefix, b_path, reset
            ));
        }
        FileDiffStatus::Deleted => {
            s.push_str(&format!(
                "{}--- {}{}{}\n",
                header_color, opts.a_prefix, a_path, reset
            ));
            s.push_str(&format!("{}+++ /dev/null{}\n", header_color, reset));
        }
        FileDiffStatus::Modified => {
            s.push_str(&format!(
                "{}--- {}{}{}\n",
                header_color, opts.a_prefix, a_path, reset
            ));
            s.push_str(&format!(
                "{}+++ {}{}{}\n",
                header_color, opts.b_prefix, b_path, reset
            ));
        }
        FileDiffStatus::Binary { ref reason } => {
            s.push_str(&format!("Binary files differ ({:?})\n", reason));
            return s;
        }
        FileDiffStatus::Unchanged => unreachable!(),
    }

    for hunk in &diff.hunks {
        s.push_str(&format!(
            "{}@@ -{},{} +{},{} @@{}\n",
            hunk_color, hunk.a_start, hunk.a_count, hunk.b_start, hunk.b_count, reset,
        ));
        for line in &hunk.lines {
            let (sigil, color) = match line.kind {
                DiffLineKind::Delete => ('-', del_color),
                DiffLineKind::Insert => ('+', add_color),
                DiffLineKind::Equal => (' ', ""),
            };
            s.push_str(color);
            s.push(sigil);
            s.push_str(&line.content);
            s.push_str(reset);
            s.push('\n');
        }
    }

    s
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::structured::file_diff;

    #[test]
    fn modified_renders_unified_with_hunk_header() {
        let a = "alpha\nbeta\ngamma\n";
        let b = "alpha\nBETA\ngamma\n";
        let d = file_diff(Some(a), Some(b), Some("g.txt"), Some("g.txt"), 1);
        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
        assert!(s.contains("--- a/g.txt"));
        assert!(s.contains("+++ b/g.txt"));
        assert!(s.contains("@@ -"));
        assert!(s.contains("-beta"));
        assert!(s.contains("+BETA"));
    }

    #[test]
    fn added_renders_dev_null_on_a_side() {
        let d = file_diff(None, Some("hi\n"), None, Some("new.txt"), 3);
        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
        assert!(s.contains("--- /dev/null"));
        assert!(s.contains("+++ b/new.txt"));
        assert!(s.contains("+hi"));
    }

    #[test]
    fn deleted_renders_dev_null_on_b_side() {
        let d = file_diff(Some("bye\n"), None, Some("gone.txt"), None, 3);
        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
        assert!(s.contains("--- a/gone.txt"));
        assert!(s.contains("+++ /dev/null"));
        assert!(s.contains("-bye"));
    }

    #[test]
    fn unchanged_renders_empty() {
        let d = file_diff(Some("same\n"), Some("same\n"), Some("p"), Some("p"), 3);
        let s = unified_diff_string(&d, &UnifiedDiffOptions::default());
        assert!(s.is_empty());
    }
}