use anyhow::Result;
use serde_json::Value;
use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use tempfile::TempDir;
mod common;
struct TestRepo {
_temp_dir: TempDir,
repo_path: PathBuf,
}
impl TestRepo {
fn new() -> Result<Self> {
let temp_dir = TempDir::new()?;
let repo_path = temp_dir.path().to_path_buf();
let status = Command::new("git")
.args(["init"])
.current_dir(&repo_path)
.status()?;
assert!(status.success(), "Failed to initialize git repo");
Command::new("git")
.args(["config", "user.name", "Test User"])
.current_dir(&repo_path)
.status()?;
Command::new("git")
.args(["config", "user.email", "test@example.com"])
.current_dir(&repo_path)
.status()?;
Ok(Self {
_temp_dir: temp_dir,
repo_path,
})
}
fn path(&self) -> &Path {
&self.repo_path
}
fn write_file(&self, path: &str, content: &str) -> Result<()> {
let file_path = self.repo_path.join(path);
if let Some(parent) = file_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(file_path, content)?;
Ok(())
}
fn commit(&self, message: &str) -> Result<()> {
Command::new("git")
.args(["add", "."])
.current_dir(&self.repo_path)
.status()?;
let status = Command::new("git")
.args(["commit", "-m", message])
.current_dir(&self.repo_path)
.status()?;
assert!(status.success(), "Failed to commit: {message}");
Ok(())
}
fn current_commit(&self) -> Result<String> {
let output = Command::new("git")
.args(["rev-parse", "HEAD"])
.current_dir(&self.repo_path)
.output()?;
Ok(String::from_utf8(output.stdout)?.trim().to_string())
}
}
fn run_sqry_diff(
repo_path: &Path,
base_ref: &str,
target_ref: &str,
extra_args: &[&str],
) -> Result<String> {
let mut args = vec!["diff", base_ref, target_ref];
args.extend_from_slice(extra_args);
let output = Command::new(common::sqry_bin())
.args(&args)
.current_dir(repo_path)
.output()?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("sqry diff failed: {stderr}");
}
Ok(String::from_utf8(output.stdout)?)
}
fn run_sqry_diff_json(
repo_path: &Path,
base_ref: &str,
target_ref: &str,
extra_args: &[&str],
) -> Result<Value> {
let mut args = vec!["--json"];
args.extend_from_slice(extra_args);
let output = run_sqry_diff(repo_path, base_ref, target_ref, &args)?;
let json: Value = serde_json::from_str(&output)?;
Ok(json)
}
#[test]
fn test_diff_self_compare_short_circuits_to_empty() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn main() {\n println!(\"Hello\");\n}\n")?;
repo.commit("Initial commit")?;
let json = run_sqry_diff_json(repo.path(), "HEAD", "HEAD", &[])?;
let changes = json["changes"].as_array().expect("changes should be array");
assert!(
changes.is_empty(),
"self-diff must return zero changes, got: {changes:?}"
);
assert_eq!(json["summary"]["added"], 0);
assert_eq!(json["summary"]["removed"], 0);
assert_eq!(json["summary"]["modified"], 0);
assert_eq!(json["summary"]["renamed"], 0);
assert_eq!(json["summary"]["signature_changed"], 0);
assert_eq!(json["total"], 0);
assert_eq!(json["truncated"], false);
let head_sha = repo.current_commit()?;
let json2 = run_sqry_diff_json(repo.path(), "HEAD", &head_sha, &[])?;
assert!(
json2["changes"].as_array().unwrap().is_empty(),
"HEAD vs explicit-SHA self-diff must also short-circuit"
);
Ok(())
}
#[test]
fn test_diff_detects_added_function() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial commit")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn main() {\n println!(\"Hello\");\n}\n")?;
repo.commit("Add main function")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
let changes = json["changes"].as_array().unwrap();
assert!(json["summary"]["added"].as_u64().unwrap() >= 1);
let main_change = changes
.iter()
.find(|c| c["name"] == "main" && c["change_type"] == "added")
.expect("Should find added main function");
assert_eq!(main_change["kind"], "function");
let file_path = main_change["target_location"]["file_path"]
.as_str()
.unwrap()
.replace('\\', "/");
assert!(
file_path.ends_with("src/main.rs"),
"Expected path ending with src/main.rs, got: {file_path}"
);
Ok(())
}
#[test]
fn test_diff_detects_removed_function() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Remove foo")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
assert_eq!(json["summary"]["removed"], 1);
let change = &json["changes"][0];
assert_eq!(change["name"], "foo");
assert_eq!(change["change_type"], "removed");
Ok(())
}
#[test]
fn test_diff_detects_modified_function() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {\n println!(\"v1\");\n}\n")?;
repo.commit("Add foo v1")?;
let base = repo.current_commit()?;
repo.write_file(
"src/main.rs",
"fn foo() {\n println!(\"v1\");\n println!(\"v2\");\n}\n",
)?;
repo.commit("Modify foo body")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
assert!(
json["summary"]["modified"].as_u64().unwrap_or(0) >= 1
|| json["summary"]["signature_changed"].as_u64().unwrap_or(0) >= 1,
"Expected at least one modification"
);
Ok(())
}
#[test]
fn test_diff_detects_signature_change() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo()")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo(x: i32) {}\n")?;
repo.commit("Change foo signature")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
let changes = json["changes"].as_array().unwrap();
assert!(!changes.is_empty(), "Expected at least one change");
assert_eq!(json["summary"]["added"].as_u64().unwrap_or(0), 2);
let param_change = changes.iter().find(|c| c["name"] == "x");
assert!(param_change.is_some(), "Should detect added parameter 'x'");
Ok(())
}
#[test]
fn test_diff_multiple_changes() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {}\nfn bar() {}\n")?;
repo.commit("Add foo and bar")?;
let base = repo.current_commit()?;
repo.write_file(
"src/main.rs",
"fn foo() {\n println!(\"modified\");\n}\nfn baz() {}\n",
)?;
repo.commit("Multiple changes")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
let total = json["total"].as_u64().unwrap();
assert!(total >= 2, "Expected at least 2 changes, got {total}");
Ok(())
}
#[test]
fn test_diff_filter_by_kind_function() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\nstruct Bar {}\n")?;
repo.commit("Add function and struct")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &["--kind", "function"])?;
let changes = json["changes"].as_array().unwrap();
for change in changes {
assert_eq!(
change["kind"].as_str().unwrap(),
"function",
"Expected only functions, got: {}",
change["kind"]
);
}
Ok(())
}
#[test]
fn test_diff_filter_by_change_type_added() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let base = repo.current_commit()?;
repo.write_file(
"src/main.rs",
"fn foo() {\n println!(\"modified\");\n}\nfn bar() {}\n",
)?;
repo.commit("Add bar, modify foo")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &["--change-type", "added"])?;
let changes = json["changes"].as_array().unwrap();
assert!(!changes.is_empty(), "Expected at least one addition");
for change in changes {
assert_eq!(
change["change_type"].as_str().unwrap(),
"added",
"Expected only added changes"
);
}
Ok(())
}
#[test]
fn test_diff_filter_combined() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\nstruct Bar {}\n")?;
repo.commit("Add both")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(
repo.path(),
&base,
&target,
&["--kind", "function", "--change-type", "added"],
)?;
let changes = json["changes"].as_array().unwrap();
for change in changes {
assert_eq!(change["kind"].as_str().unwrap(), "function");
assert_eq!(change["change_type"].as_str().unwrap(), "added");
}
Ok(())
}
#[test]
fn test_diff_json_output() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
assert!(json.is_object());
assert!(json["base_ref"].is_string());
assert!(json["target_ref"].is_string());
assert!(json["changes"].is_array());
assert!(json["summary"].is_object());
assert!(json["total"].is_number());
assert!(json["truncated"].is_boolean());
Ok(())
}
#[test]
fn test_diff_text_output_default() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let target = repo.current_commit()?;
let output = run_sqry_diff(repo.path(), &base, &target, &[])?;
assert!(output.contains("Comparing"));
assert!(output.contains("Summary:"));
assert!(output.contains("Added:"));
Ok(())
}
#[test]
fn test_diff_csv_output() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let target = repo.current_commit()?;
let output = run_sqry_diff(repo.path(), &base, &target, &["--csv", "--headers"])?;
let lines: Vec<&str> = output.lines().collect();
assert!(!lines.is_empty());
assert!(lines[0].contains("name"));
assert!(lines[0].contains("qualified_name"));
assert!(lines[0].contains("kind"));
assert!(lines[0].contains("change_type"));
Ok(())
}
#[test]
fn test_diff_limit_results() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
let mut code = String::new();
for i in 0..20 {
#[allow(clippy::format_push_string)] code.push_str(&format!("fn func{i}() {{}}\n"));
}
repo.write_file("src/main.rs", &code)?;
repo.commit("Add 20 functions")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &["--limit", "10"])?;
let changes = json["changes"].as_array().unwrap();
assert_eq!(changes.len(), 10, "Expected exactly 10 results");
assert_eq!(json["truncated"], true, "Expected truncated=true");
assert_eq!(json["total"], 20, "Expected total=20");
Ok(())
}
#[test]
fn test_diff_no_truncation() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\nfn bar() {}\n")?;
repo.commit("Add 2 functions")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &["--limit", "100"])?;
assert_eq!(json["truncated"], false, "Expected truncated=false");
Ok(())
}
#[test]
fn test_diff_invalid_base_ref() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let result = Command::new(common::sqry_bin())
.args(["diff", "invalid-ref-12345", "HEAD"])
.current_dir(repo.path())
.output()?;
assert!(!result.status.success(), "Expected command to fail");
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("invalid") || stderr.contains("not exist") || stderr.contains("ref"),
"Expected error message about invalid ref, got: {stderr}"
);
Ok(())
}
#[test]
fn test_diff_not_a_repo() -> Result<()> {
let temp_dir = TempDir::new()?;
let non_repo_dir = temp_dir.path().join("non-repo");
fs::create_dir(&non_repo_dir)?;
let result = Command::new(common::sqry_bin())
.args(["diff", "main", "HEAD"])
.env("GIT_CEILING_DIRECTORIES", temp_dir.path())
.current_dir(&non_repo_dir)
.output()?;
assert!(!result.status.success(), "Expected command to fail");
let stderr = String::from_utf8_lossy(&result.stderr);
let normalized_stderr = stderr.to_ascii_lowercase();
assert!(
normalized_stderr.contains("not a git repository") || normalized_stderr.contains("not git"),
"Expected 'Not a git repository' error, got: {stderr}"
);
Ok(())
}
#[test]
#[cfg_attr(
target_os = "macos",
ignore = "macOS /var→/private/var symlink causes spurious diff in temp dirs"
)]
#[cfg_attr(
target_os = "windows",
ignore = "Windows temp path normalization causes spurious diff"
)]
fn test_diff_same_ref_twice() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let json = run_sqry_diff_json(repo.path(), "HEAD", "HEAD", &[])?;
assert_eq!(json["total"], 0, "Expected no changes");
assert_eq!(json["summary"]["added"], 0);
assert_eq!(json["summary"]["removed"], 0);
Ok(())
}
#[test]
fn test_diff_javascript_code() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.js", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file(
"src/main.js",
"function hello() {\n console.log('Hello');\n}\n",
)?;
repo.commit("Add hello")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
let changes = json["changes"].as_array().unwrap();
assert!(json["summary"]["added"].as_u64().unwrap() >= 1);
let hello_change = changes
.iter()
.find(|c| c["name"] == "hello" && c["kind"] == "function")
.expect("Should find added hello function");
let file_path = hello_change["target_location"]["file_path"]
.as_str()
.unwrap()
.replace('\\', "/");
assert!(
file_path.ends_with("src/main.js"),
"Expected path ending with src/main.js, got: {file_path}"
);
Ok(())
}
#[test]
fn test_diff_python_code() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.py", "# empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.py", "def hello():\n print('Hello')\n")?;
repo.commit("Add hello")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
let changes = json["changes"].as_array().unwrap();
assert!(json["summary"]["added"].as_u64().unwrap() >= 1);
let hello_change = changes
.iter()
.find(|c| c["name"] == "hello" && c["kind"] == "function")
.expect("Should find added hello function");
let file_path = hello_change["target_location"]["file_path"]
.as_str()
.unwrap()
.replace('\\', "/");
assert!(
file_path.ends_with("src/main.py"),
"Expected path ending with src/main.py, got: {file_path}"
);
Ok(())
}
#[test]
fn test_diff_mixed_languages() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.write_file("src/main.js", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn rust_func() {}\n")?;
repo.write_file("src/main.js", "function jsFunc() {}\n")?;
repo.commit("Add functions")?;
let target = repo.current_commit()?;
let json = run_sqry_diff_json(repo.path(), &base, &target, &[])?;
assert_eq!(json["summary"]["added"], 2, "Expected 2 added functions");
Ok(())
}
#[test]
fn test_diff_csv_columns_filtering() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo(x: i32) -> i32 {\n x + 1\n}\n")?;
repo.commit("Add foo")?;
let target = repo.current_commit()?;
let output = run_sqry_diff(
repo.path(),
&base,
&target,
&["--csv", "--headers", "--columns", "name,kind,file"],
)?;
let lines: Vec<&str> = output.lines().collect();
assert!(!lines.is_empty());
let header = lines[0];
assert!(header.contains("name"), "Should contain 'name' column");
assert!(header.contains("kind"), "Should contain 'kind' column");
assert!(header.contains("file"), "Should contain 'file' column");
assert!(
!header.contains("change_type"),
"Should NOT contain 'change_type' column"
);
assert!(
!header.contains("signature_before"),
"Should NOT contain 'signature_before' column"
);
assert!(
!header.contains("signature_after"),
"Should NOT contain 'signature_after' column"
);
assert!(
!header.contains("qualified_name"),
"Should NOT contain 'qualified_name' column"
);
if lines.len() > 1 {
let data_line = lines[1];
let field_count = data_line.split(',').count();
assert_eq!(field_count, 3, "Data row should have exactly 3 fields");
}
Ok(())
}
#[test]
fn test_diff_csv_unsupported_columns_error() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let target = repo.current_commit()?;
let result = Command::new(common::sqry_bin())
.args([
"diff",
&base,
&target,
"--csv",
"--columns",
"column,end_line",
])
.current_dir(repo.path())
.output()?;
assert!(
!result.status.success(),
"Expected command to fail with unsupported columns"
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("No supported columns") || stderr.contains("Unsupported"),
"Expected error about unsupported columns, got: {stderr}"
);
assert!(
stderr.contains("name") && stderr.contains("kind"),
"Error should list supported columns, got: {stderr}"
);
Ok(())
}
#[test]
fn test_diff_csv_partial_unsupported_columns_warning() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file("src/main.rs", "fn foo() {}\n")?;
repo.commit("Add foo")?;
let target = repo.current_commit()?;
let result = Command::new(common::sqry_bin())
.args([
"diff",
&base,
&target,
"--csv",
"--headers",
"--columns",
"name,kind,column,end_line",
])
.current_dir(repo.path())
.output()?;
assert!(
result.status.success(),
"Command should succeed with partial match"
);
let stderr = String::from_utf8_lossy(&result.stderr);
assert!(
stderr.contains("Warning") && stderr.contains("not supported"),
"Expected warning about unsupported columns, got: {stderr}"
);
let stdout = String::from_utf8_lossy(&result.stdout);
let lines: Vec<&str> = stdout.lines().collect();
assert!(!lines.is_empty());
let header = lines[0];
assert!(header.contains("name"));
assert!(header.contains("kind"));
assert!(!header.contains("column"));
assert!(!header.contains("end_line"));
Ok(())
}
#[test]
fn test_diff_tsv_special_chars_replacement() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file(
"src/main.py",
"def complex_func(\n param1: str,\n param2: int\n) -> bool:\n return True\n",
)?;
repo.commit("Add complex function")?;
let target = repo.current_commit()?;
let output = run_sqry_diff(repo.path(), &base, &target, &["--tsv", "--headers"])?;
let lines: Vec<&str> = output.lines().collect();
assert!(!lines.is_empty());
for (i, line) in lines.iter().enumerate() {
if i > 0 {
assert!(
line.contains('\t'),
"TSV line {i} should contain tabs: {line}"
);
let fields: Vec<&str> = line.split('\t').collect();
assert!(
!fields.is_empty(),
"TSV line should have at least one field"
);
}
}
assert!(
lines.len() < 20,
"Expected reasonable number of lines (header + data), got {}",
lines.len()
);
Ok(())
}
#[test]
fn test_diff_csv_formula_protection() -> Result<()> {
let repo = TestRepo::new()?;
repo.write_file("src/main.rs", "// empty\n")?;
repo.commit("Initial")?;
let base = repo.current_commit()?;
repo.write_file(
"src/main.js",
"const test = {\n '=dangerous': function() { return 1; },\n '+risk': function() { return 2; },\n '-minus': function() { return 3; }\n};\n",
)?;
repo.commit("Add functions with formula chars")?;
let target = repo.current_commit()?;
let output_protected = run_sqry_diff(repo.path(), &base, &target, &["--csv", "--headers"])?;
let lines_protected: Vec<&str> = output_protected.lines().collect();
for line in &lines_protected[1..] {
if !line.is_empty() {
assert!(!line.is_empty(), "Each CSV line should have content");
}
}
let output_raw = run_sqry_diff(
repo.path(),
&base,
&target,
&["--csv", "--headers", "--raw-csv"],
)?;
assert!(!output_protected.is_empty());
assert!(!output_raw.is_empty());
Ok(())
}