1use serde::Deserialize;
2use std::collections::HashMap;
3
4#[derive(Debug, Deserialize)]
5pub struct RuleMapping {
6 pub provider: String,
7 pub mappings: Vec<MappingEntry>,
8}
9
10#[derive(Debug, Deserialize)]
11pub struct MappingEntry {
12 pub native_id: String,
13 pub unified_id: String,
14}
15
16pub struct RuleNormalizer {
17 mappings: HashMap<String, HashMap<String, String>>,
18}
19
20impl Default for RuleNormalizer {
21 fn default() -> Self {
22 Self::new()
23 }
24}
25
26impl RuleNormalizer {
27 #[must_use]
28 pub fn new() -> Self {
29 let mut normalizer = Self {
30 mappings: HashMap::new(),
31 };
32
33 normalizer.load_defaults();
35
36 normalizer
37 }
38
39 fn load_defaults(&mut self) {
40 const HARPER_YAML: &str = include_str!("../data/harper_mapping.yaml");
41 const LT_YAML: &str = include_str!("../data/languagetool_mapping.yaml");
42 const HUNSPELL_YAML: &str = include_str!("../data/hunspell_mapping.yaml");
43
44 for yaml_src in [HARPER_YAML, LT_YAML, HUNSPELL_YAML] {
45 let mapping: RuleMapping =
46 serde_yaml::from_str(yaml_src).expect("embedded YAML mapping should be valid");
47 let mut map = HashMap::new();
48 for entry in mapping.mappings {
49 map.insert(entry.native_id, entry.unified_id);
50 }
51 self.mappings.insert(mapping.provider, map);
52 }
53 }
54
55 #[must_use]
57 pub fn all_mappings(&self) -> Vec<(String, String, String)> {
58 let mut result = Vec::new();
59 for (provider, map) in &self.mappings {
60 for (native, unified) in map {
61 result.push((provider.clone(), native.clone(), unified.clone()));
62 }
63 }
64 result.sort();
65 result
66 }
67
68 #[must_use]
69 pub fn normalize(&self, provider: &str, native_id: &str) -> String {
70 if let Some(provider_mappings) = self.mappings.get(provider)
71 && let Some(unified_id) = provider_mappings.get(native_id)
72 {
73 return unified_id.clone();
74 }
75
76 let lowered = native_id.to_ascii_lowercase();
84 if lowered.contains("spell") {
85 "spelling.unknown".to_string()
86 } else if lowered.contains("grammar") {
87 "grammar.unknown".to_string()
88 } else {
89 "style.unknown".to_string()
90 }
91 }
92}
93
94#[must_use]
107pub fn default_severity(unified_id: &str) -> Option<i32> {
108 let category = unified_id.split('.').next().unwrap_or(unified_id);
112 Some(match category {
113 "spelling" | "grammar" => 2, "style" | "typography" => 1, _ => return None,
119 })
120}
121
122#[cfg(test)]
123mod tests {
124 use super::*;
125
126 #[test]
127 fn normalize_harper_spelling() {
128 let normalizer = RuleNormalizer::new();
129 assert_eq!(
130 normalizer.normalize("harper", "harper.Spelling"),
131 "spelling.typo"
132 );
133 assert_eq!(
134 normalizer.normalize("harper", "harper.Typo"),
135 "spelling.typo"
136 );
137 }
138
139 #[test]
140 fn normalize_lt_spelling() {
141 let normalizer = RuleNormalizer::new();
142 assert_eq!(
143 normalizer.normalize("languagetool", "languagetool.MORFOLOGIK_RULE_EN_US"),
144 "spelling.typo"
145 );
146 assert_eq!(
147 normalizer.normalize("languagetool", "languagetool.MORFOLOGIK_RULE_EN_GB"),
148 "spelling.typo"
149 );
150 }
151
152 #[test]
153 fn normalize_article_rules() {
154 let normalizer = RuleNormalizer::new();
155 assert_eq!(
156 normalizer.normalize("harper", "harper.AnA"),
157 "grammar.article"
158 );
159 assert_eq!(
160 normalizer.normalize("languagetool", "languagetool.EN_A_VS_AN"),
161 "grammar.article"
162 );
163 }
164
165 #[test]
166 fn normalize_agreement_rules() {
167 let normalizer = RuleNormalizer::new();
168 assert_eq!(
169 normalizer.normalize("harper", "harper.Agreement"),
170 "grammar.agreement"
171 );
172 assert_eq!(
173 normalizer.normalize("languagetool", "languagetool.SUBJECT_VERB_AGREEMENT"),
174 "grammar.agreement"
175 );
176 }
177
178 #[test]
179 fn normalize_style_rules() {
180 let normalizer = RuleNormalizer::new();
181 assert_eq!(
182 normalizer.normalize("harper", "harper.Readability"),
183 "style.readability"
184 );
185 assert_eq!(
186 normalizer.normalize("harper", "harper.WordChoice"),
187 "style.word_choice"
188 );
189 assert_eq!(
190 normalizer.normalize("languagetool", "languagetool.PASSIVE_VOICE"),
191 "style.passive_voice"
192 );
193 }
194
195 #[test]
196 fn normalize_typography_rules() {
197 let normalizer = RuleNormalizer::new();
198 assert_eq!(
199 normalizer.normalize("harper", "harper.Punctuation"),
200 "typography.punctuation"
201 );
202 assert_eq!(
203 normalizer.normalize("harper", "harper.Capitalization"),
204 "typography.capitalization"
205 );
206 assert_eq!(
207 normalizer.normalize("languagetool", "languagetool.DOUBLE_PUNCTUATION"),
208 "typography.punctuation"
209 );
210 }
211
212 #[test]
213 fn normalize_unknown_spelling_rule() {
214 let normalizer = RuleNormalizer::new();
215 assert_eq!(
216 normalizer.normalize("harper", "harper.SomeSpellRule_spell"),
217 "spelling.unknown"
218 );
219 }
220
221 #[test]
222 fn normalize_unknown_grammar_rule() {
223 let normalizer = RuleNormalizer::new();
224 assert_eq!(
225 normalizer.normalize("harper", "harper.SomeGrammarCheck_grammar"),
226 "grammar.unknown"
227 );
228 }
229
230 #[test]
231 fn normalize_completely_unknown_rule() {
232 let normalizer = RuleNormalizer::new();
233 assert_eq!(
234 normalizer.normalize("unknown_provider", "some.random.rule"),
235 "style.unknown"
236 );
237 }
238
239 #[test]
240 fn spelling_has_one_severity_whichever_engine_found_it() {
241 let normalizer = RuleNormalizer::new();
245 let harper = normalizer.normalize("harper", "harper.Spelling");
246 let lt = normalizer.normalize("languagetool", "languagetool.MORFOLOGIK_RULE_EN_US");
247 assert_eq!(default_severity(&harper), default_severity(<));
248 assert_eq!(default_severity(&harper), Some(2));
249 }
250
251 #[test]
252 fn style_is_never_an_error() {
253 assert_eq!(default_severity("style.passive_voice"), Some(1));
254 assert_eq!(default_severity("typography.punctuation"), Some(1));
255 }
256
257 #[test]
258 fn an_unknown_category_keeps_what_its_engine_said() {
259 assert_eq!(default_severity("vale.Custom"), None);
261 }
262}