1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
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_markers: Vec<v2::CodeMarker> = annotation
37 .markers
38 .iter()
39 .filter(|m| file_matches(&m.file, &query.file))
40 .filter(|m| {
41 query
42 .anchor
43 .as_ref()
44 .is_none_or(|qa| m.anchor.as_ref().is_some_and(|a| a.name == *qa))
45 })
46 .filter(|m| {
47 query.lines.as_ref().is_none_or(|line_range| {
48 m.lines.as_ref().is_some_and(|ml| {
49 ranges_overlap(ml.start, ml.end, line_range.start, line_range.end)
50 })
51 })
52 })
53 .cloned()
54 .collect();
55
56 let filtered_decisions: Vec<v2::Decision> = annotation
58 .decisions
59 .iter()
60 .filter(|d| decision_scope_matches(d, &query.file))
61 .cloned()
62 .collect();
63
64 let file_in_files_changed = annotation
67 .narrative
68 .files_changed
69 .iter()
70 .any(|f| file_matches(f, &query.file));
71
72 if filtered_markers.is_empty() && filtered_decisions.is_empty() && !file_in_files_changed {
73 continue;
74 }
75
76 matched.push(MatchedAnnotation {
77 commit: sha.clone(),
78 timestamp: annotation.timestamp.clone(),
79 summary: annotation.narrative.summary.clone(),
80 motivation: annotation.narrative.motivation.clone(),
81 markers: filtered_markers,
82 decisions: filtered_decisions,
83 follow_up: annotation.narrative.follow_up.clone(),
84 provenance: annotation.provenance.source.to_string(),
85 });
86 }
87
88 Ok(matched)
89}
90
91use super::matching::file_matches;
92
93fn ranges_overlap(a_start: u32, a_end: u32, b_start: u32, b_end: u32) -> bool {
95 a_start <= b_end && b_start <= a_end
96}
97
98fn decision_scope_matches(decision: &v2::Decision, file: &str) -> bool {
100 if decision.scope.is_empty() {
101 return true;
102 }
103 let norm_file = file.strip_prefix("./").unwrap_or(file);
104 decision.scope.iter().any(|s| {
105 let norm_scope = s.strip_prefix("./").unwrap_or(s);
106 let scope_file = norm_scope.split(':').next().unwrap_or(norm_scope);
107 scope_file == norm_file || norm_file.starts_with(scope_file)
108 })
109}
110
111#[cfg(test)]
112mod tests {
113 use super::*;
114 use crate::schema::common::AstAnchor;
115
116 #[test]
117 fn test_ranges_overlap() {
118 assert!(ranges_overlap(1, 10, 5, 15));
119 assert!(ranges_overlap(5, 15, 1, 10));
120 assert!(ranges_overlap(1, 10, 10, 20));
121 assert!(ranges_overlap(1, 10, 1, 10));
122 }
123
124 #[test]
125 fn test_ranges_no_overlap() {
126 assert!(!ranges_overlap(1, 5, 6, 10));
127 assert!(!ranges_overlap(6, 10, 1, 5));
128 }
129
130 #[test]
131 fn test_retrieve_filters_by_file() {
132 let ann = v2::Annotation {
133 schema: "chronicle/v2".to_string(),
134 commit: "abc123".to_string(),
135 timestamp: "2025-01-01T00:00:00Z".to_string(),
136 narrative: v2::Narrative {
137 summary: "test commit".to_string(),
138 motivation: None,
139 rejected_alternatives: vec![],
140 follow_up: None,
141 files_changed: vec!["src/main.rs".to_string(), "src/lib.rs".to_string()],
142 },
143 decisions: vec![],
144 markers: vec![
145 v2::CodeMarker {
146 file: "src/main.rs".to_string(),
147 anchor: Some(AstAnchor {
148 unit_type: "fn".to_string(),
149 name: "main".to_string(),
150 signature: None,
151 }),
152 lines: None,
153 kind: v2::MarkerKind::Contract {
154 description: "entry point".to_string(),
155 source: v2::ContractSource::Author,
156 },
157 },
158 v2::CodeMarker {
159 file: "src/lib.rs".to_string(),
160 anchor: Some(AstAnchor {
161 unit_type: "mod".to_string(),
162 name: "lib".to_string(),
163 signature: None,
164 }),
165 lines: None,
166 kind: v2::MarkerKind::Contract {
167 description: "module decl".to_string(),
168 source: v2::ContractSource::Author,
169 },
170 },
171 ],
172 effort: None,
173 provenance: v2::Provenance {
174 source: v2::ProvenanceSource::Live,
175 author: None,
176 derived_from: vec![],
177 notes: None,
178 },
179 };
180
181 let git = MockGitOps {
182 shas: vec!["abc123".to_string()],
183 note: Some(serde_json::to_string(&ann).unwrap()),
184 };
185
186 let query = ReadQuery {
187 file: "src/main.rs".to_string(),
188 anchor: None,
189 lines: None,
190 };
191
192 let results = retrieve_annotations(&git, &query).unwrap();
193 assert_eq!(results.len(), 1);
194 assert_eq!(results[0].summary, "test commit");
195 assert_eq!(results[0].markers.len(), 1);
197 assert_eq!(results[0].markers[0].file, "src/main.rs");
198 }
199
200 #[test]
201 fn test_retrieve_filters_by_anchor() {
202 let ann = v2::Annotation {
203 schema: "chronicle/v2".to_string(),
204 commit: "abc123".to_string(),
205 timestamp: "2025-01-01T00:00:00Z".to_string(),
206 narrative: v2::Narrative {
207 summary: "test commit".to_string(),
208 motivation: None,
209 rejected_alternatives: vec![],
210 follow_up: None,
211 files_changed: vec!["src/main.rs".to_string()],
212 },
213 decisions: vec![],
214 markers: vec![
215 v2::CodeMarker {
216 file: "src/main.rs".to_string(),
217 anchor: Some(AstAnchor {
218 unit_type: "fn".to_string(),
219 name: "main".to_string(),
220 signature: None,
221 }),
222 lines: None,
223 kind: v2::MarkerKind::Contract {
224 description: "entry point".to_string(),
225 source: v2::ContractSource::Author,
226 },
227 },
228 v2::CodeMarker {
229 file: "src/main.rs".to_string(),
230 anchor: Some(AstAnchor {
231 unit_type: "fn".to_string(),
232 name: "helper".to_string(),
233 signature: None,
234 }),
235 lines: None,
236 kind: v2::MarkerKind::Contract {
237 description: "helper fn".to_string(),
238 source: v2::ContractSource::Author,
239 },
240 },
241 ],
242 effort: None,
243 provenance: v2::Provenance {
244 source: v2::ProvenanceSource::Live,
245 author: None,
246 derived_from: vec![],
247 notes: None,
248 },
249 };
250
251 let git = MockGitOps {
252 shas: vec!["abc123".to_string()],
253 note: Some(serde_json::to_string(&ann).unwrap()),
254 };
255
256 let query = ReadQuery {
257 file: "src/main.rs".to_string(),
258 anchor: Some("main".to_string()),
259 lines: None,
260 };
261
262 let results = retrieve_annotations(&git, &query).unwrap();
263 assert_eq!(results.len(), 1);
264 assert_eq!(results[0].markers.len(), 1);
266 assert_eq!(results[0].markers[0].anchor.as_ref().unwrap().name, "main");
267 }
268
269 #[test]
270 fn test_retrieve_filters_by_lines() {
271 let ann = v2::Annotation {
272 schema: "chronicle/v2".to_string(),
273 commit: "abc123".to_string(),
274 timestamp: "2025-01-01T00:00:00Z".to_string(),
275 narrative: v2::Narrative {
276 summary: "test commit".to_string(),
277 motivation: None,
278 rejected_alternatives: vec![],
279 follow_up: None,
280 files_changed: vec!["src/main.rs".to_string()],
281 },
282 decisions: vec![],
283 markers: vec![
284 v2::CodeMarker {
285 file: "src/main.rs".to_string(),
286 anchor: Some(AstAnchor {
287 unit_type: "fn".to_string(),
288 name: "main".to_string(),
289 signature: None,
290 }),
291 lines: Some(crate::schema::common::LineRange { start: 1, end: 10 }),
292 kind: v2::MarkerKind::Contract {
293 description: "entry point".to_string(),
294 source: v2::ContractSource::Author,
295 },
296 },
297 v2::CodeMarker {
298 file: "src/main.rs".to_string(),
299 anchor: Some(AstAnchor {
300 unit_type: "fn".to_string(),
301 name: "helper".to_string(),
302 signature: None,
303 }),
304 lines: Some(crate::schema::common::LineRange { start: 50, end: 60 }),
305 kind: v2::MarkerKind::Contract {
306 description: "helper fn".to_string(),
307 source: v2::ContractSource::Author,
308 },
309 },
310 ],
311 effort: None,
312 provenance: v2::Provenance {
313 source: v2::ProvenanceSource::Live,
314 author: None,
315 derived_from: vec![],
316 notes: None,
317 },
318 };
319
320 let git = MockGitOps {
321 shas: vec!["abc123".to_string()],
322 note: Some(serde_json::to_string(&ann).unwrap()),
323 };
324
325 let query = ReadQuery {
326 file: "src/main.rs".to_string(),
327 anchor: None,
328 lines: Some(crate::schema::common::LineRange { start: 5, end: 15 }),
329 };
330
331 let results = retrieve_annotations(&git, &query).unwrap();
332 assert_eq!(results.len(), 1);
333 assert_eq!(results[0].markers.len(), 1);
335 assert_eq!(results[0].markers[0].anchor.as_ref().unwrap().name, "main");
336 }
337
338 #[test]
339 fn test_retrieve_skips_commits_without_notes() {
340 let git = MockGitOps {
341 shas: vec!["abc123".to_string()],
342 note: None,
343 };
344
345 let query = ReadQuery {
346 file: "src/main.rs".to_string(),
347 anchor: None,
348 lines: None,
349 };
350
351 let results = retrieve_annotations(&git, &query).unwrap();
352 assert!(results.is_empty());
353 }
354
355 #[test]
356 fn test_retrieve_includes_annotation_with_file_in_files_changed() {
357 let ann = v2::Annotation {
358 schema: "chronicle/v2".to_string(),
359 commit: "abc123".to_string(),
360 timestamp: "2025-01-01T00:00:00Z".to_string(),
361 narrative: v2::Narrative {
362 summary: "refactored main".to_string(),
363 motivation: Some("cleanup".to_string()),
364 rejected_alternatives: vec![],
365 follow_up: None,
366 files_changed: vec!["src/main.rs".to_string()],
367 },
368 decisions: vec![],
369 markers: vec![], effort: None,
371 provenance: v2::Provenance {
372 source: v2::ProvenanceSource::Live,
373 author: None,
374 derived_from: vec![],
375 notes: None,
376 },
377 };
378
379 let git = MockGitOps {
380 shas: vec!["abc123".to_string()],
381 note: Some(serde_json::to_string(&ann).unwrap()),
382 };
383
384 let query = ReadQuery {
385 file: "src/main.rs".to_string(),
386 anchor: None,
387 lines: None,
388 };
389
390 let results = retrieve_annotations(&git, &query).unwrap();
391 assert_eq!(results.len(), 1);
392 assert_eq!(results[0].summary, "refactored main");
393 assert_eq!(results[0].motivation.as_deref(), Some("cleanup"));
394 }
395
396 struct MockGitOps {
398 shas: Vec<String>,
399 note: Option<String>,
400 }
401
402 impl crate::git::GitOps for MockGitOps {
403 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, crate::error::GitError> {
404 Ok(vec![])
405 }
406 fn note_read(&self, _commit: &str) -> Result<Option<String>, crate::error::GitError> {
407 Ok(self.note.clone())
408 }
409 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), crate::error::GitError> {
410 Ok(())
411 }
412 fn note_exists(&self, _commit: &str) -> Result<bool, crate::error::GitError> {
413 Ok(self.note.is_some())
414 }
415 fn file_at_commit(
416 &self,
417 _path: &std::path::Path,
418 _commit: &str,
419 ) -> Result<String, crate::error::GitError> {
420 Ok(String::new())
421 }
422 fn commit_info(
423 &self,
424 _commit: &str,
425 ) -> Result<crate::git::CommitInfo, crate::error::GitError> {
426 Ok(crate::git::CommitInfo {
427 sha: "abc123".to_string(),
428 message: "test".to_string(),
429 author_name: "test".to_string(),
430 author_email: "test@test.com".to_string(),
431 timestamp: "2025-01-01T00:00:00Z".to_string(),
432 parent_shas: vec![],
433 })
434 }
435 fn resolve_ref(&self, _refspec: &str) -> Result<String, crate::error::GitError> {
436 Ok("abc123".to_string())
437 }
438 fn config_get(&self, _key: &str) -> Result<Option<String>, crate::error::GitError> {
439 Ok(None)
440 }
441 fn config_set(&self, _key: &str, _value: &str) -> Result<(), crate::error::GitError> {
442 Ok(())
443 }
444 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, crate::error::GitError> {
445 Ok(self.shas.clone())
446 }
447 fn list_annotated_commits(
448 &self,
449 _limit: u32,
450 ) -> Result<Vec<String>, crate::error::GitError> {
451 Ok(self.shas.clone())
452 }
453 }
454}