markdown_contract/contract.rs
1//! The contract model + builder API — programmatic construction of the
2//! body grammar the structure plane matches, mirroring the TS `section()` / `optional()`
3//! / `oneOf()` / `gap()` / `sections()` combinators.
4//!
5//! This is also the **programmatic rule seam**: the [`Rule`] (node-scoped) and
6//! [`DocRule`] (doc-scoped) traits mirror the TS `rule` / `docRule` escapes and are
7//! invoked during validation — node rules per declared, present section, doc rules over
8//! the whole projected tree.
9//!
10//! Content-plane data rides here too: [`Contract::frontmatter`] carries the compiled
11//! [`Schema`] the frontmatter plane runs, and [`LeafSpec::config`] carries the typed
12//! [`LeafConfig`] (columns / minRows / cells / everyItem / lang / maxWords) the content
13//! plane interprets. The structure plane still reads only `LeafSpec::kind` (the
14//! kind-gate); the data checks live in [`crate::content`].
15
16use crate::finding::Finding;
17use crate::registry::Ctx;
18use crate::schema::Schema;
19use crate::tree::{BlockKind, DocTree, SectionNode};
20
21// ── Rules: the programmatic escape (mirrors TS `rule` / `docRule`) ──────────────────
22
23/// A node-scoped named rule, run against each declared, present section that carries it.
24pub trait Rule {
25 /// The rule's id, used in the findings it makes.
26 fn id(&self) -> &str;
27 /// Check one section and return its findings.
28 fn run(&self, node: &SectionNode, ctx: &Ctx) -> Vec<Finding>;
29}
30
31/// A doc-scoped named rule, run once over the whole projected tree.
32pub trait DocRule {
33 /// The rule's id, used in the findings it makes.
34 fn id(&self) -> &str;
35 /// Check the whole document and return its findings.
36 fn run(&self, tree: &DocTree, ctx: &Ctx) -> Vec<Finding>;
37}
38
39struct FnRule<F> {
40 id: String,
41 f: F,
42}
43
44impl<F> Rule for FnRule<F>
45where
46 F: Fn(&SectionNode, &Ctx) -> Vec<Finding>,
47{
48 fn id(&self) -> &str {
49 &self.id
50 }
51 fn run(&self, node: &SectionNode, ctx: &Ctx) -> Vec<Finding> {
52 (self.f)(node, ctx)
53 }
54}
55
56struct FnDocRule<F> {
57 id: String,
58 f: F,
59}
60
61impl<F> DocRule for FnDocRule<F>
62where
63 F: Fn(&DocTree, &Ctx) -> Vec<Finding>,
64{
65 fn id(&self) -> &str {
66 &self.id
67 }
68 fn run(&self, tree: &DocTree, ctx: &Ctx) -> Vec<Finding> {
69 (self.f)(tree, ctx)
70 }
71}
72
73/// A per-node named rule from a closure — the TS `rule(id, fn)`.
74pub fn rule<F>(id: impl Into<String>, f: F) -> Box<dyn Rule>
75where
76 F: Fn(&SectionNode, &Ctx) -> Vec<Finding> + 'static,
77{
78 Box::new(FnRule { id: id.into(), f })
79}
80
81/// A doc-scoped named rule from a closure — the TS `docRule(id, fn)`.
82pub fn doc_rule<F>(id: impl Into<String>, f: F) -> Box<dyn DocRule>
83where
84 F: Fn(&DocTree, &Ctx) -> Vec<Finding> + 'static,
85{
86 Box::new(FnDocRule { id: id.into(), f })
87}
88
89// ── Leaves & section options ─────────────────────────────────────────────────────────
90
91/// Whether undeclared table columns are tolerated or reported (`content/table/column-extra`).
92#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
93pub enum ExtraColumns {
94 /// Extra columns are allowed.
95 #[default]
96 Ignore,
97 /// Extra columns give a finding.
98 Error,
99}
100
101/// A `table(...)` leaf's content config — the TS `TableConfig`.
102#[derive(Debug, Clone, Default)]
103pub struct TableConfig {
104 /// the column headings the table must have (their order is not checked)
105 pub columns: Vec<String>,
106 /// parsed for parity with TS and unused; a table is bound to an anchor by its
107 /// [`SectionContent::Anchored`] key
108 pub anchor: Option<String>,
109 /// the fewest body rows allowed
110 pub min_rows: Option<f64>,
111 /// per-column typed cells, in declaration order
112 pub cells: Vec<(String, Schema)>,
113 /// what to do with columns the contract does not name
114 pub extra_columns: ExtraColumns,
115}
116
117/// A list leaf's per-item check — the checkbox gate, or a schema over each item's text.
118#[derive(Debug, Clone)]
119pub enum EveryItem {
120 /// every item must be a task-list checkbox
121 Checkbox,
122 /// every item's text must satisfy the schema
123 Schema(Schema),
124}
125
126/// A `list(...)` leaf's content config, the TS `ListConfig`.
127#[derive(Debug, Clone, Default)]
128pub struct ListConfig {
129 /// parsed for parity with TS and unused: neither engine checks it
130 pub ordered: Option<bool>,
131 /// the check applied to each item
132 pub every_item: Option<EveryItem>,
133 /// the fewest items allowed
134 pub min_items: Option<f64>,
135}
136
137/// A `code(...)` leaf's content config — the TS `CodeConfig`.
138#[derive(Debug, Clone, Default)]
139pub struct CodeConfig {
140 /// the required code-fence language
141 pub lang: Option<String>,
142}
143
144/// The content checks a leaf carries: what the TS builders put on `LeafSpec.config`,
145/// with cell and item schemas compiled.
146#[derive(Debug, Clone)]
147#[allow(missing_docs)]
148pub enum LeafConfig {
149 Table(TableConfig),
150 List(ListConfig),
151 Code(CodeConfig),
152 /// The most words a paragraph in the section may have, the TS `maxWords(n)`.
153 MaxWords(f64),
154}
155
156/// A content leaf: the structural kind-gate (checked by the structure plane) plus the
157/// typed `config` the content plane interprets.
158#[derive(Debug, Clone)]
159pub struct LeafSpec {
160 /// the block kind that fills the leaf
161 pub kind: BlockKind,
162 /// the leaf's content config; `None` = kind-gate only (no data checks)
163 pub config: Option<LeafConfig>,
164 /// the leaf's `description:`, carried for `Finding.hint`
165 pub description: Option<String>,
166}
167
168impl LeafSpec {
169 /// A leaf of the given kind with no content checks.
170 pub fn new(kind: BlockKind) -> Self {
171 Self {
172 kind,
173 config: None,
174 description: None,
175 }
176 }
177
178 /// A table leaf with no data checks (kind-gate only).
179 pub fn table() -> Self {
180 Self::new(BlockKind::Table)
181 }
182
183 /// A table leaf with content checks — the TS `table({...})`.
184 pub fn table_with(config: TableConfig) -> Self {
185 Self {
186 kind: BlockKind::Table,
187 config: Some(LeafConfig::Table(config)),
188 description: None,
189 }
190 }
191
192 /// A list leaf with no data checks (kind-gate only).
193 pub fn list() -> Self {
194 Self::new(BlockKind::List)
195 }
196
197 /// A list leaf with content checks — the TS `list({...})`.
198 pub fn list_with(config: ListConfig) -> Self {
199 Self {
200 kind: BlockKind::List,
201 config: Some(LeafConfig::List(config)),
202 description: None,
203 }
204 }
205
206 /// A code leaf with no data checks (kind-gate only).
207 pub fn code() -> Self {
208 Self::new(BlockKind::Code)
209 }
210
211 /// A code leaf pinned to a language — the TS `code({ lang })`.
212 pub fn code_with(config: CodeConfig) -> Self {
213 Self {
214 kind: BlockKind::Code,
215 config: Some(LeafConfig::Code(config)),
216 description: None,
217 }
218 }
219
220 /// A paragraph leaf (kind-gate only).
221 pub fn paragraph() -> Self {
222 Self::new(BlockKind::Paragraph)
223 }
224
225 /// A paragraph leaf bounding word count — the TS `maxWords(n)`.
226 pub fn max_words(n: f64) -> Self {
227 Self {
228 kind: BlockKind::Paragraph,
229 config: Some(LeafConfig::MaxWords(n)),
230 description: None,
231 }
232 }
233}
234
235/// A section's content declaration: a single leaf, or named leaves bound by `^anchor`.
236pub enum SectionContent {
237 /// one leaf for the section
238 Single(LeafSpec),
239 /// `(anchor, leaf)` pairs, in declaration order
240 Anchored(Vec<(String, LeafSpec)>),
241}
242
243/// Per-section options — the TS `SectionOpts`.
244#[derive(Default)]
245pub struct SectionOpts {
246 /// whether the section may be absent
247 pub optional: bool,
248 /// what the section's blocks must be
249 pub content: Option<SectionContent>,
250 /// nested subsequence (recursion)
251 pub children: Option<SectionSeq>,
252 /// node-local named rules
253 pub rules: Vec<Box<dyn Rule>>,
254 /// require a `^block-id`, e.g. `"summary"`
255 pub anchor: Option<String>,
256 /// repeatable slot: the declared heading may recur as peers at one level
257 pub repeatable: bool,
258 /// minimum occurrence count for a repeatable slot (below → `structure/repeat-count`)
259 pub min: Option<usize>,
260 /// maximum occurrence count for a repeatable slot (above → `structure/repeat-count`)
261 pub max: Option<usize>,
262 /// the node's `description:`, carried for `Finding.hint`
263 pub description: Option<String>,
264}
265
266// ── The level grammar ─────────────────────────────────────────────────────────────────
267
268/// `order` and `allow_unknown` are independent knobs over a level's content model.
269#[derive(Debug, Clone, Copy, PartialEq, Eq)]
270pub enum Order {
271 /// headings may come in any order
272 None,
273 /// declared headings that are present keep their declared order
274 RecognizedRelative,
275 /// declared headings come in exactly the declared order
276 Strict,
277}
278
279/// A level's options; defaults mirror the TS engine (`order: "none"`, `allowUnknown: true`).
280#[derive(Debug, Clone, Copy, PartialEq, Eq)]
281pub struct LevelOpts {
282 /// how declared sections are ordered
283 pub order: Order,
284 /// whether sections the contract does not declare are allowed
285 pub allow_unknown: bool,
286}
287
288impl Default for LevelOpts {
289 fn default() -> Self {
290 Self {
291 order: Order::None,
292 allow_unknown: true,
293 }
294 }
295}
296
297/// A declared section slot: the heading and its aliases, plus options.
298pub struct SectionSpec {
299 /// the heading first, then its aliases; a document may use exactly one of them
300 pub names: Vec<String>,
301 #[allow(missing_docs)]
302 pub opts: Option<SectionOpts>,
303}
304
305/// A `oneOf` slot: exactly one of several headings must appear.
306pub struct OneOfSpec {
307 /// the headings, one of which must appear
308 pub names: Vec<String>,
309 #[allow(missing_docs)]
310 pub opts: Option<SectionOpts>,
311}
312
313/// A `gap()` window admitting unknown sections, optionally bounded.
314#[derive(Debug, Clone, Default, PartialEq, Eq)]
315pub struct GapSpec {
316 /// the fewest unknown sections admitted
317 pub min: Option<usize>,
318 /// the most unknown sections admitted
319 pub max: Option<usize>,
320}
321
322/// One element of a level's ordered content model — the output of the builder functions.
323pub enum Spec {
324 /// a declared section
325 Section(SectionSpec),
326 /// a slot that may be absent
327 Optional(Box<Spec>),
328 /// one slot with several accepted spellings
329 OneOf(OneOfSpec),
330 /// a window for unknown sections
331 Gap(GapSpec),
332}
333
334impl Spec {
335 /// The spec inside any `optional(...)` wrappers, and whether there was one.
336 pub(crate) fn unwrap_optional(&self) -> (&Spec, bool) {
337 match self {
338 Spec::Optional(inner) => (inner.unwrap_optional().0, true),
339 other => (other, false),
340 }
341 }
342}
343
344/// The body grammar of one level — the output of [`sections`].
345#[derive(Default)]
346pub struct SectionSeq {
347 /// the level's order and unknown-section options
348 pub opts: LevelOpts,
349 /// the level's slots, in declaration order
350 pub specs: Vec<Spec>,
351 /// the level's `description:`; only the body root has one, since a nested level's
352 /// description is its section node's
353 pub description: Option<String>,
354}
355
356// ── Builders (mirror the TS combinators) ─────────────────────────────────────────────
357
358/// A required section with a single exact heading — `section("Overview")`.
359pub fn section(name: impl Into<String>) -> Spec {
360 Spec::Section(SectionSpec {
361 names: vec![name.into()],
362 opts: None,
363 })
364}
365
366/// A section with options — `section("Summary", { anchor: "summary", … })`.
367pub fn section_with(name: impl Into<String>, opts: SectionOpts) -> Spec {
368 Spec::Section(SectionSpec {
369 names: vec![name.into()],
370 opts: Some(opts),
371 })
372}
373
374/// One required slot with several interchangeable spellings — `oneOf([...])`.
375pub fn one_of<I, S>(names: I) -> Spec
376where
377 I: IntoIterator<Item = S>,
378 S: Into<String>,
379{
380 Spec::OneOf(OneOfSpec {
381 names: names.into_iter().map(Into::into).collect(),
382 opts: None,
383 })
384}
385
386/// A `oneOf` slot with options.
387pub fn one_of_with<I, S>(names: I, opts: SectionOpts) -> Spec
388where
389 I: IntoIterator<Item = S>,
390 S: Into<String>,
391{
392 Spec::OneOf(OneOfSpec {
393 names: names.into_iter().map(Into::into).collect(),
394 opts: Some(opts),
395 })
396}
397
398/// Mark a spec optional — `optional(section("Appendix"))`.
399pub fn optional(spec: Spec) -> Spec {
400 Spec::Optional(Box::new(spec))
401}
402
403/// An unbounded gap window admitting unknown sections.
404pub fn gap() -> Spec {
405 Spec::Gap(GapSpec::default())
406}
407
408/// A bounded gap window — `gap({ min, max })`.
409pub fn gap_bounds(min: Option<usize>, max: Option<usize>) -> Spec {
410 Spec::Gap(GapSpec { min, max })
411}
412
413/// A level's ordered content model — `sections(opts, [ … ])`.
414pub fn sections(opts: LevelOpts, specs: Vec<Spec>) -> SectionSeq {
415 SectionSeq {
416 opts,
417 specs,
418 description: None,
419 }
420}
421
422// ── The contract ──────────────────────────────────────────────────────────────────────
423
424/// The frontmatter plane's compiled schema — an object [`Schema`] the plane runs over
425/// the parsed frontmatter data (the TS contract's `frontmatter` Zod object).
426#[derive(Debug, Clone)]
427pub struct FrontmatterSpec {
428 /// the object schema
429 pub schema: Schema,
430}
431
432/// A compiled contract for one markdown class: frontmatter schema, body grammar, and
433/// doc-scoped rules.
434#[derive(Default)]
435pub struct Contract {
436 /// validated by the frontmatter plane (`frontmatter/*` findings)
437 pub frontmatter: Option<FrontmatterSpec>,
438 /// the heading grammar of the document body
439 pub body: Option<SectionSeq>,
440 /// cross-plane rules over the whole projected tree
441 pub rules: Vec<Box<dyn DocRule>>,
442 /// the contract root's `description:`, the `Finding.hint` of a finding with no
443 /// nearer description in scope
444 pub description: Option<String>,
445}
446
447impl Contract {
448 /// An empty contract: no frontmatter schema, no body grammar and no rules.
449 pub fn new() -> Self {
450 Self::default()
451 }
452
453 /// Set the body grammar.
454 pub fn body(mut self, body: SectionSeq) -> Self {
455 self.body = Some(body);
456 self
457 }
458
459 /// Attach a doc-scoped rule.
460 pub fn doc_rule(mut self, rule: Box<dyn DocRule>) -> Self {
461 self.rules.push(rule);
462 self
463 }
464
465 /// Set the frontmatter schema.
466 pub fn frontmatter(mut self, spec: FrontmatterSpec) -> Self {
467 self.frontmatter = Some(spec);
468 self
469 }
470}
471
472#[cfg(test)]
473mod tests {
474 use super::*;
475 use crate::finding::SourcePos;
476 use crate::registry::{FindingSpec, Registry};
477
478 // Contract first: the builder assembles the same shapes the TS combinators produce.
479 #[test]
480 fn builders_assemble_the_grammar() {
481 let seq = sections(
482 LevelOpts {
483 order: Order::Strict,
484 allow_unknown: false,
485 },
486 vec![
487 section("Title"),
488 one_of(["Goal", "Goal / Problem statement"]),
489 gap_bounds(Some(1), Some(2)),
490 optional(section("Appendix")),
491 ],
492 );
493 assert_eq!(seq.specs.len(), 4);
494 let Spec::Section(s) = &seq.specs[0] else {
495 panic!("expected a section")
496 };
497 assert_eq!(s.names, vec!["Title"]);
498 let Spec::OneOf(o) = &seq.specs[1] else {
499 panic!("expected a oneOf")
500 };
501 assert_eq!(o.names.len(), 2);
502 let Spec::Gap(g) = &seq.specs[2] else {
503 panic!("expected a gap")
504 };
505 assert_eq!((g.min, g.max), (Some(1), Some(2)));
506 let Spec::Optional(inner) = &seq.specs[3] else {
507 panic!("expected optional")
508 };
509 assert!(matches!(**inner, Spec::Section(_)));
510 }
511
512 #[test]
513 fn max_words_is_a_paragraph_kind_leaf() {
514 // `maxWords(n)` gates on a paragraph; the bound itself is content-plane config.
515 assert_eq!(LeafSpec::max_words(120.0).kind, BlockKind::Paragraph);
516 assert_eq!(LeafSpec::table().kind, BlockKind::Table);
517 }
518
519 #[test]
520 fn a_closure_rule_runs_against_a_node_and_mints_via_ctx() {
521 let r = rule("summary/mentions-outcome", |node, ctx| {
522 vec![
523 ctx.finding(
524 FindingSpec::new(
525 "summary/mentions-outcome",
526 "Summary must mention the outcome",
527 )
528 .pos(node.pos),
529 ),
530 ]
531 });
532 let registry = Registry::default();
533 let ctx = Ctx::new("doc.md", ®istry);
534 let node = SectionNode::new("Summary", 2, SourcePos::at(1, 1));
535 let out = r.run(&node, &ctx);
536 assert_eq!(r.id(), "summary/mentions-outcome");
537 assert_eq!(out.len(), 1);
538 assert_eq!(out[0].path, "doc.md");
539 assert_eq!(out[0].pos, Some(SourcePos::at(1, 1)));
540 }
541
542 #[test]
543 fn default_level_opts_match_the_ts_defaults() {
544 let opts = LevelOpts::default();
545 assert_eq!(opts.order, Order::None);
546 assert!(opts.allow_unknown);
547 }
548}