use std::path::PathBuf;
use std::process::{Command, Output};
use std::sync::atomic::{AtomicU32, Ordering};
struct Tmp {
path: PathBuf,
}
impl Tmp {
fn new(tag: &str) -> Tmp {
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let path = std::env::temp_dir().join(format!("pc-new-it-{tag}-{}-{n}", std::process::id()));
Tmp { path }
}
fn str(&self) -> &str {
self.path.to_str().unwrap()
}
}
impl Drop for Tmp {
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.env("PROJECT_CANON_GH_ACCOUNT", "example-user");
cmd
}
fn run_new(args: &[&str]) -> Output {
base_command()
.arg("new")
.args(args)
.output()
.expect("run project-canon new")
}
fn code(output: &Output) -> i32 {
output.status.code().expect("exited with a code")
}
#[test]
fn generates_the_base_and_cli_scaffold() {
let t = Tmp::new("gen");
let out = run_new(&[t.str()]);
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
for rel in [
"AGENTS.md",
"CLAUDE.md",
"AGENTS-AI-FIRST-CLI.md",
"README.md",
".gitignore",
".workmux.yaml",
"CONFORMANCE.md",
] {
assert!(t.path.join(rel).exists(), "missing {rel}");
}
let name = t.path.file_name().unwrap().to_string_lossy().to_string();
assert!(t.path.join("Cargo.toml").is_file());
assert!(t
.path
.join(format!("crates/{name}-core/src/lib.rs"))
.is_file());
assert!(t
.path
.join(format!("crates/{name}-cli/src/main.rs"))
.is_file());
let bundled = std::fs::read_to_string(t.path.join("AGENTS-AI-FIRST-CLI.md")).unwrap();
assert_eq!(
bundled,
project_canon_core::CANON,
"bundled canon must match the single-source canon"
);
assert!(!t.path.join(".git").exists());
assert!(!t.path.join("issues").exists());
}
#[test]
fn dry_run_writes_nothing() {
let t = Tmp::new("dry");
let out = run_new(&["--dry-run", t.str()]);
assert_eq!(code(&out), 0);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("dry-run"), "{stdout}");
assert!(stdout.contains("would-create"), "{stdout}");
assert!(!t.path.exists(), "dry-run must not create the target dir");
}
#[test]
fn json_plan_is_well_formed() {
let t = Tmp::new("json");
let out = run_new(&["--json", "--dry-run", t.str()]);
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\":\"new\""));
assert!(stdout.contains("\"profile\":\"cli\""));
assert!(stdout.contains("\"dry_run\":true"));
assert!(stdout.contains("\"surface_shape\":\"flat-verb\""));
assert!(stdout.contains("\"id\":\"github-create\""));
assert!(stdout.contains("\"class\":\"external\""));
assert!(stdout.contains("\"exit_code\":0"));
}
#[test]
fn hooks_are_printed_not_run() {
let t = Tmp::new("hooks");
let out = run_new(&["--dry-run", t.str()]);
let stdout = String::from_utf8_lossy(&out.stdout);
assert!(stdout.contains("not run"), "{stdout}");
assert!(stdout.contains("gh repo create"), "{stdout}");
assert!(stdout.contains("issuectl init"), "{stdout}");
}
#[test]
fn non_empty_target_is_refused_without_force() {
let t = Tmp::new("nonempty");
std::fs::create_dir_all(&t.path).unwrap();
std::fs::write(t.path.join("preexisting"), b"x").unwrap();
let out = run_new(&[t.str()]);
assert_eq!(
code(&out),
1,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(String::from_utf8_lossy(&out.stderr).contains("not empty"));
assert!(!t.path.join("AGENTS.md").exists());
}
#[test]
fn force_fills_gaps_without_overwriting() {
let t = Tmp::new("force");
std::fs::create_dir_all(&t.path).unwrap();
std::fs::write(t.path.join("README.md"), b"KEEP ME").unwrap();
let out = run_new(&["--force", t.str()]);
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(t.path.join("AGENTS.md").is_file());
assert_eq!(
std::fs::read_to_string(t.path.join("README.md")).unwrap(),
"KEEP ME"
);
}
#[test]
fn new_requires_a_configured_gh_account() {
let t = Tmp::new("missing-account");
let xdg = std::env::temp_dir().join(format!("pc-new-missing-account-{}", std::process::id()));
let _ = std::fs::remove_dir_all(&xdg);
let mut cmd = base_command();
let out = cmd
.env("XDG_CONFIG_HOME", &xdg)
.env_remove("PROJECT_CANON_GH_ACCOUNT")
.args(["new", "--json", t.str()])
.output()
.expect("run project-canon new");
assert_eq!(code(&out), 1);
assert!(out.stdout.is_empty());
assert!(
!t.path.exists(),
"missing gh_account must fail before creating the target"
);
let error: serde_json::Value = serde_json::from_slice(&out.stderr).unwrap();
assert_eq!(error["error"]["code"], "required_config_missing");
assert!(error["error"]["message"]
.as_str()
.unwrap()
.contains("PROJECT_CANON_GH_ACCOUNT"));
let _ = std::fs::remove_dir_all(xdg);
}
#[test]
fn missing_dir_is_a_usage_error() {
let out = run_new(&["--json"]);
assert_eq!(code(&out), 1);
assert!(String::from_utf8_lossy(&out.stderr).contains("target directory"));
}
#[test]
fn bad_profile_is_a_usage_error() {
let t = Tmp::new("badprof");
let out = run_new(&["--profile", "webapp", t.str()]);
assert_eq!(code(&out), 1);
assert!(String::from_utf8_lossy(&out.stderr).contains("webapp"));
assert!(!t.path.exists());
}
#[test]
fn unknown_flag_is_a_usage_error() {
let t = Tmp::new("flag");
let out = run_new(&["--nope", t.str()]);
assert_eq!(code(&out), 1);
assert!(String::from_utf8_lossy(&out.stderr).contains("--nope"));
}
#[test]
fn unsafe_name_is_rejected() {
let t = Tmp::new("badname");
for bad in ["../escape", "foo bar", "-flag", "1tool", "foo;rm"] {
let out = run_new(&["--name", bad, t.str()]);
assert_eq!(
code(&out),
1,
"name {bad:?} should be rejected; stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
}
assert!(!t.path.exists(), "no scaffold on a rejected name");
}
#[test]
fn empty_positional_is_a_usage_error() {
let out = run_new(&["--name", "foo", ""]);
assert_eq!(code(&out), 1);
}
#[test]
fn flag_like_value_is_a_usage_error() {
let t = Tmp::new("flagval");
let out = run_new(&["--name", "--json", t.str()]);
assert_eq!(code(&out), 1);
assert!(String::from_utf8_lossy(&out.stderr).contains("flag-like"));
}
#[test]
fn generated_cli_scaffold_builds() {
let t = Tmp::new("build");
let out = run_new(&["--name", "gentool", t.str()]);
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
let target_dir = t.path.join("_build_target");
let build = Command::new(env!("CARGO"))
.arg("build")
.arg("--offline")
.current_dir(&t.path)
.env("CARGO_TARGET_DIR", &target_dir)
.output();
let build = match build {
Ok(o) => o,
Err(_) => return, };
assert!(
build.status.success(),
"generated cli scaffold must build; stdout: {} stderr: {}",
String::from_utf8_lossy(&build.stdout),
String::from_utf8_lossy(&build.stderr)
);
}
#[cfg(unix)]
#[test]
fn symlink_target_root_is_refused() {
let t = Tmp::new("symroot");
let real = Tmp::new("symroot-real");
std::fs::create_dir_all(&real.path).unwrap();
std::os::unix::fs::symlink(&real.path, &t.path).unwrap();
let out = run_new(&[t.str()]);
assert_eq!(
code(&out),
1,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
assert!(!real.path.join("AGENTS.md").exists());
let _ = std::fs::remove_file(&t.path);
}
#[test]
fn help_exits_zero_even_with_a_malformed_env() {
let out = base_command()
.arg("new")
.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 new_then_doctor_is_conformant() {
let t = Tmp::new("roundtrip");
let out = run_new(&[t.str()]);
assert_eq!(
code(&out),
0,
"stderr: {}",
String::from_utf8_lossy(&out.stderr)
);
std::fs::create_dir_all(t.path.join(".git")).unwrap();
std::fs::create_dir_all(t.path.join("issues")).unwrap();
let doctor = base_command()
.arg("doctor")
.arg(t.str())
.output()
.expect("run project-canon doctor");
assert_eq!(
code(&doctor),
0,
"doctor should pass a freshly-bootstrapped repo; stdout: {} stderr: {}",
String::from_utf8_lossy(&doctor.stdout),
String::from_utf8_lossy(&doctor.stderr)
);
assert!(String::from_utf8_lossy(&doctor.stdout).contains("conformant"));
}