Skip to main content

markdown_contract/
registry.rs

1//! The registry of each finding id's default `FindingLevel`, and the `Ctx` factory.
2//!
3//! The tables mirror the TS `registry.ts`: the structure, content, text and rule
4//! defaults. An unregistered id defaults to `"error"`. A rule can set another level for
5//! one finding with [`FindingSpec::level`]. A short list names the ids that never take a
6//! `description` as their hint.
7//!
8//! [`Ctx`] is the rule-author finding factory: `ctx.finding(spec)` stamps the document
9//! `path`, fills `level` from the registry when the caller does not supply one, and
10//! carries `pos` only when supplied (omitted for absence findings).
11
12use std::collections::HashMap;
13
14use crate::finding::{Finding, FindingLevel, SourcePos};
15
16/// The default severity for every structure-plane finding id.
17pub(crate) const STRUCTURE_LEVELS: &[(&str, FindingLevel)] = &[
18    ("structure/section-missing", FindingLevel::Error),
19    ("structure/section-order", FindingLevel::Error),
20    ("structure/duplicate-section", FindingLevel::Error),
21    ("structure/key-collision", FindingLevel::Error),
22    ("structure/anchor-missing", FindingLevel::Error),
23    ("structure/block-missing", FindingLevel::Error),
24    ("structure/block-kind", FindingLevel::Error),
25    ("structure/gap-count", FindingLevel::Error),
26    ("structure/repeat-count", FindingLevel::Error),
27    ("structure/heading-depth-jump", FindingLevel::Warn),
28];
29
30/// The default severity for every content- and frontmatter-plane finding id.
31pub(crate) const CONTENT_LEVELS: &[(&str, FindingLevel)] = &[
32    ("content/table/column-missing", FindingLevel::Error),
33    ("content/table/column-extra", FindingLevel::Error),
34    ("content/table/min-rows", FindingLevel::Error),
35    ("content/table/cell", FindingLevel::Error),
36    ("content/list/item-kind", FindingLevel::Error),
37    ("content/list/min-items", FindingLevel::Error),
38    ("content/code/lang", FindingLevel::Error),
39    ("content/max-words", FindingLevel::Error),
40    ("frontmatter/yaml", FindingLevel::Error),
41    ("frontmatter/enum", FindingLevel::Error),
42    ("frontmatter/unknown-key", FindingLevel::Error),
43    ("frontmatter/type", FindingLevel::Error),
44    ("frontmatter/required", FindingLevel::Error),
45    ("frontmatter/refine", FindingLevel::Error),
46];
47
48/// Default severities for the declarative text plane (`text/*`).
49pub(crate) const TEXT_LEVELS: &[(&str, FindingLevel)] = &[
50    ("text/requires", FindingLevel::Error),
51    ("text/forbids", FindingLevel::Error),
52    ("text/count", FindingLevel::Error),
53];
54
55/// Default severities for the rule plane — the contract-chosen namespaces the corpus
56/// contracts use. An unregistered rule id still defaults to `"error"`, so this table is
57/// the documented, not the load-bearing, source.
58pub(crate) const RULE_LEVELS: &[(&str, FindingLevel)] = &[
59    ("task/post-mortem-when-worked", FindingLevel::Error),
60    ("task/completion-note-when-closed", FindingLevel::Error),
61    ("summary/mentions-outcome", FindingLevel::Error),
62    ("summary/names-contract", FindingLevel::Warn),
63];
64
65/// The finding ids that never take a `description` as their hint, because they report
66/// on something outside every description's scope.
67const UNHINTED: &[&str] = &["frontmatter/yaml"];
68
69/// Whether a finding with `id` can take a `description` as its hint.
70pub(crate) fn takes_hint(id: &str) -> bool {
71    !UNHINTED.contains(&id)
72}
73
74/// The id → default-level registry, seeded with every plane's defaults.
75#[derive(Debug, Clone)]
76pub struct Registry {
77    levels: HashMap<&'static str, FindingLevel>,
78}
79
80impl Default for Registry {
81    fn default() -> Self {
82        let levels = STRUCTURE_LEVELS
83            .iter()
84            .chain(CONTENT_LEVELS)
85            .chain(TEXT_LEVELS)
86            .chain(RULE_LEVELS)
87            .copied()
88            .collect();
89        Self { levels }
90    }
91}
92
93impl Registry {
94    /// The default level for `id`; an unregistered id defaults to `"error"`.
95    pub fn level_for(&self, id: &str) -> FindingLevel {
96        self.levels.get(id).copied().unwrap_or(FindingLevel::Error)
97    }
98}
99
100/// The input to [`Ctx::finding`] — id and message required, level/pos/hint optional.
101#[derive(Debug, Clone)]
102pub struct FindingSpec {
103    /// the finding id, `area/.../name`
104    pub id: String,
105    /// what is wrong, in a sentence
106    pub message: String,
107    /// overrides the registry's default level for the id
108    pub level: Option<FindingLevel>,
109    /// where in the document, if anywhere
110    pub pos: Option<SourcePos>,
111    /// the nearest `description:` in scope
112    pub hint: Option<String>,
113}
114
115impl FindingSpec {
116    /// A spec with an id and a message, and no level, position or hint.
117    pub fn new(id: impl Into<String>, message: impl Into<String>) -> Self {
118        Self {
119            id: id.into(),
120            message: message.into(),
121            level: None,
122            pos: None,
123            hint: None,
124        }
125    }
126
127    /// Override the registry default level.
128    pub fn level(mut self, level: FindingLevel) -> Self {
129        self.level = Some(level);
130        self
131    }
132
133    /// Pin the finding to a source position (omit for whole-document absence findings).
134    pub fn pos(mut self, pos: SourcePos) -> Self {
135        self.pos = Some(pos);
136        self
137    }
138
139    /// Attach the nearest enclosing `description:` as the finding's hint. `None` leaves
140    /// the slot absent.
141    pub fn hint_opt(mut self, hint: Option<&str>) -> Self {
142        self.hint = hint.map(str::to_string);
143        self
144    }
145}
146
147/// The rule author's finding factory — the engine fills `path` and the id's default level.
148pub struct Ctx<'a> {
149    /// the document path stamped on every finding
150    pub path: &'a str,
151    registry: &'a Registry,
152}
153
154impl<'a> Ctx<'a> {
155    /// A factory for findings of the document at `path`, with levels from `registry`.
156    pub fn new(path: &'a str, registry: &'a Registry) -> Self {
157        Self { path, registry }
158    }
159
160    /// Build a [`Finding`]: stamps `path`, fills `level` from the registry (defaulting to
161    /// `"error"` for an unregistered id), carries `pos` only when supplied.
162    pub fn finding(&self, spec: FindingSpec) -> Finding {
163        let level = spec
164            .level
165            .unwrap_or_else(|| self.registry.level_for(&spec.id));
166        Finding {
167            id: spec.id,
168            level,
169            path: self.path.to_string(),
170            pos: spec.pos,
171            message: spec.message,
172            hint: spec.hint,
173            fix: None,
174        }
175    }
176}
177
178#[cfg(test)]
179mod tests {
180    use super::*;
181
182    // Contract first: a structure id resolves to its registered default; heading-depth-jump
183    // is the one warn-level structure default; an unregistered id falls back to error.
184    #[test]
185    fn registry_defaults_match_the_ts_tables() {
186        let r = Registry::default();
187        assert_eq!(
188            r.level_for("structure/section-missing"),
189            FindingLevel::Error
190        );
191        assert_eq!(
192            r.level_for("structure/heading-depth-jump"),
193            FindingLevel::Warn
194        );
195        assert_eq!(r.level_for("summary/names-contract"), FindingLevel::Warn);
196        assert_eq!(r.level_for("text/requires"), FindingLevel::Error);
197        assert_eq!(r.level_for("totally/unregistered"), FindingLevel::Error);
198    }
199
200    #[test]
201    fn ctx_finding_stamps_path_and_default_level() {
202        let r = Registry::default();
203        let ctx = Ctx::new("docs/note.md", &r);
204        let f = ctx.finding(
205            FindingSpec::new("structure/heading-depth-jump", "skip").pos(SourcePos::at(5, 1)),
206        );
207        assert_eq!(f.level, FindingLevel::Warn);
208        assert_eq!(f.path, "docs/note.md");
209        assert_eq!(f.pos, Some(SourcePos::at(5, 1)));
210    }
211
212    #[test]
213    fn explicit_level_overrides_the_registry() {
214        let r = Registry::default();
215        let ctx = Ctx::new("a.md", &r);
216        let f = ctx.finding(
217            FindingSpec::new("structure/section-missing", "m").level(FindingLevel::Report),
218        );
219        assert_eq!(f.level, FindingLevel::Report);
220    }
221
222    #[test]
223    fn pos_is_absent_unless_supplied() {
224        let r = Registry::default();
225        let ctx = Ctx::new("a.md", &r);
226        assert_eq!(ctx.finding(FindingSpec::new("x/y", "m")).pos, None);
227    }
228}