corpora-rules 0.1.0

Validation rule pack (E3, held-decision, schema) for the corpora docs validator.
Documentation
//! Schema-table validator: **kind↔authority** for every record, and **status↔lifecycle**
//! for decisions. Field presence/types are enforced earlier by the parser; this rule
//! covers the cross-field semantic consistency that only makes sense on a built record.

use corpora_core::{Authority, Diagnostic, Facet, Graph, Kind, Lifecycle, Status};

use crate::Rule;

pub struct Schema;

impl Rule for Schema {
    fn code(&self) -> &'static str {
        "SCHEMA"
    }

    fn check(&self, g: &Graph, out: &mut Vec<Diagnostic>) {
        for r in g.records() {
            let allowed = allowed_authorities(r.kind);
            if !allowed.contains(&r.authority) {
                out.push(Diagnostic::error(
                    "SCHEMA",
                    &r.path,
                    format!(
                        "authority {:?} is not allowed for kind {:?} (expected one of {:?})",
                        r.authority, r.kind, allowed
                    ),
                ));
            }

            if let Facet::Decision(d) = &r.facet {
                let lifecycles = allowed_lifecycles(d.status);
                if !lifecycles.contains(&r.lifecycle) {
                    out.push(Diagnostic::error(
                        "SCHEMA",
                        &r.path,
                        format!(
                            "status {:?} is inconsistent with lifecycle {:?} (expected one of {:?})",
                            d.status, r.lifecycle, lifecycles
                        ),
                    ));
                }
            }
        }
    }
}

fn allowed_authorities(kind: Kind) -> &'static [Authority] {
    use Authority::*;
    match kind {
        Kind::Decision => &[Normative],
        Kind::Axiom => &[Axiomatic, Normative],
        Kind::Invariant => &[Normative],
        Kind::Architecture => &[Normative],
        Kind::Current => &[Descriptive],
        Kind::Roadmap => &[Prospective],
        Kind::Milestone => &[Operational, Prospective],
        Kind::Evidence => &[Evidence],
        Kind::ReviewLog => &[Historical],
        Kind::Evolution => &[Historical],
        Kind::Handoff => &[Operational],
        Kind::Explainer => &[Explanatory],
        Kind::Index => &[Navigational],
    }
}

/// Allowed lifecycles per adoption status, following schema-v0 §3 semantics: pending
/// choices (open/proposed) are drafts; an accepted choice is the live `current`; a
/// superseded one is `superseded`; a dropped (`deprecated`) or never-adopted (`rejected`)
/// choice is retired to `historical` (a rejected draft may also stay `draft`).
fn allowed_lifecycles(status: Status) -> &'static [Lifecycle] {
    use Lifecycle::*;
    match status {
        Status::Open => &[Draft],
        Status::Proposed => &[Draft],
        Status::Accepted => &[Current],
        Status::Superseded => &[Superseded],
        Status::Deprecated => &[Historical],
        Status::Rejected => &[Historical, Draft],
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use corpora_core::*;
    use std::sync::Arc;

    fn build(records: Vec<Record>) -> Graph {
        Graph::build(records.into_iter().map(Arc::new).collect()).0
    }

    fn decision(status: Status, lifecycle: Lifecycle, authority: Authority) -> Record {
        Record::minimal(
            Some(Id("D1".into())),
            DocPath("d1.md".into()),
            Kind::Decision,
            lifecycle,
            authority,
            Facet::Decision(DecisionFacet {
                status,
                date: Date("2026-06-21".into()),
                implementation: None,
                fork: None,
                realized_by: vec![],
            }),
        )
    }

    #[test]
    fn wrong_authority_for_kind_flagged() {
        let g = build(vec![decision(Status::Accepted, Lifecycle::Current, Authority::Descriptive)]);
        let mut out = Vec::new();
        Schema.check(&g, &mut out);
        assert_eq!(out.len(), 1);
        assert_eq!(out[0].code, "SCHEMA");
    }

    #[test]
    fn status_lifecycle_mismatch_flagged() {
        // accepted ⇒ current; pairing it with draft is inconsistent.
        let g = build(vec![decision(Status::Accepted, Lifecycle::Draft, Authority::Normative)]);
        let mut out = Vec::new();
        Schema.check(&g, &mut out);
        assert!(out.iter().any(|d| d.message.contains("lifecycle")), "{out:?}");
    }

    #[test]
    fn consistent_decision_passes() {
        let g = build(vec![decision(Status::Accepted, Lifecycle::Current, Authority::Normative)]);
        let mut out = Vec::new();
        Schema.check(&g, &mut out);
        assert!(out.is_empty(), "{out:?}");
    }
}