use std::fs;
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use serde_json::Value;
fn temp_dir(name: &str) -> PathBuf {
let unique = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let root = std::env::temp_dir().join(format!("asr-read-diff-test-{name}-{unique}"));
fs::create_dir_all(&root).unwrap();
root
}
fn run_asr(args: &[&str], cwd: &Path, asr_home: &Path) -> std::process::Output {
Command::new(env!("CARGO_BIN_EXE_asr"))
.args(args)
.current_dir(cwd)
.env("ASR_HOME", asr_home)
.output()
.unwrap()
}
fn git(args: &[&str], cwd: &Path) {
let output = Command::new("git")
.args(args)
.current_dir(cwd)
.output()
.unwrap();
assert!(
output.status.success(),
"git {:?} failed\nstdout: {}\nstderr: {}",
args,
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
}
fn stdout_json(output: &std::process::Output) -> Value {
serde_json::from_slice(&output.stdout).unwrap_or_else(|err| {
panic!(
"stdout is not JSON: {err}\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
})
}
fn stderr_json(output: &std::process::Output) -> Value {
serde_json::from_slice(&output.stderr).unwrap_or_else(|err| {
panic!(
"stderr is not JSON: {err}\nstdout: {}\nstderr: {}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
})
}
fn registered_repo() -> (PathBuf, PathBuf, PathBuf) {
let cwd = temp_dir("cwd");
let asr_home = temp_dir("home").join("asr-home");
let repo = temp_dir("repo");
git(&["init", "-q"], &repo);
git(&["config", "user.email", "asr@example.invalid"], &repo);
git(&["config", "user.name", "ASR Test"], &repo);
fs::create_dir_all(repo.join("src")).unwrap();
fs::write(
repo.join("src/retry.rs"),
"pub fn retry_backoff() -> u64 {\n 100\n}\n",
)
.unwrap();
git(&["add", "."], &repo);
git(&["commit", "-q", "-m", "initial"], &repo);
fs::write(
repo.join("src/retry.rs"),
"pub fn retry_backoff() -> u64 {\n 200\n}\n",
)
.unwrap();
git(&["add", "."], &repo);
git(&["commit", "-q", "-m", "change"], &repo);
assert!(run_asr(&["init", "--json"], &cwd, &asr_home)
.status
.success());
assert!(run_asr(
&["repo", "add", "app", repo.to_str().unwrap(), "--json"],
&cwd,
&asr_home,
)
.status
.success());
assert!(
run_asr(&["repo", "index", "app", "--json"], &cwd, &asr_home)
.status
.success()
);
(cwd, asr_home, repo)
}
fn registered_unindexed_repo() -> (PathBuf, PathBuf, PathBuf) {
let cwd = temp_dir("live-cwd");
let asr_home = temp_dir("live-home").join("asr-home");
let repo = temp_dir("live-repo");
git(&["init", "-q"], &repo);
git(&["config", "user.email", "asr@example.invalid"], &repo);
git(&["config", "user.name", "ASR Test"], &repo);
fs::create_dir_all(repo.join("src")).unwrap();
fs::write(
repo.join("src/retry.rs"),
"pub fn retry_backoff() -> u64 {\n 100\n}\n",
)
.unwrap();
git(&["add", "."], &repo);
git(&["commit", "-q", "-m", "initial"], &repo);
assert!(run_asr(&["init", "--json"], &cwd, &asr_home)
.status
.success());
assert!(run_asr(
&["repo", "add", "app", repo.to_str().unwrap(), "--json"],
&cwd,
&asr_home,
)
.status
.success());
(cwd, asr_home, repo)
}
#[test]
fn asr_read_returns_only_requested_numbered_lines() {
let (cwd, asr_home, _repo) = registered_repo();
let output = run_asr(
&["read", "app", "src/retry.rs", "--lines", "1:2", "--json"],
&cwd,
&asr_home,
);
assert!(output.status.success());
let json = stdout_json(&output);
let lines = json["lines"].as_array().unwrap();
assert_eq!(json["repo"], "app");
assert_eq!(json["path"], "src/retry.rs");
assert_eq!(
json["source_policy"]["mode"],
"indexed_snapshot_fresh_source"
);
assert_eq!(json["source_policy"]["snapshot_bound"], true);
assert_eq!(json["start_line"], 1);
assert_eq!(json["end_line"], 2);
assert_eq!(lines.len(), 2);
assert_eq!(lines[0]["line"], 1);
assert!(lines[0]["content"]
.as_str()
.unwrap()
.contains("retry_backoff"));
}
#[test]
fn asr_read_requires_snapshot_unless_live_is_explicit() {
let (cwd, asr_home, _repo) = registered_unindexed_repo();
let default_read = run_asr(
&["read", "app", "src/retry.rs", "--lines", "1:1", "--json"],
&cwd,
&asr_home,
);
assert!(!default_read.status.success());
assert_eq!(
stderr_json(&default_read)["error"]["code"],
"repo_not_indexed"
);
let live_read = run_asr(
&[
"read",
"app",
"src/retry.rs",
"--lines",
"1:1",
"--live",
"--json",
],
&cwd,
&asr_home,
);
assert!(live_read.status.success());
let json = stdout_json(&live_read);
assert_eq!(json["source_policy"]["mode"], "live_registered_source");
assert_eq!(json["source_policy"]["live"], true);
}
#[test]
fn asr_read_rejects_unsafe_or_invalid_ranges() {
let (cwd, asr_home, _repo) = registered_repo();
let invalid = run_asr(
&["read", "app", "src/retry.rs", "--lines", "2:1", "--json"],
&cwd,
&asr_home,
);
assert!(!invalid.status.success());
assert_eq!(stderr_json(&invalid)["error"]["code"], "invalid_line_range");
let escape = run_asr(
&["read", "app", "../outside.rs", "--lines", "1:1", "--json"],
&cwd,
&asr_home,
);
assert!(!escape.status.success());
assert_eq!(stderr_json(&escape)["error"]["code"], "invalid_path");
let git_internal = run_asr(
&["read", "app", ".git/config", "--lines", "1:1", "--json"],
&cwd,
&asr_home,
);
assert!(!git_internal.status.success());
assert_eq!(stderr_json(&git_internal)["error"]["code"], "invalid_path");
let nested_git_component = run_asr(
&["read", "app", "src/.git/config", "--lines", "1:1", "--json"],
&cwd,
&asr_home,
);
assert!(!nested_git_component.status.success());
assert_eq!(
stderr_json(&nested_git_component)["error"]["code"],
"invalid_path"
);
let out_of_bounds = run_asr(
&["read", "app", "src/retry.rs", "--lines", "1:99", "--json"],
&cwd,
&asr_home,
);
assert!(!out_of_bounds.status.success());
assert_eq!(
stderr_json(&out_of_bounds)["error"]["code"],
"invalid_line_range"
);
}
#[test]
fn asr_diff_returns_hunk_metadata_not_full_files() {
let (cwd, asr_home, _repo) = registered_repo();
let output = run_asr(
&[
"diff", "app", "--base", "HEAD~1", "--head", "HEAD", "--json",
],
&cwd,
&asr_home,
);
assert!(
output.status.success(),
"stderr: {} stdout: {}",
String::from_utf8_lossy(&output.stderr),
String::from_utf8_lossy(&output.stdout)
);
let json = stdout_json(&output);
let hunks = json["hunks"].as_array().unwrap();
assert_eq!(json["repo"], "app");
assert_eq!(json["source_policy"]["mode"], "git_ref_diff");
assert_eq!(json["source_policy"]["snapshot_bound"], false);
assert!(json["hunk_count"].as_u64().unwrap() >= 1);
assert_eq!(json["changed_files"].as_array().unwrap()[0], "src/retry.rs");
assert_eq!(hunks[0]["path"], "src/retry.rs");
assert!(hunks[0]["added_lines"].as_u64().unwrap() >= 1);
assert!(hunks[0]["removed_lines"].as_u64().unwrap() >= 1);
assert!(
hunks[0].get("content").is_none(),
"diff output must not dump full hunk content"
);
}
#[test]
fn asr_diff_rejects_option_like_git_refs_before_running_git() {
let (cwd, asr_home, _repo) = registered_repo();
let output = run_asr(
&[
"diff", "app", "--base", "--help", "--head", "HEAD", "--json",
],
&cwd,
&asr_home,
);
assert!(!output.status.success());
assert_eq!(stderr_json(&output)["error"]["code"], "invalid_git_ref");
}