use std::process::Command;
fn run_export(extra_args: &[&str]) -> String {
let bin = env!("CARGO_BIN_EXE_synaps");
let mut cmd = Command::new(bin);
cmd.arg("tools").arg("export");
for arg in extra_args {
cmd.arg(arg);
}
let tmp = tempfile::tempdir().expect("tempdir");
cmd.env("HOME", tmp.path())
.env_remove("ANTHROPIC_API_KEY");
let output = cmd.output().expect("failed to spawn synaps binary");
if !output.status.success() {
panic!(
"`synaps tools export {:?}` failed:\nstatus: {}\nstderr: {}",
extra_args,
output.status,
String::from_utf8_lossy(&output.stderr)
);
}
String::from_utf8(output.stdout).expect("synaps tools export output is valid UTF-8")
}
const EXPECTED_TOOL_NAMES: &[&str] = &[
"bash",
"edit",
"find",
"grep",
"ls",
"read",
"shell_end",
"shell_send",
"shell_start",
"subagent",
"subagent_collect",
"subagent_resume",
"subagent_start",
"subagent_status",
"subagent_steer",
"write",
];
#[test]
fn export_produces_valid_json() {
let out = run_export(&[]);
serde_json::from_str::<serde_json::Value>(&out)
.expect("`synaps tools export` output must be parseable as valid JSON");
}
#[test]
fn export_pretty_produces_valid_json() {
let out = run_export(&["--pretty"]);
serde_json::from_str::<serde_json::Value>(&out)
.expect("`synaps tools export --pretty` output must be parseable as valid JSON");
}
#[test]
fn export_contains_all_16_builtin_tools() {
let out = run_export(&[]);
let manifest: Vec<serde_json::Value> = serde_json::from_str(&out)
.expect("output must be a JSON array");
assert_eq!(manifest.len(), 16,
"export must contain exactly 16 builtin tools, got {}: {:?}",
manifest.len(),
manifest.iter().filter_map(|t| t["name"].as_str()).collect::<Vec<_>>()
);
let names: Vec<&str> = manifest.iter()
.filter_map(|t| t["name"].as_str())
.collect();
for expected in EXPECTED_TOOL_NAMES {
assert!(names.contains(expected),
"tool '{}' is missing from the export manifest", expected);
}
}
#[test]
fn export_each_tool_parameters_is_valid_json_schema() {
let out = run_export(&[]);
let manifest: Vec<serde_json::Value> = serde_json::from_str(&out)
.expect("output must be a JSON array");
for entry in &manifest {
let name = entry["name"].as_str().unwrap_or("<unknown>");
let params = &entry["parameters"];
assert!(params.is_object(),
"tool '{}': 'parameters' must be a JSON object", name);
let schema_type = params["type"].as_str();
assert_eq!(schema_type, Some("object"),
"tool '{}': parameters schema must have {{\"type\": \"object\"}}", name);
assert!(params["properties"].is_object(),
"tool '{}': parameters schema must have a 'properties' object", name);
if let Some(required) = params["required"].as_array() {
let properties = params["properties"].as_object()
.expect("properties must be an object");
for req in required {
let field = req.as_str().expect("required entries must be strings");
assert!(properties.contains_key(field),
"tool '{}': required field '{}' is missing from 'properties'", name, field);
}
}
}
}
#[test]
fn export_is_deterministic_across_runs() {
let first = run_export(&[]);
let second = run_export(&[]);
assert_eq!(first, second,
"`synaps tools export` must produce byte-identical output across runs (deterministic ordering)");
}
#[test]
fn export_pretty_is_deterministic_across_runs() {
let first = run_export(&["--pretty"]);
let second = run_export(&["--pretty"]);
assert_eq!(first, second,
"`synaps tools export --pretty` must produce byte-identical output across runs");
}
#[test]
fn export_pretty_matches_committed_docs_tools_json() {
let committed_path = {
let manifest_dir = env!("CARGO_MANIFEST_DIR");
std::path::PathBuf::from(manifest_dir).join("docs").join("tools.json")
};
assert!(committed_path.exists(),
"docs/tools.json not found at {:?} — was it moved or deleted?", committed_path);
let committed = std::fs::read_to_string(&committed_path)
.expect("failed to read docs/tools.json");
let generated = run_export(&["--pretty"]);
assert_eq!(generated, committed,
"DRIFT DETECTED: `synaps tools export --pretty` output does not match docs/tools.json.\n\
Fix: run `synaps tools export --pretty > docs/tools.json` and commit."
);
}
#[test]
fn export_output_ends_with_newline() {
let out = run_export(&[]);
assert!(out.ends_with('\n'),
"`synaps tools export` output must end with a newline (POSIX convention)");
}
#[test]
fn export_pretty_output_ends_with_newline() {
let out = run_export(&["--pretty"]);
assert!(out.ends_with('\n'),
"`synaps tools export --pretty` output must end with a newline (POSIX convention)");
}
#[test]
fn export_output_is_json_array() {
let out = run_export(&[]);
let val: serde_json::Value = serde_json::from_str(&out)
.expect("output must be valid JSON");
assert!(val.is_array(),
"`synaps tools export` must emit a JSON array at the top level, got: {:?}",
val.as_object().map(|o| format!("object with {} keys", o.len()))
.unwrap_or_else(|| "non-array, non-object value".to_string())
);
}
#[test]
fn export_each_entry_has_name_description_parameters_shape() {
let out = run_export(&[]);
let manifest: Vec<serde_json::Value> = serde_json::from_str(&out)
.expect("output must be a JSON array");
for entry in &manifest {
let obj = entry.as_object()
.expect("each manifest entry must be a JSON object");
assert!(obj.contains_key("name"), "entry missing 'name' key: {:?}", entry);
assert!(obj.contains_key("description"), "entry missing 'description' key: {:?}", entry);
assert!(obj.contains_key("parameters"), "entry missing 'parameters' key: {:?}", entry);
let name = obj["name"].as_str().unwrap_or("");
assert!(!name.is_empty(), "entry 'name' must be a non-empty string");
let desc = obj["description"].as_str().unwrap_or("");
assert!(!desc.is_empty(), "tool '{}': 'description' must be a non-empty string", name);
assert!(!obj.contains_key("input_schema"),
"tool '{}': export must use 'parameters' key (raw Tool trait output), \
not 'input_schema' (Anthropic API shape) — wrong schema source?", name);
}
}
#[test]
fn export_tools_are_in_alphabetical_order() {
let out = run_export(&[]);
let manifest: Vec<serde_json::Value> = serde_json::from_str(&out)
.expect("output must be a JSON array");
let names: Vec<&str> = manifest.iter()
.filter_map(|t| t["name"].as_str())
.collect();
let mut sorted = names.clone();
sorted.sort();
assert_eq!(names, sorted,
"export output must list tools in alphabetical order; \
got {:?}, expected {:?}", names, sorted);
}
#[test]
fn drift_check_script_exits_zero_when_no_drift() {
let bash = std::process::Command::new("which")
.arg("bash")
.output()
.ok()
.filter(|o| o.status.success());
if bash.is_none() {
eprintln!("SKIP: bash not available — skipping drift-check script test");
return;
}
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let script = std::path::PathBuf::from(manifest_dir)
.join("scripts")
.join("tools-schema-drift-check.sh");
if !script.exists() {
eprintln!("SKIP: scripts/tools-schema-drift-check.sh not found");
return;
}
let bin = env!("CARGO_BIN_EXE_synaps");
let status = std::process::Command::new("bash")
.arg(&script)
.env("SYNAPS_BIN", bin)
.env("COMMITTED", std::path::PathBuf::from(manifest_dir).join("docs").join("tools.json"))
.status()
.expect("failed to run drift-check script");
assert!(status.success(),
"scripts/tools-schema-drift-check.sh must exit 0 when binary matches committed JSON, \
got exit code: {:?}", status.code());
}
#[test]
fn drift_check_script_exits_nonzero_when_drift_detected() {
let bash = std::process::Command::new("which")
.arg("bash")
.output()
.ok()
.filter(|o| o.status.success());
if bash.is_none() {
eprintln!("SKIP: bash not available — skipping drift-check script test");
return;
}
let manifest_dir = env!("CARGO_MANIFEST_DIR");
let script = std::path::PathBuf::from(manifest_dir)
.join("scripts")
.join("tools-schema-drift-check.sh");
if !script.exists() {
eprintln!("SKIP: scripts/tools-schema-drift-check.sh not found");
return;
}
let tmp = tempfile::NamedTempFile::new().expect("tempfile");
std::fs::write(tmp.path(), b"[{\"name\":\"fake_tool\",\"description\":\"not real\",\"parameters\":{\"type\":\"object\",\"properties\":{}}}]\n")
.expect("write fake committed JSON");
let bin = env!("CARGO_BIN_EXE_synaps");
let status = std::process::Command::new("bash")
.arg(&script)
.env("SYNAPS_BIN", bin)
.env("COMMITTED", tmp.path())
.status()
.expect("failed to run drift-check script");
assert!(!status.success(),
"scripts/tools-schema-drift-check.sh must exit non-zero when committed JSON diverges \
from binary output, but it exited 0 — drift detection is broken");
}