Skip to main content

ailint_core/rules/consistency/
duplicate_files.rs

1//! AIL301 `no-duplicate-guidance-files` — detect substantively identical files.
2//!
3//! See: `docs/rules/consistency/AIL301.md`
4
5use 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/// AIL301 no-duplicate-guidance-files: flags files duplicating the same content.
22#[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
85// Strip frontmatter (if any), lowercase, strip common markdown syntax noise,
86// collapse whitespace, trim.
87fn 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        // Drop heading markers.
100        while let Some(rest) = stripped.strip_prefix('#') {
101            stripped = rest;
102        }
103        stripped = stripped.trim_start();
104        // Drop leading list markers.
105        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}