Skip to main content

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", &registry);
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}