use async_trait::async_trait;
use clap::{CommandFactory, Parser};
use similar::TextDiff;
use std::path::Path;
use crate::ast::Value;
use crate::interpreter::{ExecResult, OutputData};
use crate::tools::{schema_from_clap, ExecContext, ToolCtx, GlobalFlags, Tool, ToolArgs, ToolSchema};
pub struct Diff;
#[derive(Parser, Debug)]
#[command(name = "diff", about = "Compare files line by line")]
struct DiffArgs {
#[arg(id = "unified", short = 'u', long = "unified")]
_unified: bool,
#[arg(short = 'q', long = "quiet")]
quiet: bool,
#[arg(long = "color")]
color: bool,
#[arg(short = 'C', long = "context")]
context: Option<i64>,
#[command(flatten)]
global: GlobalFlags,
files: Vec<String>,
}
#[async_trait]
impl Tool for Diff {
fn name(&self) -> &str {
"diff"
}
fn schema(&self) -> ToolSchema {
schema_from_clap(
&DiffArgs::command(),
"diff",
"Compare files line by line",
[
("Compare two files", "diff file1.txt file2.txt"),
("Quiet mode", "diff -q old.txt new.txt"),
],
)
}
async fn execute(&self, args: ToolArgs, ctx: &mut dyn ToolCtx) -> ExecResult {
let Some(ctx) = ctx.as_any_mut().downcast_mut::<ExecContext>() else {
return ExecResult::failure(1, "internal error: kernel builtin requires ExecContext");
};
let parsed = match DiffArgs::try_parse_from(
std::iter::once("diff".to_string()).chain(args.to_argv()),
) {
Ok(p) => p,
Err(e) => return ExecResult::failure(2, format!("diff: {e}")),
};
parsed.global.apply(ctx);
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 = parsed.quiet;
let colorize = parsed.color;
let context_lines = parsed
.context
.map(|n| n as usize)
.or_else(|| {
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["));
}
}