1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
4
5use super::{MatchedAnnotation, ReadQuery};
6
7pub fn retrieve_annotations(
15 git: &dyn GitOps,
16 query: &ReadQuery,
17) -> Result<Vec<MatchedAnnotation>, GitError> {
18 let shas = git.log_for_file(&query.file)?;
19 let mut matched = Vec::new();
20
21 for sha in &shas {
22 let note = match git.note_read(sha)? {
23 Some(n) => n,
24 None => continue,
25 };
26
27 let annotation = match schema::parse_annotation(¬e) {
28 Ok(a) => a,
29 Err(e) => {
30 tracing::debug!("skipping malformed annotation for {sha}: {e}");
31 continue;
32 }
33 };
34
35 let filtered_wisdom: Vec<v3::WisdomEntry> = annotation
36 .wisdom
37 .iter()
38 .filter(|w| w.file.as_ref().is_none_or(|f| file_matches(f, &query.file)))
39 .filter(|w| {
40 query.lines.as_ref().is_none_or(|line_range| {
41 w.lines.as_ref().is_none_or(|wl| {
43 ranges_overlap(wl.start, wl.end, line_range.start, line_range.end)
44 })
45 })
46 })
47 .cloned()
48 .collect();
49
50 matched.push(MatchedAnnotation {
51 commit: sha.clone(),
52 timestamp: annotation.timestamp.clone(),
53 summary: annotation.summary.clone(),
54 wisdom: filtered_wisdom,
55 provenance: annotation.provenance.source.to_string(),
56 });
57 }
58
59 Ok(matched)
60}
61
62use super::matching::file_matches;
63
64fn ranges_overlap(a_start: u32, a_end: u32, b_start: u32, b_end: u32) -> bool {
66 a_start <= b_end && b_start <= a_end
67}
68
69#[cfg(test)]
70mod tests {
71 use super::*;
72 use crate::schema::v2;
73
74 #[test]
75 fn test_ranges_overlap() {
76 assert!(ranges_overlap(1, 10, 5, 15));
77 assert!(ranges_overlap(5, 15, 1, 10));
78 assert!(ranges_overlap(1, 10, 10, 20));
79 assert!(ranges_overlap(1, 10, 1, 10));
80 }
81
82 #[test]
83 fn test_ranges_no_overlap() {
84 assert!(!ranges_overlap(1, 5, 6, 10));
85 assert!(!ranges_overlap(6, 10, 1, 5));
86 }
87
88 #[test]
89 fn test_retrieve_filters_by_file() {
90 let ann = v2::Annotation {
92 schema: "chronicle/v2".to_string(),
93 commit: "abc123".to_string(),
94 timestamp: "2025-01-01T00:00:00Z".to_string(),
95 narrative: v2::Narrative {
96 summary: "test commit".to_string(),
97 motivation: None,
98 rejected_alternatives: vec![],
99 follow_up: None,
100 files_changed: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
101 sentiments: vec![],
102 },
103 decisions: vec![],
104 markers: vec![
105 v2::CodeMarker {
106 file: "src/main.rs".to_string(),
107 anchor: Some(crate::schema::common::AstAnchor {
108 unit_type: "fn".to_string(),
109 name: "main".to_string(),
110 signature: None,
111 }),
112 lines: None,
113 kind: v2::MarkerKind::Contract {
114 description: "entry point".to_string(),
115 source: v2::ContractSource::Author,
116 },
117 },
118 v2::CodeMarker {
119 file: "src/lib.rs".to_string(),
120 anchor: Some(crate::schema::common::AstAnchor {
121 unit_type: "mod".to_string(),
122 name: "lib".to_string(),
123 signature: None,
124 }),
125 lines: None,
126 kind: v2::MarkerKind::Contract {
127 description: "module decl".to_string(),
128 source: v2::ContractSource::Author,
129 },
130 },
131 ],
132 effort: None,
133 provenance: v2::Provenance {
134 source: v2::ProvenanceSource::Live,
135 author: None,
136 derived_from: vec![],
137 notes: None,
138 },
139 };
140
141 let git = MockGitOps {
142 shas: vec!["abc123".to_string()],
143 note: Some(serde_json::to_string(&ann).unwrap()),
144 };
145
146 let query = ReadQuery {
147 file: "src/main.rs".to_string(),
148 anchor: None,
149 lines: None,
150 };
151
152 let results = retrieve_annotations(&git, &query).unwrap();
153 assert_eq!(results.len(), 1);
154 assert_eq!(results[0].summary, "test commit");
155 assert!(results[0]
157 .wisdom
158 .iter()
159 .all(|w| w.file.as_ref().is_none_or(|f| f == "src/main.rs")));
160 }
161
162 #[test]
163 fn test_retrieve_skips_commits_without_notes() {
164 let git = MockGitOps {
165 shas: vec!["abc123".to_string()],
166 note: None,
167 };
168
169 let query = ReadQuery {
170 file: "src/main.rs".to_string(),
171 anchor: None,
172 lines: None,
173 };
174
175 let results = retrieve_annotations(&git, &query).unwrap();
176 assert!(results.is_empty());
177 }
178
179 #[test]
180 fn test_retrieve_includes_annotation_without_wisdom() {
181 let ann = v2::Annotation {
184 schema: "chronicle/v2".to_string(),
185 commit: "abc123".to_string(),
186 timestamp: "2025-01-01T00:00:00Z".to_string(),
187 narrative: v2::Narrative {
188 summary: "refactored main".to_string(),
189 motivation: Some("cleanup".to_string()),
190 rejected_alternatives: vec![],
191 follow_up: None,
192 files_changed: vec!["src/main.rs".to_string()],
193 sentiments: vec![],
194 },
195 decisions: vec![],
196 markers: vec![],
197 effort: None,
198 provenance: v2::Provenance {
199 source: v2::ProvenanceSource::Live,
200 author: None,
201 derived_from: vec![],
202 notes: None,
203 },
204 };
205
206 let git = MockGitOps {
207 shas: vec!["abc123".to_string()],
208 note: Some(serde_json::to_string(&ann).unwrap()),
209 };
210
211 let query = ReadQuery {
212 file: "src/main.rs".to_string(),
213 anchor: None,
214 lines: None,
215 };
216
217 let results = retrieve_annotations(&git, &query).unwrap();
218 assert_eq!(results.len(), 1);
219 assert_eq!(results[0].summary, "refactored main");
220 }
221
222 struct MockGitOps {
224 shas: Vec<String>,
225 note: Option<String>,
226 }
227
228 impl crate::git::GitOps for MockGitOps {
229 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, crate::error::GitError> {
230 Ok(vec![])
231 }
232 fn note_read(&self, _commit: &str) -> Result<Option<String>, crate::error::GitError> {
233 Ok(self.note.clone())
234 }
235 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), crate::error::GitError> {
236 Ok(())
237 }
238 fn note_exists(&self, _commit: &str) -> Result<bool, crate::error::GitError> {
239 Ok(self.note.is_some())
240 }
241 fn file_at_commit(
242 &self,
243 _path: &std::path::Path,
244 _commit: &str,
245 ) -> Result<String, crate::error::GitError> {
246 Ok(String::new())
247 }
248 fn commit_info(
249 &self,
250 _commit: &str,
251 ) -> Result<crate::git::CommitInfo, crate::error::GitError> {
252 Ok(crate::git::CommitInfo {
253 sha: "abc123".to_string(),
254 message: "test".to_string(),
255 author_name: "test".to_string(),
256 author_email: "test@test.com".to_string(),
257 timestamp: "2025-01-01T00:00:00Z".to_string(),
258 parent_shas: vec![],
259 })
260 }
261 fn resolve_ref(&self, _refspec: &str) -> Result<String, crate::error::GitError> {
262 Ok("abc123".to_string())
263 }
264 fn config_get(&self, _key: &str) -> Result<Option<String>, crate::error::GitError> {
265 Ok(None)
266 }
267 fn config_set(&self, _key: &str, _value: &str) -> Result<(), crate::error::GitError> {
268 Ok(())
269 }
270 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, crate::error::GitError> {
271 Ok(self.shas.clone())
272 }
273 fn list_annotated_commits(
274 &self,
275 _limit: u32,
276 ) -> Result<Vec<String>, crate::error::GitError> {
277 Ok(self.shas.clone())
278 }
279 }
280}