use async_trait::async_trait;
use similar::TextDiff;
use std::path::Path;
use crate::ast::Value;
use crate::interpreter::{ExecResult, OutputData};
use crate::tools::{ExecContext, ParamSchema, Tool, ToolArgs, ToolSchema};
pub struct Diff;
#[async_trait]
impl Tool for Diff {
fn name(&self) -> &str {
"diff"
}
fn schema(&self) -> ToolSchema {
ToolSchema::new("diff", "Compare files line by line")
.param(ParamSchema::required(
"file1",
"string",
"First file to compare",
))
.param(ParamSchema::required(
"file2",
"string",
"Second file to compare",
))
.param(ParamSchema::optional(
"unified",
"bool",
Value::Bool(false),
"Output unified diff format (default)",
).with_aliases(["-u"]))
.param(ParamSchema::optional(
"quiet",
"bool",
Value::Bool(false),
"Quiet mode: only report if files differ",
).with_aliases(["-q"]))
.param(ParamSchema::optional(
"color",
"bool",
Value::Bool(false),
"Colorize output",
).with_aliases(["--color"]))
.param(ParamSchema::optional(
"context",
"int",
Value::Int(3),
"Lines of context (default: 3)",
).with_aliases(["-C"]))
.example("Compare two files", "diff file1.txt file2.txt")
.example("Quiet mode", "diff -q old.txt new.txt")
}
async fn execute(&self, args: ToolArgs, ctx: &mut ExecContext) -> ExecResult {
let file1 = match args.get_string("file1", 0) {
Some(f) => f,
None => return ExecResult::failure(2, "diff: missing first file"),
};
let file2 = match args.get_string("file2", 1) {
Some(f) => f,
None => return ExecResult::failure(2, "diff: missing second file"),
};
let path1 = ctx.resolve_path(&file1);
let path2 = ctx.resolve_path(&file2);
let content1 = match ctx.backend.read(Path::new(&path1), None).await {
Ok(data) => String::from_utf8_lossy(&data).into_owned(),
Err(e) => return ExecResult::failure(2, format!("diff: {}: {}", file1, e)),
};
let content2 = match ctx.backend.read(Path::new(&path2), None).await {
Ok(data) => String::from_utf8_lossy(&data).into_owned(),
Err(e) => return ExecResult::failure(2, format!("diff: {}: {}", file2, e)),
};
let quiet = args.has_flag("q");
let colorize = args.has_flag("color");
let context_lines = args
.get_named("context")
.and_then(|v| match v {
Value::Int(i) => Some(*i as usize),
_ => None,
})
.unwrap_or(3);
if content1 == content2 {
return ExecResult::success("");
}
if quiet {
let text = format!("Files {} and {} differ\n", file1, file2);
let mut result = ExecResult::from_output(1, text.clone(), String::new());
result.set_output(Some(OutputData::text(text)));
return result;
}
let diff = TextDiff::from_lines(&content1, &content2);
let plain = diff
.unified_diff()
.context_radius(context_lines)
.header(&file1, &file2)
.to_string();
let output = if colorize {
colorize_unified_output(&plain)
} else {
plain
};
let mut result = ExecResult::from_output(1, output.clone(), String::new());
result.set_output(Some(OutputData::text(output)));
result
}
}
fn colorize_unified_output(plain: &str) -> String {
let mut output = String::with_capacity(plain.len() + 256);
for line in plain.lines() {
if line.starts_with("---") || line.starts_with("+++") {
output.push_str("\x1b[1m");
output.push_str(line);
output.push_str("\x1b[0m");
} else if line.starts_with("@@") {
output.push_str("\x1b[36m");
output.push_str(line);
output.push_str("\x1b[0m");
} else if line.starts_with('-') {
output.push_str("\x1b[31m");
output.push_str(line);
output.push_str("\x1b[0m");
} else if line.starts_with('+') {
output.push_str("\x1b[32m");
output.push_str(line);
output.push_str("\x1b[0m");
} else {
output.push_str(line);
}
output.push('\n');
}
output
}
#[cfg(test)]
mod tests {
use super::*;
use crate::vfs::{Filesystem, MemoryFs, VfsRouter};
use std::sync::Arc;
async fn make_test_ctx() -> ExecContext {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(Path::new("file1.txt"), b"line1\nline2\nline3\n")
.await
.unwrap();
mem.write(Path::new("file2.txt"), b"line1\nmodified\nline3\n")
.await
.unwrap();
mem.write(Path::new("same1.txt"), b"identical\n")
.await
.unwrap();
mem.write(Path::new("same2.txt"), b"identical\n")
.await
.unwrap();
vfs.mount("/", mem);
ExecContext::new(Arc::new(vfs))
}
#[tokio::test]
async fn test_diff_files_differ() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("file1.txt".into()));
args.positional.push(Value::String("file2.txt".into()));
let result = Diff.execute(args, &mut ctx).await;
assert_eq!(result.code, 1); assert!(result.text_out().contains("---"));
assert!(result.text_out().contains("+++"));
assert!(result.text_out().contains("-line2"));
assert!(result.text_out().contains("+modified"));
}
#[tokio::test]
async fn test_diff_files_identical() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("same1.txt".into()));
args.positional.push(Value::String("same2.txt".into()));
let result = Diff.execute(args, &mut ctx).await;
assert!(result.ok()); assert!(result.text_out().is_empty());
}
#[tokio::test]
async fn test_diff_quiet_mode() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("file1.txt".into()));
args.positional.push(Value::String("file2.txt".into()));
args.flags.insert("q".to_string());
let result = Diff.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.text_out().contains("differ"));
assert!(!result.text_out().contains("---")); }
#[tokio::test]
async fn test_diff_missing_file() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("file1.txt".into()));
args.positional.push(Value::String("nonexistent.txt".into()));
let result = Diff.execute(args, &mut ctx).await;
assert_eq!(result.code, 2);
assert!(result.err.contains("nonexistent.txt"));
}
#[tokio::test]
async fn test_diff_correct_hunk_header() {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(
Path::new("a.txt"),
b"line1\nline2\nline3\nline4\nline5\nline6\n",
)
.await
.unwrap();
mem.write(
Path::new("b.txt"),
b"line1\nline2\nline3\nline4\nchanged5\nline6\n",
)
.await
.unwrap();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let mut args = ToolArgs::new();
args.positional.push(Value::String("a.txt".into()));
args.positional.push(Value::String("b.txt".into()));
let result = Diff.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(
result.text_out().contains("@@ -") && !result.text_out().contains("@@ -1 +1 @@"),
"hunk header should show correct line positions: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_diff_colorized_correct_hunk_header() {
let mut vfs = VfsRouter::new();
let mem = MemoryFs::new();
mem.write(
Path::new("a.txt"),
b"line1\nline2\nline3\nline4\nline5\nline6\n",
)
.await
.unwrap();
mem.write(
Path::new("b.txt"),
b"line1\nline2\nline3\nline4\nchanged5\nline6\n",
)
.await
.unwrap();
vfs.mount("/", mem);
let mut ctx = ExecContext::new(Arc::new(vfs));
let mut args = ToolArgs::new();
args.positional.push(Value::String("a.txt".into()));
args.positional.push(Value::String("b.txt".into()));
args.flags.insert("color".to_string());
let result = Diff.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.text_out().contains("\x1b["), "should have ANSI codes");
assert!(
!result.text_out().contains("@@ -1 +1 @@"),
"should not have hardcoded hunk header: {}",
result.text_out()
);
}
#[tokio::test]
async fn test_diff_colorized() {
let mut ctx = make_test_ctx().await;
let mut args = ToolArgs::new();
args.positional.push(Value::String("file1.txt".into()));
args.positional.push(Value::String("file2.txt".into()));
args.flags.insert("color".to_string());
let result = Diff.execute(args, &mut ctx).await;
assert_eq!(result.code, 1);
assert!(result.text_out().contains("\x1b["));
}
}