use serde_json::Value;
use super::{truncate, LIGHT_CYAN, LIGHT_GREEN, LIGHT_GREY, LIGHT_RED, RESET};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DiffOp {
Context,
Remove,
Add,
}
impl DiffOp {
pub fn as_str(self) -> &'static str {
match self {
DiffOp::Context => "context",
DiffOp::Remove => "remove",
DiffOp::Add => "add",
}
}
}
#[derive(Debug, Clone)]
pub struct DiffLine {
pub op: DiffOp,
pub old_line: Option<usize>,
pub new_line: Option<usize>,
pub content: String,
}
#[derive(Debug, Clone)]
pub struct DiffHunk {
pub old_start: usize,
pub new_start: usize,
pub lines: Vec<DiffLine>,
}
#[derive(Debug, Clone)]
pub struct DiffResult {
pub file_path: String,
pub additions: usize,
pub deletions: usize,
pub hunks: Vec<DiffHunk>,
}
impl DiffResult {
pub fn has_changes(&self) -> bool {
!self.hunks.is_empty()
}
pub fn to_json(&self) -> Value {
let hunk_json: Vec<Value> = self
.hunks
.iter()
.map(|h| {
let line_json: Vec<Value> = h
.lines
.iter()
.map(|l| {
serde_json::json!({
"op": l.op.as_str(),
"old_line": l.old_line,
"new_line": l.new_line,
"content": l.content,
})
})
.collect();
serde_json::json!({
"old_start": h.old_start,
"new_start": h.new_start,
"lines": line_json,
})
})
.collect();
serde_json::json!({
"file_path": self.file_path,
"additions": self.additions,
"deletions": self.deletions,
"hunks": hunk_json,
})
}
}
pub fn compute_diff(original: &str, modified: &str, file_path: &str) -> DiffResult {
let mut result = DiffResult {
file_path: file_path.to_string(),
additions: 0,
deletions: 0,
hunks: Vec::new(),
};
if original == modified {
return result;
}
let patch = diffy::create_patch(original, modified);
for hunk in patch.hunks() {
let mut lines = Vec::with_capacity(hunk.lines().len());
let mut old_line = hunk.old_range().start();
let mut new_line = hunk.new_range().start();
for line in hunk.lines() {
let strip = |s: &str| s.trim_end_matches('\n').trim_end_matches('\r').to_string();
match line {
diffy::Line::Context(s) => {
lines.push(DiffLine {
op: DiffOp::Context,
old_line: Some(old_line),
new_line: Some(new_line),
content: strip(s),
});
old_line += 1;
new_line += 1;
}
diffy::Line::Insert(s) => {
lines.push(DiffLine {
op: DiffOp::Add,
old_line: None,
new_line: Some(new_line),
content: strip(s),
});
result.additions += 1;
new_line += 1;
}
diffy::Line::Delete(s) => {
lines.push(DiffLine {
op: DiffOp::Remove,
old_line: Some(old_line),
new_line: None,
content: strip(s),
});
result.deletions += 1;
old_line += 1;
}
}
}
result.hunks.push(DiffHunk {
old_start: hunk.old_range().start(),
new_start: hunk.new_range().start(),
lines,
});
}
result
}
pub fn render_unified_diff(diff: &DiffResult, color: bool) -> String {
let mut out = String::new();
if !diff.has_changes() {
out.push_str(&format!(
"{}--- a/{}\n+++ b/{}{}\n",
if color { LIGHT_GREY } else { "" },
diff.file_path,
diff.file_path,
if color { RESET } else { "" },
));
out.push_str("(no changes)\n");
return out;
}
out.push_str(&format!(
"{}--- a/{}\n+++ b/{}{}\n",
if color { LIGHT_GREY } else { "" },
diff.file_path,
diff.file_path,
if color { RESET } else { "" },
));
for hunk in &diff.hunks {
out.push_str(&format!(
"{}@@ -{},{} +{},{} @@{}\n",
if color { LIGHT_CYAN } else { "" },
hunk.old_start,
hunk.lines.iter().filter(|l| l.op != DiffOp::Add).count(),
hunk.new_start,
hunk.lines.iter().filter(|l| l.op != DiffOp::Remove).count(),
if color { RESET } else { "" },
));
for line in &hunk.lines {
out.push_str(&unified_line(line, color));
}
}
out
}
fn unified_line(line: &DiffLine, color: bool) -> String {
let content = &line.content;
match line.op {
DiffOp::Context => format!(
" {}{}{}\n",
if color { LIGHT_GREY } else { "" },
content,
if color { RESET } else { "" }
),
DiffOp::Add => format!(
"+{}{}{}\n",
if color { LIGHT_GREEN } else { "" },
content,
if color { RESET } else { "" }
),
DiffOp::Remove => format!(
"-{}{}{}\n",
if color { LIGHT_RED } else { "" },
content,
if color { RESET } else { "" }
),
}
}
pub fn render_split_diff(diff: &DiffResult, color: bool, width: Option<usize>) -> String {
let mut out = String::new();
if !diff.has_changes() {
out.push_str(&format!(
"{}── {} (no changes) ──{}\n",
if color { LIGHT_CYAN } else { "" },
diff.file_path,
if color { RESET } else { "" },
));
return out;
}
let summary = format!(
"{} +{} -{}",
diff.file_path, diff.additions, diff.deletions
);
out.push_str(&format!(
"{}── {} ──{}\n",
if color { LIGHT_CYAN } else { "" },
summary,
if color { RESET } else { "" },
));
let gutter = 4usize; let half = match width {
Some(w) => w.saturating_sub(gutter * 2 + 10) / 2,
None => 60,
};
if width.is_some() && half < 8 {
return render_unified_diff(diff, color);
}
for (hi, hunk) in diff.hunks.iter().enumerate() {
if hi > 0 {
out.push('\n');
}
out.push_str(&format!(
"{}@@ -{},{} +{},{} @@{}\n",
if color { LIGHT_CYAN } else { "" },
hunk.old_start,
hunk.lines.iter().filter(|l| l.op != DiffOp::Add).count(),
hunk.new_start,
hunk.lines.iter().filter(|l| l.op != DiffOp::Remove).count(),
if color { RESET } else { "" },
));
out.push_str(&render_hunk_split(hunk, &RowLayout { gutter, half, color }));
}
out
}
fn render_hunk_split(hunk: &DiffHunk, layout: &RowLayout) -> String {
let mut out = String::new();
let mut old_line = hunk.old_start;
let mut new_line = hunk.new_start;
let mut i = 0;
while i < hunk.lines.len() {
let line = &hunk.lines[i];
match line.op {
DiffOp::Context => {
out.push_str(&split_row(
&SplitRow {
old_line: Some(old_line),
old_content: &line.content,
new_line: Some(new_line),
new_content: &line.content,
left_marker: " ",
right_marker: " ",
},
layout,
));
old_line += 1;
new_line += 1;
i += 1;
}
DiffOp::Remove => {
let mut removes: Vec<&DiffLine> = vec![line];
let mut j = i + 1;
while j < hunk.lines.len() && hunk.lines[j].op == DiffOp::Remove {
removes.push(&hunk.lines[j]);
j += 1;
}
let mut adds: Vec<&DiffLine> = Vec::new();
while j < hunk.lines.len() && hunk.lines[j].op == DiffOp::Add {
adds.push(&hunk.lines[j]);
j += 1;
}
let consumed = removes.len() + adds.len();
let max = removes.len().max(adds.len());
for k in 0..max {
let rem = removes.get(k);
let add = adds.get(k);
let row = match (rem, add) {
(Some(r), Some(a)) => SplitRow {
old_line: Some(old_line + k),
old_content: r.content.as_str(),
new_line: Some(new_line + k),
new_content: a.content.as_str(),
left_marker: "-",
right_marker: "+",
},
(Some(r), None) => SplitRow {
old_line: Some(old_line + k),
old_content: r.content.as_str(),
new_line: None,
new_content: "",
left_marker: "-",
right_marker: " ",
},
(None, Some(a)) => SplitRow {
old_line: None,
old_content: "",
new_line: Some(new_line + k),
new_content: a.content.as_str(),
left_marker: " ",
right_marker: "+",
},
(None, None) => unreachable!(),
};
out.push_str(&split_row(&row, layout));
}
old_line += removes.len();
new_line += adds.len();
i += consumed;
}
DiffOp::Add => {
out.push_str(&split_row(
&SplitRow {
old_line: None,
old_content: "",
new_line: Some(new_line),
new_content: &line.content,
left_marker: " ",
right_marker: "+",
},
layout,
));
new_line += 1;
i += 1;
}
}
}
out
}
#[derive(Clone, Copy)]
struct RowLayout {
gutter: usize,
half: usize,
color: bool,
}
struct SplitRow<'a> {
old_line: Option<usize>,
old_content: &'a str,
new_line: Option<usize>,
new_content: &'a str,
left_marker: &'a str,
right_marker: &'a str,
}
fn split_row(row: &SplitRow<'_>, layout: &RowLayout) -> String {
let RowLayout { gutter, half, color } = *layout;
let SplitRow {
old_line,
old_content,
new_line,
new_content,
left_marker,
right_marker,
} = row;
let ol_str = old_line
.map(|n| format!("{:>width$}", n, width = gutter))
.unwrap_or_else(|| " ".repeat(gutter));
let nl_str = new_line
.map(|n| format!("{:>width$}", n, width = gutter))
.unwrap_or_else(|| " ".repeat(gutter));
let left_paint = if color && *left_marker == "-" {
LIGHT_RED
} else if color && *left_marker == "+" {
LIGHT_GREEN
} else if color {
LIGHT_GREY
} else {
""
};
let right_paint = if color && *right_marker == "+" {
LIGHT_GREEN
} else if color {
LIGHT_GREY
} else {
""
};
let reset = if color { RESET } else { "" };
let left = truncate(old_content, half);
let right = truncate(new_content, half);
format!(
" {ol_str} {left_marker} {left_paint}{left:half$}{reset} │ {nl_str} {right_marker} {right_paint}{right:half$}{reset}\n",
ol_str = ol_str,
left_marker = left_marker,
left_paint = left_paint,
left = left,
reset = reset,
nl_str = nl_str,
right_marker = right_marker,
right_paint = right_paint,
right = right,
half = half,
)
}
#[derive(Debug)]
enum DiffPayload<'a> {
Embedded {
file: &'a str,
src: &'a Value,
hunks: &'a [Value],
},
Wrapped {
file: &'a str,
src: &'a Value,
hunks: &'a [Value],
},
List { entries: &'a [Value] },
PreRendered(String),
EchoText(String),
}
fn classify_diff_payload(data: &Value) -> Option<DiffPayload<'_>> {
if let (Some(file), Some(hunks)) = (
data.get("file_path").and_then(|v| v.as_str()),
data.get("hunks").and_then(|v| v.as_array()),
) {
return Some(DiffPayload::Embedded { file, src: data, hunks });
}
if let Some(inner) = data.get("diff") {
if let (Some(file), Some(hunks)) = (
inner.get("file_path").and_then(|v| v.as_str()),
inner.get("hunks").and_then(|v| v.as_array()),
) {
return Some(DiffPayload::Wrapped { file, src: inner, hunks });
}
if let Some(s) = inner.as_str() {
return Some(DiffPayload::PreRendered(s.to_string()));
}
}
if let Some(arr) = data.get("diffs").and_then(|v| v.as_array()) {
if !arr.is_empty() {
return Some(DiffPayload::List { entries: arr });
}
}
if let Some(s) = data.get("diff_text").and_then(|v| v.as_str()) {
return Some(DiffPayload::EchoText(s.to_string()));
}
None
}
pub(super) fn render_diff_value(data: &Value, color: bool) -> String {
match classify_diff_payload(data) {
Some(DiffPayload::Embedded { file, src, hunks }) => {
render_one_diff(file, src, hunks, color)
}
Some(DiffPayload::Wrapped { file, src, hunks }) => {
render_one_diff(file, src, hunks, color)
}
Some(DiffPayload::List { entries }) => {
let mut rendered: Vec<String> = Vec::with_capacity(entries.len());
for d in entries {
if let (Some(file), Some(inner)) = (
d.get("file").and_then(|v| v.as_str()),
d.get("diff"),
) {
if let Some(hunks) = inner.get("hunks").and_then(|v| v.as_array()) {
rendered.push(render_one_diff(file, inner, hunks, color));
} else if let Some(s) = inner.as_str() {
rendered.push(format!(
"{}── {} ──{}\n{}\n",
if color { LIGHT_CYAN } else { "" },
file,
if color { RESET } else { "" },
s,
));
} else {
rendered.push(format!(
"{}── {} ──{}\n",
if color { LIGHT_CYAN } else { "" },
file,
if color { RESET } else { "" },
));
}
}
}
rendered.join("\n")
}
Some(DiffPayload::PreRendered(s)) | Some(DiffPayload::EchoText(s)) => s,
None => String::new(),
}
}
fn render_one_diff(file: &str, src: &Value, hunks: &[Value], color: bool) -> String {
let additions = src.get("additions").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let deletions = src.get("deletions").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let mut diff = DiffResult {
file_path: file.to_string(),
additions,
deletions,
hunks: Vec::new(),
};
for h in hunks {
let old_start = h.get("old_start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let new_start = h.get("new_start").and_then(|v| v.as_u64()).unwrap_or(0) as usize;
let mut lines = Vec::new();
if let Some(ls) = h.get("lines").and_then(|v| v.as_array()) {
for l in ls {
let op = match l.get("op").and_then(|v| v.as_str()).unwrap_or("context") {
"add" => DiffOp::Add,
"remove" => DiffOp::Remove,
_ => DiffOp::Context,
};
lines.push(DiffLine {
op,
old_line: l.get("old_line").and_then(|v| v.as_u64()).map(|n| n as usize),
new_line: l.get("new_line").and_then(|v| v.as_u64()).map(|n| n as usize),
content: l
.get("content")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string(),
});
}
}
diff.hunks.push(DiffHunk {
old_start,
new_start,
lines,
});
}
render_split_diff(&diff, color, None)
}
pub(super) fn render_default(data: &Value, _color: bool) -> String {
serde_json::to_string_pretty(data).unwrap_or_else(|_| "<unprintable>".to_string())
}
pub struct DiffFormatter {
color: bool,
}
impl DiffFormatter {
pub fn new() -> Self {
Self { color: true }
}
pub fn with_color(mut self, color: bool) -> Self {
self.color = color;
self
}
pub fn format(&self, original: &str, modified: &str, file_path: &str) -> String {
let diff = compute_diff(original, modified, file_path);
render_split_diff(&diff, self.color, None)
}
}
impl Default for DiffFormatter {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
fn sample_diff() -> DiffResult {
DiffResult {
file_path: "src/foo.rs".to_string(),
additions: 1,
deletions: 1,
hunks: vec![DiffHunk {
old_start: 1,
new_start: 1,
lines: vec![
DiffLine {
op: DiffOp::Context,
old_line: Some(1),
new_line: Some(1),
content: "fn main() {".to_string(),
},
DiffLine {
op: DiffOp::Remove,
old_line: Some(2),
new_line: None,
content: " let x = 1;".to_string(),
},
DiffLine {
op: DiffOp::Add,
old_line: None,
new_line: Some(2),
content: " let x = 2;".to_string(),
},
],
}],
}
}
#[test]
fn test_compute_diff_basic() {
let d = compute_diff("a\nb\nc\n", "a\nB\nc\n", "test.rs");
assert_eq!(d.file_path, "test.rs");
assert_eq!(d.additions, 1);
assert_eq!(d.deletions, 1);
assert!(d.has_changes());
}
#[test]
fn test_compute_diff_no_changes() {
let d = compute_diff("a\nb\n", "a\nb\n", "test.rs");
assert!(!d.has_changes());
assert_eq!(d.additions, 0);
assert_eq!(d.deletions, 0);
}
#[test]
fn test_render_split_diff_has_line_numbers() {
let d = compute_diff("a\nb\nc\n", "a\nB\nc\n", "test.rs");
let s = render_split_diff(&d, false, None);
assert!(s.contains("@@ -1,3 +1,3 @@"), "got: {}", s);
assert!(s.contains("│"), "expected split separator");
assert!(s.contains(" 1 "), "expected old line 1");
assert!(s.contains(" 2 "), "expected old line 2");
}
#[test]
fn test_render_split_diff_strips_trailing_newlines() {
let d = compute_diff("a\nb\n", "a\nB\n", "test.rs");
let s = render_split_diff(&d, false, None);
for line in s.lines() {
assert!(
!line.contains("│\n"),
"row contained a mid-line newline: {:?}",
line
);
}
}
#[test]
fn test_diff_result_to_json() {
let d = sample_diff();
let j = d.to_json();
assert_eq!(j["file_path"], "src/foo.rs");
assert_eq!(j["additions"], 1);
assert_eq!(j["deletions"], 1);
assert_eq!(j["hunks"].as_array().unwrap().len(), 1);
let first_line = &j["hunks"][0]["lines"][0];
assert_eq!(first_line["op"], "context");
assert_eq!(first_line["old_line"], 1);
assert_eq!(first_line["new_line"], 1);
assert_eq!(first_line["content"], "fn main() {");
}
#[test]
fn test_render_diff_value_handles_rename_symbol_diffs_list() {
let data = serde_json::json!({
"diffs": [
{
"file": "src/foo.rs",
"diff": {
"file_path": "src/foo.rs",
"additions": 1,
"deletions": 1,
"hunks": [{
"old_start": 1,
"new_start": 1,
"lines": [
{"op": "context", "old_line": 1, "new_line": 1, "content": "fn main() {"},
{"op": "remove", "old_line": 2, "new_line": null, "content": " let x = 1;"},
{"op": "add", "old_line": null, "new_line": 2, "content": " let x = 2;"},
{"op": "context", "old_line": 3, "new_line": 3, "content": "}"}
]
}]
}
}
],
"old_name": "x",
"new_name": "y"
});
let s = render_diff_value(&data, false);
assert!(s.contains("src/foo.rs"), "expected file path in render, got: {:?}", s);
assert!(s.contains("@@"), "expected hunk header, got: {:?}", s);
assert!(s.contains("let x = 1;"), "expected removed line, got: {:?}", s);
assert!(s.contains("let x = 2;"), "expected added line, got: {:?}", s);
}
#[test]
fn test_render_diff_value_handles_empty_diffs_list() {
let data = serde_json::json!({"diffs": [], "old_name": "x", "new_name": "x"});
let s = render_diff_value(&data, false);
assert!(s.is_empty(), "got: {:?}", s);
}
#[test]
fn test_render_diff_value_passthrough_for_string_diff() {
let data = serde_json::json!({"diff": "--- a\n+++ b\n"});
let s = render_diff_value(&data, false);
assert_eq!(s, "--- a\n+++ b\n");
}
#[test]
fn test_render_diff_value_passthrough_for_diff_text() {
let data = serde_json::json!({"diff_text": "@@ -1 +1 @@\n-old\n+new\n"});
let s = render_diff_value(&data, false);
assert_eq!(s, "@@ -1 +1 @@\n-old\n+new\n");
}
#[test]
fn test_render_split_diff_respects_80_col_width() {
let diff = DiffResult {
file_path: "src/lib.rs".to_string(),
additions: 1,
deletions: 1,
hunks: vec![DiffHunk {
old_start: 1,
new_start: 1,
lines: vec![
DiffLine {
op: DiffOp::Remove,
old_line: Some(1),
new_line: None,
content: "x".repeat(60),
},
DiffLine {
op: DiffOp::Add,
old_line: None,
new_line: Some(1),
content: "y".repeat(60),
},
],
}],
};
let s = render_split_diff(&diff, false, Some(80));
for (i, line) in s.lines().enumerate() {
let stripped = strip_ansi_for_test(line);
let visible = stripped.chars().count();
assert!(
visible <= 80,
"line {} exceeds 80 cols ({} chars): {:?}",
i,
visible,
stripped,
);
}
}
#[test]
fn test_render_split_diff_modified_rows_use_minus_plus_markers() {
let diff = compute_diff("foo\n", "bar\n", "test.rs");
assert_eq!(diff.additions, 1);
assert_eq!(diff.deletions, 1);
let stripped = strip_ansi_for_test(&render_split_diff(&diff, false, None));
assert!(
stripped.contains(" - "),
"modified row must render `-` on the left, not ` `; got: {:?}",
stripped
);
assert!(
stripped.contains(" + "),
"modified row must render `+` on the right, not ` `; got: {:?}",
stripped
);
}
#[test]
fn test_render_split_diff_modified_rows_use_red_and_green_color() {
let diff = compute_diff("foo\n", "bar\n", "test.rs");
let rendered = render_split_diff(&diff, true, None);
assert!(
rendered.contains(LIGHT_RED),
"modified row's left `-` must be wrapped in LIGHT_RED; rendered: {:?}",
rendered
);
assert!(
rendered.contains(LIGHT_GREEN),
"modified row's right `+` must be wrapped in LIGHT_GREEN; rendered: {:?}",
rendered
);
}
#[test]
fn test_render_split_diff_context_rows_keep_blank_marker() {
let original = "alpha\nbeta\ngamma\ndelta\nepsilon\n";
let modified = "alpha\nbeta\nGAMMA\ndelta\nepsilon\n";
let diff = compute_diff(original, modified, "test.rs");
let stripped = strip_ansi_for_test(&render_split_diff(&diff, false, None));
let mut found_blank_row = false;
for line in stripped.lines() {
if !line.contains('│') {
continue;
}
let left = line.split('│').next().unwrap();
if left.len() < 7 {
continue;
}
let gutter_and_marker = &left[0..6];
if gutter_and_marker.starts_with(" ")
&& gutter_and_marker.ends_with(' ')
&& gutter_and_marker[1..5].trim() == "1"
{
let marker = left[6..7].chars().next();
assert_eq!(
marker,
Some(' '),
"context line ` 1 ` must use blank marker, got {:?} in left half {:?}",
marker,
left
);
found_blank_row = true;
break;
}
}
assert!(
found_blank_row,
"expected to find a context row with gutter ` 1 ` and blank marker in: {:?}",
stripped
);
}
#[test]
fn test_render_split_diff_modified_rows_respect_terminal_width() {
let original = "context_before\nold_line\ncontext_after\n";
let modified = "context_before\nnew_line\ncontext_after\n";
let diff = compute_diff(original, modified, "test.rs");
let s = render_split_diff(&diff, false, Some(80));
for (i, line) in s.lines().enumerate() {
let visible = line.chars().count();
assert!(
visible <= 80,
"row {} exceeds 80 cols ({} chars): {:?}",
i,
visible,
line
);
}
}
#[test]
fn test_split_row_right_red_arm_is_dead() {
let cases: Vec<(&str, &str)> = vec![
(" ", " "), ("-", "+"), ("-", " "), (" ", "+"), (" ", "+"), ];
for (left, right) in cases {
let s = render_split_diff(
&compute_diff("a\n", "b\n", "t.rs"),
true,
Some(200),
);
for line in s.lines() {
if !line.contains('│') {
continue;
}
let right_half = line.split('│').nth(1).unwrap();
assert!(
!right_half.contains(LIGHT_RED),
"right half of a row with markers `{}`/`{}` must not contain \
LIGHT_RED (the right-paint `-` arm is dead); got: {:?}",
left,
right,
right_half,
);
}
}
}
#[test]
fn test_render_diff_value_list_no_trailing_newline_on_invalid_entry() {
let data = serde_json::json!({
"diffs": [
{
"file": "valid.rs",
"diff": { "hunks": [] }
},
{
"diff": { "hunks": [] }
}
]
});
let s = render_diff_value(&data, false);
assert!(
!s.ends_with("\n\n"),
"rendered output must not end with consecutive newlines (dangling separator); got: {:?}",
s
);
let max_consec = s
.chars()
.fold((0usize, 0usize), |(max, cur), c| {
let cur = if c == '\n' { cur + 1 } else { 0 };
(max.max(cur), cur)
})
.0;
assert!(
max_consec <= 1,
"rendered output must not contain consecutive `\\n`; max consecutive = {}; output: {:?}",
max_consec,
s
);
}
#[test]
fn test_render_unified_diff_closes_header_color() {
let s = render_unified_diff(&sample_diff(), true);
let plus_plus_idx = s.find("+++ b/src/foo.rs").unwrap();
let after_plus_plus = &s[plus_plus_idx + "+++ b/src/foo.rs".len()..];
assert!(
after_plus_plus.starts_with("\x1b[0m"),
"+++ b/... must be closed by RESET before '\\n', got: {:?}",
&after_plus_plus[..after_plus_plus.len().min(20)],
);
let hunk_idx = s.find("@@ -1,2 +1,2 @@").unwrap();
let after_hunk = &s[hunk_idx + "@@ -1,2 +1,2 @@".len()..];
assert!(
after_hunk.starts_with("\x1b[0m"),
"@@ ... @@ must be closed by RESET before '\\n', got: {:?}",
&after_hunk[..after_hunk.len().min(20)],
);
}
#[test]
fn test_render_unified_diff_no_changes_closes_color() {
let diff = DiffResult {
file_path: "src/empty.rs".to_string(),
additions: 0,
deletions: 0,
hunks: vec![],
};
let s = render_unified_diff(&diff, true);
let idx = s.find("+++ b/src/empty.rs").unwrap();
let after = &s[idx + "+++ b/src/empty.rs".len()..];
assert!(
after.starts_with("\x1b[0m"),
"+++ b/src/empty.rs must be followed by RESET, got: {:?}",
&after[..after.len().min(20)],
);
let no_changes_idx = s.find("(no changes)").unwrap();
assert!(
!s[no_changes_idx..].starts_with("\x1b["),
"(no changes) must not be colorised, got: {:?}",
&s[no_changes_idx..no_changes_idx + 30],
);
}
#[test]
fn test_render_diff_value_list_with_pre_rendered_string() {
let data = serde_json::json!({
"diffs": [
{
"file": "src/foo.rs",
"diff": "@@ -1,1 +1,1 @@\n-old\n+new",
},
{
"file": "src/bar.rs",
"diff": serde_json::json!({"hunks": []}),
},
],
});
let s = render_diff_value(&data, false);
assert!(
s.contains("-old") && s.contains("+new"),
"pre-rendered diff string must be rendered, got: {:?}",
s
);
assert!(
s.contains("src/foo.rs"),
"file header must be rendered, got: {:?}",
s
);
assert!(s.contains("src/bar.rs"));
}
}
#[cfg(test)]
fn strip_ansi_for_test(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut iter = s.chars().peekable();
while let Some(c) = iter.next() {
if c == '\x1b' {
if iter.peek() == Some(&'[') {
iter.next();
for c2 in iter.by_ref() {
if c2.is_ascii_alphabetic() {
break;
}
}
continue;
}
}
out.push(c);
}
out
}