Skip to main content

chronicle/read/
retrieve.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
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 a v3 Annotation via `parse_annotation`
12/// 4. Filter wisdom entries matching the query (file path, 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        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                    // Include entries without lines (file-wide or repo-wide)
42                    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
64/// Check if two line ranges overlap.
65fn 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        // v2 annotation with markers on two files; parse_annotation() migrates to v3 wisdom entries.
91        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        // In v3, wisdom entries for src/main.rs should be filtered to match
156        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        // v2 annotation with no markers — just files_changed. Migrates to v3 with empty wisdom.
182        // After migration to v3, this has no wisdom entries but should still be included.
183        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    /// Minimal mock for testing retrieve logic.
223    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}