spec-spine-cli 0.24.0

The `spec-spine` command-line tool: compile a markdown spec corpus into a deterministic authority registry and query it. A thin wrapper over spec-spine-core.
//! `spec-spine compile`: write the per-spec registry shards (deterministic;
//! spec 022) under `<derived_dir>/spec-registry/by-spec/`, plus the wall-clock
//! `build-meta.json` sidecar. The single monolithic `registry.json` is no
//! longer emitted, so two PRs that add or edit different specs write disjoint
//! files and never conflict on a global content-hash line.
//!
//! `--check` (spec 028) is the non-writing form: it compiles in memory and
//! compares against the committed shards, the registry counterpart of
//! `index check`.

use std::fs;
use std::path::Path;

use spec_spine_core::shard::{self, BY_SPEC_DIR};
use spec_spine_core::{
    CompileOutcome, Freshness, compare_committed_registry, registry_dir, registry_shard_files,
};
use spec_spine_types::{
    BUILD_META_SCHEMA_VERSION, BuildMeta, Error, Severity, Verdict, verdict::verb,
};
use time::OffsetDateTime;
use time::format_description::well_known::Rfc3339;

use crate::load_repo_config;
use crate::out;

/// Returns the process exit code.
///
/// Writing form: `0` if validation passed, `1` if it failed. `--check` form:
/// `0` fresh, `1` validation failed, `2` stale (spec 028 §3.2). Validation
/// outranks staleness, because a corpus that does not validate cannot vouch
/// for its shards.
///
/// `json` (spec 034) replaces the stdout prose with one verdict envelope and
/// changes no exit code. It is rejected without `--check`, per spec 034 §4: the
/// writing form's purpose is to mutate `.derived`, and handing a driver a
/// machine-readable verdict from the command that just regenerated the shards
/// the next gate compares against invites exactly the mid-chain confusion the
/// non-writing `--check` exists to avoid.
///
/// `fail_on_warn` (spec 064 §3.2) turns any warning-tier violation into exit
/// `1`, on every form of the verb. It gates the **exit code only**: it never
/// touches `validation.passed`, which spec 001 §3.2 fixes as "false iff any
/// error-tier violation is present", and never changes an emitted byte, which
/// `compile.rs::fail_on_warn_writes_identical_shards` pins. That is the same
/// arrangement spec 003 uses for the lint's `L-` codes, where severity gating
/// is applied by the CLI over diagnostics the layer below just produces.
pub fn run(
    repo: &Path,
    check: bool,
    json: bool,
    spec: Option<&str>,
    fail_on_warn: bool,
) -> Result<u8, Error> {
    // Spec 049 §3.1: `--spec` and `--check` are different questions (is this
    // well-formed / do the committed shards match), and a combined form would
    // have to invent an answer for a spec with no shard.
    if let Some(id) = spec {
        if check {
            let err = Error::Config(
                "compile --spec is incompatible with --check: --spec validates one spec \
                 against the committed registry, --check compares the whole shard tree \
                 (spec 049 3.1)"
                    .to_string(),
            );
            if json {
                crate::emit_error_envelope(verb::COMPILE_SPEC, &err);
            } else {
                eprintln!("spec-spine: {err}");
            }
            return Ok(err.exit_code());
        }
        return run_one_spec(repo, id, json, fail_on_warn);
    }
    if json && !check {
        // Written here rather than raised for `main` to render: `json_verb`
        // deliberately maps only `compile --check`, so a caller that asked for
        // JSON still gets JSON and the envelope's verb is a local, documented
        // choice (`compile.check` is the only machine-readable form of this
        // command, and the message names the flag that reaches it).
        let err = Error::Config(
            "compile --json requires --check: the writing form has no machine-readable \
             verdict (spec 034 4); use `compile --check --json`"
                .to_string(),
        );
        crate::emit_error_envelope(verb::COMPILE_CHECK, &err);
        return Ok(err.exit_code());
    }
    let cfg = load_repo_config(repo)?;
    let outcome = spec_spine_core::compile(&cfg, repo)?;

    if check {
        if !outcome.validation_passed {
            if json {
                // No stderr copy: the envelope `main` renders from this error
                // carries the violations themselves (spec 034 D-4), so printing
                // them again would make this the one failure path in the chain
                // that writes prose to a second channel under `--json`.
                return Err(Error::Validation(
                    outcome.registry.validation.violations.clone(),
                ));
            }
            report_validation_failure(&outcome);
            return Ok(1);
        }
        let freshness = compare_committed_registry(&cfg, repo, &outcome.shards)?;
        // A refused warning is exit 1, the validation-failure rung, and it
        // outranks staleness for the reason spec 062 §3.3 gives: staleness is
        // not the more severe answer when the corpus itself was refused.
        let warn_refused = fail_on_warn && outcome.warning_count() > 0;
        if json {
            let code = if warn_refused {
                1
            } else if matches!(freshness, Freshness::Fresh) {
                0
            } else {
                2
            };
            out::verdict(&Verdict::report(
                verb::COMPILE_CHECK,
                code,
                freshness_report(&freshness),
            ))?;
            return Ok(code);
        }
        if warn_refused {
            report_warn_refusal(&outcome);
            return Ok(1);
        }
        return match freshness {
            Freshness::Fresh => {
                outln!(
                    "spec-registry is fresh: {} shard(s) match the corpus",
                    outcome.registry.specs.len()
                );
                Ok(0)
            }
            // Stale detail goes to stderr so it surfaces in a CI log. `actual`
            // is already the count line plus one line per stale shard; the
            // paired `expected` ("N shard(s) matching the corpus") is
            // deliberately not printed, because the operator's next action does
            // not depend on it. It stays on the typed verdict for library and
            // JSON-facade consumers.
            Freshness::Stale { actual, .. } => {
                eprintln!("{actual}");
                eprintln!("spec-registry is STALE: run `spec-spine compile` and commit the result");
                Ok(2)
            }
        };
    }

    let out_dir = registry_dir(&cfg, repo);
    fs::create_dir_all(&out_dir)
        .map_err(|e| Error::Io(format!("create {}: {e}", out_dir.display())))?;

    // Per-spec shards. `sync_dir` prunes a removed spec's shard, so the shard set
    // always equals the current corpus.
    let shard_files = registry_shard_files(&outcome.shards)?;
    let by_spec = out_dir.join(BY_SPEC_DIR);
    shard::sync_dir(&by_spec, &shard_files)?;

    // Drop a pre-024 monolithic registry.json on upgrade (it is no longer the
    // committed form; the shard tree supersedes it).
    let legacy = out_dir.join("registry.json");
    if legacy.exists() {
        fs::remove_file(&legacy)
            .map_err(|e| Error::Io(format!("remove {}: {e}", legacy.display())))?;
    }

    // build-meta.json carries the wall clock; the CLI owns it. Excluded from
    // determinism/golden checks and from version control (see .gitignore).
    let meta = BuildMeta {
        schema_version: BUILD_META_SCHEMA_VERSION.to_string(),
        built_at: now_rfc3339(),
        compiler_id: cfg.branding.compiler_id.clone(),
        compiler_version: env!("CARGO_PKG_VERSION").to_string(),
    };
    let meta_json =
        serde_json::to_string_pretty(&meta).map_err(|e| Error::Schema(e.to_string()))? + "\n";
    let meta_path = out_dir.join("build-meta.json");
    fs::write(&meta_path, meta_json)
        .map_err(|e| Error::Io(format!("write {}: {e}", meta_path.display())))?;

    let warnings = outcome.warning_count();

    if outcome.validation_passed {
        outln!(
            "compiled {} spec(s) -> {} ({} warning(s))",
            outcome.registry.specs.len(),
            by_spec.display(),
            warnings
        );
        // The shards above are already written, and identically so: the flag
        // decides the exit code after emission, never what was emitted.
        if fail_on_warn && warnings > 0 {
            report_warn_refusal(&outcome);
            return Ok(1);
        }
        Ok(0)
    } else {
        report_validation_failure(&outcome);
        Ok(1)
    }
}

/// The `{ fresh, expected?, actual? }` value, byte-for-byte the shape
/// `spec_spine_core::check_registry_freshness_json` (and its index twin
/// `check_freshness_json`) return, so a consumer handles one freshness type for
/// both committed trees.
///
/// Rebuilt here rather than shared with the facade because the facade recompiles
/// the corpus to answer, which is a second full compile pass on a gate that runs
/// in CI; the CLI already holds the typed verdict. `cli.rs` pins the two against
/// each other so the duplication cannot drift silently.
pub(crate) fn freshness_report(freshness: &Freshness) -> serde_json::Value {
    match freshness {
        Freshness::Fresh => serde_json::json!({ "fresh": true }),
        Freshness::Stale { expected, actual } => {
            serde_json::json!({ "fresh": false, "expected": expected, "actual": actual })
        }
    }
}

/// Print every warning-tier violation, then say the flag that refused them
/// (spec 064 §3.2).
///
/// Always stderr, so the refusal surfaces in a CI log next to the errors that
/// use the same channel. The codes are named because a bare exit `1` from a
/// verb that also spends `1` on validation failure would leave a reader unable
/// to tell which happened.
fn report_warn_refusal(outcome: &CompileOutcome) {
    for v in &outcome.registry.validation.violations {
        if v.severity == Severity::Warning {
            let at = v.path.as_deref().unwrap_or("-");
            eprintln!("  {} [{}] {}", v.code, at, v.message);
        }
    }
    eprintln!(
        "REFUSED: {} warning(s) across {} spec(s) (--fail-on-warn)",
        outcome.warning_count(),
        outcome.registry.specs.len()
    );
}

/// Print every error-tier violation, then the summary. Always stderr, so the
/// failures surface in a CI log. Shared by the writing and `--check` forms so
/// both fail with the same diagnostic.
fn report_validation_failure(outcome: &CompileOutcome) {
    let violations = &outcome.registry.validation.violations;
    for v in violations {
        if v.severity == Severity::Error {
            let at = v.path.as_deref().unwrap_or("-");
            eprintln!("  {} [{}] {}", v.code, at, v.message);
        }
    }
    let errors = violations
        .iter()
        .filter(|v| v.severity == Severity::Error)
        .count();
    let warnings = violations
        .iter()
        .filter(|v| v.severity == Severity::Warning)
        .count();
    eprintln!(
        "validation FAILED: {errors} error(s), {warnings} warning(s) across {} spec(s)",
        outcome.registry.specs.len()
    );
}

fn now_rfc3339() -> String {
    OffsetDateTime::now_utc()
        .format(&Rfc3339)
        .unwrap_or_else(|_| "unknown".to_string())
}

/// `compile --spec <id>`: validate one spec, write nothing (spec 049).
///
/// Exit `0` when the spec produces no error-tier violation, `1` when it does or
/// when the id resolves to nothing, `3` for I/O, parse, schema or config
/// failure. Never `2`: nothing here is a staleness question.
fn run_one_spec(repo: &Path, id: &str, json: bool, fail_on_warn: bool) -> Result<u8, Error> {
    let cfg = load_repo_config(repo)?;
    let report = spec_spine_core::compile_spec(&cfg, repo, id)?;
    let warnings = report
        .violations
        .iter()
        .filter(|v| v.severity == Severity::Warning)
        .count();
    let code = if !report.passed || (fail_on_warn && warnings > 0) {
        1
    } else {
        0
    };

    if json {
        let value = serde_json::to_value(&report).map_err(|e| Error::Schema(e.to_string()))?;
        out::verdict(&Verdict::report(verb::COMPILE_SPEC, code, value))?;
        return Ok(code);
    }

    for v in &report.violations {
        // Errors to stderr so they surface in a CI log; warnings and info too,
        // since the whole output of this verb is its diagnostics.
        eprintln!("  {} [{}] {}", v.code, report.spec_path, v.message);
    }
    if report.passed && code == 1 {
        // Valid on its own axis, refused on the flag's. Saying only "valid"
        // beside an exit 1 would read as a bug in the tool.
        eprintln!(
            "{}: REFUSED: {warnings} warning(s) (--fail-on-warn), nothing written",
            report.spec_id
        );
    } else if report.passed {
        outln!(
            "{}: valid ({warnings} warning(s), nothing written)",
            report.spec_id
        );
    } else {
        eprintln!(
            "{}: INVALID: {} error(s)",
            report.spec_id,
            report
                .violations
                .iter()
                .filter(|v| v.severity == Severity::Error)
                .count()
        );
    }
    Ok(code)
}