// SPDX-License-Identifier: GPL-3.0-only
//! Command-tier governance spine — the shared CLI/status machinery the per-kind
//! governance modules (`adr`, and `policy` from SL-030) bind with a `GovKind`
//! descriptor. Extracted from `adr.rs` (SL-030 PHASE-02) so a second kind rides
//! the same compute/io rather than copying it (design §5.1, D1).
//!
//! Layering (ADR-001): this is a **command-tier** module — it legitimately uses
//! `root::find`/`clock::today` (shell concerns), so it sits *above* the pure leaf
//! `listing.rs`, not beside it. It depends downward on `entity`/`meta`/`listing`
//! and sideways on `root`/`clock`/`input`; the per-kind modules (`adr`/`policy`)
//! depend on it, and `boot` calls `list_rows` directly. No engine/leaf module
//! depends on `governance`, so no cycle is introduced.
//!
//! Two faces: **io/compute** helpers that take a resolved `root`/`path`
//! (`list_rows`, `set_status`, `read_doc`, `parse_ref`, `format_show`,
//! `show_json` — boot calls `list_rows`) and the thin **shell** wrappers
//! (`run_*`) that do `root::find` + `clock::today` + stdout.

use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};

use anyhow::Context;

use serde::Serialize;

use crate::dtoml;
use crate::entity::{self, Inputs, Kind, MaterialiseRequest};
use crate::listing::{self, Format, ListArgs};
use crate::meta::{self, Meta};

/// The per-kind descriptor the spine is parameterized over (design §5.2). Four
/// fields, all exercised by every governance kind from day one — no dead field.
/// `stem` serves both the file naming (`<stem>-NNN.toml`) AND the JSON
/// envelope/object key, so a kind can never name its files and its JSON
/// incoherently (Codex MINOR-7 — `json_label` dropped).
pub(crate) struct GovKind {
    /// The entity-engine kind: dir, canonical-id prefix, scaffold fn.
    pub kind: Kind,
    /// Known-set — the authority `validate_statuses` checks `--status` against.
    pub statuses: &'static [&'static str],
    /// The default-list hide-set predicate.
    pub hidden: fn(&str) -> bool,
}

// ---------------------------------------------------------------------------
// list — the shared filter/format/project pipeline
// ---------------------------------------------------------------------------

/// One governance entity projected to its faithful JSON row (D7): the prefixed
/// canonical id plus the authored list fields. Replaces the per-kind `AdrRow`.
#[derive(Debug, Serialize)]
struct GovRow {
    id: String,
    status: String,
    slug: String,
    title: String,
    tags: Vec<String>,
}

/// The list rows as a string — the compute half of `run_list`, extracted so the
/// boot snapshot (SL-011) projects the same rows in-process. Rides the shared
/// spine: `listing::build` resolves the filter + format, `validate_statuses`
/// guards `--status` against the kind's known-set (A-2), `retain` applies the
/// hide-set, the kind owns the sort (by id) and the column/JSON projection.
pub(crate) fn list_rows(g: &GovKind, root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
    listing::validate_statuses(&args.status, g.statuses)?;
    let render = args.render;
    let columns = args.columns.take();
    let (filter, format) = listing::build(args)?;
    let gov_root = root.join(g.kind.dir);
    let mut metas = listing::retain(
        meta::read_metas(&gov_root, g.kind.stem, g.kind.prefix)?,
        &filter,
        g.hidden,
        |m| key(g, m),
    );
    metas.sort_by_key(|m| m.id);
    // One materialisation feeds both surfaces — governance's table and JSON
    // rows coincide (SL-037 A4: GovRow is all-String, id pre-prefixed).
    let rows = gov_rows(g, &metas);
    let any_tagged = rows.iter().any(|r| !r.tags.is_empty());
    match format {
        Format::Table => {
            let effective_default = listing::default_with_tags(GOV_DEFAULT, any_tagged);
            let sel =
                listing::select_columns(&GOV_COLUMNS, &effective_default, columns.as_deref())?;
            Ok(listing::render_columns(&rows, &sel, render))
        }
        Format::Json => listing::json_envelope(g.kind.stem, &rows),
    }
}

/// Project a governance `Meta` to its filterable fields (design §5.2). `tags` is
/// empty — governance kinds carry no tag reader yet (Codex BLOCKER-2, parity
/// limitation: ADR's `--tag` matched nothing either; a real reader is a follow-up).
fn key(g: &GovKind, m: &Meta) -> listing::FilterFields {
    listing::FilterFields {
        canonical: listing::canonical_id(g.kind.prefix, m.id),
        slug: m.slug.clone(),
        title: m.title.clone(),
        status: m.status.clone(),
        tags: m.tags.clone(),
    }
}

/// The table columns every governance kind can show (`--columns` tokens over
/// `R = GovRow` — extractors are non-capturing, SL-037 D5; the prefixed id is
/// already materialised in the row). Selection-token order: declaration order
/// is what the unknown-column error lists.
const GOV_COLUMNS: [listing::Column<GovRow>; 5] = [
    listing::Column {
        name: "id",
        header: "id",
        cell: |r| r.id.clone(),
        paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
            owo_colors::AnsiColors::Cyan,
        )),
    },
    listing::Column {
        name: "status",
        header: "status",
        cell: |r| r.status.clone(),
        paint: listing::ColumnPaint::ByValue(|r| listing::status_hue(&r.status)),
    },
    listing::Column {
        name: "tags",
        header: "tags",
        cell: |r| r.tags.join(", "),
        paint: listing::ColumnPaint::PerToken {
            split: |r| r.tags.clone(),
            render: listing::paint_tag,
        },
    },
    listing::Column {
        name: "slug",
        header: "slug",
        cell: |r| r.slug.clone(),
        paint: listing::ColumnPaint::None,
    },
    listing::Column {
        name: "title",
        header: "title",
        cell: |r| r.title.clone(),
        paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
    },
];

/// The default visible set — slug-free (SL-037 D4); `--columns …,slug` reveals it.
const GOV_DEFAULT: &[&str] = &["id", "status", "title"];

/// Faithful rows (D7) — the prefixed id plus the authored list fields. Feeds
/// both the column-projected table and the JSON envelope (table+JSON rows
/// coincide for governance, SL-037 A4).
fn gov_rows(g: &GovKind, metas: &[Meta]) -> Vec<GovRow> {
    metas
        .iter()
        .map(|m| GovRow {
            id: listing::canonical_id(g.kind.prefix, m.id),
            status: m.status.clone(),
            slug: m.slug.clone(),
            title: m.title.clone(),
            tags: m.tags.clone(),
        })
        .collect()
}

// ---------------------------------------------------------------------------
// show — reassemble <stem>-NNN.toml (as data) + <stem>-NNN.md (prose)
// ---------------------------------------------------------------------------

/// The inert `[relationships]` table, read as data for `show` (preserved on disk,
/// ignored by `Meta`). Every axis defaults to empty so a hand-trimmed file still
/// parses. Replaces the per-kind `Relationships`.
///
/// SL-048 PHASE-04 (the cut, OD-3): the `related` axis migrated to uniform
/// `[[relation]]` rows (read via `relation::read_block`), so it is NO LONGER a typed
/// field here. The supersession PAIR (`supersedes` + its ADR-004 §5 carve-out reverse
/// `superseded_by`) and `tags` (free-text classification, not entity refs) stay TYPED
/// — `supersedes` excluded from migration until IMP-006 builds the transactional
/// supersede verb, `superseded_by`/`tags` never were tier-1.
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Deserialize, Serialize)]
struct Relationships {
    #[serde(default)]
    supersedes: Vec<String>,
    #[serde(default)]
    superseded_by: Vec<String>,
}

/// The full `<stem>-NNN.toml` read as data for `show` — `Meta`'s four list fields
/// plus the dates and the relationships table. JSON-faithful (D7). Replaces the
/// per-kind `AdrDoc`.
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, Serialize)]
struct Doc {
    id: u32,
    slug: String,
    title: String,
    status: String,
    created: String,
    updated: String,
    #[serde(default)]
    tags: Vec<String>,
    #[serde(default)]
    relationships: Relationships,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "crate::estimate::deserialize_lenient"
    )]
    estimate: Option<crate::estimate::EstimateFacet>,
    #[serde(
        default,
        skip_serializing_if = "Option::is_none",
        deserialize_with = "crate::value::deserialize_lenient"
    )]
    value: Option<crate::value::ValueFacet>,
}

/// Parse a governance reference — `ADR-007`, `adr-7`, or the bare id `7` — to its
/// numeric id. Delegates to [`parse_entity_ref`]; the error-message label is
/// `"an {prefix}"` (preserving the pre-SL-167 format, article and all).
fn parse_ref(g: &GovKind, reference: &str) -> anyhow::Result<u32> {
    let label = format!("an {}", g.kind.prefix);
    parse_entity_ref(g.kind.prefix, &label, reference)
}

/// Parse an entity reference by prefix — accepts both `PREFIX-NNN` and bare `NNN`.
/// The prefix is stripped in exactly two literal cases (`PREFIX-` or its
/// lowercase), **not** case-insensitively: a case-insensitive strip would newly
/// accept mixed-case forms, an observable behaviour change.
///
/// `prefix` is the canonical prefix string, e.g. `"ADR"`, `"SL"`.
/// `kind_label` is the prose label for error messages, e.g. `"an ADR"`, `"a slice"`.
pub(crate) fn parse_entity_ref(
    prefix: &str,
    kind_label: &str,
    reference: &str,
) -> anyhow::Result<u32> {
    let upper = format!("{prefix}-");
    let lower = format!("{prefix}-").to_lowercase();
    let digits = reference
        .strip_prefix(&upper)
        .or_else(|| reference.strip_prefix(&lower))
        .unwrap_or(reference);
    digits.parse::<u32>().with_context(|| {
        format!("not {kind_label} reference: `{reference}` (expected `{prefix}-007` or `7`)")
    })
}

/// Read one entity's `<stem>-NNN.toml` (as data) and `<stem>-NNN.md` (prose body).
/// Returns the parsed `Doc`, the raw TOML `text` (so the tier-1 `related` `[[relation]]`
/// rows can be read by `relation::read_block` — SL-048 PHASE-04), and the prose body.
fn read_doc(g: &GovKind, root: &Path, id: u32) -> anyhow::Result<(Doc, String, String)> {
    let name = format!("{id:03}");
    let toml_path = entity::id_path(root, &g.kind, id, entity::Ext::Toml);
    let text = fs::read_to_string(&toml_path).with_context(|| {
        format!(
            "{} {name} not found at {}",
            g.kind.stem,
            toml_path.display()
        )
    })?;
    let doc: Doc = dtoml::parse_entity_toml(&text, g.kind.prefix, id)
        .with_context(|| format!("Failed to parse {}", toml_path.display()))?;
    let md_path = entity::id_path(root, &g.kind, id, entity::Ext::Md);
    let body = fs::read_to_string(&md_path)
        .with_context(|| format!("Failed to read {}", md_path.display()))?;
    Ok((doc, text, body))
}

/// The TYPED supersession pair of one governance entity — `(supersedes,
/// superseded_by)` read DIRECTLY from the typed `[relationships]` table (SL-048 §5.5,
/// R2-m2). The generic `read_block`/`outbound_for` path deliberately EXCLUDES
/// `superseded_by` (the ADR-004 §5 derived-inbound carve-out — no reader projects it),
/// so the `validate` supersession cross-check needs this dedicated seam to read the
/// stored reverse field. Pure read, used only by `validate` to report drift between the
/// stored `superseded_by` and the reciprocal derived from `supersedes` in-edges — it
/// NEVER rewrites (the reseat precedent). `g` selects the governance kind (ADR/POL/STD).
///
/// SL-095 PHASE-02: `supersedes` is now a tier-1 relation, so it is read from the
/// `[[relation]]` block, NOT the typed `Doc`. `read_block` is used (NOT `tier1_edges`)
/// so `validate` can see illegal rows. `superseded_by` is still read from the typed `Doc`.
pub(crate) fn supersession_pair(
    g: &GovKind,
    root: &Path,
    id: u32,
) -> anyhow::Result<(Vec<String>, Vec<String>)> {
    let (doc, _toml_text, _body) = read_doc(g, root, id)?;
    // ADR-010 Amendment: governance supersedes stays typed, never migrated to [[relation]].
    Ok((
        doc.relationships.supersedes,
        doc.relationships.superseded_by,
    ))
}

/// A governance entity's authored outbound relations (SL-046 §5.2). Emits
/// `supersedes` → [`RelationLabel::Supersedes`] and `related` →
/// [`RelationLabel::Related`] ONLY. NEVER `superseded_by` (ADR-004 §3/§5: a
/// derived-inbound carve-out, not projected — the reader derives "superseded by"
/// from `in_edges`) and NEVER `tags` (free-text classification, not entity refs).
/// Reads via the shared `read_doc` reader (no new TOML parse). Shared by ADR / POL /
/// STD via the caller-supplied `g`. An empty axis emits nothing.
///
/// SL-095 PHASE-02: `supersedes` is now a tier-1 relation, read from `[[relation]]`
/// with `related` via the shared `tier1_edges` seam.
pub(crate) fn relation_edges(
    g: &GovKind,
    root: &Path,
    id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
    use crate::relation::tier1_edges;
    let (_doc, toml_text, _body) = read_doc(g, root, id)?;
    tier1_edges(&g.kind, &toml_text)
}

/// Render the readable whole for `Table` mode: an identity header, the flat
/// fields, the non-empty relationship axes, then the prose body verbatim. House
/// style: `Vec<String>` parts each carrying their own newline, joined by `concat`
/// (the `backlog::format_show` precedent — avoids the `push_str(&format!)` lint).
///
/// SL-048 PHASE-04 (OD-3): `supersedes`/`superseded_by`/`tags` stay TYPED (read from
/// the struct); only `related` is reconstructed from the migrated `[[relation]]` block
/// (passed in as `related`). The axis render order is unchanged (`supersedes` →
/// `superseded_by` → `related` → `tags`), so output is byte-identical across the migration.
///
/// SL-095 PHASE-02: `supersedes` is now passed in like `related` (read from
/// `[[relation]]`), not a field on `Doc`.
#[expect(clippy::too_many_arguments, reason = "display config adds 4 params")]
fn format_show(
    g: &GovKind,
    doc: &Doc,
    related: &[String],
    body: &str,
    estimation_unit: &str,
    value_unit: &str,
    lower_pct: f64,
    upper_pct: f64,
) -> String {
    let mut parts: Vec<String> = Vec::new();
    parts.push(format!(
        "{} — {}\n",
        listing::canonical_id(g.kind.prefix, doc.id),
        doc.title
    ));
    parts.push(format!("{} · {}\n", doc.slug, doc.status));
    parts.push(format!(
        "created {} · updated {}\n",
        doc.created, doc.updated
    ));
    if let Some(ref est) = doc.estimate {
        parts.push(format!(
            "{}\n",
            crate::estimate::display::format_estimate_confidence(
                est,
                lower_pct,
                upper_pct,
                estimation_unit,
            )
        ));
    }
    if let Some(ref val) = doc.value {
        parts.push(format!(
            "{}\n",
            crate::value::format_value_normal(val, value_unit)
        ));
    }

    let rel = &doc.relationships;
    if !rel.supersedes.is_empty()
        || !rel.superseded_by.is_empty()
        || !related.is_empty()
        || !doc.tags.is_empty()
    {
        parts.push("\nrelationships:\n".to_string());
        for (label, refs) in [
            ("supersedes", &rel.supersedes),
            ("superseded_by", &rel.superseded_by),
            ("related", &related.to_vec()),
            ("tags", &doc.tags),
        ] {
            if !refs.is_empty() {
                parts.push(format!("  {label}: {}\n", refs.join(", ")));
            }
        }
    }

    parts.push(format!("\n{body}"));
    parts.concat()
}

/// Render the `Json` show: the faithful toml-as-data (`Doc`) plus the prose body,
/// under the shared `{kind, <stem>, body}` envelope. The dynamic `<stem>` object
/// key forces a hand-built `serde_json::Map` — the `json!` macro cannot take a
/// runtime key (design R2). Keys serialize BTreeMap-sorted (no `preserve_order`)
/// with no trailing newline — the contract the black-box golden pins.
///
/// SL-048 PHASE-04 (R2-C2′ / OD-3): `related` migrated out of the typed `Doc` into
/// `[[relation]]`, so the serialized `Doc` no longer carries it — it is reconstructed
/// from `related` (read via `read_block`) and spliced back into the `relationships`
/// object under the SAME `related` key, preserving the byte-exact JSON shape (OD-2;
/// all four axes present, `[]` when empty). `serde_json` sorts object keys, so the
/// emitted `relationships` key order (`related`, `superseded_by`, `supersedes`,
/// `tags`) is unchanged across the migration.
///
/// SL-095 PHASE-02: `supersedes` is now passed in like `related` (read from
/// `[[relation]]`), not a field on `Doc`, and spliced back in.
fn show_json(g: &GovKind, doc: &Doc, related: &[String], body: &str) -> anyhow::Result<String> {
    let mut map = serde_json::Map::new();
    map.insert(
        "kind".to_string(),
        serde_json::Value::String(g.kind.stem.to_string()),
    );
    let mut doc_value = serde_json::to_value(doc)
        .with_context(|| format!("failed to serialize {}", g.kind.stem))?;
    // Splice the migrated `related` axis back into the `relationships` object so the
    // JSON shape is byte-identical to the pre-migration typed-field serialization.
    if let Some(rel) = doc_value
        .get_mut("relationships")
        .and_then(serde_json::Value::as_object_mut)
    {
        // supersedes is now typed (ADR-010 Amendment) — already serialized via doc.
        rel.insert("related".to_string(), serde_json::json!(related));
    }
    map.insert(g.kind.stem.to_string(), doc_value);
    map.insert(
        "body".to_string(),
        serde_json::Value::String(body.to_string()),
    );
    serde_json::to_string_pretty(&serde_json::Value::Object(map))
        .with_context(|| format!("failed to serialize {} show JSON", g.kind.stem))
}

// ---------------------------------------------------------------------------
// status — edit-preserving authored-toml transition
// ---------------------------------------------------------------------------

/// Edit-preserving status transition on one authored `<stem>-NNN.toml`: set
/// `status`, stamp `updated`. `toml_edit` mutates the file in place, so the inert
/// `[relationships]` table, hand-added comments, and unknown keys all survive (the
/// file is never reserialised). Carries the I5 no-op guard (an unchanged status
/// writes nothing) and the F-1 malformed-refuse guard (a missing scaffold-seeded
/// key would otherwise tail-insert into `[relationships]` — silent corruption).
/// The date is supplied by the shell (pure/imperative split). The concrete clap
/// enum is bound in the per-kind `run_status` wrapper; this takes a `&str`.
pub(crate) fn set_status(
    g: &GovKind,
    root: &Path,
    id: u32,
    status: &str,
    today: &str,
) -> anyhow::Result<()> {
    let name = format!("{id:03}");
    let path = entity::id_path(root, &g.kind, id, entity::Ext::Toml);
    // Delegate the write-core (no-op guard + F-1 refuse + edit-preserving insert) to
    // the shared authored-TOML seam; this flat kind carries no gate of its own. The
    // F-1 hint is non-destructive (EX-4): restore the seeded keys, never regenerate.
    let hint = format!(
        "malformed {stem} {name}: missing seeded `status`/`updated` — restore the seeded keys before the transition; the file is left untouched",
        stem = g.kind.stem
    );
    crate::dep_seq::set_authored_status(&path, &[("status", status), ("updated", today)], &hint)?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Shell wrappers (root::find + clock + stdout) — bound per kind by &GovKind
// ---------------------------------------------------------------------------

/// `doctrine <kind> new` — allocate the next id and scaffold a new entity. Touches
/// disk via the shared `Fresh` engine path — the monotonic id and race-retry are
/// inherited. The kind's scaffold fn (on `g.kind`) lays out the fileset.
pub(crate) fn run_new(
    g: &GovKind,
    path: Option<PathBuf>,
    title: Option<String>,
    slug: Option<String>,
) -> anyhow::Result<()> {
    let root = crate::root::find(path, &crate::root::default_markers())?;
    let trunk_ids = crate::git::trunk_entity_ids(&root, g.kind.dir)?;
    let (backend, mut reserved) =
        crate::reserve::backend(&root, g.kind.prefix, crate::install::prompt_confirm)?;
    let title = crate::input::resolve_title(title)?;
    let slug = crate::input::resolve_slug(&title, slug)?;
    let date = crate::clock::today();
    let out = entity::materialise(
        &g.kind,
        &*backend,
        &root,
        &MaterialiseRequest::Fresh,
        &Inputs {
            slug: &slug,
            title: &title,
            date: &date,
        },
        &trunk_ids,
        &mut reserved,
    )?;

    let id = out
        .eid
        .numeric_id()
        .with_context(|| format!("{} kind must yield a numeric id", g.kind.stem))?;
    writeln!(
        io::stdout(),
        "Created {} {id:03}: {}",
        g.kind.prefix,
        out.dir.display()
    )?;
    Ok(())
}

/// `doctrine <kind> list` — the migrated read surface: prefixed ids + header, the
/// shared filter flags, the kind's hide-set by default, sorted by id.
pub(crate) fn run_list(g: &GovKind, path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
    let root = crate::root::find(path, &crate::root::default_markers())?;
    let mut out = io::stdout();
    write!(out, "{}", list_rows(g, &root, args)?)?;
    Ok(())
}

/// `doctrine <kind> show <ref>` — the inspect verb. READ-ONLY: resolve the ref to
/// its id, read THAT entity's toml (as data) + md (prose), render the readable
/// whole (`Table`) or the faithful toml-as-data + body (`Json`). No cross-corpus
/// scan; only the one entity's files are opened.
pub(crate) fn run_show(
    g: &GovKind,
    path: Option<PathBuf>,
    reference: &str,
    format: Format,
) -> anyhow::Result<()> {
    use crate::relation::RelationLabel;
    let root = crate::root::find(path, &crate::root::default_markers())?;
    let id = parse_ref(g, reference)?;
    let (doc, toml_text, body) = read_doc(g, &root, id)?;

    // ADR-010 Amendment: supersedes stays typed; related comes from [[relation]].
    let edges = crate::relation::tier1_edges(&g.kind, &toml_text)?;
    let related: Vec<String> = edges
        .iter()
        .filter(|e| e.label == RelationLabel::Related)
        .map(|e| e.target.clone())
        .collect();

    let out = match format {
        Format::Table => {
            let cfg = crate::dtoml::load_doctrine_toml(&root)?;
            let estimation_unit = crate::estimate::resolve_unit(&cfg.estimation);
            let value_unit = crate::value::resolve_unit(&cfg.value);
            let (lower_pct, upper_pct) = crate::estimate::resolve_confidence(&cfg.estimation)?;
            format_show(
                g,
                &doc,
                &related,
                &body,
                &estimation_unit,
                &value_unit,
                lower_pct,
                upper_pct,
            )
        }
        Format::Json => show_json(g, &doc, &related, &body)?,
    };
    write!(io::stdout(), "{out}")?;
    Ok(())
}

/// `doctrine <kind> paths <ref>…` — resolve each ref to its entity directory and
/// print the root-relative paths according to the selection.
pub(crate) fn run_paths(
    g: &GovKind,
    path: Option<PathBuf>,
    refs: &[String],
    sel: &crate::paths::PathSelection,
) -> anyhow::Result<()> {
    let root = crate::root::find(path, &crate::root::default_markers())?;
    let gov_root = root.join(g.kind.dir);
    let mut all_lines: Vec<String> = Vec::new();
    for r in refs {
        let id = parse_ref(g, r)?;
        let name = format!("{id:03}");
        let entity_dir = gov_root.join(&name);
        let toml_name = format!("{}-{name}.toml", g.kind.stem);
        let md_name = format!("{}-{name}.md", g.kind.stem);
        let identity_toml = entity_dir.join(&toml_name);
        let identity_md = entity_dir.join(&md_name);
        let set =
            crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), &root)?;
        let lines = crate::paths::select_paths(&set, sel)?;
        all_lines.extend(lines);
    }
    writeln!(io::stdout(), "{}", all_lines.join("\n"))?;
    Ok(())
}

// ---------------------------------------------------------------------------
// Tests — the shared-spine behaviour, driven by the ADR descriptor (SL-030
// PHASE-02 VT-2; relocated from adr.rs). The ADR-specific render/scaffold tests
// stay in adr.rs. A cfg(test)-only edge to crate::adr (the real descriptor) —
// production code stays adr -> governance only.
// ---------------------------------------------------------------------------

#[cfg(test)]
mod tests {
    use super::*;
    use crate::adr::{ADR_KIND, AdrStatus};
    use crate::test_support::SCHEMA_ADR;

    fn adr_root(root: &Path) -> PathBuf {
        root.join(ADR_KIND.kind.dir)
    }

    /// A no-constraint `ListArgs` (the default `adr list`).
    fn args() -> ListArgs {
        ListArgs::default()
    }

    /// Build a small tree: two ADRs, the first flipped to a given status.
    fn two_adrs(root: &Path, first_status: AdrStatus) {
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Adopt CI".into()),
            None,
        )
        .unwrap();
        set_status(
            &ADR_KIND,
            root,
            1,
            first_status.as_str(),
            &crate::clock::today(),
        )
        .unwrap();
    }

    // --- VT-1: `adr new` writes the tree and allocates monotonically ---

    #[test]
    fn run_new_writes_the_adr_tree_and_allocates_monotonically() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // explicit path short-circuits root detection; the title arg avoids stdin.
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Adopt CI".into()),
            None,
        )
        .unwrap();

        let adr = adr_root(root);
        assert!(adr.join("001/adr-001.toml").is_file());
        assert!(adr.join("001/adr-001.md").is_file());
        assert_eq!(
            fs::read_link(adr.join("001-use-rust")).unwrap(),
            Path::new("001")
        );
        // a second `new` lands the next id (monotonic, engine race-retry inherited).
        assert!(adr.join("002/adr-002.toml").is_file());
        assert_eq!(
            fs::read_link(adr.join("002-adopt-ci")).unwrap(),
            Path::new("002")
        );
    }

    // --- EX-1 / VT-1: the full chain through the real verbs end to end ---

    #[test]
    fn end_to_end_new_x2_list_status_accept_then_filtered_list() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path().to_path_buf();
        run_new(&ADR_KIND, Some(root.clone()), Some("Use Rust".into()), None).unwrap();
        run_new(&ADR_KIND, Some(root.clone()), Some("Adopt CI".into()), None).unwrap();
        // list (the run_list pipeline): both ADRs, sorted by id. `--all` reveals
        // every status; the spine owns the filter, the kind owns the id sort.
        let all = list_rows(
            &ADR_KIND,
            &root,
            ListArgs {
                all: true,
                ..ListArgs::default()
            },
        )
        .unwrap();
        assert!(all.contains("ADR-001"));
        assert!(all.contains("ADR-002"));

        // authored mutation via the real verb core (not a rewrite).
        set_status(
            &ADR_KIND,
            &root,
            1,
            AdrStatus::Accepted.as_str(),
            &crate::clock::today(),
        )
        .unwrap();

        // list --status accepted: only 001 survives the filter.
        let accepted = list_rows(
            &ADR_KIND,
            &root,
            ListArgs {
                status: vec!["accepted".into()],
                ..ListArgs::default()
            },
        )
        .unwrap();
        assert!(accepted.contains("ADR-001"));
        assert!(!accepted.contains("ADR-002"));
    }

    // --- list_rows on the spine — prefixed ids, header, hide-set, filters ---

    #[test]
    fn list_rows_emits_prefixed_ids_and_a_header() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        let out = list_rows(&ADR_KIND, root, args()).unwrap();
        let lines: Vec<&str> = out.lines().collect();
        // VT-1: a header row, then prefixed ADR- ids — not bare `001`.
        assert!(lines[0].starts_with("id"), "header row: {:?}", lines[0]);
        assert!(lines[0].contains("status"), "header names columns");
        assert!(out.contains("ADR-001 │ accepted"), "prefixed id: {out}");
        assert!(out.contains("ADR-002"), "second ADR present: {out}");
        assert!(!out.contains("\n001  "), "no bare numeric id: {out}");
    }

    #[test]
    fn list_rows_hide_set_drops_rejected_superseded_deprecated_by_default() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Keep".into()),
            None,
        )
        .unwrap();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Gone".into()),
            None,
        )
        .unwrap();
        set_status(
            &ADR_KIND,
            root,
            2,
            AdrStatus::Superseded.as_str(),
            "2099-01-01",
        )
        .unwrap();

        // default: the superseded ADR-002 is hidden.
        let out = list_rows(&ADR_KIND, root, args()).unwrap();
        assert!(out.contains("ADR-001"), "non-hidden ADR kept: {out}");
        assert!(
            !out.contains("ADR-002"),
            "superseded hidden by default: {out}"
        );
    }

    #[test]
    fn list_rows_all_and_explicit_status_reveal_the_hide_set() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Keep".into()),
            None,
        )
        .unwrap();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Gone".into()),
            None,
        )
        .unwrap();
        set_status(
            &ADR_KIND,
            root,
            2,
            AdrStatus::Superseded.as_str(),
            "2099-01-01",
        )
        .unwrap();

        // --all reveals it.
        let all = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                all: true,
                ..Default::default()
            },
        )
        .unwrap();
        assert!(all.contains("ADR-002"), "--all reveals superseded: {all}");

        // an explicit --status also reveals it (terminal-hide override).
        let by_status = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                status: vec!["superseded".into()],
                ..Default::default()
            },
        )
        .unwrap();
        assert!(
            by_status.contains("ADR-002"),
            "explicit status reveals: {by_status}"
        );
        assert!(
            !by_status.contains("ADR-001"),
            "and filters to it: {by_status}"
        );
    }

    #[test]
    fn list_rows_filter_matches_slug_and_title() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        let out = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                substr: Some("adopt".into()),
                all: true,
                ..Default::default()
            },
        )
        .unwrap();
        assert!(out.contains("ADR-002"), "substr matches adopt-ci: {out}");
        assert!(!out.contains("ADR-001"), "use-rust filtered out: {out}");
    }

    #[test]
    fn list_rows_regexp_matches_canonical_id() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        // a regex over the canonical id (the slug/title do not contain `ADR-002`).
        let out = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                regexp: Some("ADR-002".into()),
                all: true,
                ..Default::default()
            },
        )
        .unwrap();
        assert!(out.contains("ADR-002"), "regex matches canonical: {out}");
        assert!(!out.contains("ADR-001"), "non-matching dropped: {out}");
    }

    #[test]
    fn list_rows_json_is_the_shared_envelope_with_prefixed_ids() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        let out = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                json: true,
                all: true,
                ..Default::default()
            },
        )
        .unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["kind"], "adr");
        let rows = parsed["rows"].as_array().unwrap();
        assert_eq!(rows.len(), 2);
        assert_eq!(rows[0]["id"], "ADR-001");
        assert_eq!(rows[0]["status"], "accepted");
        assert_eq!(rows[0]["slug"], "use-rust");
    }

    // --- SL-037 column model: slug-free default, --columns projection ---

    #[test]
    fn list_rows_default_table_omits_slug() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        let out = list_rows(&ADR_KIND, root, args()).unwrap();
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(
            lines[0].split_whitespace().collect::<Vec<_>>(),
            ["id", "│", "status", "│", "title"],
            "default header is slug-free: {out}"
        );
        assert!(!out.contains("use-rust"), "slug cell hidden: {out}");
        assert!(out.contains("Use Rust"), "title cell present: {out}");
    }

    #[test]
    fn list_rows_columns_selects_orders_and_reveals_slug() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        let out = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                columns: Some(vec!["slug".into(), "id".into()]),
                ..Default::default()
            },
        )
        .unwrap();
        let lines: Vec<&str> = out.lines().collect();
        assert_eq!(
            lines[0].split_whitespace().collect::<Vec<_>>(),
            ["slug", "│", "id"],
            "requested order wins: {out}"
        );
        assert!(out.contains("use-rust"), "slug revealed: {out}");
        assert!(!out.contains("accepted"), "unselected status hidden: {out}");
    }

    #[test]
    fn list_rows_unknown_column_is_the_uniform_error_listing_available() {
        let dir = tempfile::tempdir().unwrap();
        let err = list_rows(
            &ADR_KIND,
            dir.path(),
            ListArgs {
                columns: Some(vec!["bogus".into()]),
                ..Default::default()
            },
        )
        .unwrap_err()
        .to_string();
        assert!(err.contains("unknown column `bogus`"), "names it: {err}");
        assert!(
            err.contains("id, status, tags, slug, title"),
            "lists the available set: {err}"
        );
    }

    #[test]
    fn list_rows_json_ignores_columns_and_keeps_slug() {
        // D7: --columns has no effect under --json; JSON rows stay faithful (D2).
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        two_adrs(root, AdrStatus::Accepted);

        let plain = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                json: true,
                all: true,
                ..Default::default()
            },
        )
        .unwrap();
        let projected = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                json: true,
                all: true,
                columns: Some(vec!["id".into()]),
                ..Default::default()
            },
        )
        .unwrap();
        assert_eq!(plain, projected, "--columns is a no-op under --json");
        let parsed: serde_json::Value = serde_json::from_str(&projected).unwrap();
        assert_eq!(parsed["rows"][0]["slug"], "use-rust");
    }

    #[test]
    fn list_rows_empty_tree_is_the_empty_string() {
        let dir = tempfile::tempdir().unwrap();
        // no ADRs at all → "" (header suppressed on empty, §5.5).
        assert_eq!(list_rows(&ADR_KIND, dir.path(), args()).unwrap(), "");
    }

    // --- VT-4: --status validates against the kind known-set (A-2) ---

    #[test]
    fn list_rows_rejects_an_unknown_status_with_the_uniform_error() {
        let dir = tempfile::tempdir().unwrap();
        let err = list_rows(
            &ADR_KIND,
            dir.path(),
            ListArgs {
                status: vec!["bogus".into()],
                ..Default::default()
            },
        )
        .unwrap_err()
        .to_string();
        assert!(err.contains("bogus"), "names the bad value: {err}");
        assert!(err.contains("accepted"), "lists the known set: {err}");
    }

    #[test]
    fn list_rows_accepts_every_known_status() {
        let dir = tempfile::tempdir().unwrap();
        for s in ADR_KIND.statuses {
            assert!(
                list_rows(
                    &ADR_KIND,
                    dir.path(),
                    ListArgs {
                        status: vec![(*s).to_string()],
                        ..Default::default()
                    },
                )
                .is_ok(),
                "known status `{s}` accepted"
            );
        }
    }

    // --- ordering-preservation through list_rows ---

    /// Write an ADR's authored toml directly at an explicit id (creating its dir).
    /// Bypasses the monotonic `Fresh` allocator so the fixture's creation order can
    /// be made deliberately out of id-order — the spine's per-kind sort, not read
    /// order, must produce the result. Only the fields the spine reads are written.
    fn adr_at(root: &Path, id: u32, status: &str, slug: &str, title: &str) {
        let name = format!("{id:03}");
        let dir = adr_root(root).join(&name);
        fs::create_dir_all(&dir).unwrap();
        let toml = format!(
            "schema = \"{SCHEMA_ADR}\"\nversion = 1\n\nid = {id}\nslug = \"{slug}\"\ntitle = \"{title}\"\nstatus = \"{status}\"\ncreated = \"2026-06-04\"\nupdated = \"2026-06-04\"\n"
        );
        fs::write(dir.join(format!("adr-{name}.toml")), toml).unwrap();
    }

    /// The byte offsets of each prefixed id in render order — ascending offsets
    /// iff the rows are emitted in that sequence.
    fn id_order(out: &str, ids: &[&str]) -> Vec<usize> {
        ids.iter()
            .map(|id| {
                out.find(id)
                    .unwrap_or_else(|| panic!("{id} present: {out}"))
            })
            .collect()
    }

    #[test]
    fn list_rows_orders_by_id_ascending_regardless_of_creation_order() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        // Create OUT of id order: 003, then 001, then 002.
        adr_at(root, 3, "accepted", "gamma", "Gamma");
        adr_at(root, 1, "accepted", "alpha", "Alpha");
        adr_at(root, 2, "accepted", "beta", "Beta");

        let out = list_rows(&ADR_KIND, root, args()).unwrap();
        let offsets = id_order(&out, &["ADR-001", "ADR-002", "ADR-003"]);
        assert!(
            offsets[0] < offsets[1] && offsets[1] < offsets[2],
            "ADR rows must render in ascending id order (sort, not read order): {out}"
        );
    }

    // --- show — table + json, reassembling toml + md ---

    #[test]
    fn parse_ref_accepts_prefixed_padded_and_bare_ids() {
        assert_eq!(parse_ref(&ADR_KIND, "ADR-007").unwrap(), 7);
        assert_eq!(parse_ref(&ADR_KIND, "adr-7").unwrap(), 7);
        assert_eq!(parse_ref(&ADR_KIND, "7").unwrap(), 7);
        assert_eq!(parse_ref(&ADR_KIND, "042").unwrap(), 42);
        assert!(parse_ref(&ADR_KIND, "nope").is_err());
        // R4: the strip is two literal cases, NOT case-insensitive — a mixed-case
        // prefix is NOT stripped, so it fails to parse (an observable ADR contract).
        assert!(parse_ref(&ADR_KIND, "AdR-7").is_err());
    }

    #[test]
    fn parse_entity_ref_delegates_correctly_for_each_prefix() {
        // ADR
        assert_eq!(parse_entity_ref("ADR", "an ADR", "ADR-007").unwrap(), 7);
        assert_eq!(parse_entity_ref("ADR", "an ADR", "7").unwrap(), 7);
        let err = parse_entity_ref("ADR", "an ADR", "nope")
            .unwrap_err()
            .to_string();
        assert!(err.contains("an ADR"), "error names the kind label: {err}");
        assert!(err.contains("ADR-007"), "error shows expected form: {err}");
        // policy
        assert_eq!(parse_entity_ref("POL", "a policy", "POL-001").unwrap(), 1);
        let err = parse_entity_ref("POL", "a policy", "bad")
            .unwrap_err()
            .to_string();
        assert!(err.contains("a policy"), "{err}");
        // slice
        assert_eq!(parse_entity_ref("SL", "a slice", "SL-025").unwrap(), 25);
        // padded
        assert_eq!(parse_entity_ref("RFC", "an RFC", "011").unwrap(), 11);
        // mixed-case prefix NOT stripped (R4 invariant)
        assert!(parse_entity_ref("ADR", "an ADR", "AdR-7").is_err());
    }

    #[test]
    fn read_doc_reassembles_toml_as_data_and_md_body() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();

        let (doc, _toml_text, body) = read_doc(&ADR_KIND, root, 1).unwrap();
        assert_eq!(doc.id, 1);
        assert_eq!(doc.slug, "use-rust");
        assert_eq!(doc.status, "proposed");
        // the inert relationships table parses as data (empty by default).
        assert!(doc.relationships.superseded_by.is_empty());
        // the md prose body is read verbatim.
        assert!(body.contains("ADR-001: Use Rust"));
        assert!(body.contains("## Context"));
    }

    #[test]
    fn format_show_renders_identity_relationships_and_body() {
        let doc = Doc {
            id: 7,
            slug: "use-rust".into(),
            title: "Use Rust".into(),
            status: "accepted".into(),
            created: "2026-06-01".into(),
            updated: "2026-06-08".into(),
            tags: vec!["lang".into()],
            relationships: Relationships {
                supersedes: vec!["ADR-003".to_string()],
                superseded_by: vec![],
            },
            estimate: None,
            value: None,
        };
        // related is read from [[relation]] rows; supersedes stays typed (ADR-010).
        let related = vec!["ADR-004".to_string()];
        let out = format_show(
            &ADR_KIND,
            &doc,
            &related,
            "# ADR-007: Use Rust\n\nbody.\n",
            "points",
            "points",
            0.0,
            1.0,
        );
        assert!(out.contains("ADR-007 — Use Rust"), "identity: {out}");
        assert!(out.contains("use-rust · accepted"), "flat fields: {out}");
        assert!(out.contains("created 2026-06-01 · updated 2026-06-08"));
        assert!(out.contains("supersedes: ADR-003"), "relationships: {out}");
        assert!(out.contains("related: ADR-004"), "related axis: {out}");
        assert!(out.contains("tags: lang"), "tags axis: {out}");
        assert!(
            out.contains("# ADR-007: Use Rust"),
            "prose body appended: {out}"
        );
    }

    #[test]
    fn format_show_renders_estimate_and_value_when_present() {
        let doc = Doc {
            id: 8,
            slug: "use-rust".into(),
            title: "Use Rust".into(),
            status: "accepted".into(),
            created: "2026-01-01".into(),
            updated: "2026-01-02".into(),
            tags: vec![],
            relationships: Relationships::default(),
            estimate: Some(crate::estimate::EstimateFacet {
                lower: 3.0,
                upper: 8.0,
            }),
            value: Some(crate::value::ValueFacet { value: 42.0 }),
        };
        let related: Vec<String> = vec![];
        let out = format_show(
            &ADR_KIND,
            &doc,
            &related,
            "# Body\n",
            "espresso_shots",
            "magic_beans",
            0.1,
            0.9,
        );
        assert!(
            out.contains("estimate: 3.5–7.5 espresso_shots (80% confidence)"),
            "estimate row: {out}"
        );
        assert!(out.contains("value: 42.0 magic_beans"), "value row: {out}");
    }

    #[test]
    fn show_json_is_faithful_toml_as_data_plus_body() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        let (doc, _toml_text, body) = read_doc(&ADR_KIND, root, 1).unwrap();

        let out = show_json(&ADR_KIND, &doc, &[], &body).unwrap();
        let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
        assert_eq!(parsed["kind"], "adr");
        assert_eq!(parsed["adr"]["id"], 1);
        assert_eq!(parsed["adr"]["slug"], "use-rust");
        assert_eq!(parsed["adr"]["status"], "proposed");
        // supersedes is typed (ADR-010) — serialized from doc.relationships.
        // related is reconstructed from [[relation]] and spliced back in.
        assert!(parsed["adr"]["relationships"]["supersedes"].is_array());
        assert!(parsed["adr"]["relationships"]["related"].is_array());
        assert!(
            parsed["body"].as_str().unwrap().contains("## Context"),
            "body carried in json"
        );
    }

    #[test]
    fn run_show_on_a_missing_adr_errors() {
        let dir = tempfile::tempdir().unwrap();
        let err = run_show(
            &ADR_KIND,
            Some(dir.path().to_path_buf()),
            "ADR-009",
            Format::Table,
        )
        .unwrap_err();
        assert!(err.to_string().contains("not found"), "got: {err}");
    }

    // --- `adr list`'s pipeline reads stem "adr" and filters ---

    #[test]
    fn read_metas_round_trips_created_adrs_and_filters_by_status() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Adopt CI".into()),
            None,
        )
        .unwrap();
        let adr = adr_root(root);

        // flip 002 to accepted via a raw rewrite — enough to prove the list filter
        // selects on the authored toml field (D5).
        let p = adr.join("002/adr-002.toml");
        let flipped = fs::read_to_string(&p)
            .unwrap()
            .replace("status = \"proposed\"", "status = \"accepted\"");
        fs::write(&p, flipped).unwrap();

        // read_metas reads the stem faithfully (the reader round-trip, VT-3); the
        // spine owns the sort/filter, so sort the read set here to pin id 1's fields.
        let mut all = meta::read_metas(&adr, "adr", "ADR").unwrap();
        all.sort_by_key(|m| m.id);
        assert_eq!(all.iter().map(|m| m.id).collect::<Vec<_>>(), vec![1, 2]);
        assert_eq!(
            all.first(),
            Some(&Meta {
                id: 1,
                slug: "use-rust".into(),
                title: "Use Rust".into(),
                status: "proposed".into(),
                tags: vec![],
            })
        );

        // list --status accepted selects on the authored field (the spine filter).
        let accepted = list_rows(
            &ADR_KIND,
            root,
            ListArgs {
                status: vec!["accepted".into()],
                ..ListArgs::default()
            },
        )
        .unwrap();
        assert!(accepted.contains("ADR-002"));
        assert!(!accepted.contains("ADR-001"));
    }

    // --- status flips, `updated` bumps, the rest of the file survives ---

    #[test]
    fn set_status_flips_status_bumps_updated_and_preserves_the_rest() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        let adr = adr_root(root);

        // an injected date distinct from today() so the bump is visible (VT-1).
        set_status(
            &ADR_KIND,
            root,
            1,
            AdrStatus::Accepted.as_str(),
            "2099-01-01",
        )
        .unwrap();

        // re-read through the shared reader: the authored status flipped.
        assert_eq!(
            meta::read_meta(&adr, "adr", 1, "ADR").unwrap().status,
            "accepted"
        );

        let body = fs::read_to_string(adr.join("001/adr-001.toml")).unwrap();
        // `updated` bumped to the injected date; `created` (the seed) untouched.
        assert!(body.contains("updated = \"2099-01-01\""));
        assert!(!body.contains("created = \"2099-01-01\""));
        // toml_edit preserved the inert table and its hand-authored comments.
        assert!(body.contains("[relationships]"));
        assert!(body.contains("`doctrine supersede") || body.contains("doctrine link ADR"));
        // ADR-010: supersedes stays typed — preserved by set_status.
        assert!(body.contains("supersedes    = []"));
    }

    // --- the I5 no-op guard — an unchanged status writes nothing ---

    #[test]
    fn set_status_to_the_current_value_writes_nothing() {
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        let p = adr_root(root).join("001/adr-001.toml");
        let before = fs::read_to_string(&p).unwrap();

        // seed status is "proposed"; the distinct date would bump `updated` IF it
        // wrote — so byte-equality proves the guard short-circuited (I5).
        set_status(
            &ADR_KIND,
            root,
            1,
            AdrStatus::Proposed.as_str(),
            "2099-01-01",
        )
        .unwrap();

        assert_eq!(fs::read_to_string(&p).unwrap(), before);
    }

    // --- a missing id among existing ADRs is a hard error (I3) ---

    #[test]
    fn set_status_on_a_missing_id_among_existing_adrs_errors() {
        // F-2: prove I3 — a missing id *among existing ADRs* is a hard error, not an
        // implicit create.
        let dir = tempfile::tempdir().unwrap();
        let root = dir.path();
        run_new(
            &ADR_KIND,
            Some(root.to_path_buf()),
            Some("Use Rust".into()),
            None,
        )
        .unwrap();
        let err = set_status(
            &ADR_KIND,
            root,
            9,
            AdrStatus::Accepted.as_str(),
            "2099-01-01",
        )
        .unwrap_err();
        assert!(err.to_string().contains("not found"));
    }

    // --- F-1: a malformed entity missing template-seeded keys is refused ---

    #[test]
    fn set_status_on_an_adr_missing_updated_errors() {
        let dir = tempfile::tempdir().unwrap();
        let p = adr_root(dir.path()).join("003/adr-003.toml");
        fs::create_dir_all(p.parent().unwrap()).unwrap();
        // `updated` omitted; a tail `insert` would have landed it in `[relationships]`.
        fs::write(
            &p,
            "status = \"proposed\"\n\n[relationships]\nsupersedes = []\n",
        )
        .unwrap();
        let err = set_status(
            &ADR_KIND,
            dir.path(),
            3,
            AdrStatus::Accepted.as_str(),
            "2099-01-01",
        )
        .unwrap_err();
        let msg = err.to_string().to_lowercase();
        assert!(msg.contains("malformed"), "{msg}");
        // EX-4: the refuse is non-destructive — never instructs regeneration.
        assert!(
            !msg.contains("regenerate") && !msg.contains(" new`") && !msg.contains("scaffold"),
            "F-1 refuse must be non-destructive: {msg}"
        );
    }

    // --- PHASE-03 paths verb golden tests ---

    /// Build a temp project root with one ADR entity (dir + identity files + an extra file).
    fn adr_fixture(id: u32, extra: &[&str]) -> tempfile::TempDir {
        let tmp = tempfile::tempdir().unwrap();
        let name = format!("{id:03}");
        let dir = adr_root(tmp.path()).join(&name);
        fs::create_dir_all(&dir).unwrap();
        fs::write(dir.join(format!("adr-{name}.toml")), "toml").unwrap();
        fs::write(dir.join(format!("adr-{name}.md")), "md").unwrap();
        for e in extra {
            fs::write(dir.join(e), e).unwrap();
        }
        tmp
    }

    #[test]
    fn paths_full_output_shows_toml_md_and_extras_in_canonical_order() {
        let tmp = adr_fixture(1, &["notes.md", "z.log"]);
        let root = tmp.path();
        let sel = crate::paths::PathSelection {
            toml: false,
            md: false,
            entity: false,
            single: false,
        };
        let gov_root = root.join(ADR_KIND.kind.dir);
        let entity_dir = gov_root.join("001");
        let identity_toml = entity_dir.join("adr-001.toml");
        let identity_md = entity_dir.join("adr-001.md");
        let set =
            crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
                .unwrap();
        let lines = crate::paths::select_paths(&set, &sel).unwrap();
        assert_eq!(
            lines.join("\n"),
            ".doctrine/adr/001/adr-001.toml\n.doctrine/adr/001/adr-001.md\n.doctrine/adr/001/notes.md\n.doctrine/adr/001/z.log"
        );
    }

    #[test]
    fn paths_single_truncates_to_first() {
        let tmp = adr_fixture(1, &["notes.md"]);
        let root = tmp.path();
        let sel = crate::paths::PathSelection {
            toml: false,
            md: false,
            entity: false,
            single: true,
        };
        let gov_root = root.join(ADR_KIND.kind.dir);
        let entity_dir = gov_root.join("001");
        let set = crate::paths::scan_entity_dir(
            &entity_dir,
            &entity_dir.join("adr-001.toml"),
            Some(&entity_dir.join("adr-001.md")),
            &root,
        )
        .unwrap();
        let lines = crate::paths::select_paths(&set, &sel).unwrap();
        assert_eq!(lines.len(), 1);
        assert_eq!(lines[0], ".doctrine/adr/001/adr-001.toml");
    }

    #[test]
    fn paths_toml_only() {
        let tmp = adr_fixture(1, &["notes.md"]);
        let root = tmp.path();
        let sel = crate::paths::PathSelection {
            toml: true,
            md: false,
            entity: false,
            single: false,
        };
        let gov_root = root.join(ADR_KIND.kind.dir);
        let entity_dir = gov_root.join("001");
        let set = crate::paths::scan_entity_dir(
            &entity_dir,
            &entity_dir.join("adr-001.toml"),
            Some(&entity_dir.join("adr-001.md")),
            root,
        )
        .unwrap();
        let lines = crate::paths::select_paths(&set, &sel).unwrap();
        assert_eq!(lines, vec![".doctrine/adr/001/adr-001.toml"]);
    }

    #[test]
    fn paths_md_only() {
        let tmp = adr_fixture(1, &[]);
        let root = tmp.path();
        let sel = crate::paths::PathSelection {
            toml: false,
            md: true,
            entity: false,
            single: false,
        };
        let gov_root = root.join(ADR_KIND.kind.dir);
        let entity_dir = gov_root.join("001");
        let set = crate::paths::scan_entity_dir(
            &entity_dir,
            &entity_dir.join("adr-001.toml"),
            Some(&entity_dir.join("adr-001.md")),
            root,
        )
        .unwrap();
        let lines = crate::paths::select_paths(&set, &sel).unwrap();
        assert_eq!(lines, vec![".doctrine/adr/001/adr-001.md"]);
    }

    #[test]
    fn paths_entity_gives_toml_and_md() {
        let tmp = adr_fixture(1, &["extra.txt"]);
        let root = tmp.path();
        let sel = crate::paths::PathSelection {
            toml: false,
            md: false,
            entity: true,
            single: false,
        };
        let gov_root = root.join(ADR_KIND.kind.dir);
        let entity_dir = gov_root.join("001");
        let set = crate::paths::scan_entity_dir(
            &entity_dir,
            &entity_dir.join("adr-001.toml"),
            Some(&entity_dir.join("adr-001.md")),
            root,
        )
        .unwrap();
        let lines = crate::paths::select_paths(&set, &sel).unwrap();
        assert_eq!(
            lines,
            vec![
                ".doctrine/adr/001/adr-001.toml",
                ".doctrine/adr/001/adr-001.md"
            ]
        );
    }

    #[test]
    fn paths_multi_ref_splat_preserves_order() {
        let tmp = adr_fixture(1, &[]);
        let root = tmp.path();
        // Add a second ADR at id 2.
        {
            let dir = adr_root(root).join("002");
            fs::create_dir_all(&dir).unwrap();
            fs::write(dir.join("adr-002.toml"), "toml").unwrap();
            fs::write(dir.join("adr-002.md"), "md").unwrap();
        }
        let sel = crate::paths::PathSelection {
            toml: false,
            md: false,
            entity: false,
            single: false,
        };
        let gov_root = root.join(ADR_KIND.kind.dir);
        let mut all_lines: Vec<String> = Vec::new();
        for n in ["001", "002"] {
            let entity_dir = gov_root.join(n);
            let set = crate::paths::scan_entity_dir(
                &entity_dir,
                &entity_dir.join(format!("adr-{n}.toml")),
                Some(&entity_dir.join(format!("adr-{n}.md"))),
                root,
            )
            .unwrap();
            all_lines.extend(crate::paths::select_paths(&set, &sel).unwrap());
        }
        assert_eq!(all_lines.len(), 4);
        assert!(all_lines[0].contains("001/adr-001.toml"));
        assert!(all_lines[2].contains("002/adr-002.toml"));
    }

    #[test]
    fn paths_invalid_ref_errors() {
        let tmp = adr_fixture(1, &[]);
        let root = tmp.path();
        // Parse a ref to a non-existent entity.
        let _id = parse_ref(&ADR_KIND, "ADR-99999").unwrap();
        let gov_root = root.join(ADR_KIND.kind.dir);
        let entity_dir = gov_root.join("99999");
        let result = crate::paths::scan_entity_dir(
            &entity_dir,
            &entity_dir.join("adr-99999.toml"),
            Some(&entity_dir.join("adr-99999.md")),
            root,
        );
        assert!(result.is_err());
    }
}
