use std::io::Write;
use std::process::{Command, Stdio};
use ansi_to_tui::IntoText;
use ratatui::text::Text;
pub fn try_render_with_delta(content: &str, is_dark: bool, width: u16) -> Option<Text<'static>> {
let theme_flag = if is_dark { "--dark" } else { "--light" };
let width_arg = format!("--width={width}");
let mut child = Command::new("delta")
.args([
"--no-gitconfig",
"--pager=never",
"--line-numbers",
"--navigate=never",
theme_flag,
&width_arg,
])
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::null())
.spawn()
.ok()?;
child.stdin.take()?.write_all(content.as_bytes()).ok()?;
let output = child.wait_with_output().ok()?;
if !output.status.success() {
return None;
}
output.stdout.into_text().ok()
}
#[derive(Debug, Clone, PartialEq)]
pub enum DiffKind {
Context,
Added,
Removed,
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub line_no: Option<usize>,
pub content: String,
pub kind: DiffKind,
}
impl DiffLine {
fn empty(kind: DiffKind) -> Self {
Self {
line_no: None,
content: String::new(),
kind,
}
}
fn new(line_no: usize, content: &str, kind: DiffKind) -> Self {
Self {
line_no: Some(line_no),
content: content.to_string(),
kind,
}
}
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub old_start: usize,
pub new_start: usize,
pub lines: Vec<(DiffLine, DiffLine)>,
}
#[derive(Debug, Clone)]
pub struct FileDiff {
pub path: String,
pub old_content: String,
pub new_content: String,
pub hunks: Vec<DiffHunk>,
}
impl FileDiff {
pub fn from_strings(path: &str, old: &str, new: &str) -> Self {
let hunks = compute_diff(old, new);
Self {
path: path.to_string(),
old_content: old.to_string(),
new_content: new.to_string(),
hunks,
}
}
pub fn recompute(&self) -> Self {
Self::from_strings(&self.path, &self.old_content, &self.new_content)
}
}
#[derive(Debug, Clone, PartialEq)]
enum EditOp {
Equal,
Insert,
Delete,
}
pub fn compute_diff(old: &str, new: &str) -> Vec<DiffHunk> {
let old_lines: Vec<&str> = if old.is_empty() {
Vec::new()
} else {
old.lines().collect()
};
let new_lines: Vec<&str> = if new.is_empty() {
Vec::new()
} else {
new.lines().collect()
};
let ops = lcs_diff(&old_lines, &new_lines);
if ops.iter().all(|op| *op == EditOp::Equal) {
return Vec::new();
}
struct AnnotatedLine {
old_no: Option<usize>,
new_no: Option<usize>,
old_text: String,
new_text: String,
changed: bool,
}
let mut annotated: Vec<AnnotatedLine> = Vec::new();
let mut oi = 0usize;
let mut ni = 0usize;
for op in &ops {
match op {
EditOp::Equal => {
annotated.push(AnnotatedLine {
old_no: Some(oi + 1),
new_no: Some(ni + 1),
old_text: old_lines[oi].to_string(),
new_text: new_lines[ni].to_string(),
changed: false,
});
oi += 1;
ni += 1;
}
EditOp::Delete => {
annotated.push(AnnotatedLine {
old_no: Some(oi + 1),
new_no: None,
old_text: old_lines[oi].to_string(),
new_text: String::new(),
changed: true,
});
oi += 1;
}
EditOp::Insert => {
annotated.push(AnnotatedLine {
old_no: None,
new_no: Some(ni + 1),
old_text: String::new(),
new_text: new_lines[ni].to_string(),
changed: true,
});
ni += 1;
}
}
}
const CONTEXT_LINES: usize = 3;
let mut hunks: Vec<DiffHunk> = Vec::new();
let mut change_indices: Vec<usize> = Vec::new();
for (i, ann) in annotated.iter().enumerate() {
if ann.changed {
change_indices.push(i);
}
}
if change_indices.is_empty() {
return Vec::new();
}
let mut ranges: Vec<(usize, usize)> = Vec::new();
let mut start = change_indices[0].saturating_sub(CONTEXT_LINES);
let mut end = (change_indices[0] + CONTEXT_LINES).min(annotated.len().saturating_sub(1));
for &idx in &change_indices[1..] {
let new_start = idx.saturating_sub(CONTEXT_LINES);
let new_end = (idx + CONTEXT_LINES).min(annotated.len().saturating_sub(1));
if new_start <= end + 1 {
end = new_end;
} else {
ranges.push((start, end));
start = new_start;
end = new_end;
}
}
ranges.push((start, end));
for (range_start, range_end) in ranges {
let mut lines: Vec<(DiffLine, DiffLine)> = Vec::new();
let old_start = annotated[range_start].old_no.unwrap_or_else(|| {
for i in (0..range_start).rev() {
if let Some(n) = annotated[i].old_no {
return n + 1;
}
}
1
});
let new_start = annotated[range_start].new_no.unwrap_or_else(|| {
for i in (0..range_start).rev() {
if let Some(n) = annotated[i].new_no {
return n + 1;
}
}
1
});
for ann in &annotated[range_start..=range_end] {
if ann.changed {
if let (Some(old_no), None) = (ann.old_no, ann.new_no) {
lines.push((
DiffLine::new(old_no, &ann.old_text, DiffKind::Removed),
DiffLine::empty(DiffKind::Removed),
));
} else if let (None, Some(new_no)) = (ann.old_no, ann.new_no) {
lines.push((
DiffLine::empty(DiffKind::Added),
DiffLine::new(new_no, &ann.new_text, DiffKind::Added),
));
}
} else if let (Some(old_no), Some(new_no)) = (ann.old_no, ann.new_no) {
lines.push((
DiffLine::new(old_no, &ann.old_text, DiffKind::Context),
DiffLine::new(new_no, &ann.new_text, DiffKind::Context),
));
}
}
hunks.push(DiffHunk {
old_start,
new_start,
lines,
});
}
hunks
}
fn lcs_diff<'a>(old: &[&'a str], new: &[&'a str]) -> Vec<EditOp> {
let m = old.len();
let n = new.len();
let mut table = vec![vec![0u32; n + 1]; m + 1];
for i in 1..=m {
for j in 1..=n {
if old[i - 1] == new[j - 1] {
table[i][j] = table[i - 1][j - 1] + 1;
} else {
table[i][j] = table[i - 1][j].max(table[i][j - 1]);
}
}
}
let mut ops = Vec::new();
let mut i = m;
let mut j = n;
while i > 0 || j > 0 {
if i > 0 && j > 0 && old[i - 1] == new[j - 1] {
ops.push(EditOp::Equal);
i -= 1;
j -= 1;
} else if j > 0 && (i == 0 || table[i][j - 1] >= table[i - 1][j]) {
ops.push(EditOp::Insert);
j -= 1;
} else {
ops.push(EditOp::Delete);
i -= 1;
}
}
ops.reverse();
ops
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_compute_diff_identical() {
let text = "line1\nline2\nline3\n";
let hunks = compute_diff(text, text);
assert!(hunks.is_empty(), "identical text should produce no hunks");
}
#[test]
fn test_compute_diff_added_lines() {
let old = "line1\nline2\n";
let new = "line1\nline2\nline3\nline4\n";
let hunks = compute_diff(old, new);
assert!(!hunks.is_empty(), "should have at least one hunk");
let has_added = hunks.iter().any(|h| {
h.lines
.iter()
.any(|(_, right)| right.kind == DiffKind::Added)
});
assert!(has_added, "should contain added lines");
}
#[test]
fn test_compute_diff_removed_lines() {
let old = "line1\nline2\nline3\nline4\n";
let new = "line1\nline2\n";
let hunks = compute_diff(old, new);
assert!(!hunks.is_empty(), "should have at least one hunk");
let has_removed = hunks.iter().any(|h| {
h.lines
.iter()
.any(|(left, _)| left.kind == DiffKind::Removed)
});
assert!(has_removed, "should contain removed lines");
}
#[test]
fn test_compute_diff_mixed_changes() {
let old = "aaa\nbbb\nccc\nddd\n";
let new = "aaa\nBBB\nccc\neee\n";
let hunks = compute_diff(old, new);
assert!(!hunks.is_empty());
let has_removed = hunks.iter().any(|h| {
h.lines
.iter()
.any(|(left, _)| left.kind == DiffKind::Removed)
});
let has_added = hunks.iter().any(|h| {
h.lines
.iter()
.any(|(_, right)| right.kind == DiffKind::Added)
});
assert!(has_removed, "should have removed lines");
assert!(has_added, "should have added lines");
}
#[test]
fn test_compute_diff_empty_to_content() {
let old = "";
let new = "line1\nline2\nline3\n";
let hunks = compute_diff(old, new);
assert!(
!hunks.is_empty(),
"adding content to empty should produce hunks"
);
let added_count: usize = hunks
.iter()
.map(|h| {
h.lines
.iter()
.filter(|(_, r)| r.kind == DiffKind::Added)
.count()
})
.sum();
assert_eq!(added_count, 3, "should have 3 added lines");
}
#[test]
fn test_file_diff_from_strings() {
let diff = FileDiff::from_strings("test.rs", "old\n", "new\n");
assert_eq!(diff.path, "test.rs");
assert_eq!(diff.old_content, "old\n");
assert_eq!(diff.new_content, "new\n");
assert!(!diff.hunks.is_empty(), "should compute hunks");
}
#[test]
fn test_diff_hunk_context_lines() {
let mut old_lines: Vec<String> = Vec::new();
let mut new_lines: Vec<String> = Vec::new();
for i in 0..20 {
old_lines.push(format!("line{i}"));
new_lines.push(format!("line{i}"));
}
old_lines[10] = "OLD_LINE_10".to_string();
new_lines[10] = "NEW_LINE_10".to_string();
let old = old_lines.join("\n");
let new = new_lines.join("\n");
let hunks = compute_diff(&old, &new);
assert_eq!(hunks.len(), 1, "should be exactly one hunk");
let hunk = &hunks[0];
let context_before: usize = hunk
.lines
.iter()
.take_while(|(left, _)| left.kind == DiffKind::Context)
.count();
assert!(
context_before <= 3,
"should have at most 3 context lines before the change, got {context_before}"
);
let context_after: usize = hunk
.lines
.iter()
.rev()
.take_while(|(left, _)| left.kind == DiffKind::Context)
.count();
assert!(
context_after <= 3,
"should have at most 3 context lines after the change, got {context_after}"
);
}
}