Skip to main content

markdown_contract/declarative/
config.rs

1//! `load_config`: compile a YAML config (`kind: config`) into the runner's
2//! [`CorpusConfig`], mirroring the TS `loadConfig`. A `rules` list maps `include` /
3//! `exclude` globs to a contract, with an optional `contracts` name map.
4//!
5//! A contract ref is a `.yaml` / `.yml` contract file or an inline contract object. Any other
6//! file, such as a `.ts` module, is rejected with [`DeclarativeError::CodeContractRef`].
7//!
8//! The core loader does no I/O: a file ref resolves through the caller's `resolver`
9//! (ref to YAML text). [`load_config_file`] supplies a `std::fs` resolver anchored at
10//! the config file's directory.
11
12use 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
20/// Resolves a contract-file ref (a `.yaml` / `.yml` path, possibly relative) to its YAML text.
21pub type ContractResolver<'a> = dyn FnMut(&str) -> Result<String, String> + 'a;
22
23fn err(msg: String) -> DeclarativeError {
24    DeclarativeError::InvalidConfig(msg)
25}
26
27/// Compile a declarative YAML config (text) into a [`CorpusConfig`]. `resolver` reads a
28/// referenced `.yaml` / `.yml` contract file's text (the fs seam).
29pub 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    // A string contract ref is the contract's name, kept as the rule's label.
100    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        // An inline contract object needs no envelope.
122        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    // A name resolves through the `contracts` map; otherwise the string is itself a path.
130    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/// Read a YAML config file and compile it. Contract refs resolve relative to the
149/// config file's directory, which is made absolute without resolving symlinks, as
150/// Node's `path.resolve` does.
151#[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    // Contract first: an inline contract compiles and routes.
184    #[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); // inline contracts carry no name
194        assert!(cfg.rules[0].contract.body.is_some());
195    }
196
197    // Inline contracts compile through the v2 compilers: v2 spellings bind
198    // and v1 spellings are migration-hint errors.
199    #[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        // A v1 spelling inside a v2 config's inline contract gets the migration hint.
217        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    // The typed hooks: a .ts ref and a missing contract are distinguishable errors.
241    #[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    // The same shapes the TS `loadConfig` accepts or rejects: a `contracts` map entry
273    // that is not a string is ignored, so the name is read as a path.
274    #[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    // A relative ref resolves against the config file's directory, not the cwd; an
321    // absolute ref is read as it is.
322    #[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}