Skip to main content

chronicle/read/
history.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::Annotation;
4
5/// Query parameters for timeline reconstruction.
6#[derive(Debug, Clone)]
7pub struct HistoryQuery {
8    pub file: String,
9    pub anchor: Option<String>,
10    pub limit: u32,
11    pub follow_related: bool,
12}
13
14/// A single timeline entry.
15#[derive(Debug, Clone, serde::Serialize)]
16pub struct TimelineEntry {
17    pub commit: String,
18    pub timestamp: String,
19    pub commit_message: String,
20    pub context_level: String,
21    pub provenance: String,
22    pub intent: String,
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub reasoning: Option<String>,
25    #[serde(default, skip_serializing_if = "Vec::is_empty")]
26    pub constraints: Vec<String>,
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub risk_notes: Option<String>,
29    #[serde(default, skip_serializing_if = "Vec::is_empty")]
30    pub related_context: Vec<RelatedContext>,
31}
32
33/// Related annotation context included in timeline entries.
34#[derive(Debug, Clone, serde::Serialize)]
35pub struct RelatedContext {
36    pub commit: String,
37    pub anchor: String,
38    pub relationship: String,
39    #[serde(skip_serializing_if = "Option::is_none")]
40    pub intent: Option<String>,
41}
42
43/// Statistics about the history query.
44#[derive(Debug, Clone, serde::Serialize)]
45pub struct HistoryStats {
46    pub commits_in_log: u32,
47    pub annotations_found: u32,
48    pub related_followed: u32,
49}
50
51/// Output of a history query.
52#[derive(Debug, Clone, serde::Serialize)]
53pub struct HistoryOutput {
54    pub schema: String,
55    pub query: QueryEcho,
56    pub timeline: Vec<TimelineEntry>,
57    pub stats: HistoryStats,
58}
59
60/// Echo of the query parameters in the output.
61#[derive(Debug, Clone, serde::Serialize)]
62pub struct QueryEcho {
63    pub file: String,
64    pub anchor: Option<String>,
65}
66
67/// Reconstruct the annotation timeline for a file+anchor across commits.
68///
69/// 1. Get commits that touched the file via `log_for_file`
70/// 2. For each commit, fetch annotation and filter to matching region
71/// 3. Sort chronologically (oldest first)
72/// 4. Optionally follow related_annotations
73/// 5. Apply limit
74pub fn build_timeline(git: &dyn GitOps, query: &HistoryQuery) -> Result<HistoryOutput, GitError> {
75    let shas = git.log_for_file(&query.file)?;
76    let commits_in_log = shas.len() as u32;
77
78    let mut entries: Vec<TimelineEntry> = Vec::new();
79    let mut related_followed: u32 = 0;
80
81    for sha in &shas {
82        let note = match git.note_read(sha)? {
83            Some(n) => n,
84            None => continue,
85        };
86
87        let annotation: Annotation = match serde_json::from_str(&note) {
88            Ok(a) => a,
89            Err(_) => continue,
90        };
91
92        let commit_msg = git
93            .commit_info(sha)
94            .map(|ci| ci.message.clone())
95            .unwrap_or_default();
96
97        for region in &annotation.regions {
98            if !file_matches(&region.file, &query.file) {
99                continue;
100            }
101            if let Some(ref anchor_name) = query.anchor {
102                if !anchor_matches(&region.ast_anchor.name, anchor_name) {
103                    continue;
104                }
105            }
106
107            let mut related_context = Vec::new();
108            if query.follow_related {
109                for rel in &region.related_annotations {
110                    if let Ok(Some(rel_note)) = git.note_read(&rel.commit) {
111                        if let Ok(rel_ann) = serde_json::from_str::<Annotation>(&rel_note) {
112                            let rel_intent = rel_ann
113                                .regions
114                                .iter()
115                                .find(|r| anchor_matches(&r.ast_anchor.name, &rel.anchor))
116                                .map(|r| r.intent.clone());
117                            related_context.push(RelatedContext {
118                                commit: rel.commit.clone(),
119                                anchor: rel.anchor.clone(),
120                                relationship: rel.relationship.clone(),
121                                intent: rel_intent,
122                            });
123                            related_followed += 1;
124                        }
125                    }
126                }
127            }
128
129            let constraints: Vec<String> =
130                region.constraints.iter().map(|c| c.text.clone()).collect();
131
132            entries.push(TimelineEntry {
133                commit: sha.clone(),
134                timestamp: annotation.timestamp.clone(),
135                commit_message: commit_msg.clone(),
136                context_level: format!("{:?}", annotation.context_level).to_lowercase(),
137                provenance: format!("{:?}", annotation.provenance.operation).to_lowercase(),
138                intent: region.intent.clone(),
139                reasoning: region.reasoning.clone(),
140                constraints,
141                risk_notes: region.risk_notes.clone(),
142                related_context,
143            });
144        }
145    }
146
147    // Sort chronologically (oldest first). git log returns newest first, so reverse.
148    entries.reverse();
149
150    let annotations_found = entries.len() as u32;
151
152    // Apply limit — keep the N most recent (from the end)
153    if entries.len() > query.limit as usize {
154        let start = entries.len() - query.limit as usize;
155        entries = entries.split_off(start);
156    }
157
158    Ok(HistoryOutput {
159        schema: "chronicle-history/v1".to_string(),
160        query: QueryEcho {
161            file: query.file.clone(),
162            anchor: query.anchor.clone(),
163        },
164        timeline: entries,
165        stats: HistoryStats {
166            commits_in_log,
167            annotations_found,
168            related_followed,
169        },
170    })
171}
172
173fn file_matches(a: &str, b: &str) -> bool {
174    fn norm(s: &str) -> &str {
175        s.strip_prefix("./").unwrap_or(s)
176    }
177    norm(a) == norm(b)
178}
179
180fn anchor_matches(region_anchor: &str, query_anchor: &str) -> bool {
181    if region_anchor == query_anchor {
182        return true;
183    }
184    let region_short = region_anchor.rsplit("::").next().unwrap_or(region_anchor);
185    let query_short = query_anchor.rsplit("::").next().unwrap_or(query_anchor);
186    region_short == query_anchor || region_anchor == query_short || region_short == query_short
187}
188
189#[cfg(test)]
190mod tests {
191    use super::*;
192    use crate::schema::annotation::*;
193
194    struct MockGitOps {
195        file_log: Vec<String>,
196        notes: std::collections::HashMap<String, String>,
197        commit_messages: std::collections::HashMap<String, String>,
198    }
199
200    impl GitOps for MockGitOps {
201        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
202            Ok(vec![])
203        }
204        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
205            Ok(self.notes.get(commit).cloned())
206        }
207        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
208            Ok(())
209        }
210        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
211            Ok(self.notes.contains_key(commit))
212        }
213        fn file_at_commit(
214            &self,
215            _path: &std::path::Path,
216            _commit: &str,
217        ) -> Result<String, GitError> {
218            Ok(String::new())
219        }
220        fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
221            Ok(crate::git::CommitInfo {
222                sha: commit.to_string(),
223                message: self
224                    .commit_messages
225                    .get(commit)
226                    .cloned()
227                    .unwrap_or_default(),
228                author_name: "test".to_string(),
229                author_email: "test@test.com".to_string(),
230                timestamp: "2025-01-01T00:00:00Z".to_string(),
231                parent_shas: vec![],
232            })
233        }
234        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
235            Ok("abc123".to_string())
236        }
237        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
238            Ok(None)
239        }
240        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
241            Ok(())
242        }
243        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
244            Ok(self.file_log.clone())
245        }
246        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
247            Ok(vec![])
248        }
249    }
250
251    fn make_annotation(
252        commit: &str,
253        timestamp: &str,
254        regions: Vec<RegionAnnotation>,
255    ) -> Annotation {
256        Annotation {
257            schema: "chronicle/v1".to_string(),
258            commit: commit.to_string(),
259            timestamp: timestamp.to_string(),
260            task: None,
261            summary: "test".to_string(),
262            context_level: ContextLevel::Enhanced,
263            regions,
264            cross_cutting: vec![],
265            provenance: Provenance {
266                operation: ProvenanceOperation::Initial,
267                derived_from: vec![],
268                original_annotations_preserved: false,
269                synthesis_notes: None,
270            },
271        }
272    }
273
274    fn make_region(
275        file: &str,
276        anchor: &str,
277        intent: &str,
278        related: Vec<RelatedAnnotation>,
279    ) -> RegionAnnotation {
280        RegionAnnotation {
281            file: file.to_string(),
282            ast_anchor: AstAnchor {
283                unit_type: "fn".to_string(),
284                name: anchor.to_string(),
285                signature: None,
286            },
287            lines: LineRange { start: 1, end: 10 },
288            intent: intent.to_string(),
289            reasoning: None,
290            constraints: vec![],
291            semantic_dependencies: vec![],
292            related_annotations: related,
293            tags: vec![],
294            risk_notes: None,
295            corrections: vec![],
296        }
297    }
298
299    #[test]
300    fn test_single_commit_history() {
301        let ann = make_annotation(
302            "commit1",
303            "2025-01-01T00:00:00Z",
304            vec![make_region("src/main.rs", "main", "entry point", vec![])],
305        );
306
307        let mut notes = std::collections::HashMap::new();
308        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
309        let mut msgs = std::collections::HashMap::new();
310        msgs.insert("commit1".to_string(), "initial commit".to_string());
311
312        let git = MockGitOps {
313            file_log: vec!["commit1".to_string()],
314            notes,
315            commit_messages: msgs,
316        };
317
318        let query = HistoryQuery {
319            file: "src/main.rs".to_string(),
320            anchor: Some("main".to_string()),
321            limit: 10,
322            follow_related: true,
323        };
324
325        let result = build_timeline(&git, &query).unwrap();
326        assert_eq!(result.timeline.len(), 1);
327        assert_eq!(result.timeline[0].intent, "entry point");
328        assert_eq!(result.timeline[0].commit_message, "initial commit");
329    }
330
331    #[test]
332    fn test_multi_commit_chronological_order() {
333        let ann1 = make_annotation(
334            "commit1",
335            "2025-01-01T00:00:00Z",
336            vec![make_region("src/main.rs", "main", "v1 entry", vec![])],
337        );
338        let ann2 = make_annotation(
339            "commit2",
340            "2025-01-02T00:00:00Z",
341            vec![make_region("src/main.rs", "main", "v2 entry", vec![])],
342        );
343        let ann3 = make_annotation(
344            "commit3",
345            "2025-01-03T00:00:00Z",
346            vec![make_region("src/main.rs", "main", "v3 entry", vec![])],
347        );
348
349        let mut notes = std::collections::HashMap::new();
350        notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
351        notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
352        notes.insert("commit3".to_string(), serde_json::to_string(&ann3).unwrap());
353
354        let git = MockGitOps {
355            // git log returns newest first
356            file_log: vec![
357                "commit3".to_string(),
358                "commit2".to_string(),
359                "commit1".to_string(),
360            ],
361            notes,
362            commit_messages: std::collections::HashMap::new(),
363        };
364
365        let query = HistoryQuery {
366            file: "src/main.rs".to_string(),
367            anchor: Some("main".to_string()),
368            limit: 10,
369            follow_related: false,
370        };
371
372        let result = build_timeline(&git, &query).unwrap();
373        assert_eq!(result.timeline.len(), 3);
374        // Oldest first
375        assert_eq!(result.timeline[0].intent, "v1 entry");
376        assert_eq!(result.timeline[1].intent, "v2 entry");
377        assert_eq!(result.timeline[2].intent, "v3 entry");
378    }
379
380    #[test]
381    fn test_limit_respected() {
382        let ann1 = make_annotation(
383            "commit1",
384            "2025-01-01T00:00:00Z",
385            vec![make_region("src/main.rs", "main", "v1", vec![])],
386        );
387        let ann2 = make_annotation(
388            "commit2",
389            "2025-01-02T00:00:00Z",
390            vec![make_region("src/main.rs", "main", "v2", vec![])],
391        );
392        let ann3 = make_annotation(
393            "commit3",
394            "2025-01-03T00:00:00Z",
395            vec![make_region("src/main.rs", "main", "v3", vec![])],
396        );
397
398        let mut notes = std::collections::HashMap::new();
399        notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
400        notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
401        notes.insert("commit3".to_string(), serde_json::to_string(&ann3).unwrap());
402
403        let git = MockGitOps {
404            file_log: vec![
405                "commit3".to_string(),
406                "commit2".to_string(),
407                "commit1".to_string(),
408            ],
409            notes,
410            commit_messages: std::collections::HashMap::new(),
411        };
412
413        let query = HistoryQuery {
414            file: "src/main.rs".to_string(),
415            anchor: Some("main".to_string()),
416            limit: 2,
417            follow_related: false,
418        };
419
420        let result = build_timeline(&git, &query).unwrap();
421        // Should return 2 most recent
422        assert_eq!(result.timeline.len(), 2);
423        assert_eq!(result.timeline[0].intent, "v2");
424        assert_eq!(result.timeline[1].intent, "v3");
425        assert_eq!(result.stats.annotations_found, 3);
426    }
427
428    #[test]
429    fn test_follow_related() {
430        let related_ann = make_annotation(
431            "related_commit",
432            "2025-01-01T00:00:00Z",
433            vec![make_region(
434                "src/tls.rs",
435                "TlsSessionCache::new",
436                "session cache init",
437                vec![],
438            )],
439        );
440
441        let main_ann = make_annotation(
442            "commit1",
443            "2025-01-02T00:00:00Z",
444            vec![make_region(
445                "src/main.rs",
446                "main",
447                "entry point",
448                vec![RelatedAnnotation {
449                    commit: "related_commit".to_string(),
450                    anchor: "TlsSessionCache::new".to_string(),
451                    relationship: "depends on session cache".to_string(),
452                }],
453            )],
454        );
455
456        let mut notes = std::collections::HashMap::new();
457        notes.insert(
458            "commit1".to_string(),
459            serde_json::to_string(&main_ann).unwrap(),
460        );
461        notes.insert(
462            "related_commit".to_string(),
463            serde_json::to_string(&related_ann).unwrap(),
464        );
465
466        let git = MockGitOps {
467            file_log: vec!["commit1".to_string()],
468            notes,
469            commit_messages: std::collections::HashMap::new(),
470        };
471
472        let query = HistoryQuery {
473            file: "src/main.rs".to_string(),
474            anchor: Some("main".to_string()),
475            limit: 10,
476            follow_related: true,
477        };
478
479        let result = build_timeline(&git, &query).unwrap();
480        assert_eq!(result.timeline.len(), 1);
481        assert_eq!(result.timeline[0].related_context.len(), 1);
482        assert_eq!(
483            result.timeline[0].related_context[0].anchor,
484            "TlsSessionCache::new"
485        );
486        assert_eq!(
487            result.timeline[0].related_context[0].intent,
488            Some("session cache init".to_string())
489        );
490        assert_eq!(result.stats.related_followed, 1);
491    }
492
493    #[test]
494    fn test_follow_related_disabled() {
495        let main_ann = make_annotation(
496            "commit1",
497            "2025-01-02T00:00:00Z",
498            vec![make_region(
499                "src/main.rs",
500                "main",
501                "entry point",
502                vec![RelatedAnnotation {
503                    commit: "related_commit".to_string(),
504                    anchor: "TlsSessionCache::new".to_string(),
505                    relationship: "depends on session cache".to_string(),
506                }],
507            )],
508        );
509
510        let mut notes = std::collections::HashMap::new();
511        notes.insert(
512            "commit1".to_string(),
513            serde_json::to_string(&main_ann).unwrap(),
514        );
515
516        let git = MockGitOps {
517            file_log: vec!["commit1".to_string()],
518            notes,
519            commit_messages: std::collections::HashMap::new(),
520        };
521
522        let query = HistoryQuery {
523            file: "src/main.rs".to_string(),
524            anchor: Some("main".to_string()),
525            limit: 10,
526            follow_related: false,
527        };
528
529        let result = build_timeline(&git, &query).unwrap();
530        assert_eq!(result.timeline.len(), 1);
531        assert!(result.timeline[0].related_context.is_empty());
532        assert_eq!(result.stats.related_followed, 0);
533    }
534
535    #[test]
536    fn test_commit_without_annotation_skipped() {
537        let ann = make_annotation(
538            "commit1",
539            "2025-01-01T00:00:00Z",
540            vec![make_region("src/main.rs", "main", "v1", vec![])],
541        );
542
543        let mut notes = std::collections::HashMap::new();
544        notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
545        // commit2 has no note
546
547        let git = MockGitOps {
548            file_log: vec!["commit2".to_string(), "commit1".to_string()],
549            notes,
550            commit_messages: std::collections::HashMap::new(),
551        };
552
553        let query = HistoryQuery {
554            file: "src/main.rs".to_string(),
555            anchor: Some("main".to_string()),
556            limit: 10,
557            follow_related: false,
558        };
559
560        let result = build_timeline(&git, &query).unwrap();
561        assert_eq!(result.timeline.len(), 1);
562        assert_eq!(result.stats.commits_in_log, 2);
563    }
564}