markdown_contract/declarative/
load.rs1use 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
19const CONTRACT_KEYS: &[&str] = &["mcVersion", "kind", "frontmatter", "body", "description"];
21
22pub 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
33pub 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 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#[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 #[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 #[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 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 #[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 ("frontmatter/required", Some("the decision record")),
212 ("frontmatter/type", Some("the D-number")),
214 (
216 "text/requires/summary/1tc7itx",
217 Some("one paragraph, outcome first")
218 ),
219 ("content/max-words", Some("one paragraph, outcome first")),
221 ("content/table/min-rows", Some("one row per touched file")),
223 ("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 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 #[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}