Skip to main content

chronicle/read/
staleness.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3
4/// Default threshold: an annotation is considered stale if more than 5
5/// commits have touched the file since the annotation was written.
6const DEFAULT_STALENESS_THRESHOLD: usize = 5;
7
8/// Staleness information for a single annotation.
9#[derive(Debug, Clone, serde::Serialize)]
10pub struct StalenessInfo {
11    pub annotation_commit: String,
12    pub latest_file_commit: String,
13    pub commits_since: usize,
14    pub stale: bool,
15}
16
17/// Compute staleness for an annotation on a given file.
18///
19/// Returns `None` if the annotation commit isn't in the file's history
20/// (e.g., the file was renamed).
21pub fn compute_staleness(
22    git: &dyn GitOps,
23    file: &str,
24    annotation_commit: &str,
25) -> Result<Option<StalenessInfo>, GitError> {
26    compute_staleness_with_threshold(git, file, annotation_commit, DEFAULT_STALENESS_THRESHOLD)
27}
28
29/// Compute staleness with a custom threshold.
30pub fn compute_staleness_with_threshold(
31    git: &dyn GitOps,
32    file: &str,
33    annotation_commit: &str,
34    threshold: usize,
35) -> Result<Option<StalenessInfo>, GitError> {
36    let shas = git.log_for_file(file)?;
37    if shas.is_empty() {
38        return Ok(None);
39    }
40
41    let latest = shas[0].clone();
42
43    // Find the position of the annotation commit in the file's history.
44    // shas are ordered newest-first, so position 0 = HEAD of the file.
45    let position = shas.iter().position(|sha| sha == annotation_commit);
46
47    match position {
48        Some(pos) => Ok(Some(StalenessInfo {
49            annotation_commit: annotation_commit.to_string(),
50            latest_file_commit: latest,
51            commits_since: pos,
52            stale: pos > threshold,
53        })),
54        None => {
55            // Annotation commit not found in file history — could be
56            // a renamed file or the commit didn't touch this file directly.
57            // Treat as stale (the annotation is about a different version).
58            Ok(Some(StalenessInfo {
59                annotation_commit: annotation_commit.to_string(),
60                latest_file_commit: latest,
61                commits_since: shas.len(),
62                stale: true,
63            }))
64        }
65    }
66}
67
68/// Scan annotated commits and report staleness across the repo.
69pub fn scan_staleness(git: &dyn GitOps, limit: u32) -> Result<StalenessReport, GitError> {
70    let annotated = git.list_annotated_commits(limit)?;
71    let mut total_annotations = 0usize;
72    let mut stale_count = 0usize;
73    let mut stale_files: Vec<StaleFileEntry> = Vec::new();
74
75    for sha in &annotated {
76        let note = match git.note_read(sha)? {
77            Some(n) => n,
78            None => continue,
79        };
80
81        let annotation = match crate::schema::parse_annotation(&note) {
82            Ok(a) => a,
83            Err(e) => {
84                tracing::debug!("skipping malformed annotation for {sha}: {e}");
85                continue;
86            }
87        };
88
89        total_annotations += 1;
90
91        // In v3, files come from wisdom entries (no narrative.files_changed).
92        let files: Vec<String> = annotation
93            .wisdom
94            .iter()
95            .filter_map(|w| w.file.clone())
96            .collect::<std::collections::HashSet<_>>()
97            .into_iter()
98            .collect();
99
100        for file in &files {
101            if let Some(info) = compute_staleness(git, file, &annotation.commit)? {
102                if info.stale {
103                    stale_count += 1;
104                    stale_files.push(StaleFileEntry {
105                        file: file.clone(),
106                        annotation_commit: annotation.commit.clone(),
107                        commits_since: info.commits_since,
108                    });
109                }
110            }
111        }
112    }
113
114    Ok(StalenessReport {
115        total_annotations,
116        stale_count,
117        stale_files,
118    })
119}
120
121/// Summary report of staleness across the repo.
122#[derive(Debug, Clone, serde::Serialize)]
123pub struct StalenessReport {
124    pub total_annotations: usize,
125    pub stale_count: usize,
126    pub stale_files: Vec<StaleFileEntry>,
127}
128
129/// A single stale file entry in the report.
130#[derive(Debug, Clone, serde::Serialize)]
131pub struct StaleFileEntry {
132    pub file: String,
133    pub annotation_commit: String,
134    pub commits_since: usize,
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use crate::git::diff::FileDiff;
141    use crate::git::CommitInfo;
142
143    struct MockGitOps {
144        file_log: Vec<String>,
145        annotated_commits: Vec<String>,
146        notes: std::collections::HashMap<String, String>,
147    }
148
149    impl GitOps for MockGitOps {
150        fn diff(&self, _commit: &str) -> Result<Vec<FileDiff>, GitError> {
151            Ok(vec![])
152        }
153        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
154            Ok(self.notes.get(commit).cloned())
155        }
156        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
157            Ok(())
158        }
159        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
160            Ok(self.notes.contains_key(commit))
161        }
162        fn file_at_commit(
163            &self,
164            _path: &std::path::Path,
165            _commit: &str,
166        ) -> Result<String, GitError> {
167            Ok(String::new())
168        }
169        fn commit_info(&self, commit: &str) -> Result<CommitInfo, GitError> {
170            Ok(CommitInfo {
171                sha: commit.to_string(),
172                message: "test".to_string(),
173                author_name: "test".to_string(),
174                author_email: "test@test.com".to_string(),
175                timestamp: "2025-01-01T00:00:00Z".to_string(),
176                parent_shas: vec![],
177            })
178        }
179        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
180            Ok("abc123".to_string())
181        }
182        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
183            Ok(None)
184        }
185        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
186            Ok(())
187        }
188        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
189            Ok(self.file_log.clone())
190        }
191        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
192            Ok(self.annotated_commits.clone())
193        }
194    }
195
196    #[test]
197    fn test_staleness_fresh_annotation() {
198        let git = MockGitOps {
199            file_log: vec!["commit1".to_string()],
200            annotated_commits: vec![],
201            notes: std::collections::HashMap::new(),
202        };
203
204        let info = compute_staleness(&git, "src/main.rs", "commit1")
205            .unwrap()
206            .unwrap();
207        assert_eq!(info.commits_since, 0);
208        assert!(!info.stale);
209    }
210
211    #[test]
212    fn test_staleness_annotation_is_stale() {
213        // 7 commits newer than the annotation commit
214        let git = MockGitOps {
215            file_log: vec![
216                "c7".to_string(),
217                "c6".to_string(),
218                "c5".to_string(),
219                "c4".to_string(),
220                "c3".to_string(),
221                "c2".to_string(),
222                "c1".to_string(),
223                "c0".to_string(), // annotation commit at position 7
224            ],
225            annotated_commits: vec![],
226            notes: std::collections::HashMap::new(),
227        };
228
229        let info = compute_staleness(&git, "src/main.rs", "c0")
230            .unwrap()
231            .unwrap();
232        assert_eq!(info.commits_since, 7);
233        assert!(info.stale);
234        assert_eq!(info.latest_file_commit, "c7");
235    }
236
237    #[test]
238    fn test_staleness_just_under_threshold() {
239        // 5 commits newer = exactly at threshold, not stale
240        let git = MockGitOps {
241            file_log: vec![
242                "c5".to_string(),
243                "c4".to_string(),
244                "c3".to_string(),
245                "c2".to_string(),
246                "c1".to_string(),
247                "c0".to_string(),
248            ],
249            annotated_commits: vec![],
250            notes: std::collections::HashMap::new(),
251        };
252
253        let info = compute_staleness(&git, "src/main.rs", "c0")
254            .unwrap()
255            .unwrap();
256        assert_eq!(info.commits_since, 5);
257        assert!(!info.stale); // exactly at threshold, not over
258    }
259
260    #[test]
261    fn test_staleness_empty_file_log() {
262        let git = MockGitOps {
263            file_log: vec![],
264            annotated_commits: vec![],
265            notes: std::collections::HashMap::new(),
266        };
267
268        let info = compute_staleness(&git, "src/main.rs", "commit1").unwrap();
269        assert!(info.is_none());
270    }
271
272    #[test]
273    fn test_staleness_commit_not_in_history() {
274        let git = MockGitOps {
275            file_log: vec!["other_commit".to_string()],
276            annotated_commits: vec![],
277            notes: std::collections::HashMap::new(),
278        };
279
280        let info = compute_staleness(&git, "src/main.rs", "missing_commit")
281            .unwrap()
282            .unwrap();
283        assert!(info.stale);
284        assert_eq!(info.commits_since, 1);
285    }
286
287    #[test]
288    fn test_custom_threshold() {
289        let git = MockGitOps {
290            file_log: vec!["c2".to_string(), "c1".to_string(), "c0".to_string()],
291            annotated_commits: vec![],
292            notes: std::collections::HashMap::new(),
293        };
294
295        let info = compute_staleness_with_threshold(&git, "src/main.rs", "c0", 1)
296            .unwrap()
297            .unwrap();
298        assert_eq!(info.commits_since, 2);
299        assert!(info.stale); // 2 > 1
300    }
301}