1use crate::corpus::{walk_responses, CannedResponse};
4use anyhow::Result;
5use regex::Regex;
6use serde::Serialize;
7use std::path::Path;
8use std::sync::OnceLock;
9
10pub const REQUIRED_PREFIX: &str = "snell";
12
13#[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#[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
43pub fn check_corpus(root: &Path) -> Result<CheckReport> {
45 let docs = walk_responses(root)?;
46 Ok(check_responses(&docs))
47}
48
49pub 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 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 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 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
195pub 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}