Skip to main content

chronicle/read/
retrieve.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
4
5use super::{MatchedAnnotation, ReadQuery};
6
7/// Retrieve matching annotations for a file from git notes.
8///
9/// 1. Find commits that touched the file via `git log --follow`
10/// 2. For each commit, try to read the chronicle note
11/// 3. Parse the note as an Annotation (v1 or v2 via parse_annotation)
12/// 4. Filter markers matching the query (file path, anchor, line range)
13/// 5. Return results sorted newest-first (preserving git log order)
14pub 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(&note) {
28            Ok(a) => a,
29            Err(e) => {
30                tracing::debug!("skipping malformed annotation for {sha}: {e}");
31                continue;
32            }
33        };
34
35        // Filter markers by file/anchor/lines
36        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        // Filter decisions by scope
57        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        // Include annotation if it has matching markers, matching decisions,
65        // or if the file is in files_changed (relevant context even without markers)
66        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
93/// Check if two line ranges overlap.
94fn 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
98/// Check if a decision's scope matches the queried file.
99fn 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        // Only the marker for src/main.rs should be included
196        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        // Only the marker for "main" anchor should be included
265        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        // Only the marker overlapping lines 5-15 should be included
334        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![], // no markers, but file is in files_changed
370            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    /// Minimal mock for testing retrieve logic.
397    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}