mdtype_rules_stdlib/
required_sections.rs1use 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
11pub const ID: &str = "body.required_sections";
13
14pub struct Rule {
16 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
60pub 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(¶ms).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(¶ms).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(¶ms).is_err());
199 }
200}