Skip to main content

markdown_contract/declarative/
load.rs

1//! `load_contract`: compile a YAML contract into the same [`Contract`] the builders
2//! produce, so a YAML contract gives the same findings as a built one.
3//!
4//! It compiles the frontmatter schema ([`super::schema_v2`]), the body grammar
5//! ([`super::body_v2`]), the contract-root `description:` (for `Finding.hint`) and the
6//! body root's `requires:` / `forbids:` as one document text rule. Custom rules cannot
7//! be written in YAML.
8
9use serde_yaml::Value;
10
11use super::body_v2::compile_body_v2;
12use super::errors::DeclarativeError;
13use super::parse::{DeclarativeKind, parse_declarative_doc};
14use super::schema_v2::compile_frontmatter_v2;
15use super::text::compile_body_text_rule;
16use super::yaml::render;
17use crate::contract::{Contract, FrontmatterSpec};
18
19/// The keys a contract mapping may hold.
20const CONTRACT_KEYS: &[&str] = &["mcVersion", "kind", "frontmatter", "body", "description"];
21
22/// Compile a declarative YAML contract (text) into a runtime [`Contract`].
23pub fn load_contract(yaml_text: &str) -> Result<Contract, DeclarativeError> {
24    let doc = parse_declarative_doc(yaml_text)?;
25    if doc.kind != DeclarativeKind::Contract {
26        return Err(DeclarativeError::InvalidDocument(
27            "expected a contract document (kind: contract), got kind: config".into(),
28        ));
29    }
30    compile_contract_object(&doc.raw)
31}
32
33/// Build a [`Contract`] from a `{ description?, frontmatter?, body? }` mapping with no
34/// envelope, as an inline config contract is written. An `mcVersion` other than 2 and
35/// any other key are rejected.
36pub fn compile_contract_object(raw: &serde_yaml::Mapping) -> Result<Contract, DeclarativeError> {
37    if let Some(v) = raw.get("mcVersion")
38        && v.as_f64() != Some(2.0)
39    {
40        return Err(DeclarativeError::InvalidDocument(format!(
41            "contract: unsupported mcVersion {} (only 2 is supported; rewrite a version 1 contract with `markdown-contract migrate`)",
42            render(Some(v))
43        )));
44    }
45    for key in raw.keys() {
46        if !key.as_str().is_some_and(|k| CONTRACT_KEYS.contains(&k)) {
47            return Err(DeclarativeError::InvalidDocument(format!(
48                "contract: unknown key '{}'",
49                super::yaml::scalar_string(key)
50            )));
51        }
52    }
53
54    let mut contract = Contract::new();
55
56    if let Some(d) = raw.get("description") {
57        let Some(d) = d.as_str() else {
58            return Err(DeclarativeError::InvalidDocument(
59                "contract: description must be a string".into(),
60            ));
61        };
62        contract.description = Some(d.to_string());
63    }
64
65    if let Some(fm) = raw.get("frontmatter") {
66        contract.frontmatter = Some(FrontmatterSpec {
67            schema: compile_frontmatter_v2(fm)?,
68        });
69    }
70
71    if let Some(body) = raw.get("body") {
72        contract.body = Some(compile_body_v2(body, "body")?);
73        // The body root's `requires:` / `forbids:` become one document rule. As in TS,
74        // its findings take the contract's description as their hint, not the body's.
75        if let Value::Mapping(body_map) = body
76            && let Some(doc_rule) = compile_body_text_rule(body_map, "body")?
77        {
78            contract.rules.push(doc_rule);
79        }
80    }
81
82    Ok(contract)
83}
84
85/// Read a `*.contract.yaml` file and compile it into a [`Contract`].
86#[cfg(feature = "native")]
87pub fn load_contract_file(path: &std::path::Path) -> Result<Contract, DeclarativeError> {
88    let text = std::fs::read_to_string(path).map_err(|e| DeclarativeError::FileRead {
89        path: path.display().to_string(),
90        reason: e.to_string(),
91    })?;
92    load_contract(&text)
93}
94
95#[cfg(test)]
96mod tests {
97    use super::*;
98    use crate::finding::FindingLevel;
99    use crate::validate::validate;
100
101    fn brief(findings: &[crate::finding::Finding]) -> Vec<(&str, FindingLevel, Option<u32>)> {
102        findings
103            .iter()
104            .map(|f| (f.id.as_str(), f.level, f.pos.map(|p| p.line)))
105            .collect()
106    }
107
108    // Contract first — one YAML contract, both planes, one pass.
109    #[test]
110    fn a_yaml_contract_validates_both_planes() {
111        let contract = load_contract(
112            "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",
113        )
114        .unwrap();
115        let pass = "---\nid: D-0099\nstatus: open/proposed\n---\n\n## Summary\n\nA summary.\n\n## Context\n\nWhy.\n";
116        assert_eq!(validate(pass, &contract, "d.md"), vec![]);
117        let fail = "---\nid: D-0099\nstatus: open/draft\n---\n\n## Summary\n\nA summary.\n";
118        assert_eq!(
119            brief(&validate(fail, &contract, "d.md")),
120            vec![
121                ("frontmatter/enum", FindingLevel::Error, Some(3)),
122                ("structure/section-missing", FindingLevel::Error, Some(6)),
123            ]
124        );
125    }
126
127    #[test]
128    fn body_root_text_keys_attach_one_doc_rule() {
129        let contract = load_contract(
130            "mcVersion: 2\nkind: contract\nbody:\n  sections:\n    - section: Summary\n  forbids:\n    - pattern: '}scripts/'\n      normalize: false\n",
131        )
132        .unwrap();
133        assert_eq!(contract.rules.len(), 1);
134        let fail = "## Summary\n\nreaches }scripts/x.sh directly\n";
135        assert_eq!(
136            brief(&validate(fail, &contract, "d.md")),
137            vec![("text/forbids/doc/o9pijh", FindingLevel::Error, Some(3))]
138        );
139    }
140
141    #[test]
142    fn a_config_document_is_not_a_contract() {
143        let Err(e) = load_contract("mcVersion: 2\nkind: config\nrules: []\n") else {
144            panic!("expected a DeclarativeError")
145        };
146        assert!(e.to_string().contains("expected a contract document"));
147    }
148
149    // ── The envelope gate ───────────────────────────────────────────────────
150
151    // v2 loads, v1 names the migrate command, v3 is rejected.
152    #[test]
153    fn envelope_accepts_v2_and_rejects_v1_and_v3() {
154        let v2 = load_contract(
155            "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",
156        )
157        .unwrap();
158        let doc = "---\nid: D-0001\n---\n\n## Summary\n\nfine\n";
159        assert_eq!(validate(doc, &v2, "d.md"), vec![]);
160
161        let Err(e) = load_contract("mcVersion: 1\nkind: contract\nbody:\n  sections: []\n") else {
162            panic!("expected a DeclarativeError")
163        };
164        assert_eq!(
165            e.to_string(),
166            "mcVersion 1 is no longer supported; rewrite the file as mcVersion 2 with `markdown-contract migrate --write <file>` (the CLI of @osdlc/markdown-contract)"
167        );
168
169        let Err(e) = load_contract("mcVersion: 3\nkind: contract\nbody:\n  sections: []\n") else {
170            panic!("expected a DeclarativeError")
171        };
172        assert_eq!(
173            e.to_string(),
174            "unsupported mcVersion: 3 (this build supports 2)"
175        );
176    }
177
178    #[test]
179    fn a_v1_spelling_is_a_migration_error() {
180        let Err(e) = load_contract(
181            "mcVersion: 2\nkind: contract\nbody:\n  sections:\n    - section: Entry\n      repeatable: true\n",
182        ) else {
183            panic!("expected a DeclarativeError")
184        };
185        assert!(e.to_string().contains("'repeatable' is the v1 spelling"));
186    }
187
188    // ── description → Finding.hint ─────────────────────────────────────────
189
190    fn hints(findings: &[crate::finding::Finding]) -> Vec<(&str, Option<&str>)> {
191        findings
192            .iter()
193            .map(|f| (f.id.as_str(), f.hint.as_deref()))
194            .collect()
195    }
196
197    // Nearest enclosing description wins: leaf beats section beats body root beats
198    // contract root; scopes with no nearer description fall outward.
199    #[test]
200    fn hint_resolves_to_the_nearest_enclosing_description() {
201        let contract = load_contract(
202            "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",
203        )
204        .unwrap();
205        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";
206        let out = validate(doc, &contract, "d.md");
207        assert_eq!(
208            hints(&out),
209            vec![
210                // a missing field with no description falls back to the contract root
211                ("frontmatter/required", Some("the decision record")),
212                // the failing field's own stored description
213                ("frontmatter/type", Some("the D-number")),
214                // text findings: the bound section's effective hint
215                (
216                    "text/requires/summary/1tc7itx",
217                    Some("one paragraph, outcome first")
218                ),
219                // content findings: the section's description (maxWords carries none)
220                ("content/max-words", Some("one paragraph, outcome first")),
221                // the leaf's own description beats the section's absence
222                ("content/table/min-rows", Some("one row per touched file")),
223                // an unknown section: the body root's description
224                ("structure/section-order", Some("the standard shape")),
225            ]
226        );
227    }
228
229    #[test]
230    fn hint_is_absent_when_no_description_is_in_scope() {
231        // A description-free contract produces findings with no hint slot at all —
232        // the `hint` key never serializes.
233        let contract = load_contract(
234            "mcVersion: 2\nkind: contract\nbody:\n  sections:\n    - section: Summary\n",
235        )
236        .unwrap();
237        let out = validate("## Wrong\n\nprose\n", &contract, "d.md");
238        assert!(!out.is_empty());
239        assert!(out.iter().all(|f| f.hint.is_none()));
240        assert!(!serde_json::to_string(&out).unwrap().contains("hint"));
241    }
242
243    // As in TS, the body root's text rule is a document rule, so its findings take the
244    // contract's description, not the body's.
245    #[test]
246    fn body_root_text_rule_takes_the_contract_description() {
247        let yaml = |contract_description: &str| {
248            format!(
249                "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"
250            )
251        };
252        let doc = "## Summary\n\nreaches }scripts/x.sh\n";
253        let with = load_contract(&yaml("description: the record\n")).unwrap();
254        assert_eq!(
255            hints(&validate(doc, &with, "d.md")),
256            vec![("text/forbids/doc/o9pijh", Some("the record"))]
257        );
258        let without = load_contract(&yaml("")).unwrap();
259        assert_eq!(
260            hints(&validate(doc, &without, "d.md")),
261            vec![("text/forbids/doc/o9pijh", None)]
262        );
263    }
264
265    #[test]
266    fn a_contract_mapping_is_closed_and_inline_versions_must_be_2() {
267        let msg = |yaml: &str| {
268            let raw: serde_yaml::Mapping = serde_yaml::from_str(yaml).unwrap();
269            match compile_contract_object(&raw) {
270                Err(e) => e.to_string(),
271                Ok(_) => panic!("expected a DeclarativeError for {yaml}"),
272            }
273        };
274        assert_eq!(msg("bodyy: {}\n"), "contract: unknown key 'bodyy'");
275        assert!(msg("mcVersion: 1\n").starts_with("contract: unsupported mcVersion 1"));
276        assert_eq!(
277            msg("description: 3\n"),
278            "contract: description must be a string"
279        );
280        let raw: serde_yaml::Mapping =
281            serde_yaml::from_str("mcVersion: 2.0\nbody:\n  sections: []\n").unwrap();
282        assert!(compile_contract_object(&raw).is_ok());
283    }
284}