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_one() {
let f = Fixture::conformant("badprof");
let out = run_doctor(&["--profile", "webapp", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 1, "bad --profile is caller-actionable");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(stderr.contains("webapp"), "{stderr}");
}
#[test]
fn missing_target_is_a_usage_error_exit_one() {
let out = run_doctor(&["/no/such/repo/anywhere"]);
assert_eq!(code(&out), 1);
}
#[test]
fn unknown_flag_is_a_usage_error_exit_one() {
let f = Fixture::conformant("flag");
let out = run_doctor(&["--nope", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 1);
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",
"canon.s23",
"canon.s24",
] {
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_24_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..=24 {
let id = format!("canon.s{n:02}");
assert!(stdout.contains(&id), "cli profile should resolve {id}");
}
}
#[test]
fn configured_private_marker_is_a_must_gap_but_own_coordinates_are_allowed() {
let f = Fixture::conformant("s23");
std::fs::write(
f.path.join(".git/config"),
"[remote \"origin\"]\n url = git@github.com:example-owner/example-tool.git\n",
)
.unwrap();
std::fs::write(
f.path.join("README.md"),
"[]\n\
brew install example-owner/example-tool/example-tool\n",
)
.unwrap();
let out = base_command()
.env(
"PROJECT_CANON_USER_SPECIFIC_DENY_LIST",
"example-owner,example-tool,private-widget",
)
.args(["doctor", "--json", f.path.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(code(&out), 0, "own coordinates must be known-good");
std::fs::write(
f.path.join("AGENTS.md"),
"repo_default = \"private-widget\"\n",
)
.unwrap();
let out = base_command()
.env(
"PROJECT_CANON_USER_SPECIFIC_DENY_LIST",
"example-owner,example-tool,private-widget",
)
.args(["doctor", "--json", f.path.to_str().unwrap()])
.output()
.unwrap();
assert_eq!(code(&out), 1);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("canon.s23"), "{stdout}");
assert!(stdout.contains("AGENTS.md:1"), "{stdout}");
assert!(!stdout.contains("private-widget"), "{stdout}");
}
#[test]
fn unverified_deferral_is_a_must_gap_and_an_open_local_owner_passes() {
let f = Fixture::conformant("s24-owner");
let slug = ["enable", "widget"].join("-");
std::fs::write(
f.path.join("README.md"),
format!("Feature disabled until issue {slug} lands.\n"),
)
.unwrap();
let out = run_doctor(&["--json", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 1);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("canon.s24"), "{stdout}");
assert!(stdout.contains("unresolved local issue"), "{stdout}");
let item = f.path.join("issues").join(&slug).join("item.md");
std::fs::create_dir_all(item.parent().unwrap()).unwrap();
std::fs::write(item, "---\nstatus: open\n---\n\n# Fixture owner\n").unwrap();
let out = run_doctor(&["--json", f.path.to_str().unwrap()]);
assert_eq!(code(&out), 0, "{}", String::from_utf8_lossy(&out.stdout));
}
#[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), 1);
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), 1);
}
#[test]
fn json_unknown_flag_uses_the_fatal_error_envelope() {
let out = base_command().args(["--json", "--bogus"]).output().unwrap();
assert_eq!(code(&out), 1);
assert!(out.stdout.is_empty(), "failure must not write stdout");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.trim().starts_with('{'),
"expected JSON error: {stderr}"
);
assert!(stderr.contains("\"schema_version\":1"));
assert!(stderr.contains("\"code\":\"usage_error\""));
}
#[test]
fn json_domain_validation_error_uses_the_fatal_error_envelope() {
let out = run_doctor(&["--json", "/definitely/not/a/project-canon-repo"]);
assert_eq!(code(&out), 1);
assert!(out.stdout.is_empty(), "failure must not write stdout");
let stderr = String::from_utf8_lossy(&out.stderr);
assert!(
stderr.trim().starts_with('{'),
"expected JSON error: {stderr}"
);
assert!(stderr.contains("\"code\":\"invalid_target\""));
}
#[test]
fn fixture_paths_exist() {
let f = Fixture::conformant("sanity");
assert!(Path::new(&f.path).is_dir());
}