use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};
use anyhow::bail;
use clap::Subcommand;
use crate::verify::{CheckKind, CheckPlan};
#[derive(Debug, Subcommand)]
pub(crate) enum CheckCommand {
Quick {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Commit {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Gate {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Prove {
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Regression {
#[command(subcommand)]
command: RegressionCommand,
},
Plan {
#[arg(value_parser = crate::slice::parse_cli_id)]
id: u32,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Debug, Subcommand)]
pub(crate) enum RegressionCommand {
Capture {
#[arg(long)]
base: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Diff {
#[arg(long)]
base: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: CheckCommand) -> anyhow::Result<()> {
use std::io::Write;
let (kind, path) = match cmd {
CheckCommand::Regression { command } => return dispatch_regression(command),
CheckCommand::Plan { id, path } => return run_check_plan(path, id),
CheckCommand::Quick { path } => (CheckKind::Quick, path),
CheckCommand::Commit { path } => (CheckKind::Commit, path),
CheckCommand::Gate { path } => (CheckKind::Gate, path),
CheckCommand::Prove { path } => (CheckKind::Prove, path),
};
let root = crate::root::find(path, &crate::root::default_markers())?;
let cfg = crate::coverage_store::load_config(&root)?;
match crate::verify::resolve_check(&cfg, kind) {
CheckPlan::Noop(note) => {
writeln!(std::io::stdout(), "{note}")?;
#[expect(
clippy::disallowed_methods,
reason = "the check verb forwards a terminal exit status; an owned no-op exits 0 (design §5.4)"
)]
{
std::process::exit(0);
}
}
CheckPlan::Empty(k) => bail!(
"[verification].{} is empty — set a non-empty argv in {}",
k.key(),
crate::dtoml::DOCTRINE_TOML
),
CheckPlan::Run(argv) => run_proxy(&root, &argv, kind),
}
}
fn dispatch_regression(cmd: RegressionCommand) -> anyhow::Result<()> {
match cmd {
RegressionCommand::Capture { base, path } => {
let root = crate::root::find(path, &crate::root::default_markers())?;
crate::regression_run::run_capture(&root, &base)
}
RegressionCommand::Diff { base, path } => {
let root = crate::root::find(path, &crate::root::default_markers())?;
let code = crate::regression_run::run_diff(&root, &base)?;
#[expect(
clippy::disallowed_methods,
reason = "the regression diff verb forwards its gate verdict as the terminal exit code (INV-7)"
)]
{
std::process::exit(code);
}
}
}
}
fn run_check_plan(path: Option<PathBuf>, id: u32) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let slice_root = root.join(".doctrine/slice");
let plan = crate::slice::read_plan(&slice_root, id)?;
let findings = crate::plan::check_vt_shape(&plan);
if findings.is_empty() {
writeln!(
std::io::stdout(),
"SL-{id:03}: all VT rows carry structured mandates"
)?;
return Ok(());
}
let bare_count = findings
.iter()
.filter(|f| !matches!(f.problem, crate::plan::VtShapeProblem::MissingWaiverReason))
.count();
let opaque_count = findings.len() - bare_count;
if bare_count > 0 {
writeln!(
std::io::stdout(),
"SL-{id:03}: {bare_count} bare VT(s) found"
)?;
}
if opaque_count > 0 {
writeln!(
std::io::stdout(),
" ({opaque_count} opaque waiver(s) — add waived_reason)"
)?;
}
writeln!(std::io::stdout())?;
let mut lines: Vec<String> = Vec::new();
for f in &findings {
let (label, detail) = match f.problem {
crate::plan::VtShapeProblem::BareTestFile => (
"BareTestFile",
"no test_file — VT is UNCHECKABLE at runtime",
),
crate::plan::VtShapeProblem::BareKeywords => {
("BareKeywords", "keywords empty — vacuous pass at runtime")
}
crate::plan::VtShapeProblem::MissingWaiverReason => (
"MissingWaiverReason",
"waived but no reason recorded — opaque",
),
};
lines.push(format!(
" {:<12} {:<8} {:<24} — {detail}",
f.phase_id, f.vt_id, label
));
}
lines.sort();
for line in &lines {
writeln!(std::io::stdout(), "{line}")?;
}
if bare_count > 0 {
writeln!(
std::io::stdout(),
"\nAdd `test_file` + `keywords` to each bare VT row. Re-run until clean."
)?;
#[expect(
clippy::disallowed_methods,
reason = "check plan exits non-zero on bare VTs (IMP-209)"
)]
{
std::process::exit(1);
}
}
Ok(())
}
fn run_proxy(root: &Path, argv: &[String], kind: CheckKind) -> anyhow::Result<()> {
let Some((program, args)) = argv.split_first() else {
bail!("internal: empty check argv (resolve_check INV-2 violated)");
};
let status = Command::new(program)
.args(args)
.current_dir(root)
.status()
.map_err(|e| {
if e.kind() == std::io::ErrorKind::NotFound {
anyhow::anyhow!(
"`{program}` not found — set [verification].{} in {}",
kind.key(),
crate::dtoml::DOCTRINE_TOML
)
} else {
anyhow::Error::new(e).context(format!("failed to spawn `{program}`"))
}
})?;
#[expect(
clippy::disallowed_methods,
reason = "true exit forwarding (CR-F5): the proxied child's code/signal is the verb's terminal status (design §5.4)"
)]
{
std::process::exit(exit_code(status));
}
}
fn exit_code(status: ExitStatus) -> i32 {
if let Some(code) = status.code() {
return code;
}
#[cfg(unix)]
{
use std::os::unix::process::ExitStatusExt;
status.signal().map_or(1, |s| 128 + s)
}
#[cfg(not(unix))]
{
1
}
}