use std::path::{Path, PathBuf};
use anyhow::bail;
use clap::Subcommand;
use crate::verify::{CheckKind, CheckPlan, SuiteStatus};
#[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 (report, fail) = plan_check_report(&root, id)?;
write!(std::io::stdout(), "{report}")?;
if fail {
#[expect(
clippy::disallowed_methods,
reason = "check plan exits non-zero on bare VTs (IMP-209) or undeclared VT test_files (SL-224)"
)]
{
std::process::exit(1);
}
}
Ok(())
}
fn plan_check_report(root: &Path, id: u32) -> anyhow::Result<(String, bool)> {
use std::fmt::Write as _;
let slice_root = root.join(".doctrine/slice");
let plan = crate::slice::read_plan(&slice_root, id)?;
let findings = crate::plan::check_vt_shape(&plan);
let selectors =
crate::slice::selectors(root, id, Some(crate::slice::SelectorIntent::DesignTarget))?;
let coverage = crate::plan::undeclared_test_files(&plan, &selectors);
let mut out = String::new();
if findings.is_empty() && coverage.is_empty() {
writeln!(out, "SL-{id:03}: all VT rows carry structured mandates")?;
return Ok((out, false));
}
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!(out, "SL-{id:03}: {bare_count} bare VT(s) found")?;
}
if opaque_count > 0 {
writeln!(
out,
" ({opaque_count} opaque waiver(s) — add waived_reason)"
)?;
}
if !coverage.is_empty() {
writeln!(
out,
"SL-{id:03}: {} undeclared VT test_file(s) found",
coverage.len()
)?;
}
writeln!(out)?;
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
));
}
for f in &coverage {
lines.push(format!(
" {:<12} {:<8} {:<24} — test_file `{}` declared by no design-target selector",
f.phase_id, f.vt_id, "UndeclaredTestFile", f.path
));
}
lines.sort();
for line in &lines {
writeln!(out, "{line}")?;
}
if bare_count > 0 {
writeln!(
out,
"\nAdd `test_file` + `keywords` to each bare VT row. Re-run until clean."
)?;
}
if !coverage.is_empty() {
let paths: Vec<String> = coverage.iter().map(|f| f.path.clone()).collect();
write!(
out,
"\n{}",
crate::conformance::undeclared_detail(id, &paths)
)?;
}
let fail = bare_count > 0 || !coverage.is_empty();
Ok((out, fail))
}
fn run_proxy(root: &Path, argv: &[String], kind: CheckKind) -> anyhow::Result<()> {
let code = match crate::verify::run_suite(root, argv) {
SuiteStatus::Completed { code } => code,
SuiteStatus::EmptyArgv => {
bail!("internal: empty check argv (resolve_check INV-2 violated)")
}
SuiteStatus::NotFound { program } => bail!(
"`{program}` not found — set [verification].{} in {}",
kind.key(),
crate::dtoml::DOCTRINE_TOML
),
SuiteStatus::SpawnFailed { program, detail } => {
bail!("failed to spawn `{program}`: {detail}")
}
};
#[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(code);
}
}
#[cfg(test)]
mod check_plan_report_tests {
use super::*;
fn scaffold(id: u32, selector: &str, test_file: &str) -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let slice_dir = dir.path().join(".doctrine/slice").join(format!("{id:03}"));
std::fs::create_dir_all(&slice_dir).unwrap();
std::fs::write(
slice_dir.join("plan.toml"),
format!(
r#"
[[phase]]
id = "PHASE-01"
verification = [
{{ id = "VT-1", test_file = "{test_file}", keywords = ["fn"] }},
]
"#
),
)
.unwrap();
std::fs::write(
slice_dir.join(format!("slice-{id:03}.toml")),
format!(
r#"id = {id}
slug = "test-slice"
title = "Test Slice"
status = "started"
created = "2026-07-24"
updated = "2026-07-24"
tags = []
[[selector]]
selector = "{selector}"
intent = "design-target"
"#
),
)
.unwrap();
std::fs::write(
slice_dir.join(format!("slice-{id:03}.md")),
"# Test Slice\n",
)
.unwrap();
dir
}
#[test]
fn covered_test_file_reports_clean_no_fail() {
let dir = scaffold(42, "src/plan.rs", "src/plan.rs");
let (report, fail) = plan_check_report(dir.path(), 42).unwrap();
assert!(!fail);
assert!(report.contains("all VT rows carry structured mandates"));
}
#[test]
fn undeclared_test_file_folds_into_fail_and_names_path() {
let dir = scaffold(42, "src/other.rs", "src/plan.rs");
let (report, fail) = plan_check_report(dir.path(), 42).unwrap();
assert!(fail);
assert!(report.contains("UndeclaredTestFile"));
assert!(report.contains("src/plan.rs"));
assert!(
report
.contains("doctrine slice selector add SL-042 src/plan.rs --intent design-target")
);
}
}