use std::path::{Path, PathBuf};
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
struct Fixture {
path: PathBuf,
}
impl Fixture {
fn new(tag: &str) -> Fixture {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("pc-doctor-it-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&path).unwrap();
Fixture { path }
}
fn touch(&self, rel: &str) -> &Self {
std::fs::write(self.path.join(rel), b"x").unwrap();
self
}
fn mkdir(&self, rel: &str) -> &Self {
std::fs::create_dir_all(self.path.join(rel)).unwrap();
self
}
fn conformant(tag: &str) -> Fixture {
let f = Fixture::new(tag);
f.touch("AGENTS.md")
.touch("CLAUDE.md")
.touch("README.md")
.touch(".gitignore")
.mkdir("issues")
.mkdir(".git")
.mkdir("crates/pc-core")
.mkdir("crates/pc-cli");
f
}
}
impl Drop for Fixture {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn base_command() -> Command {
let mut cmd = Command::new(env!("CARGO_BIN_EXE_project-canon"));
for (key, _) in std::env::vars_os() {
if key.to_string_lossy().starts_with("PROJECT_CANON_") {
cmd.env_remove(key);
}
}
cmd
}
fn run_doctor(args: &[&str]) -> Output {
base_command()
.arg("doctor")
.args(args)
.output()
.expect("run project-canon doctor")
}
fn code(output: &Output) -> i32 {
output.status.code().expect("exited with a code")
}
#[test]
fn conformant_repo_exits_zero() {
let f = Fixture::conformant("ok");
let out = run_doctor(&[f.path.to_str().unwrap()]);
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("conformant"), "{stdout}");
}
#[test]
fn must_gap_exits_one() {
let f = Fixture::conformant("gap");
std::fs::remove_dir_all(f.path.join("issues")).unwrap(); let out = run_doctor(&[f.path.to_str().unwrap()]);
assert_eq!(code(&out), 1);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("FAIL"), "{stdout}");
assert!(stdout.contains("non-conformant"), "{stdout}");
}
#[test]
fn bad_profile_is_a_usage_error_exit_two() {
let f = Fixture::conformant("badprof");
let out = run_doctor(&["--profile", "webapp", f.path.to_str().unwrap()]);
assert_eq!(
code(&out),
2,
"bad --profile must be distinct from a conformance gap"
);
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("webapp"), "{stderr}");
}
#[test]
fn missing_target_is_a_usage_error_exit_two() {
let out = run_doctor(&["/no/such/repo/anywhere"]);
assert_eq!(code(&out), 2);
}
#[test]
fn unknown_flag_is_a_usage_error_exit_two() {
let f = Fixture::conformant("flag");
let out = run_doctor(&["--nope", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 2);
assert!(String::from_utf8_lossy(&out.stderr).contains("--nope"));
}
#[test]
fn help_exits_zero() {
let out = run_doctor(&["--help"]);
assert_eq!(code(&out), 0);
assert!(String::from_utf8_lossy(&out.stdout).contains("USAGE"));
}
#[test]
fn json_output_is_well_formed_and_matches_the_gate() {
let f = Fixture::conformant("json-ok");
let out = run_doctor(&["--json", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 0);
let stdout = String::from_utf8_lossy(&out.stdout);
let stdout = stdout.trim();
assert!(stdout.starts_with('{') && stdout.ends_with('}'), "{stdout}");
assert!(stdout.contains("\"schema_version\":1"));
assert!(stdout.contains("\"verb\":\"doctor\""));
assert!(stdout.contains("\"profile\":\"cli\""));
assert!(stdout.contains("\"conformant\":true"));
assert!(stdout.contains("\"exit_code\":0"));
for section in ["canon.s01", "canon.s18", "canon.s22"] {
assert!(stdout.contains(section), "missing {section}");
}
let g = Fixture::conformant("json-gap");
std::fs::remove_dir_all(g.path.join(".git")).unwrap();
let out = run_doctor(&["--json", g.path.to_str().unwrap()]);
assert_eq!(code(&out), 1);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("\"conformant\":false"));
assert!(stdout.contains("\"exit_code\":1"));
assert!(stdout.contains("\"status\":\"fail\""));
}
#[test]
fn profile_cli_resolves_all_22_canon_sections() {
let f = Fixture::conformant("resolve");
let out = run_doctor(&["--profile", "cli", "--json", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 0);
let stdout = String::from_utf8_lossy(&out.stdout);
for n in 1u8..=22 {
let id = format!("canon.s{n:02}");
assert!(stdout.contains(&id), "cli profile should resolve {id}");
}
}
#[test]
fn default_target_is_cwd() {
let f = Fixture::conformant("cwd");
let out = base_command()
.arg("doctor")
.current_dir(&f.path)
.output()
.unwrap();
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn inline_value_on_a_valueless_flag_is_a_usage_error() {
let f = Fixture::conformant("inline");
let out = run_doctor(&["--json=false", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 2);
assert!(String::from_utf8_lossy(&out.stderr).contains("--json"));
}
#[test]
fn double_dash_lets_a_dashy_repo_path_through() {
let f = Fixture::conformant("dashy");
let out = run_doctor(&["--", f.path.to_str().unwrap()]);
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
#[test]
fn help_exits_zero_even_with_a_malformed_env() {
let out = base_command()
.arg("doctor")
.arg("--help")
.env("PROJECT_CANON_TW_ENABLED", "not-a-bool")
.output()
.unwrap();
assert_eq!(code(&out), 0);
assert!(String::from_utf8_lossy(&out.stdout).contains("USAGE"));
}
#[test]
fn top_level_unknown_flag_is_a_usage_error() {
let out = base_command().arg("--bogus").output().unwrap();
assert_eq!(code(&out), 2);
}
#[test]
fn fixture_paths_exist() {
let f = Fixture::conformant("sanity");
assert!(Path::new(&f.path).is_dir());
}