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 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#[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#[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 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(), ],
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 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); }
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); }
292}