markdown_contract/declarative/
config.rs1use serde_yaml::Value;
13
14use super::errors::DeclarativeError;
15use super::load::{compile_contract_object, load_contract};
16use super::parse::{DeclarativeKind, parse_declarative_doc};
17use crate::contract::Contract;
18use crate::runner::{CorpusConfig, CorpusRule};
19
20pub type ContractResolver<'a> = dyn FnMut(&str) -> Result<String, String> + 'a;
22
23fn err(msg: String) -> DeclarativeError {
24 DeclarativeError::InvalidConfig(msg)
25}
26
27pub fn load_config(
30 yaml_text: &str,
31 resolver: &mut ContractResolver<'_>,
32) -> Result<CorpusConfig, DeclarativeError> {
33 let doc = parse_declarative_doc(yaml_text)?;
34 if doc.kind != DeclarativeKind::Config {
35 return Err(DeclarativeError::InvalidDocument(
36 "expected a config document (kind: config), got kind: contract".into(),
37 ));
38 }
39 compile_config(&doc.raw, resolver)
40}
41
42fn compile_config(
43 raw: &serde_yaml::Mapping,
44 resolver: &mut ContractResolver<'_>,
45) -> Result<CorpusConfig, DeclarativeError> {
46 let contracts = match raw.get("contracts") {
47 Some(Value::Mapping(m)) => m.clone(),
48 _ => serde_yaml::Mapping::new(),
49 };
50 let Some(Value::Sequence(rules)) = raw.get("rules") else {
51 return Err(err(
52 "config.rules must be a list of { include, exclude?, contract }".into(),
53 ));
54 };
55 let rules = rules
56 .iter()
57 .enumerate()
58 .map(|(i, r)| compile_rule(r, &format!("rules[{i}]"), &contracts, resolver))
59 .collect::<Result<Vec<_>, _>>()?;
60 Ok(CorpusConfig { rules })
61}
62
63fn glob_list(v: Option<&Value>) -> Option<Vec<String>> {
64 v.and_then(Value::as_sequence)
65 .map(|seq| seq.iter().map(|g| g.as_str().map(str::to_string)).collect())
66 .unwrap_or(None)
67}
68
69fn compile_rule(
70 rule: &Value,
71 path: &str,
72 contracts: &serde_yaml::Mapping,
73 resolver: &mut ContractResolver<'_>,
74) -> Result<CorpusRule, DeclarativeError> {
75 let Value::Mapping(map) = rule else {
76 return Err(err(format!("{path}: a rule must be a mapping")));
77 };
78 let include = match glob_list(map.get("include")) {
79 Some(globs) if !globs.is_empty() => globs,
80 _ => {
81 return Err(err(format!(
82 "{path}.include must be a non-empty list of globs"
83 )));
84 }
85 };
86 let exclude = match map.get("exclude") {
87 None => Vec::new(),
88 some => {
89 glob_list(some).ok_or_else(|| err(format!("{path}.exclude must be a list of globs")))?
90 }
91 };
92 let contract_ref = map.get("contract");
93 let contract = resolve_contract(
94 contract_ref,
95 &format!("{path}.contract"),
96 contracts,
97 resolver,
98 )?;
99 let name = contract_ref.and_then(Value::as_str).map(str::to_string);
101 Ok(CorpusRule {
102 include,
103 exclude,
104 contract,
105 name,
106 })
107}
108
109fn resolve_contract(
110 reference: Option<&Value>,
111 path: &str,
112 contracts: &serde_yaml::Mapping,
113 resolver: &mut ContractResolver<'_>,
114) -> Result<Contract, DeclarativeError> {
115 let Some(reference) = reference else {
116 return Err(err(format!(
117 "{path}: a rule needs a contract (a name, a .yaml path, or an inline contract)"
118 )));
119 };
120 if let Value::Mapping(inline) = reference {
121 return compile_contract_object(inline);
123 }
124 let Some(name) = reference.as_str() else {
125 return Err(err(format!(
126 "{path}: contract must be a name, a .yaml path, or an inline contract"
127 )));
128 };
129 let target = contracts
131 .get(Value::String(name.into()))
132 .and_then(Value::as_str)
133 .unwrap_or(name);
134 let lower = target.to_ascii_lowercase();
135 if !(lower.ends_with(".yaml") || lower.ends_with(".yml")) {
136 return Err(DeclarativeError::CodeContractRef {
137 path: path.into(),
138 target: target.into(),
139 });
140 }
141 let text = resolver(target).map_err(|reason| DeclarativeError::ContractRefRead {
142 target: target.into(),
143 reason,
144 })?;
145 load_contract(&text)
146}
147
148#[cfg(feature = "native")]
152pub fn load_config_file(path: &std::path::Path) -> Result<CorpusConfig, DeclarativeError> {
153 let read_err = |e: std::io::Error| DeclarativeError::FileRead {
154 path: path.display().to_string(),
155 reason: e.to_string(),
156 };
157 let abs = std::path::absolute(path).map_err(read_err)?;
158 let text = std::fs::read_to_string(&abs).map_err(read_err)?;
159 let base = abs
160 .parent()
161 .map(std::path::Path::to_path_buf)
162 .unwrap_or_default();
163 let mut resolver = move |target: &str| -> Result<String, String> {
164 let target_path = std::path::Path::new(target);
165 let resolved = if target_path.is_absolute() {
166 target_path.to_path_buf()
167 } else {
168 base.join(target_path)
169 };
170 std::fs::read_to_string(&resolved).map_err(|e| e.to_string())
171 };
172 load_config(&text, &mut resolver)
173}
174
175#[cfg(test)]
176mod tests {
177 use super::*;
178
179 fn no_files(target: &str) -> Result<String, String> {
180 Err(format!("no such contract: {target}"))
181 }
182
183 #[test]
185 fn inline_contract_rules_compile() {
186 let cfg = load_config(
187 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n contract:\n body:\n sections:\n - section: Overview\n",
188 &mut no_files,
189 )
190 .unwrap();
191 assert_eq!(cfg.rules.len(), 1);
192 assert_eq!(cfg.rules[0].include, vec!["**/*.md"]);
193 assert_eq!(cfg.rules[0].name, None); assert!(cfg.rules[0].contract.body.is_some());
195 }
196
197 #[test]
200 fn inline_contracts_compile_as_v2() {
201 let cfg = load_config(
202 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n contract:\n body:\n additionalSections: false\n sections:\n - section: Overview\n",
203 &mut no_files,
204 )
205 .unwrap();
206 assert!(
207 !cfg.rules[0]
208 .contract
209 .body
210 .as_ref()
211 .unwrap()
212 .opts
213 .allow_unknown
214 );
215
216 let Err(e) = load_config(
218 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n contract:\n body:\n allowUnknown: false\n sections:\n - section: Overview\n",
219 &mut no_files,
220 ) else {
221 panic!("expected a DeclarativeError")
222 };
223 assert!(e.to_string().contains("'allowUnknown' is the v1 spelling"));
224 }
225
226 #[test]
227 fn a_name_resolves_through_the_contracts_map() {
228 let mut resolver = |target: &str| -> Result<String, String> {
229 assert_eq!(target, "decision.contract.yaml");
230 Ok("mcVersion: 2\nkind: contract\nbody:\n sections:\n - section: Summary\n".into())
231 };
232 let cfg = load_config(
233 "mcVersion: 2\nkind: config\ncontracts:\n decision: decision.contract.yaml\nrules:\n - include: ['**/D-*.md']\n contract: decision\n",
234 &mut resolver,
235 )
236 .unwrap();
237 assert_eq!(cfg.rules[0].name.as_deref(), Some("decision"));
238 }
239
240 #[test]
242 fn code_refs_and_shape_violations_are_rejected() {
243 let Err(e) = load_config(
244 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n contract: ./task.contract.ts\n",
245 &mut no_files,
246 ) else {
247 panic!("expected a DeclarativeError")
248 };
249 assert!(matches!(e, DeclarativeError::CodeContractRef { .. }));
250
251 assert!(load_config("mcVersion: 2\nkind: config\n", &mut no_files).is_err());
252 assert!(
253 load_config(
254 "mcVersion: 2\nkind: config\nrules:\n - include: []\n contract: {}\n",
255 &mut no_files
256 )
257 .is_err()
258 );
259 assert!(
260 load_config(
261 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n",
262 &mut no_files
263 )
264 .is_err()
265 );
266 let Err(e) = load_config("mcVersion: 2\nkind: contract\n", &mut no_files) else {
267 panic!("expected a DeclarativeError")
268 };
269 assert!(e.to_string().contains("expected a config document"));
270 }
271
272 #[test]
275 fn malformed_rule_fields_are_config_errors() {
276 let config_err = |yaml: &str| match load_config(yaml, &mut no_files) {
277 Err(DeclarativeError::InvalidConfig(msg)) => msg,
278 Err(e) => panic!("expected InvalidConfig, got {e:?}"),
279 Ok(_) => panic!("expected InvalidConfig, got a config"),
280 };
281 assert_eq!(
282 config_err(
283 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n exclude: x\n contract: {}\n"
284 ),
285 "rules[0].exclude must be a list of globs"
286 );
287 assert_eq!(
288 config_err(
289 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n contract: 5\n"
290 ),
291 "rules[0].contract: contract must be a name, a .yaml path, or an inline contract"
292 );
293 let Err(e) = load_config(
294 "mcVersion: 2\nkind: config\ncontracts:\n decision: 5\nrules:\n - include: ['**/*.md']\n contract: decision\n",
295 &mut no_files,
296 ) else {
297 panic!("expected a DeclarativeError")
298 };
299 assert!(
300 matches!(&e, DeclarativeError::CodeContractRef { target, .. } if target == "decision"),
301 "{e:?}"
302 );
303 }
304
305 #[cfg(feature = "native")]
306 fn scratch(name: &str) -> std::path::PathBuf {
307 let dir = std::env::temp_dir().join(format!(
308 "markdown-contract-config-{name}-{}",
309 std::process::id()
310 ));
311 let _ = std::fs::remove_dir_all(&dir);
312 std::fs::create_dir_all(&dir).unwrap();
313 dir
314 }
315
316 #[cfg(feature = "native")]
317 const CONTRACT: &str =
318 "mcVersion: 2\nkind: contract\nbody:\n sections:\n - section: Summary\n";
319
320 #[cfg(feature = "native")]
323 #[test]
324 fn load_config_file_resolves_refs_against_the_config_dir() {
325 let dir = scratch("refs");
326 std::fs::create_dir_all(dir.join("contracts")).unwrap();
327 std::fs::write(dir.join("contracts/a.contract.yaml"), CONTRACT).unwrap();
328 let abs = dir.join("contracts/a.contract.yaml");
329 let config = format!(
330 "mcVersion: 2\nkind: config\nrules:\n - include: ['a/**']\n contract: ./contracts/a.contract.yaml\n - include: ['b/**']\n contract: '{}'\n",
331 abs.display()
332 );
333 std::fs::write(dir.join("config.yaml"), config).unwrap();
334 let cfg = load_config_file(&dir.join("config.yaml")).unwrap();
335 assert_eq!(cfg.rules.len(), 2);
336 assert!(cfg.rules.iter().all(|r| r.contract.body.is_some()));
337 std::fs::remove_dir_all(&dir).unwrap();
338 }
339
340 #[cfg(feature = "native")]
341 #[test]
342 fn missing_files_name_what_could_not_be_read() {
343 let dir = scratch("missing");
344 std::fs::write(
345 dir.join("config.yaml"),
346 "mcVersion: 2\nkind: config\nrules:\n - include: ['**/*.md']\n contract: ./nope.contract.yaml\n",
347 )
348 .unwrap();
349 let Err(e) = load_config_file(&dir.join("config.yaml")) else {
350 panic!("expected a DeclarativeError")
351 };
352 assert!(
353 matches!(&e, DeclarativeError::ContractRefRead { target, .. } if target == "./nope.contract.yaml"),
354 "{e:?}"
355 );
356
357 let missing = dir.join("absent.yaml");
358 let missing_name = missing.display().to_string();
359 let Err(e) = load_config_file(&missing) else {
360 panic!("expected a DeclarativeError")
361 };
362 assert!(
363 matches!(&e, DeclarativeError::FileRead { path, .. } if *path == missing_name),
364 "{e:?}"
365 );
366 let Err(e) = super::super::load::load_contract_file(&missing) else {
367 panic!("expected a DeclarativeError")
368 };
369 assert!(
370 matches!(&e, DeclarativeError::FileRead { path, .. } if *path == missing_name),
371 "{e:?}"
372 );
373
374 std::fs::write(dir.join("a.contract.yaml"), CONTRACT).unwrap();
375 assert!(
376 super::super::load::load_contract_file(&dir.join("a.contract.yaml"))
377 .unwrap()
378 .body
379 .is_some()
380 );
381 std::fs::remove_dir_all(&dir).unwrap();
382 }
383}