use crate::structured::{DiffLineKind, FileDiff, FileDiffStatus};
#[derive(Debug, Clone)]
pub struct UnifiedDiffOptions {
pub a_prefix: String,
pub b_prefix: String,
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";
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,
));
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());
}
}