use crate::tui::event::DiffData;
use bitrouter_substrate::translate::ToolStatus;
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Line {
Message(String),
Thought(String),
Code { text: String, lang: String },
Tool {
id: String,
title: String,
status: ToolStatus,
},
Diff(DiffLine),
Error(String),
AutoResolved(String),
Note(String),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiffLine {
Header {
path: String,
adds: usize,
dels: usize,
},
Add(String),
Del(String),
Ctx(String),
Gap,
}
const MAX_DIFF_BYTES: usize = 200 * 1024;
pub fn unified_to_lines(text: &str) -> Vec<Line> {
text.lines()
.map(|l| {
if l.starts_with("@@") {
Line::Diff(DiffLine::Gap)
} else if l.starts_with("+++") || l.starts_with("---") || l.starts_with("diff --git") {
Line::Diff(DiffLine::Ctx(l.to_string()))
} else if let Some(rest) = l.strip_prefix('+') {
Line::Diff(DiffLine::Add(rest.to_string()))
} else if let Some(rest) = l.strip_prefix('-') {
Line::Diff(DiffLine::Del(rest.to_string()))
} else {
Line::Diff(DiffLine::Ctx(l.strip_prefix(' ').unwrap_or(l).to_string()))
}
})
.collect()
}
pub fn diff_lines(diff: &DiffData) -> Vec<Line> {
use similar::{ChangeTag, TextDiff};
if diff.old.len() + diff.new.len() > MAX_DIFF_BYTES {
return vec![
Line::Diff(DiffLine::Header {
path: diff.path.clone(),
adds: 0,
dels: 0,
}),
Line::Diff(DiffLine::Ctx("(diff too large to render)".to_string())),
];
}
let text_diff = TextDiff::from_lines(&diff.old, &diff.new);
let (mut adds, mut dels) = (0usize, 0usize);
let mut body: Vec<Line> = Vec::new();
for (i, group) in text_diff.grouped_ops(2).iter().enumerate() {
if i > 0 {
body.push(Line::Diff(DiffLine::Gap));
}
for op in group {
for change in text_diff.iter_changes(op) {
let text = change
.value()
.trim_end_matches('\n')
.trim_end_matches('\r')
.to_string();
body.push(Line::Diff(match change.tag() {
ChangeTag::Insert => {
adds += 1;
DiffLine::Add(text)
}
ChangeTag::Delete => {
dels += 1;
DiffLine::Del(text)
}
ChangeTag::Equal => DiffLine::Ctx(text),
}));
}
}
}
let mut out = vec![Line::Diff(DiffLine::Header {
path: diff.path.clone(),
adds,
dels,
})];
out.extend(body);
out
}
pub fn parse_rendered_diff(s: &str) -> Option<DiffData> {
let (path, rest) = s.split_once("\n[old]\n")?;
let (old, new) = rest.split_once("\n[new]\n")?;
Some(DiffData {
path: path.to_string(),
old: old.to_string(),
new: new.to_string(),
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn diff_lines_render_header_chips_hunks_and_gap() {
let old: String = (0..30).map(|i| format!("l{i}\n")).collect();
let new = old.replace("l3\n", "L3\n").replace("l25\n", "L25\n");
let lines = diff_lines(&DiffData {
path: "src/x.rs".into(),
old,
new,
});
assert_eq!(
lines[0],
Line::Diff(DiffLine::Header {
path: "src/x.rs".into(),
adds: 2,
dels: 2
})
);
assert!(
lines.contains(&Line::Diff(DiffLine::Gap)),
"distant hunks separated by a gap: {lines:?}"
);
assert!(lines.contains(&Line::Diff(DiffLine::Del("l3".into()))));
assert!(lines.contains(&Line::Diff(DiffLine::Add("L3".into()))));
}
#[test]
fn oversized_diff_renders_a_placeholder() {
let lines = diff_lines(&DiffData {
path: "big".into(),
old: "x".repeat(300 * 1024),
new: String::new(),
});
assert_eq!(lines.len(), 2);
assert!(matches!(
&lines[1],
Line::Diff(DiffLine::Ctx(t)) if t.contains("too large")
));
}
#[test]
fn parse_rendered_diff_roundtrips_the_substrate_format() {
let rendered = "src/x.rs\n[old]\na\nb\n[new]\na\nc";
assert_eq!(
parse_rendered_diff(rendered),
Some(DiffData {
path: "src/x.rs".into(),
old: "a\nb".into(),
new: "a\nc".into(),
})
);
assert_eq!(parse_rendered_diff("no markers here"), None);
}
#[test]
fn unified_diff_parses_into_diff_lines() {
let lines = unified_to_lines(
"diff --git a/x b/x\n--- a/x\n+++ b/x\n@@ -1,2 +1,2 @@\n ctx\n-old\n+new",
);
assert!(lines.contains(&Line::Diff(DiffLine::Add("new".into()))));
assert!(lines.contains(&Line::Diff(DiffLine::Del("old".into()))));
assert!(lines.contains(&Line::Diff(DiffLine::Gap)), "@@ → gap");
assert!(lines.contains(&Line::Diff(DiffLine::Ctx("ctx".into()))));
}
}