ailint_core/rules/semantic/
duplicate_rules.rs1use std::collections::HashMap;
6
7use serde::Deserialize;
8
9use crate::parser::{DocumentContent, ParsedDocument};
10use crate::rules::semantic::AIL103;
11use crate::rules::{Rule, RuleContext, RuleId, Severity, Violation};
12
13#[derive(Debug, Default, Deserialize)]
14#[serde(default, deny_unknown_fields)]
15struct Options {
16 normalize: Option<bool>,
17}
18
19#[derive(Debug, Default)]
21pub struct NoDuplicateRulesRule;
22
23impl Rule for NoDuplicateRulesRule {
24 fn id(&self) -> RuleId {
25 AIL103
26 }
27
28 fn default_severity(&self) -> Severity {
29 Severity::Info
30 }
31
32 fn description(&self) -> &'static str {
33 "Same rule text appears more than once in the file."
34 }
35
36 fn fix_hint(&self) -> &'static str {
37 "Delete or merge the duplicate."
38 }
39
40 fn run(&self, doc: &ParsedDocument, ctx: &RuleContext<'_>) -> Vec<Violation> {
41 let md = match &doc.content {
42 DocumentContent::Markdown(m) => m,
43 _ => return Vec::new(),
44 };
45 let opts: Options = ctx
46 .options
47 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
48 .unwrap_or_default();
49 let _normalize = opts.normalize.unwrap_or(true);
50
51 let mut first_seen: HashMap<String, usize> = HashMap::new();
52 let mut out = Vec::new();
53 for item in &md.list_items {
54 let norm = normalize(&item.text);
55 if norm.chars().count() < 5 {
56 continue;
57 }
58 match first_seen.get(&norm) {
59 Some(&first_line) => {
60 let snippet: String = item.text.chars().take(120).collect();
61 let mut v = Violation::new(
62 AIL103,
63 self.default_severity(),
64 doc.path.clone(),
65 "duplicate rule",
66 )
67 .at(item.line, 1)
68 .with_detail(format!("first seen at L{first_line}"));
69 v.snippet = Some(snippet);
70 out.push(v);
71 }
72 None => {
73 first_seen.insert(norm, item.line);
74 }
75 }
76 }
77 out
78 }
79}
80
81fn normalize(s: &str) -> String {
82 let lower = s.to_lowercase();
83 let mut buf = String::with_capacity(lower.len());
84 let mut prev_space = true;
85 for ch in lower.chars() {
86 if ch.is_alphanumeric() {
87 buf.push(ch);
88 prev_space = false;
89 } else if (ch.is_whitespace() || ch.is_ascii_punctuation()) && !prev_space {
90 buf.push(' ');
91 prev_space = true;
92 }
93 }
94 buf.trim().to_string()
95}