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 description(&self) -> &'static str {
35 "File content is byte-identical to another guidance file."
36 }
37
38 fn fix_hint(&self) -> &'static str {
39 "Keep one file as the source of truth; delete or symlink the other."
40 }
41
42 fn run_batch(&self, docs: &[ParsedDocument], ctx: &RuleContext<'_>) -> Vec<Violation> {
43 let opts: Options = ctx
44 .options
45 .and_then(|v| serde_yaml::from_value(v.clone()).ok())
46 .unwrap_or_default();
47 let min_len = opts.min_fingerprint_length.unwrap_or(DEFAULT_MIN_LEN);
48
49 let mut groups: HashMap<String, Vec<usize>> = HashMap::new();
50 for (idx, doc) in docs.iter().enumerate() {
51 let fp = fingerprint(doc);
52 if fp.len() < min_len {
53 continue;
54 }
55 groups.entry(fp).or_default().push(idx);
56 }
57
58 let mut out = Vec::new();
59 for indices in groups.values() {
60 if indices.len() < 2 {
61 continue;
62 }
63 let first = &docs[indices[0]];
64 let first_name = first
65 .path
66 .file_name()
67 .and_then(|s| s.to_str())
68 .unwrap_or("<unknown>");
69 for &idx in indices.iter().skip(1) {
70 let doc = &docs[idx];
71 let v = Violation::new(
72 AIL301,
73 self.default_severity(),
74 doc.path.clone(),
75 "duplicate content",
76 )
77 .with_detail(format!("matches {first_name}"));
78 out.push(v);
79 }
80 }
81 out
82 }
83}
84
85fn fingerprint(doc: &ParsedDocument) -> String {
88 let body: &str = match &doc.content {
89 DocumentContent::Markdown(md) => match &md.frontmatter {
90 Some(fm) => &doc.raw[fm.byte_range.end..],
91 None => &doc.raw,
92 },
93 _ => &doc.raw,
94 };
95
96 let mut buf = String::with_capacity(body.len());
97 for line in body.lines() {
98 let mut stripped = line.trim_start();
99 while let Some(rest) = stripped.strip_prefix('#') {
101 stripped = rest;
102 }
103 stripped = stripped.trim_start();
104 for marker in ["- ", "* ", "+ "] {
106 if let Some(rest) = stripped.strip_prefix(marker) {
107 stripped = rest;
108 break;
109 }
110 }
111 for ch in stripped.chars() {
112 if ch == '`' {
113 continue;
114 }
115 for lc in ch.to_lowercase() {
116 buf.push(lc);
117 }
118 }
119 buf.push(' ');
120 }
121
122 let mut out = String::with_capacity(buf.len());
123 let mut prev_space = true;
124 for ch in buf.chars() {
125 if ch.is_whitespace() {
126 if !prev_space {
127 out.push(' ');
128 prev_space = true;
129 }
130 } else {
131 out.push(ch);
132 prev_space = false;
133 }
134 }
135 out.trim().to_string()
136}
137
138#[cfg(test)]
139mod tests {
140 use super::*;
141 use crate::parser::markdown::MarkdownDoc;
142 use std::path::PathBuf;
143
144 fn md_doc(raw: &str) -> ParsedDocument {
145 ParsedDocument {
146 path: PathBuf::from("x.md"),
147 file_type: crate::file_type::FileType::AgentsMd,
148 raw: raw.to_string(),
149 content: DocumentContent::Markdown(MarkdownDoc::default()),
150 }
151 }
152
153 #[test]
154 fn fingerprint_ignores_markdown_syntax() {
155 let a = md_doc("# Heading\n\n- one\n- two\n");
156 let b = md_doc("## HEADING\n\n* one\n* two\n");
157 assert_eq!(fingerprint(&a), fingerprint(&b));
158 }
159}