1use std::collections::HashMap;
13
14use crate::finding::{Finding, FindingLevel, SourcePos};
15
16pub(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
30pub(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
48pub(crate) const TEXT_LEVELS: &[(&str, FindingLevel)] = &[
50 ("text/requires", FindingLevel::Error),
51 ("text/forbids", FindingLevel::Error),
52 ("text/count", FindingLevel::Error),
53];
54
55pub(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
65const UNHINTED: &[&str] = &["frontmatter/yaml"];
68
69pub(crate) fn takes_hint(id: &str) -> bool {
71 !UNHINTED.contains(&id)
72}
73
74#[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 pub fn level_for(&self, id: &str) -> FindingLevel {
96 self.levels.get(id).copied().unwrap_or(FindingLevel::Error)
97 }
98}
99
100#[derive(Debug, Clone)]
102pub struct FindingSpec {
103 pub id: String,
105 pub message: String,
107 pub level: Option<FindingLevel>,
109 pub pos: Option<SourcePos>,
111 pub hint: Option<String>,
113}
114
115impl FindingSpec {
116 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 pub fn level(mut self, level: FindingLevel) -> Self {
129 self.level = Some(level);
130 self
131 }
132
133 pub fn pos(mut self, pos: SourcePos) -> Self {
135 self.pos = Some(pos);
136 self
137 }
138
139 pub fn hint_opt(mut self, hint: Option<&str>) -> Self {
142 self.hint = hint.map(str::to_string);
143 self
144 }
145}
146
147pub struct Ctx<'a> {
149 pub path: &'a str,
151 registry: &'a Registry,
152}
153
154impl<'a> Ctx<'a> {
155 pub fn new(path: &'a str, registry: &'a Registry) -> Self {
157 Self { path, registry }
158 }
159
160 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 #[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}