ailint_core/rules/consistency/
duplicate_files.rs1use std::collections::HashMap;
6
7use serde::Deserialize;
8
9use crate::parser::{DocumentContent, ParsedDocument};
10use crate::rules::consistency::AIL301;
11use crate::rules::{BatchRule, RuleContext, RuleId, Severity, Violation};
12
13const DEFAULT_MIN_LEN: usize = 100;
14
15#[derive(Debug, Default, Deserialize)]
16#[serde(default, deny_unknown_fields)]
17struct Options {
18 min_fingerprint_length: Option<usize>,
19}
20
21#[derive(Debug, Default)]
23pub struct NoDuplicateGuidanceFilesRule;
24
25impl BatchRule for NoDuplicateGuidanceFilesRule {
26 fn id(&self) -> RuleId {
27 AIL301
28 }
29
30 fn default_severity(&self) -> Severity {
31 Severity::Info
32 }
33
34 fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation> {
35 let opts: Options = ctx
36 .options
37 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
38 .unwrap_or_default();
39 let min_len = opts.min_fingerprint_length.unwrap_or(DEFAULT_MIN_LEN);
40
41 let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
42 for (idx, doc) in docs.iter().enumerate() {
43 let fp = fingerprint(doc);
44 if fp.len() < min_len {
45 continue;
46 }
47 groups.entry(fp).or_default().push(idx);
48 }
49
50 let mut out = Vec::new();
51 for indices in groups.values() {
52 if indices.len() < 2 {
53 continue;
54 }
55 let first = &docs[indices[0]];
56 let first_name = first
57 .path
58 .file_name()
59 .and_then(|s| s.to_str())
60 .unwrap_or("<unknown>");
61 for &idx in indices.iter().skip(1) {
62 let doc = &docs[idx];
63 let mut v = Violation::new(
64 AIL301,
65 self.default_severity(),
66 doc.path.clone(),
67 format!("duplicate content: matches '{}'", first_name),
68 )
69 .at(1, 1);
70 v.fix_hint = Some(
71 "consolidate into a single source of truth and delete or shim the duplicate"
72 .into(),
73 );
74 out.push(v);
75 }
76 }
77 out
78 }
79}
80
81fn fingerprint(doc: &ParsedDocument) -> String {
84 let body: &str = match &doc.content {
85 DocumentContent::Markdown(md) => match &md.frontmatter {
86 Some(fm) => &doc.raw[fm.byte_range.end..],
87 None => &doc.raw,
88 },
89 _ => &doc.raw,
90 };
91
92 let mut buf = String::with_capacity(body.len());
93 for line in body.lines() {
94 let mut stripped = line.trim_start();
95 while let Some(rest) = stripped.strip_prefix('#') {
97 stripped = rest;
98 }
99 stripped = stripped.trim_start();
100 for marker in ["- ", "* ", "+ "] {
102 if let Some(rest) = stripped.strip_prefix(marker) {
103 stripped = rest;
104 break;
105 }
106 }
107 for ch in stripped.chars() {
108 if ch == '`' {
109 continue;
110 }
111 for lc in ch.to_lowercase() {
112 buf.push(lc);
113 }
114 }
115 buf.push(' ');
116 }
117
118 let mut out = String::with_capacity(buf.len());
119 let mut prev_space = true;
120 for ch in buf.chars() {
121 if ch.is_whitespace() {
122 if !prev_space {
123 out.push(' ');
124 prev_space = true;
125 }
126 } else {
127 out.push(ch);
128 prev_space = false;
129 }
130 }
131 out.trim().to_string()
132}
133
134#[cfg(test)]
135mod tests {
136 use super::*;
137 use crate::parser::markdown::MarkdownDoc;
138 use std::path::PathBuf;
139
140 fn md_doc(raw: &str) -> ParsedDocument {
141 ParsedDocument {
142 path: PathBuf::from("x.md"),
143 file_type: crate::file_type::FileType::AgentsMd,
144 raw: raw.to_string(),
145 content: DocumentContent::Markdown(MarkdownDoc::default()),
146 }
147 }
148
149 #[test]
150 fn fingerprint_ignores_markdown_syntax() {
151 let a = md_doc("# Heading\n\n- one\n- two\n");
152 let b = md_doc("## HEADING\n\n* one\n* two\n");
153 assert_eq!(fingerprint(&a), fingerprint(&b));
154 }
155}