normalize_native_rules/
stale_docs.rs1use normalize_output::OutputFormatter;
4use normalize_output::diagnostics::{DiagnosticsReport, Issue, RelatedLocation, Severity};
5use serde::Serialize;
6use std::path::Path;
7
8#[derive(Debug, Serialize, schemars::JsonSchema)]
10struct StaleDoc {
11 doc_path: String,
12 doc_modified: u64,
13 stale_covers: Vec<StaleCover>,
14}
15
16#[derive(Debug, Serialize, schemars::JsonSchema)]
18struct StaleCover {
19 pattern: String,
20 code_modified: u64,
21 matching_files: Vec<String>,
22}
23
24#[derive(Debug, Serialize, schemars::JsonSchema)]
26pub struct StaleDocsReport {
27 stale_docs: Vec<StaleDoc>,
28 files_checked: usize,
29 files_with_covers: usize,
30}
31
32impl OutputFormatter for StaleDocsReport {
33 fn format_text(&self) -> String {
34 let mut lines = Vec::new();
35 lines.push("Stale Documentation Check".to_string());
36 lines.push(String::new());
37 lines.push(format!("Files checked: {}", self.files_checked));
38 lines.push(format!("Files with covers: {}", self.files_with_covers));
39 lines.push(String::new());
40
41 if self.stale_docs.is_empty() {
42 lines.push("No stale docs found. All covered code is older than docs.".to_string());
43 } else {
44 lines.push(format!("Stale docs ({}):", self.stale_docs.len()));
45 lines.push(String::new());
46 for doc in &self.stale_docs {
47 lines.push(format!(" {}", doc.doc_path));
48 for cover in &doc.stale_covers {
49 let days_stale = cover.code_modified.saturating_sub(doc.doc_modified) / 86400;
50 lines.push(format!(
51 " {} ({} files, ~{} days stale)",
52 cover.pattern,
53 cover.matching_files.len(),
54 days_stale
55 ));
56 }
57 }
58 }
59
60 lines.join("\n")
61 }
62}
63
64pub fn build_stale_docs_report(
66 root: &Path,
67 walk_config: &normalize_rules_config::WalkConfig,
68) -> StaleDocsReport {
69 use std::sync::OnceLock;
70
71 static COVERS_RE: OnceLock<regex::Regex> = OnceLock::new();
72 let covers_re =
74 COVERS_RE.get_or_init(|| regex::Regex::new(r"<!--\s*covers:\s*(.+?)\s*-->").unwrap());
75
76 let md_files: Vec<_> = crate::walk::gitignore_walk(root, walk_config)
77 .filter(|e| e.path().extension().and_then(|s| s.to_str()) == Some("md"))
78 .map(|e| e.path().to_path_buf())
79 .collect();
80
81 if md_files.is_empty() {
82 return StaleDocsReport {
83 stale_docs: Vec::new(),
84 files_checked: 0,
85 files_with_covers: 0,
86 };
87 }
88
89 let mut stale_docs: Vec<StaleDoc> = Vec::new();
90 let mut files_with_covers = 0;
91
92 for md_file in &md_files {
93 let content = match std::fs::read_to_string(md_file) {
94 Ok(c) => c,
95 Err(_) => continue,
96 };
97
98 let covers: Vec<String> = covers_re
99 .captures_iter(&content)
100 .map(|cap| cap[1].to_string())
101 .collect();
102
103 if covers.is_empty() {
104 continue;
105 }
106
107 files_with_covers += 1;
108
109 let rel_path = md_file
110 .strip_prefix(root)
111 .unwrap_or(md_file)
112 .display()
113 .to_string();
114
115 let doc_modified = std::fs::metadata(md_file)
116 .and_then(|m| m.modified())
117 .map(|t| {
118 t.duration_since(std::time::UNIX_EPOCH)
119 .unwrap_or(std::time::Duration::ZERO)
120 .as_secs()
121 })
122 .unwrap_or(0);
123
124 let mut stale_covers: Vec<StaleCover> = Vec::new();
125
126 for cover_pattern in covers {
127 for pattern in cover_pattern.split(',').map(|s| s.trim()) {
128 if pattern.is_empty() {
129 continue;
130 }
131
132 let matching = find_covered_files(root, pattern, walk_config);
133
134 if matching.is_empty() {
135 continue;
136 }
137
138 let code_modified = matching
139 .iter()
140 .filter_map(|f| {
141 std::fs::metadata(root.join(f))
142 .and_then(|m| m.modified())
143 .map(|t| {
144 t.duration_since(std::time::UNIX_EPOCH)
145 .unwrap_or(std::time::Duration::ZERO)
146 .as_secs()
147 })
148 .ok()
149 })
150 .max()
151 .unwrap_or(0);
152
153 if code_modified > doc_modified {
154 stale_covers.push(StaleCover {
155 pattern: pattern.to_string(),
156 code_modified,
157 matching_files: matching,
158 });
159 }
160 }
161 }
162
163 if !stale_covers.is_empty() {
164 stale_docs.push(StaleDoc {
165 doc_path: rel_path,
166 doc_modified,
167 stale_covers,
168 });
169 }
170 }
171
172 StaleDocsReport {
173 stale_docs,
174 files_checked: md_files.len(),
175 files_with_covers,
176 }
177}
178
179impl From<StaleDocsReport> for DiagnosticsReport {
180 fn from(report: StaleDocsReport) -> Self {
181 DiagnosticsReport {
182 issues: report
183 .stale_docs
184 .into_iter()
185 .flat_map(|doc| {
186 doc.stale_covers.into_iter().map(move |cover| {
187 let days_stale =
188 cover.code_modified.saturating_sub(doc.doc_modified) / 86400;
189 Issue {
190 file: doc.doc_path.clone(),
191 line: None,
192 column: None,
193 end_line: None,
194 end_column: None,
195 rule_id: "stale-doc".into(),
196 message: format!(
197 "covers `{}` ({} files, ~{} days stale)",
198 cover.pattern,
199 cover.matching_files.len(),
200 days_stale
201 ),
202 severity: Severity::Info,
203 source: "stale-docs".into(),
204 related: cover
205 .matching_files
206 .iter()
207 .map(|f| RelatedLocation {
208 file: f.clone(),
209 line: None,
210 message: None,
211 })
212 .collect(),
213 suggestion: Some(format!(
214 "update {} to reflect recent changes",
215 doc.doc_path
216 )),
217 }
218 })
219 })
220 .collect(),
221 files_checked: report.files_checked,
222 sources_run: vec!["stale-docs".into()],
223 tool_errors: vec![],
224 daemon_cached: false,
225 }
226 }
227}
228
229fn find_covered_files(
231 root: &Path,
232 pattern: &str,
233 walk_config: &normalize_rules_config::WalkConfig,
234) -> Vec<String> {
235 let pattern_path = std::path::Path::new(pattern);
238 if pattern_path
239 .components()
240 .any(|c| matches!(c, std::path::Component::ParentDir))
241 {
242 return vec![];
243 }
244
245 if pattern.contains('*') {
247 let full_pattern = root.join(pattern);
249 let non_glob_prefix: std::path::PathBuf = full_pattern
252 .components()
253 .take_while(|c| !c.as_os_str().to_string_lossy().contains('*'))
254 .collect();
255 if let (Ok(canon_prefix), Ok(canon_root)) =
256 (non_glob_prefix.canonicalize(), root.canonicalize())
257 && !canon_prefix.starts_with(&canon_root)
258 {
259 return vec![];
260 }
261 match glob::glob(full_pattern.to_str().unwrap_or("")) {
262 Err(e) => {
263 tracing::warn!(
264 "normalize-native-rules: invalid glob pattern {:?}: {}",
265 full_pattern,
266 e
267 );
268 vec![]
269 }
270 Ok(paths) => paths
271 .filter_map(|p| p.ok())
272 .filter(|p| p.is_file())
273 .filter_map(|p| p.strip_prefix(root).ok().map(|r| r.display().to_string()))
274 .collect(),
275 }
276 } else {
277 let target = root.join(pattern);
279 if target.is_file() {
280 vec![pattern.to_string()]
281 } else if target.is_dir() {
282 crate::walk::gitignore_walk(&target, walk_config)
284 .filter(|e| e.file_type().is_some_and(|ft| ft.is_file()))
285 .filter_map(|e| {
286 e.path()
287 .strip_prefix(root)
288 .ok()
289 .map(|r| r.display().to_string())
290 })
291 .collect()
292 } else {
293 vec![]
294 }
295 }
296}