Skip to main content

chronicle/read/
history.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
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}
12
13/// A single timeline entry.
14#[derive(Debug, Clone, serde::Serialize)]
15pub struct TimelineEntry {
16    pub commit: String,
17    pub timestamp: String,
18    pub commit_message: String,
19    pub context_level: String,
20    pub provenance: String,
21    pub intent: String,
22    #[serde(skip_serializing_if = "Option::is_none")]
23    pub reasoning: Option<String>,
24    #[serde(default, skip_serializing_if = "Vec::is_empty")]
25    pub constraints: Vec<String>,
26    #[serde(skip_serializing_if = "Option::is_none")]
27    pub risk_notes: Option<String>,
28}
29
30/// Statistics about the history query.
31#[derive(Debug, Clone, serde::Serialize)]
32pub struct HistoryStats {
33    pub commits_in_log: u32,
34    pub annotations_found: u32,
35}
36
37/// Output of a history query.
38#[derive(Debug, Clone, serde::Serialize)]
39pub struct HistoryOutput {
40    pub schema: String,
41    pub query: QueryEcho,
42    pub timeline: Vec<TimelineEntry>,
43    pub stats: HistoryStats,
44}
45
46/// Echo of the query parameters in the output.
47#[derive(Debug, Clone, serde::Serialize)]
48pub struct QueryEcho {
49    pub file: String,
50    pub anchor: Option<String>,
51}
52
53/// Reconstruct the annotation timeline for a file+anchor across commits.
54///
55/// 1. Get commits that touched the file via `log_for_file`
56/// 2. For each commit, fetch annotation and check relevance
57/// 3. Sort chronologically (oldest first)
58/// 4. Apply limit
59pub fn build_timeline(git: &dyn GitOps, query: &HistoryQuery) -> Result<HistoryOutput, GitError> {
60    let shas = git.log_for_file(&query.file)?;
61    let commits_in_log = shas.len() as u32;
62
63    let mut entries: Vec<TimelineEntry> = Vec::new();
64
65    for sha in &shas {
66        let note = match git.note_read(sha)? {
67            Some(n) => n,
68            None => continue,
69        };
70
71        let annotation = match schema::parse_annotation(&note) {
72            Ok(a) => a,
73            Err(e) => {
74                tracing::debug!("skipping malformed annotation for {sha}: {e}");
75                continue;
76            }
77        };
78
79        let commit_msg = git
80            .commit_info(sha)
81            .map(|ci| ci.message.clone())
82            .unwrap_or_default();
83
84        // Check if this annotation is relevant to the queried file
85        let file_in_files_changed = annotation
86            .narrative
87            .files_changed
88            .iter()
89            .any(|f| file_matches(f, &query.file));
90        let file_in_markers = annotation
91            .markers
92            .iter()
93            .any(|m| file_matches(&m.file, &query.file));
94
95        if !file_in_files_changed && !file_in_markers {
96            continue;
97        }
98
99        // If anchor is specified, check if any marker has matching anchor
100        if let Some(ref anchor_name) = query.anchor {
101            let has_matching_anchor = annotation.markers.iter().any(|m| {
102                file_matches(&m.file, &query.file)
103                    && m.anchor
104                        .as_ref()
105                        .map(|a| anchor_matches(&a.name, anchor_name))
106                        .unwrap_or(false)
107            });
108            if !has_matching_anchor && !file_in_files_changed {
109                continue;
110            }
111        }
112
113        // Extract constraints from Contract markers matching the file
114        let constraints: Vec<String> = annotation
115            .markers
116            .iter()
117            .filter(|m| file_matches(&m.file, &query.file))
118            .filter(|m| {
119                query.anchor.as_ref().is_none_or(|qa| {
120                    m.anchor
121                        .as_ref()
122                        .is_some_and(|a| anchor_matches(&a.name, qa))
123                })
124            })
125            .filter_map(|m| {
126                if let v2::MarkerKind::Contract { description, .. } = &m.kind {
127                    Some(description.clone())
128                } else {
129                    None
130                }
131            })
132            .collect();
133
134        // Extract risk notes from Hazard markers matching the file
135        let risk_notes: Option<String> = {
136            let hazards: Vec<String> = annotation
137                .markers
138                .iter()
139                .filter(|m| file_matches(&m.file, &query.file))
140                .filter(|m| {
141                    query.anchor.as_ref().is_none_or(|qa| {
142                        m.anchor
143                            .as_ref()
144                            .is_some_and(|a| anchor_matches(&a.name, qa))
145                    })
146                })
147                .filter_map(|m| {
148                    if let v2::MarkerKind::Hazard { description } = &m.kind {
149                        Some(description.clone())
150                    } else {
151                        None
152                    }
153                })
154                .collect();
155            if hazards.is_empty() {
156                None
157            } else {
158                Some(hazards.join("; "))
159            }
160        };
161
162        let context_level = annotation.provenance.source.to_string();
163
164        entries.push(TimelineEntry {
165            commit: sha.clone(),
166            timestamp: annotation.timestamp.clone(),
167            commit_message: commit_msg,
168            context_level: context_level.clone(),
169            provenance: context_level,
170            intent: annotation.narrative.summary.clone(),
171            reasoning: annotation.narrative.motivation.clone(),
172            constraints,
173            risk_notes,
174        });
175    }
176
177    // Sort chronologically (oldest first). git log returns newest first, so reverse.
178    entries.reverse();
179
180    let annotations_found = entries.len() as u32;
181
182    // Apply limit — keep the N most recent (from the end)
183    if entries.len() > query.limit as usize {
184        let start = entries.len() - query.limit as usize;
185        entries = entries.split_off(start);
186    }
187
188    Ok(HistoryOutput {
189        schema: "chronicle-history/v1".to_string(),
190        query: QueryEcho {
191            file: query.file.clone(),
192            anchor: query.anchor.clone(),
193        },
194        timeline: entries,
195        stats: HistoryStats {
196            commits_in_log,
197            annotations_found,
198        },
199    })
200}
201
202use super::matching::{anchor_matches, file_matches};
203
204#[cfg(test)]
205mod tests {
206    use super::*;
207    use crate::schema::common::AstAnchor;
208
209    struct MockGitOps {
210        file_log: Vec<String>,
211        notes: std::collections::HashMap<String, String>,
212        commit_messages: std::collections::HashMap<String, String>,
213    }
214
215    impl GitOps for MockGitOps {
216        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
217            Ok(vec![])
218        }
219        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
220            Ok(self.notes.get(commit).cloned())
221        }
222        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
223            Ok(())
224        }
225        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
226            Ok(self.notes.contains_key(commit))
227        }
228        fn file_at_commit(
229            &self,
230            _path: &std::path::Path,
231            _commit: &str,
232        ) -> Result<String, GitError> {
233            Ok(String::new())
234        }
235        fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
236            Ok(crate::git::CommitInfo {
237                sha: commit.to_string(),
238                message: self
239                    .commit_messages
240                    .get(commit)
241                    .cloned()
242                    .unwrap_or_default(),
243                author_name: "test".to_string(),
244                author_email: "test@test.com".to_string(),
245                timestamp: "2025-01-01T00:00:00Z".to_string(),
246                parent_shas: vec![],
247            })
248        }
249        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
250            Ok("abc123".to_string())
251        }
252        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
253            Ok(None)
254        }
255        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
256            Ok(())
257        }
258        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
259            Ok(self.file_log.clone())
260        }
261        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
262            Ok(vec![])
263        }
264    }
265
266    fn make_v2_annotation_with_intent(
267        commit: &str,
268        timestamp: &str,
269        summary: &str,
270        files_changed: Vec<&str>,
271        markers: Vec<v2::CodeMarker>,
272    ) -> String {
273        let ann = v2::Annotation {
274            schema: "chronicle/v2".to_string(),
275            commit: commit.to_string(),
276            timestamp: timestamp.to_string(),
277            narrative: v2::Narrative {
278                summary: summary.to_string(),
279                motivation: None,
280                rejected_alternatives: vec![],
281                follow_up: None,
282                files_changed: files_changed.into_iter().map(|s| s.to_string()).collect(),
283            },
284            decisions: vec![],
285            markers,
286            effort: None,
287            provenance: v2::Provenance {
288                source: v2::ProvenanceSource::Live,
289                author: None,
290                derived_from: vec![],
291                notes: None,
292            },
293        };
294        serde_json::to_string(&ann).unwrap()
295    }
296
297    fn make_contract_marker(file: &str, anchor: &str, description: &str) -> v2::CodeMarker {
298        v2::CodeMarker {
299            file: file.to_string(),
300            anchor: Some(AstAnchor {
301                unit_type: "fn".to_string(),
302                name: anchor.to_string(),
303                signature: None,
304            }),
305            lines: None,
306            kind: v2::MarkerKind::Contract {
307                description: description.to_string(),
308                source: v2::ContractSource::Author,
309            },
310        }
311    }
312
313    #[test]
314    fn test_single_commit_history() {
315        let note = make_v2_annotation_with_intent(
316            "commit1",
317            "2025-01-01T00:00:00Z",
318            "entry point",
319            vec!["src/main.rs"],
320            vec![make_contract_marker(
321                "src/main.rs",
322                "main",
323                "must not panic",
324            )],
325        );
326
327        let mut notes = std::collections::HashMap::new();
328        notes.insert("commit1".to_string(), note);
329        let mut msgs = std::collections::HashMap::new();
330        msgs.insert("commit1".to_string(), "initial commit".to_string());
331
332        let git = MockGitOps {
333            file_log: vec!["commit1".to_string()],
334            notes,
335            commit_messages: msgs,
336        };
337
338        let query = HistoryQuery {
339            file: "src/main.rs".to_string(),
340            anchor: Some("main".to_string()),
341            limit: 10,
342        };
343
344        let result = build_timeline(&git, &query).unwrap();
345        assert_eq!(result.timeline.len(), 1);
346        assert_eq!(result.timeline[0].intent, "entry point");
347        assert_eq!(result.timeline[0].commit_message, "initial commit");
348    }
349
350    #[test]
351    fn test_multi_commit_chronological_order() {
352        let note1 = make_v2_annotation_with_intent(
353            "commit1",
354            "2025-01-01T00:00:00Z",
355            "v1 entry",
356            vec!["src/main.rs"],
357            vec![],
358        );
359        let note2 = make_v2_annotation_with_intent(
360            "commit2",
361            "2025-01-02T00:00:00Z",
362            "v2 entry",
363            vec!["src/main.rs"],
364            vec![],
365        );
366        let note3 = make_v2_annotation_with_intent(
367            "commit3",
368            "2025-01-03T00:00:00Z",
369            "v3 entry",
370            vec!["src/main.rs"],
371            vec![],
372        );
373
374        let mut notes = std::collections::HashMap::new();
375        notes.insert("commit1".to_string(), note1);
376        notes.insert("commit2".to_string(), note2);
377        notes.insert("commit3".to_string(), note3);
378
379        let git = MockGitOps {
380            // git log returns newest first
381            file_log: vec![
382                "commit3".to_string(),
383                "commit2".to_string(),
384                "commit1".to_string(),
385            ],
386            notes,
387            commit_messages: std::collections::HashMap::new(),
388        };
389
390        let query = HistoryQuery {
391            file: "src/main.rs".to_string(),
392            anchor: None,
393            limit: 10,
394        };
395
396        let result = build_timeline(&git, &query).unwrap();
397        assert_eq!(result.timeline.len(), 3);
398        // Oldest first
399        assert_eq!(result.timeline[0].intent, "v1 entry");
400        assert_eq!(result.timeline[1].intent, "v2 entry");
401        assert_eq!(result.timeline[2].intent, "v3 entry");
402    }
403
404    #[test]
405    fn test_limit_respected() {
406        let note1 = make_v2_annotation_with_intent(
407            "commit1",
408            "2025-01-01T00:00:00Z",
409            "v1",
410            vec!["src/main.rs"],
411            vec![],
412        );
413        let note2 = make_v2_annotation_with_intent(
414            "commit2",
415            "2025-01-02T00:00:00Z",
416            "v2",
417            vec!["src/main.rs"],
418            vec![],
419        );
420        let note3 = make_v2_annotation_with_intent(
421            "commit3",
422            "2025-01-03T00:00:00Z",
423            "v3",
424            vec!["src/main.rs"],
425            vec![],
426        );
427
428        let mut notes = std::collections::HashMap::new();
429        notes.insert("commit1".to_string(), note1);
430        notes.insert("commit2".to_string(), note2);
431        notes.insert("commit3".to_string(), note3);
432
433        let git = MockGitOps {
434            file_log: vec![
435                "commit3".to_string(),
436                "commit2".to_string(),
437                "commit1".to_string(),
438            ],
439            notes,
440            commit_messages: std::collections::HashMap::new(),
441        };
442
443        let query = HistoryQuery {
444            file: "src/main.rs".to_string(),
445            anchor: None,
446            limit: 2,
447        };
448
449        let result = build_timeline(&git, &query).unwrap();
450        // Should return 2 most recent
451        assert_eq!(result.timeline.len(), 2);
452        assert_eq!(result.timeline[0].intent, "v2");
453        assert_eq!(result.timeline[1].intent, "v3");
454        assert_eq!(result.stats.annotations_found, 3);
455    }
456
457    #[test]
458    fn test_commit_without_annotation_skipped() {
459        let note = make_v2_annotation_with_intent(
460            "commit1",
461            "2025-01-01T00:00:00Z",
462            "v1",
463            vec!["src/main.rs"],
464            vec![],
465        );
466
467        let mut notes = std::collections::HashMap::new();
468        notes.insert("commit1".to_string(), note);
469        // commit2 has no note
470
471        let git = MockGitOps {
472            file_log: vec!["commit2".to_string(), "commit1".to_string()],
473            notes,
474            commit_messages: std::collections::HashMap::new(),
475        };
476
477        let query = HistoryQuery {
478            file: "src/main.rs".to_string(),
479            anchor: None,
480            limit: 10,
481        };
482
483        let result = build_timeline(&git, &query).unwrap();
484        assert_eq!(result.timeline.len(), 1);
485        assert_eq!(result.stats.commits_in_log, 2);
486    }
487}