markdown-contract 0.4.1

Contracts for markdown documents: declare the frontmatter, headings, tables and lists a document must have, and get findings with line numbers. The Rust twin of @osdlc/markdown-contract.
Documentation
//! `load_contract`: compile a YAML contract into the same [`Contract`] the builders
//! produce, so a YAML contract gives the same findings as a built one.
//!
//! It compiles the frontmatter schema ([`super::schema_v2`]), the body grammar
//! ([`super::body_v2`]), the contract-root `description:` (for `Finding.hint`) and the
//! body root's `requires:` / `forbids:` as one document text rule. Custom rules cannot
//! be written in YAML.

use serde_yaml::Value;

use super::body_v2::compile_body_v2;
use super::errors::DeclarativeError;
use super::parse::{DeclarativeKind, parse_declarative_doc};
use super::schema_v2::compile_frontmatter_v2;
use super::text::compile_body_text_rule;
use super::yaml::render;
use crate::contract::{Contract, FrontmatterSpec};

/// The keys a contract mapping may hold.
const CONTRACT_KEYS: &[&str] = &["mcVersion", "kind", "frontmatter", "body", "description"];

/// Compile a declarative YAML contract (text) into a runtime [`Contract`].
pub fn load_contract(yaml_text: &str) -> Result<Contract, DeclarativeError> {
    let doc = parse_declarative_doc(yaml_text)?;
    if doc.kind != DeclarativeKind::Contract {
        return Err(DeclarativeError::InvalidDocument(
            "expected a contract document (kind: contract), got kind: config".into(),
        ));
    }
    compile_contract_object(&doc.raw)
}

/// Build a [`Contract`] from a `{ description?, frontmatter?, body? }` mapping with no
/// envelope, as an inline config contract is written. An `mcVersion` other than 2 and
/// any other key are rejected.
pub fn compile_contract_object(raw: &serde_yaml::Mapping) -> Result<Contract, DeclarativeError> {
    if let Some(v) = raw.get("mcVersion")
        && v.as_f64() != Some(2.0)
    {
        return Err(DeclarativeError::InvalidDocument(format!(
            "contract: unsupported mcVersion {} (only 2 is supported; rewrite a version 1 contract with `markdown-contract migrate`)",
            render(Some(v))
        )));
    }
    for key in raw.keys() {
        if !key.as_str().is_some_and(|k| CONTRACT_KEYS.contains(&k)) {
            return Err(DeclarativeError::InvalidDocument(format!(
                "contract: unknown key '{}'",
                super::yaml::scalar_string(key)
            )));
        }
    }

    let mut contract = Contract::new();

    if let Some(d) = raw.get("description") {
        let Some(d) = d.as_str() else {
            return Err(DeclarativeError::InvalidDocument(
                "contract: description must be a string".into(),
            ));
        };
        contract.description = Some(d.to_string());
    }

    if let Some(fm) = raw.get("frontmatter") {
        contract.frontmatter = Some(FrontmatterSpec {
            schema: compile_frontmatter_v2(fm)?,
        });
    }

    if let Some(body) = raw.get("body") {
        contract.body = Some(compile_body_v2(body, "body")?);
        // The body root's `requires:` / `forbids:` become one document rule. As in TS,
        // its findings take the contract's description as their hint, not the body's.
        if let Value::Mapping(body_map) = body
            && let Some(doc_rule) = compile_body_text_rule(body_map, "body")?
        {
            contract.rules.push(doc_rule);
        }
    }

    Ok(contract)
}

/// Read a `*.contract.yaml` file and compile it into a [`Contract`].
#[cfg(feature = "native")]
pub fn load_contract_file(path: &std::path::Path) -> Result<Contract, DeclarativeError> {
    let text = std::fs::read_to_string(path).map_err(|e| DeclarativeError::FileRead {
        path: path.display().to_string(),
        reason: e.to_string(),
    })?;
    load_contract(&text)
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::finding::FindingLevel;
    use crate::validate::validate;

    fn brief(findings: &[crate::finding::Finding]) -> Vec<(&str, FindingLevel, Option<u32>)> {
        findings
            .iter()
            .map(|f| (f.id.as_str(), f.level, f.pos.map(|p| p.line)))
            .collect()
    }

    // Contract first — one YAML contract, both planes, one pass.
    #[test]
    fn a_yaml_contract_validates_both_planes() {
        let contract = load_contract(
            "mcVersion: 2\nkind: contract\nfrontmatter:\n  type: object\n  additionalProperties: false\n  required: [id, status]\n  properties:\n    id:\n      type: string\n      pattern: '^D-[0-9A-Z]{4}$'\n    status:\n      enum: [open/proposed, open/accepted]\n\nbody:\n  order: none\n  additionalSections: true\n  sections:\n    - section: Summary\n    - section: Context\n",
        )
        .unwrap();
        let pass = "---\nid: D-0099\nstatus: open/proposed\n---\n\n## Summary\n\nA summary.\n\n## Context\n\nWhy.\n";
        assert_eq!(validate(pass, &contract, "d.md"), vec![]);
        let fail = "---\nid: D-0099\nstatus: open/draft\n---\n\n## Summary\n\nA summary.\n";
        assert_eq!(
            brief(&validate(fail, &contract, "d.md")),
            vec![
                ("frontmatter/enum", FindingLevel::Error, Some(3)),
                ("structure/section-missing", FindingLevel::Error, Some(6)),
            ]
        );
    }

    #[test]
    fn body_root_text_keys_attach_one_doc_rule() {
        let contract = load_contract(
            "mcVersion: 2\nkind: contract\nbody:\n  sections:\n    - section: Summary\n  forbids:\n    - pattern: '}scripts/'\n      normalize: false\n",
        )
        .unwrap();
        assert_eq!(contract.rules.len(), 1);
        let fail = "## Summary\n\nreaches }scripts/x.sh directly\n";
        assert_eq!(
            brief(&validate(fail, &contract, "d.md")),
            vec![("text/forbids/doc/o9pijh", FindingLevel::Error, Some(3))]
        );
    }

    #[test]
    fn a_config_document_is_not_a_contract() {
        let Err(e) = load_contract("mcVersion: 2\nkind: config\nrules: []\n") else {
            panic!("expected a DeclarativeError")
        };
        assert!(e.to_string().contains("expected a contract document"));
    }

    // ── The envelope gate ───────────────────────────────────────────────────

    // v2 loads, v1 names the migrate command, v3 is rejected.
    #[test]
    fn envelope_accepts_v2_and_rejects_v1_and_v3() {
        let v2 = load_contract(
            "mcVersion: 2\nkind: contract\nfrontmatter:\n  type: object\n  additionalProperties: false\n  properties:\n    id: { type: string }\n  required: [id]\nbody:\n  additionalSections: false\n  sections:\n    - section: Summary\n",
        )
        .unwrap();
        let doc = "---\nid: D-0001\n---\n\n## Summary\n\nfine\n";
        assert_eq!(validate(doc, &v2, "d.md"), vec![]);

        let Err(e) = load_contract("mcVersion: 1\nkind: contract\nbody:\n  sections: []\n") else {
            panic!("expected a DeclarativeError")
        };
        assert_eq!(
            e.to_string(),
            "mcVersion 1 is no longer supported; rewrite the file as mcVersion 2 with `markdown-contract migrate --write <file>` (the CLI of @osdlc/markdown-contract)"
        );

        let Err(e) = load_contract("mcVersion: 3\nkind: contract\nbody:\n  sections: []\n") else {
            panic!("expected a DeclarativeError")
        };
        assert_eq!(
            e.to_string(),
            "unsupported mcVersion: 3 (this build supports 2)"
        );
    }

    #[test]
    fn a_v1_spelling_is_a_migration_error() {
        let Err(e) = load_contract(
            "mcVersion: 2\nkind: contract\nbody:\n  sections:\n    - section: Entry\n      repeatable: true\n",
        ) else {
            panic!("expected a DeclarativeError")
        };
        assert!(e.to_string().contains("'repeatable' is the v1 spelling"));
    }

    // ── description → Finding.hint ─────────────────────────────────────────

    fn hints(findings: &[crate::finding::Finding]) -> Vec<(&str, Option<&str>)> {
        findings
            .iter()
            .map(|f| (f.id.as_str(), f.hint.as_deref()))
            .collect()
    }

    // Nearest enclosing description wins: leaf beats section beats body root beats
    // contract root; scopes with no nearer description fall outward.
    #[test]
    fn hint_resolves_to_the_nearest_enclosing_description() {
        let contract = load_contract(
            "mcVersion: 2\nkind: contract\ndescription: the decision record\nfrontmatter:\n  type: object\n  properties:\n    id:\n      type: string\n      description: the D-number\n    status: { type: string }\n  required: [id, status]\nbody:\n  description: the standard shape\n  additionalSections: false\n  sections:\n    - section: Summary\n      description: one paragraph, outcome first\n      content:\n        maxWords: 5\n      requires:\n        - pattern: outcome\n    - section: Files\n      content:\n        table:\n          columns: [Location]\n          minRows: 1\n          description: one row per touched file\n",
        )
        .unwrap();
        let doc = "---\nid: 7\n---\n\n## Summary\n\nSix words are in this paragraph.\n\n## Files\n\n| Location |\n| - |\n\n## Extras\n\nx\n";
        let out = validate(doc, &contract, "d.md");
        assert_eq!(
            hints(&out),
            vec![
                // a missing field with no description falls back to the contract root
                ("frontmatter/required", Some("the decision record")),
                // the failing field's own stored description
                ("frontmatter/type", Some("the D-number")),
                // text findings: the bound section's effective hint
                (
                    "text/requires/summary/1tc7itx",
                    Some("one paragraph, outcome first")
                ),
                // content findings: the section's description (maxWords carries none)
                ("content/max-words", Some("one paragraph, outcome first")),
                // the leaf's own description beats the section's absence
                ("content/table/min-rows", Some("one row per touched file")),
                // an unknown section: the body root's description
                ("structure/section-order", Some("the standard shape")),
            ]
        );
    }

    #[test]
    fn hint_is_absent_when_no_description_is_in_scope() {
        // A description-free contract produces findings with no hint slot at all —
        // the `hint` key never serializes.
        let contract = load_contract(
            "mcVersion: 2\nkind: contract\nbody:\n  sections:\n    - section: Summary\n",
        )
        .unwrap();
        let out = validate("## Wrong\n\nprose\n", &contract, "d.md");
        assert!(!out.is_empty());
        assert!(out.iter().all(|f| f.hint.is_none()));
        assert!(!serde_json::to_string(&out).unwrap().contains("hint"));
    }

    // As in TS, the body root's text rule is a document rule, so its findings take the
    // contract's description, not the body's.
    #[test]
    fn body_root_text_rule_takes_the_contract_description() {
        let yaml = |contract_description: &str| {
            format!(
                "mcVersion: 2\nkind: contract\n{contract_description}body:\n  description: no script paths\n  sections:\n    - section: Summary\n  forbids:\n    - pattern: '}}scripts/'\n      normalize: false\n"
            )
        };
        let doc = "## Summary\n\nreaches }scripts/x.sh\n";
        let with = load_contract(&yaml("description: the record\n")).unwrap();
        assert_eq!(
            hints(&validate(doc, &with, "d.md")),
            vec![("text/forbids/doc/o9pijh", Some("the record"))]
        );
        let without = load_contract(&yaml("")).unwrap();
        assert_eq!(
            hints(&validate(doc, &without, "d.md")),
            vec![("text/forbids/doc/o9pijh", None)]
        );
    }

    #[test]
    fn a_contract_mapping_is_closed_and_inline_versions_must_be_2() {
        let msg = |yaml: &str| {
            let raw: serde_yaml::Mapping = serde_yaml::from_str(yaml).unwrap();
            match compile_contract_object(&raw) {
                Err(e) => e.to_string(),
                Ok(_) => panic!("expected a DeclarativeError for {yaml}"),
            }
        };
        assert_eq!(msg("bodyy: {}\n"), "contract: unknown key 'bodyy'");
        assert!(msg("mcVersion: 1\n").starts_with("contract: unsupported mcVersion 1"));
        assert_eq!(
            msg("description: 3\n"),
            "contract: description must be a string"
        );
        let raw: serde_yaml::Mapping =
            serde_yaml::from_str("mcVersion: 2.0\nbody:\n  sections: []\n").unwrap();
        assert!(compile_contract_object(&raw).is_ok());
    }
}