doctrine 0.9.3

Project tooling CLI
// SPDX-License-Identifier: GPL-3.0-only
//! `doctrine check {quick|commit|gate}` — the cadence proxy verb (SL-163).
//!
//! Resolves a project-declared check command from the OWNED `[verification]`
//! contract and proxy-executes it: inherit stdio, no timeout, forward the exit
//! code (incl. `128+signo` on signal death). The pure cadence resolution lives in
//! the `verify` leaf ([`crate::verify::resolve_check`]); this module is the impure
//! shell (ADR-001) — root detection, the config read, spawn, and exit forwarding.
//!
//! The OPPOSITE posture to `coverage_verify::run_argv` (pipe + capture + cap): a
//! dev gate streams live and may legitimately run long (design §5.4 / D5).

use std::path::{Path, PathBuf};
use std::process::{Command, ExitStatus};

use anyhow::bail;
use clap::Subcommand;

use crate::verify::{CheckKind, CheckPlan};

/// The three check cadences as a clap subcommand. Each carries `-p/--path` (CR-F6),
/// threaded to root detection (aids e2e temp-root). clap-owned (ADR-001 / A2) —
/// bridges to the leaf [`CheckKind`] via [`From`], keeping the leaf clap-free.
#[derive(Debug, Subcommand)]
pub(crate) enum CheckCommand {
    /// Per-edit cadence. Unconfigured ⇒ an owned no-op (exit 0; never fails a hook).
    Quick {
        /// Explicit project root (default: auto-detect).
        #[arg(short = 'p', long)]
        path: Option<PathBuf>,
    },
    /// Per-commit cadence. Unconfigured ⇒ `just check`.
    Commit {
        /// Explicit project root (default: auto-detect).
        #[arg(short = 'p', long)]
        path: Option<PathBuf>,
    },
    /// End-of-phase cadence. Unconfigured ⇒ `just gate`.
    Gate {
        /// Explicit project root (default: auto-detect).
        #[arg(short = 'p', long)]
        path: Option<PathBuf>,
    },
    /// S1 regression baseline-diff (SL-170) — capture a failure-set baseline at a
    /// base ref, then diff a later run against it. NOT a cadence proxy: this verb
    /// runs the per-test suite itself and partitions failures (new/changed/fixed/
    /// persistent), halting on a regression regardless of which env it surfaces under.
    Regression {
        #[command(subcommand)]
        command: RegressionCommand,
    },
}

/// `doctrine check regression {capture|diff}` — the S1 gate verbs. `--base <B>` is
/// the funnel's live pre-spawn HEAD (INV-2), the cache key + render label.
#[derive(Debug, Subcommand)]
pub(crate) enum RegressionCommand {
    /// Run the suite on the coord tree (at `B`) and record `baseline-<B>`; no-op
    /// on a cache hit.
    Capture {
        /// The base ref `B` (the funnel's pre-spawn HEAD) — cache key + label.
        #[arg(long)]
        base: String,
        /// Explicit project root (default: auto-detect).
        #[arg(short = 'p', long)]
        path: Option<PathBuf>,
    },
    /// Run the suite at `S`, diff against `baseline-<B>`, and exit non-zero iff a
    /// new or changed failure surfaced (INV-7), or a run was unobtainable (INV-5).
    Diff {
        /// The base ref `B` whose baseline to diff against.
        #[arg(long)]
        base: String,
        /// Explicit project root (default: auto-detect).
        #[arg(short = 'p', long)]
        path: Option<PathBuf>,
    },
}

/// `doctrine check <kind>` — resolve the cadence's plan from the owned config and
/// act: print + exit-0 the owned no-op, error toward the key on an empty override,
/// or proxy-spawn the resolved argv (diverging via [`run_proxy`]).
pub(crate) fn dispatch(cmd: CheckCommand) -> anyhow::Result<()> {
    use std::io::Write;
    // `regression` is a real handler (runs the suite + caches), not a cadence proxy.
    let (kind, path) = match cmd {
        CheckCommand::Regression { command } => return dispatch_regression(command),
        CheckCommand::Quick { path } => (CheckKind::Quick, path),
        CheckCommand::Commit { path } => (CheckKind::Commit, path),
        CheckCommand::Gate { path } => (CheckKind::Gate, 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}")?;
            // Owned no-op: doctrine exits 0 itself — no child spawned (CR-F3).
            #[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),
    }
}

/// Route a `check regression {capture|diff}` invocation: resolve the root, then
/// capture (write a baseline) or diff (exit non-zero on a regression — INV-7).
/// `diff` forwards its computed exit code as the verb's terminal status.
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);
            }
        }
    }
}

/// Proxy-spawn `argv` with `cwd == root`, INHERITING stdio (live stream; not piped)
/// and NO timeout (design §5.4 / D5). Reached only with a non-empty `argv` (INV-2).
/// Diverges via [`std::process::exit`] on a completed child — safe, stdio is
/// inherited so nothing is buffered/owned to flush (R2). A missing program
/// (`ENOENT`) yields an actionable error naming the owned config key (D3).
fn run_proxy(root: &Path, argv: &[String], kind: CheckKind) -> anyhow::Result<()> {
    let Some((program, args)) = argv.split_first() else {
        // INV-2: resolve_check never yields Run([]) — defend rather than panic.
        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));
    }
}

/// True exit forwarding (CR-F5): a normal exit yields its code; a signal-killed
/// child re-exits `128 + signo` (shell convention), not a flattened `1`. Unix-only
/// branch; doctrine targets linux/nixos.
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
    }
}