1use crate::error::GitError;
2use crate::git::GitOps;
3
4const DEFAULT_STALENESS_THRESHOLD: usize = 5;
7
8#[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
17pub 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
29pub 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 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 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
68pub 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(¬e) {
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 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#[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#[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 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(), ],
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 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); }
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); }
301}