Skip to main content

canonic/
check.rs

1//! Corpus quality checks for the shared `snell` prefix migration.
2
3use crate::corpus::{walk_responses, CannedResponse};
4use anyhow::Result;
5use regex::Regex;
6use serde::Serialize;
7use std::path::Path;
8use std::sync::OnceLock;
9
10/// Shared id/prefix convention for the published response library.
11pub const REQUIRED_PREFIX: &str = "snell";
12
13/// One quality finding.
14#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
15pub struct CheckFinding {
16    pub path: String,
17    pub id: String,
18    pub code: String,
19    pub message: String,
20}
21
22/// Aggregated quality report.
23#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
24pub struct CheckReport {
25    pub findings: Vec<CheckFinding>,
26    pub checked: usize,
27}
28
29impl CheckReport {
30    pub fn ok(&self) -> bool {
31        self.findings.is_empty()
32    }
33
34    pub fn summary_line(&self) -> String {
35        format!(
36            "check: {} file(s), {} finding(s)",
37            self.checked,
38            self.findings.len()
39        )
40    }
41}
42
43/// Run all quality rules over a corpus directory.
44pub fn check_corpus(root: &Path) -> Result<CheckReport> {
45    let docs = walk_responses(root)?;
46    Ok(check_responses(&docs))
47}
48
49/// Pure quality rules over already-loaded responses (unit-testable).
50pub fn check_responses(docs: &[CannedResponse]) -> CheckReport {
51    let mut findings = Vec::new();
52    for doc in docs {
53        findings.extend(check_one(doc));
54    }
55    // Duplicate ids across corpus
56    let mut seen: std::collections::HashMap<&str, &Path> = std::collections::HashMap::new();
57    for doc in docs {
58        if let Some(prev) = seen.insert(doc.id.as_str(), doc.path.as_path()) {
59            findings.push(CheckFinding {
60                path: doc.path.display().to_string(),
61                id: doc.id.clone(),
62                code: "duplicate-id".into(),
63                message: format!(
64                    "id `{}` also used by {}",
65                    doc.id,
66                    prev.display()
67                ),
68            });
69        }
70    }
71    CheckReport {
72        findings,
73        checked: docs.len(),
74    }
75}
76
77fn check_one(doc: &CannedResponse) -> Vec<CheckFinding> {
78    let mut out = Vec::new();
79    let path = doc.path.display().to_string();
80
81    if !doc.id.starts_with(&format!("{REQUIRED_PREFIX}-")) {
82        out.push(finding(
83            &path,
84            &doc.id,
85            "id-prefix",
86            format!(
87                "id must start with `{REQUIRED_PREFIX}-` (shared library prefix); got `{}`",
88                doc.id
89            ),
90        ));
91    }
92
93    let stem = doc
94        .path
95        .file_stem()
96        .and_then(|s| s.to_str())
97        .unwrap_or("");
98    if stem != doc.id {
99        out.push(finding(
100            &path,
101            &doc.id,
102            "id-filename",
103            format!("filename stem `{stem}` must equal id `{}`", doc.id),
104        ));
105    }
106
107    match doc.prefix.as_deref() {
108        Some(p) if p == REQUIRED_PREFIX => {}
109        Some(p) => out.push(finding(
110            &path,
111            &doc.id,
112            "prefix-field",
113            format!("front matter `prefix` must be `{REQUIRED_PREFIX}`; got `{p}`"),
114        )),
115        None => out.push(finding(
116            &path,
117            &doc.id,
118            "prefix-field",
119            format!("front matter `prefix: {REQUIRED_PREFIX}` is required"),
120        )),
121    }
122
123    match doc.sop.as_deref() {
124        Some(s) if !s.trim().is_empty() => {}
125        _ => out.push(finding(
126            &path,
127            &doc.id,
128            "sop-field",
129            "front matter `sop` is required (Confluence URL or the literal `none`)".into(),
130        )),
131    }
132
133    if doc.title.trim().is_empty() {
134        out.push(finding(
135            &path,
136            &doc.id,
137            "title",
138            "title must be non-empty".into(),
139        ));
140    }
141
142    if let Some(msg) = personal_signature_hit(&doc.content) {
143        out.push(finding(
144            &path,
145            &doc.id,
146            "personal-signature",
147            format!("possible personal sign-off; use shared team closing ({msg})"),
148        ));
149    }
150
151    out
152}
153
154fn finding(path: &str, id: &str, code: &str, message: String) -> CheckFinding {
155    CheckFinding {
156        path: path.into(),
157        id: id.into(),
158        code: code.into(),
159        message,
160    }
161}
162
163fn personal_signature_hit(content: &str) -> Option<&'static str> {
164    // Reject common personal closings that should be team-generic for shared responses.
165    static RE: OnceLock<Regex> = OnceLock::new();
166    let re = RE.get_or_init(|| {
167        Regex::new(
168            r"(?im)^\s*(cheers|thanks,?\s*$|best,?\s*$|yours,?\s*$|sincerely,?\s*$)\s*$|^\s*[-–—]\s*[A-Z][a-z]{1,12}\s*$|^\s*(John|Jane|Manuel|Alice|Bob)\s*$",
169        )
170        .expect("signature regex")
171    });
172    if re.is_match(content) {
173        return Some("matched personal closing/name pattern");
174    }
175    // Explicit ban of "Regards," followed only by a single first name line that is not the team string
176    let lines: Vec<&str> = content.lines().map(|l| l.trim()).collect();
177    for w in lines.windows(2) {
178        if w[0].eq_ignore_ascii_case("regards,") || w[0].eq_ignore_ascii_case("regards") {
179            let next = w[1];
180            if !next.is_empty()
181                && !next.to_lowercase().contains("support")
182                && !next.to_lowercase().contains("team")
183                && next.split_whitespace().count() <= 2
184                && next
185                    .chars()
186                    .all(|c| c.is_alphabetic() || c.is_whitespace() || c == '-')
187            {
188                return Some("Regards followed by personal name instead of team");
189            }
190        }
191    }
192    None
193}
194
195/// Format a human-readable check report.
196pub fn format_check_report(report: &CheckReport) -> String {
197    let mut s = report.summary_line();
198    s.push('\n');
199    for f in &report.findings {
200        s.push_str(&format!(
201            "[{}] {} ({}): {}\n",
202            f.code, f.path, f.id, f.message
203        ));
204    }
205    if report.ok() {
206        s.push_str("All quality checks passed.\n");
207    }
208    s
209}
210
211#[cfg(test)]
212mod tests {
213    use super::*;
214    use crate::corpus::CannedResponse;
215    use std::path::PathBuf;
216
217    fn doc(id: &str, prefix: Option<&str>, sop: Option<&str>, content: &str) -> CannedResponse {
218        CannedResponse {
219            id: id.into(),
220            title: "Title".into(),
221            prefix: prefix.map(str::to_string),
222            sop: sop.map(str::to_string),
223            body: content.into(),
224            content: content.into(),
225            path: PathBuf::from(format!("{id}.md")),
226            tags: vec![],
227        }
228    }
229
230    #[test]
231    fn accepts_well_formed_snell_response() {
232        let d = doc(
233            "snell-project-space-not-backup",
234            Some("snell"),
235            Some("none"),
236            "Hello,\n\nBody.\n\nRegards,\nSupport Team\n",
237        );
238        let r = check_responses(&[d]);
239        assert!(r.ok(), "{:?}", r.findings);
240    }
241
242    #[test]
243    fn rejects_missing_prefix_and_bad_id() {
244        let d = doc("password-reset", None, None, "Body\n");
245        let r = check_responses(&[d]);
246        let codes: Vec<_> = r.findings.iter().map(|f| f.code.as_str()).collect();
247        assert!(codes.contains(&"id-prefix"), "{codes:?}");
248        assert!(codes.contains(&"prefix-field"), "{codes:?}");
249        assert!(codes.contains(&"sop-field"), "{codes:?}");
250    }
251
252    #[test]
253    fn rejects_personal_signoff_name_after_regards() {
254        let d = doc(
255            "snell-foo",
256            Some("snell"),
257            Some("none"),
258            "Hello.\n\nRegards,\nAlice\n",
259        );
260        let r = check_responses(&[d]);
261        assert!(
262            r.findings.iter().any(|f| f.code == "personal-signature"),
263            "{:?}",
264            r.findings
265        );
266    }
267
268    #[test]
269    fn rejects_duplicate_ids() {
270        let a = doc("snell-a", Some("snell"), Some("none"), "x\n\nRegards,\nSupport Team\n");
271        let mut b = a.clone();
272        b.path = PathBuf::from("other/snell-a.md");
273        let r = check_responses(&[a, b]);
274        assert!(r.findings.iter().any(|f| f.code == "duplicate-id"));
275    }
276}