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        for file in &annotation.narrative.files_changed {
92            if let Some(info) = compute_staleness(git, file, &annotation.commit)? {
93                if info.stale {
94                    stale_count += 1;
95                    stale_files.push(StaleFileEntry {
96                        file: file.clone(),
97                        annotation_commit: annotation.commit.clone(),
98                        commits_since: info.commits_since,
99                    });
100                }
101            }
102        }
103    }
104
105    Ok(StalenessReport {
106        total_annotations,
107        stale_count,
108        stale_files,
109    })
110}
111
112/// Summary report of staleness across the repo.
113#[derive(Debug, Clone, serde::Serialize)]
114pub struct StalenessReport {
115    pub total_annotations: usize,
116    pub stale_count: usize,
117    pub stale_files: Vec<StaleFileEntry>,
118}
119
120/// A single stale file entry in the report.
121#[derive(Debug, Clone, serde::Serialize)]
122pub struct StaleFileEntry {
123    pub file: String,
124    pub annotation_commit: String,
125    pub commits_since: usize,
126}
127
128#[cfg(test)]
129mod tests {
130    use super::*;
131    use crate::git::diff::FileDiff;
132    use crate::git::CommitInfo;
133
134    struct MockGitOps {
135        file_log: Vec<String>,
136        annotated_commits: Vec<String>,
137        notes: std::collections::HashMap<String, String>,
138    }
139
140    impl GitOps for MockGitOps {
141        fn diff(&self, _commit: &str) -> Result<Vec<FileDiff>, GitError> {
142            Ok(vec![])
143        }
144        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
145            Ok(self.notes.get(commit).cloned())
146        }
147        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
148            Ok(())
149        }
150        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
151            Ok(self.notes.contains_key(commit))
152        }
153        fn file_at_commit(
154            &self,
155            _path: &std::path::Path,
156            _commit: &str,
157        ) -> Result<String, GitError> {
158            Ok(String::new())
159        }
160        fn commit_info(&self, commit: &str) -> Result<CommitInfo, GitError> {
161            Ok(CommitInfo {
162                sha: commit.to_string(),
163                message: "test".to_string(),
164                author_name: "test".to_string(),
165                author_email: "test@test.com".to_string(),
166                timestamp: "2025-01-01T00:00:00Z".to_string(),
167                parent_shas: vec![],
168            })
169        }
170        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
171            Ok("abc123".to_string())
172        }
173        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
174            Ok(None)
175        }
176        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
177            Ok(())
178        }
179        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
180            Ok(self.file_log.clone())
181        }
182        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
183            Ok(self.annotated_commits.clone())
184        }
185    }
186
187    #[test]
188    fn test_staleness_fresh_annotation() {
189        let git = MockGitOps {
190            file_log: vec!["commit1".to_string()],
191            annotated_commits: vec![],
192            notes: std::collections::HashMap::new(),
193        };
194
195        let info = compute_staleness(&git, "src/main.rs", "commit1")
196            .unwrap()
197            .unwrap();
198        assert_eq!(info.commits_since, 0);
199        assert!(!info.stale);
200    }
201
202    #[test]
203    fn test_staleness_annotation_is_stale() {
204        // 7 commits newer than the annotation commit
205        let git = MockGitOps {
206            file_log: vec![
207                "c7".to_string(),
208                "c6".to_string(),
209                "c5".to_string(),
210                "c4".to_string(),
211                "c3".to_string(),
212                "c2".to_string(),
213                "c1".to_string(),
214                "c0".to_string(), // annotation commit at position 7
215            ],
216            annotated_commits: vec![],
217            notes: std::collections::HashMap::new(),
218        };
219
220        let info = compute_staleness(&git, "src/main.rs", "c0")
221            .unwrap()
222            .unwrap();
223        assert_eq!(info.commits_since, 7);
224        assert!(info.stale);
225        assert_eq!(info.latest_file_commit, "c7");
226    }
227
228    #[test]
229    fn test_staleness_just_under_threshold() {
230        // 5 commits newer = exactly at threshold, not stale
231        let git = MockGitOps {
232            file_log: vec![
233                "c5".to_string(),
234                "c4".to_string(),
235                "c3".to_string(),
236                "c2".to_string(),
237                "c1".to_string(),
238                "c0".to_string(),
239            ],
240            annotated_commits: vec![],
241            notes: std::collections::HashMap::new(),
242        };
243
244        let info = compute_staleness(&git, "src/main.rs", "c0")
245            .unwrap()
246            .unwrap();
247        assert_eq!(info.commits_since, 5);
248        assert!(!info.stale); // exactly at threshold, not over
249    }
250
251    #[test]
252    fn test_staleness_empty_file_log() {
253        let git = MockGitOps {
254            file_log: vec![],
255            annotated_commits: vec![],
256            notes: std::collections::HashMap::new(),
257        };
258
259        let info = compute_staleness(&git, "src/main.rs", "commit1").unwrap();
260        assert!(info.is_none());
261    }
262
263    #[test]
264    fn test_staleness_commit_not_in_history() {
265        let git = MockGitOps {
266            file_log: vec!["other_commit".to_string()],
267            annotated_commits: vec![],
268            notes: std::collections::HashMap::new(),
269        };
270
271        let info = compute_staleness(&git, "src/main.rs", "missing_commit")
272            .unwrap()
273            .unwrap();
274        assert!(info.stale);
275        assert_eq!(info.commits_since, 1);
276    }
277
278    #[test]
279    fn test_custom_threshold() {
280        let git = MockGitOps {
281            file_log: vec!["c2".to_string(), "c1".to_string(), "c0".to_string()],
282            annotated_commits: vec![],
283            notes: std::collections::HashMap::new(),
284        };
285
286        let info = compute_staleness_with_threshold(&git, "src/main.rs", "c0", 1)
287            .unwrap()
288            .unwrap();
289        assert_eq!(info.commits_since, 2);
290        assert!(info.stale); // 2 > 1
291    }
292}