use crate::config::McpConfig;
use anyhow::Result;
use serde_json::Value;
use std::path::PathBuf;
use tokio::process::Command;
pub async fn compare_runs(
baseline_path: String,
current_path: String,
cfg: &McpConfig,
) -> Result<Value> {
cfg.check_path_allowed(&baseline_path)?;
cfg.check_path_allowed(¤t_path)?;
let tmp = tempfile_path();
let output = Command::new(&cfg.bin_path)
.arg("diff")
.arg(&baseline_path)
.arg(¤t_path)
.arg("--json-out")
.arg(&tmp)
.arg("--quiet")
.output()
.await?;
if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr);
anyhow::bail!("oxide-sloc diff failed: {stderr}");
}
let content = tokio::fs::read_to_string(&tmp).await?;
let _ = tokio::fs::remove_file(&tmp).await;
Ok(serde_json::from_str(&content)?)
}
fn tempfile_path() -> PathBuf {
let mut p = std::env::temp_dir();
p.push(format!(
"sloc-mcp-diff-{}.json",
uuid::Uuid::new_v4().simple()
));
p
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::McpConfig;
fn unrestricted_cfg() -> McpConfig {
McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![],
}
}
fn restricted_cfg() -> McpConfig {
McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec![std::env::temp_dir()],
}
}
#[test]
fn tempfile_path_in_temp_dir() {
let p = tempfile_path();
assert!(p.starts_with(std::env::temp_dir()));
}
#[test]
fn tempfile_path_has_json_extension() {
let p = tempfile_path();
assert_eq!(p.extension().and_then(|e| e.to_str()), Some("json"));
}
#[test]
fn tempfile_path_unique_across_calls() {
assert_ne!(tempfile_path(), tempfile_path());
}
#[tokio::test]
async fn compare_runs_restricted_rejects_paths_outside_root() {
let cfg = restricted_cfg();
let result = compare_runs(".".into(), ".".into(), &cfg).await;
let _ = result;
}
#[tokio::test]
async fn compare_runs_unrestricted_rejects_nonexistent_binary() {
let mut cfg = unrestricted_cfg();
cfg.bin_path = "__nonexistent_binary_12345__".into();
let result = compare_runs(".".into(), ".".into(), &cfg).await;
assert!(result.is_err(), "nonexistent binary must cause an error");
}
fn find_oxide_sloc_binary() -> Option<std::path::PathBuf> {
let manifest = std::path::Path::new(env!("CARGO_MANIFEST_DIR"));
let workspace = manifest.parent()?.parent()?;
let ext = if cfg!(windows) { ".exe" } else { "" };
let bin = workspace
.join("target")
.join("debug")
.join(format!("oxide-sloc{ext}"));
if bin.exists() {
Some(bin)
} else {
None
}
}
async fn run_analyze(bin: &std::path::Path, dir: &std::path::Path) -> PathBuf {
let out = dir.join("result.json");
let status = tokio::process::Command::new(bin)
.args([
"analyze",
dir.to_str().unwrap(),
"--json-out",
out.to_str().unwrap(),
"--quiet",
])
.status()
.await
.expect("oxide-sloc must run");
assert!(status.success(), "analyze must succeed");
out
}
#[tokio::test]
async fn compare_runs_success_with_real_binary() {
let Some(bin) = find_oxide_sloc_binary() else {
eprintln!("oxide-sloc binary not found in target/debug — skipping");
return;
};
let dir1 = tempfile::tempdir().unwrap();
std::fs::write(dir1.path().join("a.rs"), "fn foo() {}\n").unwrap();
let dir2 = tempfile::tempdir().unwrap();
std::fs::write(dir2.path().join("a.rs"), "fn foo() {}\nfn bar() {}\n").unwrap();
let baseline = run_analyze(&bin, dir1.path()).await;
let current = run_analyze(&bin, dir2.path()).await;
let cfg = McpConfig {
server_url: None,
bin_path: bin.to_str().unwrap().to_owned(),
api_key: None,
allowed_roots: vec![std::env::temp_dir()],
};
let result = compare_runs(
baseline.to_str().unwrap().to_owned(),
current.to_str().unwrap().to_owned(),
&cfg,
)
.await;
assert!(result.is_ok(), "compare_runs must succeed: {result:?}");
let v = result.unwrap();
assert!(
v.is_object() || v.is_array(),
"diff output must be JSON: {v}"
);
}
#[tokio::test]
async fn compare_runs_with_nonexistent_json_paths_fails() {
let Some(bin) = find_oxide_sloc_binary() else {
eprintln!("oxide-sloc binary not found in target/debug — skipping");
return;
};
let cfg = McpConfig {
server_url: None,
bin_path: bin.to_str().unwrap().to_owned(),
api_key: None,
allowed_roots: vec![],
};
let result = compare_runs(
"/nonexistent/__sloc_baseline_xyz__.json".to_owned(),
"/nonexistent/__sloc_current_xyz__.json".to_owned(),
&cfg,
)
.await;
assert!(
result.is_err(),
"diff with nonexistent JSON paths must fail"
);
}
#[tokio::test]
async fn compare_runs_path_not_in_allowed_roots_is_rejected() {
let cfg = McpConfig {
server_url: None,
bin_path: "oxide-sloc".into(),
api_key: None,
allowed_roots: vec!["/nonexistent/__no_such_allowed_root__".into()],
};
let result = compare_runs(
"/some/baseline.json".to_owned(),
"/some/current.json".to_owned(),
&cfg,
)
.await;
assert!(
result.is_err(),
"disallowed path must return an error, got Ok"
);
}
}