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 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
81// Strip frontmatter (if any), lowercase, strip common markdown syntax noise,
82// collapse whitespace, trim.
83fn 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        // Drop heading markers.
96        while let Some(rest) = stripped.strip_prefix('#') {
97            stripped = rest;
98        }
99        stripped = stripped.trim_start();
100        // Drop leading list markers.
101        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}