use std::path::Path;
use std::process::ExitCode;
use project_canon_core::{
AppStatus, Archetype, Dimension, Layer, Model, Questionnaire, Resolution, Severity,
SurfaceShape,
};
use crate::error::{fail, json_requested, write_stdout, CliError};
use crate::json::Json;
use crate::probes::{mechanical_probe, ProbeContext};
const SCHEMA_VERSION: i64 = 1;
const EXIT_CONFORMANT: u8 = 0;
const EXIT_GAP: u8 = 1;
pub fn run(args: &[String]) -> ExitCode {
let parsed = match parse_args(args) {
Ok(Command::Help) => {
print!("{HELP}");
return ExitCode::from(EXIT_CONFORMANT); }
Ok(Command::Run(a)) => a,
Err(err) => {
return fail(
json_requested(args),
CliError::actionable("usage_error", format!("doctor: {err}")),
);
}
};
let env_config = match crate::config::resolve() {
Ok(config) => config,
Err(error) => return fail(parsed.json, error.into_cli()),
};
let repo = Path::new(&parsed.repo);
if !repo.is_dir() {
return fail(
parsed.json,
CliError::actionable(
"invalid_target",
format!("doctor: target repo is not a directory: {:?}", parsed.repo),
),
);
}
let target = match std::fs::canonicalize(repo) {
Ok(p) => p.display().to_string(),
Err(err) => {
return fail(
parsed.json,
CliError::system(
"io_error",
format!("doctor: cannot resolve target {:?}: {err}", parsed.repo),
),
);
}
};
let model = Model::standard();
let questionnaire = Questionnaire::builder(parsed.profile).build();
let resolution = model.resolve(&questionnaire);
let probe_context = ProbeContext {
user_specific_deny_list: &env_config.user_specific_deny_list,
};
let report = match build_report(
&model,
&resolution,
parsed.profile,
&target,
repo,
&probe_context,
) {
Ok(r) => r,
Err(fault) => {
return fail(
parsed.json,
CliError::system(
"io_error",
format!(
"doctor: cannot probe {} ({}): {}",
fault.dim_id, target, fault.source
),
),
);
}
};
let output = if parsed.json {
format!("{}\n", report.to_json())
} else {
report.render_human(parsed.verbose)
};
let write_status = write_stdout(&output, parsed.json);
if write_status != ExitCode::SUCCESS {
return write_status;
}
ExitCode::from(report.exit_code())
}
#[derive(Debug, PartialEq, Eq)]
struct DoctorArgs {
repo: String,
profile: Archetype,
json: bool,
verbose: bool,
#[allow(dead_code)] assume_defaults: bool,
}
#[derive(Debug, PartialEq, Eq)]
enum Command {
Help,
Run(DoctorArgs),
}
fn parse_args(args: &[String]) -> Result<Command, String> {
let mut repo: Option<String> = None;
let mut profile: Option<Archetype> = None;
let mut json = false;
let mut verbose = false;
let mut assume_defaults = false;
let mut positional_only = false;
let mut iter = args.iter();
while let Some(arg) = iter.next() {
if positional_only {
set_positional(&mut repo, arg)?;
continue;
}
if arg == "--" {
positional_only = true;
continue;
}
let (flag, inline) = match arg.split_once('=') {
Some((f, v)) if f.starts_with("--") => (f, Some(v)),
_ => (arg.as_str(), None),
};
match flag {
"--help" => {
reject_inline("--help", inline)?;
return Ok(Command::Help);
}
"--json" => {
reject_inline("--json", inline)?;
set_flag(&mut json, "--json")?;
}
"--verbose" => {
reject_inline("--verbose", inline)?;
set_flag(&mut verbose, "--verbose")?;
}
"--assume-defaults" => {
reject_inline("--assume-defaults", inline)?;
set_flag(&mut assume_defaults, "--assume-defaults")?;
}
"--profile" => {
if profile.is_some() {
return Err("repeated flag: --profile".to_string());
}
let value = match inline {
Some(v) => v.to_string(),
None => {
let value = iter.next().cloned().ok_or_else(|| {
"--profile requires a value (cli/service/library/release)".to_string()
})?;
if value.starts_with('-') {
return Err(format!(
"--profile requires a value, got flag-like token {value:?}"
));
}
value
}
};
profile = Some(parse_archetype(&value)?);
}
other if other.starts_with('-') => {
return Err(format!("unknown flag: {other}"));
}
_ => set_positional(&mut repo, arg)?,
}
}
Ok(Command::Run(DoctorArgs {
repo: repo.unwrap_or_else(|| ".".to_string()),
profile: profile.unwrap_or(Archetype::Cli),
json,
verbose,
assume_defaults,
}))
}
fn set_positional(repo: &mut Option<String>, arg: &str) -> Result<(), String> {
if repo.is_some() {
return Err(format!("unexpected extra argument: {arg:?}"));
}
*repo = Some(arg.to_string());
Ok(())
}
fn reject_inline(flag: &str, inline: Option<&str>) -> Result<(), String> {
match inline {
Some(value) => Err(format!("flag {flag} does not take a value (got {value:?})")),
None => Ok(()),
}
}
fn set_flag(slot: &mut bool, name: &str) -> Result<(), String> {
if *slot {
return Err(format!("repeated flag: {name}"));
}
*slot = true;
Ok(())
}
fn parse_archetype(s: &str) -> Result<Archetype, String> {
Archetype::ALL
.into_iter()
.find(|a| a.slug() == s)
.ok_or_else(|| {
let valid = Archetype::ALL
.iter()
.map(|a| a.slug())
.collect::<Vec<_>>()
.join("/");
format!("invalid --profile {s:?} (expected one of {valid})")
})
}
const HELP: &str = "\
project-canon doctor — mechanical conformance gate (read-only, non-interactive)
USAGE:
project-canon doctor [--profile <archetype>] [--assume-defaults] [--json] [--verbose] [<repo>]
ARGS:
<repo> Target repo to probe (default: current directory). Read-only.
FLAGS:
--profile <archetype> cli | service | library | release (default: cli)
--assume-defaults Characterize non-interactively with conservative defaults (v0 default).
--json Emit the structured §10 report on stdout.
--verbose Also list skipped (n/a + deferred) checks in the human matrix.
--help Show this help.
EXIT CODES:
0 conformant — every mechanically-decided MUST passed
1 non-conformant — a mechanically-decided MUST gap (the gate tripped)
2 usage/operational error (bad flag, bad --profile, missing target, malformed env)
";
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum CheckStatus {
Ok,
Warn,
Fail,
Skipped,
}
impl CheckStatus {
fn json(self) -> &'static str {
match self {
CheckStatus::Ok => "ok",
CheckStatus::Warn => "warn",
CheckStatus::Fail => "fail",
CheckStatus::Skipped => "skipped",
}
}
fn tag(self) -> &'static str {
match self {
CheckStatus::Ok => "OK",
CheckStatus::Warn => "WARN",
CheckStatus::Fail => "FAIL",
CheckStatus::Skipped => "SKIP",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SkipReason {
NotApplicable { gated_by: &'static str },
DeferredToReview,
}
#[derive(Debug, Clone)]
struct Check {
id: &'static str,
title: &'static str,
severity: Severity,
layer: Layer,
canon_section: Option<u8>,
status: CheckStatus,
gates: bool,
message: String,
skip_reason: Option<SkipReason>,
}
#[derive(Debug, Clone)]
struct Report {
tool: &'static str,
target: String,
profile: Archetype,
surface_shape: Option<SurfaceShape>,
checks: Vec<Check>,
}
impl Report {
fn count(&self, status: CheckStatus) -> usize {
self.checks.iter().filter(|c| c.status == status).count()
}
fn gaps(&self) -> usize {
self.checks
.iter()
.filter(|c| c.status == CheckStatus::Fail && c.gates)
.count()
}
fn is_conformant(&self) -> bool {
self.gaps() == 0
}
fn exit_code(&self) -> u8 {
if self.is_conformant() {
EXIT_CONFORMANT
} else {
EXIT_GAP
}
}
fn to_json(&self) -> Json {
let checks = self
.checks
.iter()
.map(|c| {
let (reason, gated_by) = match c.skip_reason {
Some(SkipReason::NotApplicable { gated_by }) => {
(Some("not-applicable"), Some(gated_by))
}
Some(SkipReason::DeferredToReview) => (Some("deferred-to-review"), None),
None => (None, None),
};
Json::Object(vec![
("id".into(), Json::str(c.id)),
("title".into(), Json::str(c.title)),
("severity".into(), Json::str(severity_str(c.severity))),
("layer".into(), Json::str(layer_str(c.layer))),
(
"canon_section".into(),
c.canon_section.map_or(Json::Null, |n| Json::Int(n as i64)),
),
("status".into(), Json::str(c.status.json())),
("gates".into(), Json::Bool(c.gates)),
("message".into(), Json::str(c.message.clone())),
("reason".into(), Json::opt_str(reason)),
("gated_by".into(), Json::opt_str(gated_by)),
])
})
.collect();
let summary = Json::Object(vec![
("ok".into(), Json::Int(self.count(CheckStatus::Ok) as i64)),
(
"warn".into(),
Json::Int(self.count(CheckStatus::Warn) as i64),
),
(
"fail".into(),
Json::Int(self.count(CheckStatus::Fail) as i64),
),
(
"skipped".into(),
Json::Int(self.count(CheckStatus::Skipped) as i64),
),
("gaps".into(), Json::Int(self.gaps() as i64)),
]);
Json::Object(vec![
("schema_version".into(), Json::Int(SCHEMA_VERSION)),
("tool".into(), Json::str(self.tool)),
("verb".into(), Json::str("doctor")),
("target".into(), Json::str(self.target.clone())),
("profile".into(), Json::str(self.profile.slug())),
(
"surface_shape".into(),
Json::opt_str(self.surface_shape.map(surface_shape_str)),
),
("checks".into(), Json::Array(checks)),
("summary".into(), summary),
("conformant".into(), Json::Bool(self.is_conformant())),
("exit_code".into(), Json::Int(self.exit_code() as i64)),
])
}
fn render_human(&self, verbose: bool) -> String {
let mut out = String::new();
out.push_str(&format!(
"project-canon doctor: {} (profile: {})\n",
self.target,
self.profile.slug()
));
for c in &self.checks {
if c.status == CheckStatus::Skipped && !verbose {
continue;
}
out.push_str(&format!(
"{:<5} {:<20} {}\n",
c.status.tag(),
c.id,
c.message
));
}
let verdict = if self.is_conformant() {
"mechanically conformant".to_string()
} else {
format!(
"{} mechanical MUST gap{} (non-conformant)",
self.gaps(),
if self.gaps() == 1 { "" } else { "s" }
)
};
out.push_str(&format!(
"summary: {} ok, {} warn, {} fail, {} skipped \u{2192} {}\n",
self.count(CheckStatus::Ok),
self.count(CheckStatus::Warn),
self.count(CheckStatus::Fail),
self.count(CheckStatus::Skipped),
verdict,
));
out
}
}
fn severity_str(s: Severity) -> &'static str {
match s {
Severity::Must => "must",
Severity::MustWhenApplies => "must-when-applies",
Severity::Should => "should",
}
}
fn layer_str(layer: Layer) -> String {
match layer {
Layer::Base => "base".to_string(),
Layer::Profile(a) => format!("profile:{}", a.slug()),
}
}
fn surface_shape_str(shape: SurfaceShape) -> &'static str {
match shape {
SurfaceShape::NounVerb => "noun-verb",
SurfaceShape::FlatVerb => "flat-verb",
}
}
fn is_gating_severity(severity: Severity) -> bool {
matches!(severity, Severity::Must | Severity::MustWhenApplies)
}
#[derive(Debug)]
struct ProbeFault {
dim_id: &'static str,
source: std::io::Error,
}
fn build_report(
model: &Model,
resolution: &Resolution,
profile: Archetype,
target: &str,
repo: &Path,
probe_context: &ProbeContext<'_>,
) -> Result<Report, ProbeFault> {
let mut checks = Vec::with_capacity(resolution.entries().len());
for rd in resolution.entries() {
let dim = model
.dimension(rd.id)
.expect("resolution ids resolve in the model");
checks.push(grade(dim, rd.status, repo, probe_context)?);
}
Ok(Report {
tool: "project-canon",
target: target.to_string(),
profile,
surface_shape: resolution.surface_shape(),
checks,
})
}
fn grade(
dim: &Dimension,
status: AppStatus,
repo: &Path,
probe_context: &ProbeContext<'_>,
) -> Result<Check, ProbeFault> {
let base = |status, gates, message, skip_reason| Check {
id: dim.id,
title: dim.title,
severity: dim.severity,
layer: dim.layer,
canon_section: dim.canon_section(),
status,
gates,
message,
skip_reason,
};
if let AppStatus::NotApplicable { gated_by } = status {
let label = gated_by.label();
return Ok(base(
CheckStatus::Skipped,
false,
format!("n/a — conditional trigger off ({label} = no)"),
Some(SkipReason::NotApplicable { gated_by: label }),
));
}
match mechanical_probe(dim.id) {
None => Ok(base(
CheckStatus::Skipped,
false,
"no mechanical probe — deferred to review".to_string(),
Some(SkipReason::DeferredToReview),
)),
Some(probe) => {
let gates = is_gating_severity(dim.severity);
let outcome = probe(repo, probe_context).map_err(|source| ProbeFault {
dim_id: dim.id,
source,
})?;
let status = if outcome.passed {
CheckStatus::Ok
} else if gates {
CheckStatus::Fail
} else {
CheckStatus::Warn
};
Ok(base(status, gates, outcome.message, None))
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn parse(args: &[&str]) -> Result<Command, String> {
parse_args(&args.iter().map(|s| s.to_string()).collect::<Vec<_>>())
}
#[test]
fn defaults_are_cli_profile_and_cwd() {
let cmd = parse(&[]).unwrap();
assert_eq!(
cmd,
Command::Run(DoctorArgs {
repo: ".".to_string(),
profile: Archetype::Cli,
json: false,
verbose: false,
assume_defaults: false,
})
);
}
#[test]
fn parses_all_flags_and_positional() {
let cmd = parse(&[
"--profile",
"service",
"--json",
"--verbose",
"--assume-defaults",
"/some/repo",
])
.unwrap();
assert_eq!(
cmd,
Command::Run(DoctorArgs {
repo: "/some/repo".to_string(),
profile: Archetype::Service,
json: true,
verbose: true,
assume_defaults: true,
})
);
}
#[test]
fn profile_accepts_equals_form() {
let Command::Run(a) = parse(&["--profile=library"]).unwrap() else {
panic!("expected Run");
};
assert_eq!(a.profile, Archetype::Library);
}
#[test]
fn help_short_circuits() {
assert_eq!(parse(&["--help"]).unwrap(), Command::Help);
assert_eq!(parse(&["--json", "--help"]).unwrap(), Command::Help);
}
#[test]
fn unknown_flag_is_rejected_and_echoed() {
let err = parse(&["--nope"]).unwrap_err();
assert!(err.contains("--nope"), "{err}");
}
#[test]
fn bad_profile_echoes_value_and_valid_set() {
let err = parse(&["--profile", "webapp"]).unwrap_err();
assert!(err.contains("webapp"), "{err}");
assert!(err.contains("cli"), "{err}");
}
#[test]
fn missing_profile_value_is_rejected() {
assert!(parse(&["--profile"]).is_err());
}
#[test]
fn repeated_flag_is_rejected() {
assert!(parse(&["--json", "--json"]).is_err());
assert!(parse(&["--profile", "cli", "--profile", "cli"]).is_err());
}
#[test]
fn extra_positional_is_rejected() {
assert!(parse(&["repo-a", "repo-b"]).is_err());
}
struct TmpRepo {
path: std::path::PathBuf,
}
impl TmpRepo {
fn new(tag: &str) -> TmpRepo {
use std::sync::atomic::{AtomicU32, Ordering};
static N: AtomicU32 = AtomicU32::new(0);
let n = N.fetch_add(1, Ordering::Relaxed);
let path =
std::env::temp_dir().join(format!("pc-doctor-{tag}-{}-{n}", std::process::id()));
std::fs::create_dir_all(&path).unwrap();
TmpRepo { path }
}
fn touch(&self, rel: &str) {
let p = self.path.join(rel);
if let Some(parent) = p.parent() {
std::fs::create_dir_all(parent).unwrap();
}
std::fs::write(&p, b"x").unwrap();
}
fn mkdir(&self, rel: &str) {
std::fs::create_dir_all(self.path.join(rel)).unwrap();
}
}
impl Drop for TmpRepo {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.path);
}
}
fn conformant_repo(tag: &str) -> TmpRepo {
let repo = TmpRepo::new(tag);
repo.touch("AGENTS.md");
repo.touch("CLAUDE.md");
repo.mkdir("issues");
repo.mkdir(".git");
repo.touch("README.md");
repo.touch(".gitignore");
repo.mkdir("crates/pc-core");
repo.mkdir("crates/pc-cli");
repo
}
fn report_for(repo: &Path, profile: Archetype) -> Report {
let model = Model::standard();
let resolution = model.resolve(&Questionnaire::builder(profile).build());
let deny_list = std::collections::BTreeSet::new();
let context = ProbeContext {
user_specific_deny_list: &deny_list,
};
build_report(&model, &resolution, profile, "target", repo, &context).expect("no I/O fault")
}
#[test]
fn conformant_repo_has_no_gaps_and_exits_zero() {
let repo = conformant_repo("ok");
let report = report_for(&repo.path, Archetype::Cli);
assert_eq!(report.gaps(), 0);
assert!(report.is_conformant());
assert_eq!(report.exit_code(), EXIT_CONFORMANT);
assert!(report.checks.iter().all(|c| c.status != CheckStatus::Fail));
}
#[test]
fn a_missing_must_scaffold_is_a_gap_and_exits_one() {
let repo = conformant_repo("gap");
std::fs::remove_file(repo.path.join("AGENTS.md")).unwrap();
let report = report_for(&repo.path, Archetype::Cli);
assert_eq!(report.gaps(), 1);
assert!(!report.is_conformant());
assert_eq!(report.exit_code(), EXIT_GAP);
let doc = report
.checks
.iter()
.find(|c| c.id == "base.doc-pattern")
.unwrap();
assert_eq!(doc.status, CheckStatus::Fail);
assert!(doc.gates);
}
#[test]
fn a_missing_should_is_a_warn_not_a_gap() {
let repo = conformant_repo("warn");
std::fs::remove_file(repo.path.join("README.md")).unwrap();
let report = report_for(&repo.path, Archetype::Cli);
assert_eq!(report.gaps(), 0);
assert_eq!(report.exit_code(), EXIT_CONFORMANT);
let readme = report
.checks
.iter()
.find(|c| c.id == "base.readme")
.unwrap();
assert_eq!(readme.status, CheckStatus::Warn);
assert!(!readme.gates);
}
#[test]
fn behavioral_canon_sections_are_deferred_not_failed() {
let repo = conformant_repo("defer");
let report = report_for(&repo.path, Archetype::Cli);
let s1 = report.checks.iter().find(|c| c.id == "canon.s01").unwrap();
assert_eq!(s1.status, CheckStatus::Skipped);
assert_eq!(s1.skip_reason, Some(SkipReason::DeferredToReview));
assert!(!s1.gates);
}
#[test]
fn conditional_sections_are_na_under_conservative_defaults() {
let repo = conformant_repo("na");
let report = report_for(&repo.path, Archetype::Cli);
let s11 = report.checks.iter().find(|c| c.id == "canon.s11").unwrap();
assert_eq!(s11.status, CheckStatus::Skipped);
assert_eq!(
s11.skip_reason,
Some(SkipReason::NotApplicable { gated_by: "Q3" })
);
}
#[test]
fn json_report_carries_the_schema_and_summary() {
let repo = conformant_repo("json");
let report = report_for(&repo.path, Archetype::Cli);
let json = report.to_json().to_string();
assert!(json.contains("\"schema_version\":1"));
assert!(json.contains("\"verb\":\"doctor\""));
assert!(json.contains("\"profile\":\"cli\""));
assert!(json.contains("\"conformant\":true"));
assert!(json.contains("\"exit_code\":0"));
assert!(json.contains("\"surface_shape\":\"flat-verb\""));
}
#[test]
fn human_matrix_hides_skipped_unless_verbose() {
let repo = conformant_repo("human");
let report = report_for(&repo.path, Archetype::Cli);
let terse = report.render_human(false);
assert!(!terse.contains("SKIP"), "{terse}");
assert!(terse.contains("conformant"));
let verbose = report.render_human(true);
assert!(verbose.contains("SKIP"), "{verbose}");
}
#[test]
fn non_cli_profile_has_null_surface_shape() {
let repo = conformant_repo("svc");
let report = report_for(&repo.path, Archetype::Service);
assert_eq!(report.surface_shape, None);
assert!(report
.to_json()
.to_string()
.contains("\"surface_shape\":null"));
}
#[test]
fn valueless_flags_reject_an_inline_value() {
for arg in [
"--json=false",
"--verbose=0",
"--assume-defaults=no",
"--help=x",
] {
let err = parse(&[arg]).unwrap_err();
assert!(
err.contains("does not take a value"),
"{arg} should be rejected, got: {err}"
);
}
}
#[test]
fn double_dash_stops_flag_parsing() {
let Command::Run(a) = parse(&["--", "-weird-repo"]).unwrap() else {
panic!("expected Run");
};
assert_eq!(a.repo, "-weird-repo");
assert!(parse(&["--", "a", "b"]).is_err());
}
#[test]
fn empty_profile_value_is_rejected() {
assert!(parse(&["--profile="]).is_err());
}
#[test]
fn every_fail_gates_and_deferred_never_gates() {
let repo = conformant_repo("inv");
std::fs::remove_file(repo.path.join("AGENTS.md")).unwrap();
let report = report_for(&repo.path, Archetype::Cli);
for c in &report.checks {
if c.status == CheckStatus::Fail {
assert!(c.gates, "a FAIL row must gate: {}", c.id);
}
if c.skip_reason == Some(SkipReason::DeferredToReview) {
assert!(!c.gates, "a deferred row must not gate: {}", c.id);
}
}
}
}