Skip to main content

mdtype_rules_stdlib/
required_sections.rs

1//! `body.required_sections` — assert that each named H2 heading exists.
2
3use std::collections::HashSet;
4
5use mdtype_core::nodes::{AstNode, NodeValue};
6use mdtype_core::{BodyRule, BodyRuleFactory, Diagnostic, Error, Fixit, ParsedDocument, Severity};
7use serde::Deserialize;
8
9use crate::heading_text;
10
11/// Rule id.
12pub const ID: &str = "body.required_sections";
13
14/// Configured rule instance.
15pub struct Rule {
16    /// Exact heading texts (without `##`) that must appear as H2 headings.
17    pub sections: Vec<String>,
18}
19
20impl BodyRule for Rule {
21    fn id(&self) -> &'static str {
22        ID
23    }
24
25    fn check(&self, doc: &ParsedDocument, out: &mut Vec<Diagnostic>) {
26        let present = collect_h2_headings(doc.ast);
27        for required in &self.sections {
28            if !present.contains(required.as_str()) {
29                out.push(Diagnostic {
30                    file: doc.path.clone(),
31                    line: None,
32                    rule: ID,
33                    severity: Severity::Error,
34                    message: format!("missing H2 section '{required}'; add it as '## {required}'"),
35                    fixit: Some(Fixit::AppendSection {
36                        heading: format!("## {required}"),
37                        after: None,
38                    }),
39                });
40            }
41        }
42    }
43}
44
45fn collect_h2_headings<'a>(root: &'a AstNode<'a>) -> HashSet<String> {
46    let mut out = HashSet::new();
47    for node in root.descendants() {
48        let data = node.data.borrow();
49        let NodeValue::Heading(heading) = &data.value else {
50            continue;
51        };
52        if heading.level != 2 {
53            continue;
54        }
55        out.insert(heading_text(node));
56    }
57    out
58}
59
60/// Factory. Params shape: `{ sections: [String, ...] }`.
61pub struct Factory;
62
63impl BodyRuleFactory for Factory {
64    fn id(&self) -> &'static str {
65        ID
66    }
67
68    fn build(&self, params: &serde_json::Value) -> Result<Box<dyn BodyRule>, Error> {
69        let parsed: Params = serde_json::from_value(params.clone())
70            .map_err(|e| Error::Schema(format!("{ID}: invalid params: {e}")))?;
71        if parsed.sections.is_empty() {
72            return Err(Error::Schema(format!("{ID}: `sections` must not be empty")));
73        }
74        Ok(Box::new(Rule {
75            sections: parsed.sections,
76        }))
77    }
78}
79
80#[derive(Debug, Deserialize)]
81struct Params {
82    sections: Vec<String>,
83}
84
85#[cfg(test)]
86mod tests {
87    use std::path::PathBuf;
88
89    use mdtype_core::{comrak, Arena, BodyRule, BodyRuleFactory, ParsedDocument};
90    use serde_json::json;
91
92    use super::{Factory, Rule, ID};
93
94    fn doc_for<'a>(
95        arena: &'a Arena<mdtype_core::nodes::AstNode<'a>>,
96        body: &str,
97    ) -> ParsedDocument<'a> {
98        let ast = comrak::parse_document(arena, body, &comrak::Options::default());
99        ParsedDocument {
100            path: PathBuf::from("fixture.md"),
101            frontmatter: serde_json::Value::Null,
102            ast,
103            body_line_offset: 1,
104        }
105    }
106
107    #[test]
108    fn all_sections_present_emits_nothing() {
109        let arena = Arena::new();
110        let body = "## Summary\n\nx\n\n## Background\n\ny\n\n## Conclusion\n\nz\n";
111        let doc = doc_for(&arena, body);
112        let rule = Rule {
113            sections: vec!["Summary".into(), "Background".into(), "Conclusion".into()],
114        };
115        let mut diags = Vec::new();
116        rule.check(&doc, &mut diags);
117        assert!(diags.is_empty(), "expected clean, got {diags:?}");
118    }
119
120    #[test]
121    fn one_missing_emits_one_diagnostic() {
122        let arena = Arena::new();
123        let body = "## Summary\n\nx\n\n## Conclusion\n\nz\n";
124        let doc = doc_for(&arena, body);
125        let rule = Rule {
126            sections: vec!["Summary".into(), "Background".into(), "Conclusion".into()],
127        };
128        let mut diags = Vec::new();
129        rule.check(&doc, &mut diags);
130        assert_eq!(diags.len(), 1);
131        let d = &diags[0];
132        assert_eq!(d.rule, ID);
133        assert!(d.line.is_none());
134        assert!(d.message.contains("Background"), "{}", d.message);
135    }
136
137    #[test]
138    fn none_present_emits_one_diagnostic_per_required() {
139        let arena = Arena::new();
140        let body = "Just some prose with no headings at all.\n";
141        let doc = doc_for(&arena, body);
142        let rule = Rule {
143            sections: vec!["Summary".into(), "Background".into()],
144        };
145        let mut diags = Vec::new();
146        rule.check(&doc, &mut diags);
147        assert_eq!(diags.len(), 2);
148    }
149
150    #[test]
151    fn h2_with_inline_emphasis_matches_plain_text() {
152        let arena = Arena::new();
153        let body = "## *Summary*\n\nbody\n";
154        let doc = doc_for(&arena, body);
155        let rule = Rule {
156            sections: vec!["Summary".into()],
157        };
158        let mut diags = Vec::new();
159        rule.check(&doc, &mut diags);
160        assert!(
161            diags.is_empty(),
162            "expected emphasis stripped, got {diags:?}"
163        );
164    }
165
166    #[test]
167    fn h1_does_not_satisfy_required_h2() {
168        let arena = Arena::new();
169        let body = "# Summary\n\nbody\n";
170        let doc = doc_for(&arena, body);
171        let rule = Rule {
172            sections: vec!["Summary".into()],
173        };
174        let mut diags = Vec::new();
175        rule.check(&doc, &mut diags);
176        assert_eq!(diags.len(), 1);
177    }
178
179    #[test]
180    fn factory_parses_sections() {
181        let factory = Factory;
182        let params = json!({ "sections": ["Summary", "Conclusion"] });
183        let rule = factory.build(&params).expect("build");
184        assert_eq!(rule.id(), ID);
185    }
186
187    #[test]
188    fn factory_rejects_empty_sections() {
189        let factory = Factory;
190        let params = json!({ "sections": [] });
191        assert!(factory.build(&params).is_err());
192    }
193
194    #[test]
195    fn factory_rejects_missing_sections_key() {
196        let factory = Factory;
197        let params = json!({});
198        assert!(factory.build(&params).is_err());
199    }
200}