Skip to main content

chronicle/read/
retrieve.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::Annotation;
4
5use super::{MatchedRegion, ReadQuery};
6
7/// Retrieve matching region 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
12/// 4. Filter regions matching the query (file path, anchor, line range)
13/// 5. Return results sorted newest-first (preserving git log order)
14pub fn retrieve_regions(
15    git: &dyn GitOps,
16    query: &ReadQuery,
17) -> Result<Vec<MatchedRegion>, 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: Annotation = match serde_json::from_str(&note) {
28            Ok(a) => a,
29            Err(_) => continue, // skip malformed notes
30        };
31
32        for region in &annotation.regions {
33            if !file_matches(&region.file, &query.file) {
34                continue;
35            }
36            if let Some(ref anchor_name) = query.anchor {
37                if region.ast_anchor.name != *anchor_name {
38                    continue;
39                }
40            }
41            if let Some(ref line_range) = query.lines {
42                if !ranges_overlap(
43                    region.lines.start,
44                    region.lines.end,
45                    line_range.start,
46                    line_range.end,
47                ) {
48                    continue;
49                }
50            }
51            matched.push(MatchedRegion {
52                commit: sha.clone(),
53                timestamp: annotation.timestamp.clone(),
54                region: region.clone(),
55                summary: annotation.summary.clone(),
56            });
57        }
58    }
59
60    Ok(matched)
61}
62
63/// Check if two file paths refer to the same file.
64/// Normalizes by stripping leading "./" if present.
65fn file_matches(region_file: &str, query_file: &str) -> bool {
66    fn norm(s: &str) -> &str {
67        s.strip_prefix("./").unwrap_or(s)
68    }
69    norm(region_file) == norm(query_file)
70}
71
72/// Check if two line ranges overlap.
73fn ranges_overlap(a_start: u32, a_end: u32, b_start: u32, b_end: u32) -> bool {
74    a_start <= b_end && b_start <= a_end
75}
76
77#[cfg(test)]
78mod tests {
79    use super::*;
80
81    #[test]
82    fn test_file_matches_exact() {
83        assert!(file_matches("src/main.rs", "src/main.rs"));
84    }
85
86    #[test]
87    fn test_file_matches_dot_slash() {
88        assert!(file_matches("./src/main.rs", "src/main.rs"));
89        assert!(file_matches("src/main.rs", "./src/main.rs"));
90    }
91
92    #[test]
93    fn test_file_no_match() {
94        assert!(!file_matches("src/lib.rs", "src/main.rs"));
95    }
96
97    #[test]
98    fn test_ranges_overlap() {
99        assert!(ranges_overlap(1, 10, 5, 15));
100        assert!(ranges_overlap(5, 15, 1, 10));
101        assert!(ranges_overlap(1, 10, 10, 20));
102        assert!(ranges_overlap(1, 10, 1, 10));
103    }
104
105    #[test]
106    fn test_ranges_no_overlap() {
107        assert!(!ranges_overlap(1, 5, 6, 10));
108        assert!(!ranges_overlap(6, 10, 1, 5));
109    }
110
111    #[test]
112    fn test_retrieve_filters_by_file() {
113        use crate::schema::annotation::*;
114
115        let git = MockGitOps {
116            shas: vec!["abc123".to_string()],
117            note: Some(
118                serde_json::to_string(&Annotation {
119                    schema: "chronicle/v1".to_string(),
120                    commit: "abc123".to_string(),
121                    timestamp: "2025-01-01T00:00:00Z".to_string(),
122                    task: None,
123                    summary: "test commit".to_string(),
124                    context_level: ContextLevel::Enhanced,
125                    regions: vec![
126                        RegionAnnotation {
127                            file: "src/main.rs".to_string(),
128                            ast_anchor: AstAnchor {
129                                unit_type: "fn".to_string(),
130                                name: "main".to_string(),
131                                signature: None,
132                            },
133                            lines: LineRange { start: 1, end: 10 },
134                            intent: "entry point".to_string(),
135                            reasoning: None,
136                            constraints: vec![],
137                            semantic_dependencies: vec![],
138                            related_annotations: vec![],
139                            tags: vec![],
140                            risk_notes: None,
141                            corrections: vec![],
142                        },
143                        RegionAnnotation {
144                            file: "src/lib.rs".to_string(),
145                            ast_anchor: AstAnchor {
146                                unit_type: "mod".to_string(),
147                                name: "lib".to_string(),
148                                signature: None,
149                            },
150                            lines: LineRange { start: 1, end: 5 },
151                            intent: "module decl".to_string(),
152                            reasoning: None,
153                            constraints: vec![],
154                            semantic_dependencies: vec![],
155                            related_annotations: vec![],
156                            tags: vec![],
157                            risk_notes: None,
158                            corrections: vec![],
159                        },
160                    ],
161                    cross_cutting: vec![],
162                    provenance: Provenance {
163                        operation: ProvenanceOperation::Initial,
164                        derived_from: vec![],
165                        original_annotations_preserved: false,
166                        synthesis_notes: None,
167                    },
168                })
169                .unwrap(),
170            ),
171        };
172
173        let query = ReadQuery {
174            file: "src/main.rs".to_string(),
175            anchor: None,
176            lines: None,
177        };
178
179        let results = retrieve_regions(&git, &query).unwrap();
180        assert_eq!(results.len(), 1);
181        assert_eq!(results[0].region.file, "src/main.rs");
182        assert_eq!(results[0].region.intent, "entry point");
183    }
184
185    #[test]
186    fn test_retrieve_filters_by_anchor() {
187        use crate::schema::annotation::*;
188
189        let git = MockGitOps {
190            shas: vec!["abc123".to_string()],
191            note: Some(
192                serde_json::to_string(&Annotation {
193                    schema: "chronicle/v1".to_string(),
194                    commit: "abc123".to_string(),
195                    timestamp: "2025-01-01T00:00:00Z".to_string(),
196                    task: None,
197                    summary: "test commit".to_string(),
198                    context_level: ContextLevel::Enhanced,
199                    regions: vec![
200                        RegionAnnotation {
201                            file: "src/main.rs".to_string(),
202                            ast_anchor: AstAnchor {
203                                unit_type: "fn".to_string(),
204                                name: "main".to_string(),
205                                signature: None,
206                            },
207                            lines: LineRange { start: 1, end: 10 },
208                            intent: "entry point".to_string(),
209                            reasoning: None,
210                            constraints: vec![],
211                            semantic_dependencies: vec![],
212                            related_annotations: vec![],
213                            tags: vec![],
214                            risk_notes: None,
215                            corrections: vec![],
216                        },
217                        RegionAnnotation {
218                            file: "src/main.rs".to_string(),
219                            ast_anchor: AstAnchor {
220                                unit_type: "fn".to_string(),
221                                name: "helper".to_string(),
222                                signature: None,
223                            },
224                            lines: LineRange { start: 12, end: 20 },
225                            intent: "helper fn".to_string(),
226                            reasoning: None,
227                            constraints: vec![],
228                            semantic_dependencies: vec![],
229                            related_annotations: vec![],
230                            tags: vec![],
231                            risk_notes: None,
232                            corrections: vec![],
233                        },
234                    ],
235                    cross_cutting: vec![],
236                    provenance: Provenance {
237                        operation: ProvenanceOperation::Initial,
238                        derived_from: vec![],
239                        original_annotations_preserved: false,
240                        synthesis_notes: None,
241                    },
242                })
243                .unwrap(),
244            ),
245        };
246
247        let query = ReadQuery {
248            file: "src/main.rs".to_string(),
249            anchor: Some("main".to_string()),
250            lines: None,
251        };
252
253        let results = retrieve_regions(&git, &query).unwrap();
254        assert_eq!(results.len(), 1);
255        assert_eq!(results[0].region.ast_anchor.name, "main");
256    }
257
258    #[test]
259    fn test_retrieve_filters_by_lines() {
260        use crate::schema::annotation::*;
261
262        let git = MockGitOps {
263            shas: vec!["abc123".to_string()],
264            note: Some(
265                serde_json::to_string(&Annotation {
266                    schema: "chronicle/v1".to_string(),
267                    commit: "abc123".to_string(),
268                    timestamp: "2025-01-01T00:00:00Z".to_string(),
269                    task: None,
270                    summary: "test commit".to_string(),
271                    context_level: ContextLevel::Enhanced,
272                    regions: vec![
273                        RegionAnnotation {
274                            file: "src/main.rs".to_string(),
275                            ast_anchor: AstAnchor {
276                                unit_type: "fn".to_string(),
277                                name: "main".to_string(),
278                                signature: None,
279                            },
280                            lines: LineRange { start: 1, end: 10 },
281                            intent: "entry point".to_string(),
282                            reasoning: None,
283                            constraints: vec![],
284                            semantic_dependencies: vec![],
285                            related_annotations: vec![],
286                            tags: vec![],
287                            risk_notes: None,
288                            corrections: vec![],
289                        },
290                        RegionAnnotation {
291                            file: "src/main.rs".to_string(),
292                            ast_anchor: AstAnchor {
293                                unit_type: "fn".to_string(),
294                                name: "helper".to_string(),
295                                signature: None,
296                            },
297                            lines: LineRange { start: 50, end: 60 },
298                            intent: "helper fn".to_string(),
299                            reasoning: None,
300                            constraints: vec![],
301                            semantic_dependencies: vec![],
302                            related_annotations: vec![],
303                            tags: vec![],
304                            risk_notes: None,
305                            corrections: vec![],
306                        },
307                    ],
308                    cross_cutting: vec![],
309                    provenance: Provenance {
310                        operation: ProvenanceOperation::Initial,
311                        derived_from: vec![],
312                        original_annotations_preserved: false,
313                        synthesis_notes: None,
314                    },
315                })
316                .unwrap(),
317            ),
318        };
319
320        let query = ReadQuery {
321            file: "src/main.rs".to_string(),
322            anchor: None,
323            lines: Some(LineRange { start: 5, end: 15 }),
324        };
325
326        let results = retrieve_regions(&git, &query).unwrap();
327        assert_eq!(results.len(), 1);
328        assert_eq!(results[0].region.ast_anchor.name, "main");
329    }
330
331    #[test]
332    fn test_retrieve_skips_commits_without_notes() {
333        let git = MockGitOps {
334            shas: vec!["abc123".to_string()],
335            note: None,
336        };
337
338        let query = ReadQuery {
339            file: "src/main.rs".to_string(),
340            anchor: None,
341            lines: None,
342        };
343
344        let results = retrieve_regions(&git, &query).unwrap();
345        assert!(results.is_empty());
346    }
347
348    /// Minimal mock for testing retrieve logic.
349    struct MockGitOps {
350        shas: Vec<String>,
351        note: Option<String>,
352    }
353
354    impl crate::git::GitOps for MockGitOps {
355        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, crate::error::GitError> {
356            Ok(vec![])
357        }
358        fn note_read(&self, _commit: &str) -> Result<Option<String>, crate::error::GitError> {
359            Ok(self.note.clone())
360        }
361        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), crate::error::GitError> {
362            Ok(())
363        }
364        fn note_exists(&self, _commit: &str) -> Result<bool, crate::error::GitError> {
365            Ok(self.note.is_some())
366        }
367        fn file_at_commit(
368            &self,
369            _path: &std::path::Path,
370            _commit: &str,
371        ) -> Result<String, crate::error::GitError> {
372            Ok(String::new())
373        }
374        fn commit_info(
375            &self,
376            _commit: &str,
377        ) -> Result<crate::git::CommitInfo, crate::error::GitError> {
378            Ok(crate::git::CommitInfo {
379                sha: "abc123".to_string(),
380                message: "test".to_string(),
381                author_name: "test".to_string(),
382                author_email: "test@test.com".to_string(),
383                timestamp: "2025-01-01T00:00:00Z".to_string(),
384                parent_shas: vec![],
385            })
386        }
387        fn resolve_ref(&self, _refspec: &str) -> Result<String, crate::error::GitError> {
388            Ok("abc123".to_string())
389        }
390        fn config_get(&self, _key: &str) -> Result<Option<String>, crate::error::GitError> {
391            Ok(None)
392        }
393        fn config_set(&self, _key: &str, _value: &str) -> Result<(), crate::error::GitError> {
394            Ok(())
395        }
396        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, crate::error::GitError> {
397            Ok(self.shas.clone())
398        }
399        fn list_annotated_commits(
400            &self,
401            _limit: u32,
402        ) -> Result<Vec<String>, crate::error::GitError> {
403            Ok(self.shas.clone())
404        }
405    }
406}