Skip to main content

normalize_native_rules/
stale_docs.rs

1//! Find stale documentation where covered code has changed
2
3use normalize_output::OutputFormatter;
4use normalize_output::diagnostics::{DiagnosticsReport, Issue, RelatedLocation, Severity};
5use serde::Serialize;
6use std::path::Path;
7
8/// A doc file with stale code coverage
9#[derive(Debug, Serialize, schemars::JsonSchema)]
10struct StaleDoc {
11    doc_path: String,
12    doc_modified: u64,
13    stale_covers: Vec<StaleCover>,
14}
15
16/// A stale coverage declaration
17#[derive(Debug, Serialize, schemars::JsonSchema)]
18struct StaleCover {
19    pattern: String,
20    code_modified: u64,
21    matching_files: Vec<String>,
22}
23
24/// Report produced by the stale-doc native rule check.
25#[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
64/// Build a StaleDocsReport without printing (for service layer).
65pub 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    // normalize-syntax-allow: rust/unwrap-in-impl - compile-time constant regex pattern
73    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
229/// Find files matching a cover pattern (glob or path prefix)
230fn find_covered_files(
231    root: &Path,
232    pattern: &str,
233    walk_config: &normalize_rules_config::WalkConfig,
234) -> Vec<String> {
235    // Reject patterns that could escape the project root via path traversal.
236    // Check both the pattern string and the resolved full path.
237    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    // Check if it's a glob pattern
246    if pattern.contains('*') {
247        // Use glob matching
248        let full_pattern = root.join(pattern);
249        // Guard: ensure the constructed glob path still lives under root
250        // (after stripping glob wildcards, the prefix must be under root).
251        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        // Treat as exact path or prefix
278        let target = root.join(pattern);
279        if target.is_file() {
280            vec![pattern.to_string()]
281        } else if target.is_dir() {
282            // Find all files in directory
283            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}