Skip to main content

chronicle/read/
history.rs

1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
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        // The commit is already in log_for_file results, so it touched this file.
85        // Always include annotated commits — the git log is the relevance signal.
86
87        // Extract gotcha wisdom entries as constraints
88        let constraints: Vec<String> = annotation
89            .wisdom
90            .iter()
91            .filter(|w| w.category == v3::WisdomCategory::Gotcha)
92            .filter(|w| w.file.as_ref().is_none_or(|f| file_matches(f, &query.file)))
93            .map(|w| w.content.clone())
94            .collect();
95
96        // No direct equivalent for risk_notes in v3 — leave as None
97        let risk_notes: Option<String> = None;
98
99        let context_level = annotation.provenance.source.to_string();
100
101        entries.push(TimelineEntry {
102            commit: sha.clone(),
103            timestamp: annotation.timestamp.clone(),
104            commit_message: commit_msg,
105            context_level: context_level.clone(),
106            provenance: context_level,
107            intent: annotation.summary.clone(),
108            reasoning: None,
109            constraints,
110            risk_notes,
111        });
112    }
113
114    // Sort chronologically (oldest first). git log returns newest first, so reverse.
115    entries.reverse();
116
117    let annotations_found = entries.len() as u32;
118
119    // Apply limit — keep the N most recent (from the end)
120    if entries.len() > query.limit as usize {
121        let start = entries.len() - query.limit as usize;
122        entries = entries.split_off(start);
123    }
124
125    Ok(HistoryOutput {
126        schema: "chronicle-history/v1".to_string(),
127        query: QueryEcho {
128            file: query.file.clone(),
129            anchor: query.anchor.clone(),
130        },
131        timeline: entries,
132        stats: HistoryStats {
133            commits_in_log,
134            annotations_found,
135        },
136    })
137}
138
139use super::matching::file_matches;
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use crate::schema::common::AstAnchor;
145    use crate::schema::v2;
146
147    struct MockGitOps {
148        file_log: Vec<String>,
149        notes: std::collections::HashMap<String, String>,
150        commit_messages: std::collections::HashMap<String, String>,
151    }
152
153    impl GitOps for MockGitOps {
154        fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
155            Ok(vec![])
156        }
157        fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
158            Ok(self.notes.get(commit).cloned())
159        }
160        fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
161            Ok(())
162        }
163        fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
164            Ok(self.notes.contains_key(commit))
165        }
166        fn file_at_commit(
167            &self,
168            _path: &std::path::Path,
169            _commit: &str,
170        ) -> Result<String, GitError> {
171            Ok(String::new())
172        }
173        fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
174            Ok(crate::git::CommitInfo {
175                sha: commit.to_string(),
176                message: self
177                    .commit_messages
178                    .get(commit)
179                    .cloned()
180                    .unwrap_or_default(),
181                author_name: "test".to_string(),
182                author_email: "test@test.com".to_string(),
183                timestamp: "2025-01-01T00:00:00Z".to_string(),
184                parent_shas: vec![],
185            })
186        }
187        fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
188            Ok("abc123".to_string())
189        }
190        fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
191            Ok(None)
192        }
193        fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
194            Ok(())
195        }
196        fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
197            Ok(self.file_log.clone())
198        }
199        fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
200            Ok(vec![])
201        }
202    }
203
204    fn make_v2_annotation_with_intent(
205        commit: &str,
206        timestamp: &str,
207        summary: &str,
208        files_changed: Vec<&str>,
209        markers: Vec<v2::CodeMarker>,
210    ) -> String {
211        let ann = v2::Annotation {
212            schema: "chronicle/v2".to_string(),
213            commit: commit.to_string(),
214            timestamp: timestamp.to_string(),
215            narrative: v2::Narrative {
216                summary: summary.to_string(),
217                motivation: None,
218                rejected_alternatives: vec![],
219                follow_up: None,
220                files_changed: files_changed.into_iter().map(|s| s.to_string()).collect(),
221                sentiments: vec![],
222            },
223            decisions: vec![],
224            markers,
225            effort: None,
226            provenance: v2::Provenance {
227                source: v2::ProvenanceSource::Live,
228                author: None,
229                derived_from: vec![],
230                notes: None,
231            },
232        };
233        serde_json::to_string(&ann).unwrap()
234    }
235
236    fn make_contract_marker(file: &str, anchor: &str, description: &str) -> v2::CodeMarker {
237        v2::CodeMarker {
238            file: file.to_string(),
239            anchor: Some(AstAnchor {
240                unit_type: "fn".to_string(),
241                name: anchor.to_string(),
242                signature: None,
243            }),
244            lines: None,
245            kind: v2::MarkerKind::Contract {
246                description: description.to_string(),
247                source: v2::ContractSource::Author,
248            },
249        }
250    }
251
252    #[test]
253    fn test_single_commit_history() {
254        let note = make_v2_annotation_with_intent(
255            "commit1",
256            "2025-01-01T00:00:00Z",
257            "entry point",
258            vec!["src/main.rs"],
259            vec![make_contract_marker(
260                "src/main.rs",
261                "main",
262                "must not panic",
263            )],
264        );
265
266        let mut notes = std::collections::HashMap::new();
267        notes.insert("commit1".to_string(), note);
268        let mut msgs = std::collections::HashMap::new();
269        msgs.insert("commit1".to_string(), "initial commit".to_string());
270
271        let git = MockGitOps {
272            file_log: vec!["commit1".to_string()],
273            notes,
274            commit_messages: msgs,
275        };
276
277        let query = HistoryQuery {
278            file: "src/main.rs".to_string(),
279            anchor: Some("main".to_string()),
280            limit: 10,
281        };
282
283        let result = build_timeline(&git, &query).unwrap();
284        assert_eq!(result.timeline.len(), 1);
285        assert_eq!(result.timeline[0].intent, "entry point");
286        assert_eq!(result.timeline[0].commit_message, "initial commit");
287    }
288
289    #[test]
290    fn test_multi_commit_chronological_order() {
291        let note1 = make_v2_annotation_with_intent(
292            "commit1",
293            "2025-01-01T00:00:00Z",
294            "v1 entry",
295            vec!["src/main.rs"],
296            vec![],
297        );
298        let note2 = make_v2_annotation_with_intent(
299            "commit2",
300            "2025-01-02T00:00:00Z",
301            "v2 entry",
302            vec!["src/main.rs"],
303            vec![],
304        );
305        let note3 = make_v2_annotation_with_intent(
306            "commit3",
307            "2025-01-03T00:00:00Z",
308            "v3 entry",
309            vec!["src/main.rs"],
310            vec![],
311        );
312
313        let mut notes = std::collections::HashMap::new();
314        notes.insert("commit1".to_string(), note1);
315        notes.insert("commit2".to_string(), note2);
316        notes.insert("commit3".to_string(), note3);
317
318        let git = MockGitOps {
319            // git log returns newest first
320            file_log: vec![
321                "commit3".to_string(),
322                "commit2".to_string(),
323                "commit1".to_string(),
324            ],
325            notes,
326            commit_messages: std::collections::HashMap::new(),
327        };
328
329        let query = HistoryQuery {
330            file: "src/main.rs".to_string(),
331            anchor: None,
332            limit: 10,
333        };
334
335        let result = build_timeline(&git, &query).unwrap();
336        assert_eq!(result.timeline.len(), 3);
337        // Oldest first
338        assert_eq!(result.timeline[0].intent, "v1 entry");
339        assert_eq!(result.timeline[1].intent, "v2 entry");
340        assert_eq!(result.timeline[2].intent, "v3 entry");
341    }
342
343    #[test]
344    fn test_limit_respected() {
345        let note1 = make_v2_annotation_with_intent(
346            "commit1",
347            "2025-01-01T00:00:00Z",
348            "v1",
349            vec!["src/main.rs"],
350            vec![],
351        );
352        let note2 = make_v2_annotation_with_intent(
353            "commit2",
354            "2025-01-02T00:00:00Z",
355            "v2",
356            vec!["src/main.rs"],
357            vec![],
358        );
359        let note3 = make_v2_annotation_with_intent(
360            "commit3",
361            "2025-01-03T00:00:00Z",
362            "v3",
363            vec!["src/main.rs"],
364            vec![],
365        );
366
367        let mut notes = std::collections::HashMap::new();
368        notes.insert("commit1".to_string(), note1);
369        notes.insert("commit2".to_string(), note2);
370        notes.insert("commit3".to_string(), note3);
371
372        let git = MockGitOps {
373            file_log: vec![
374                "commit3".to_string(),
375                "commit2".to_string(),
376                "commit1".to_string(),
377            ],
378            notes,
379            commit_messages: std::collections::HashMap::new(),
380        };
381
382        let query = HistoryQuery {
383            file: "src/main.rs".to_string(),
384            anchor: None,
385            limit: 2,
386        };
387
388        let result = build_timeline(&git, &query).unwrap();
389        // Should return 2 most recent
390        assert_eq!(result.timeline.len(), 2);
391        assert_eq!(result.timeline[0].intent, "v2");
392        assert_eq!(result.timeline[1].intent, "v3");
393        assert_eq!(result.stats.annotations_found, 3);
394    }
395
396    #[test]
397    fn test_commit_without_annotation_skipped() {
398        let note = make_v2_annotation_with_intent(
399            "commit1",
400            "2025-01-01T00:00:00Z",
401            "v1",
402            vec!["src/main.rs"],
403            vec![],
404        );
405
406        let mut notes = std::collections::HashMap::new();
407        notes.insert("commit1".to_string(), note);
408        // commit2 has no note
409
410        let git = MockGitOps {
411            file_log: vec!["commit2".to_string(), "commit1".to_string()],
412            notes,
413            commit_messages: std::collections::HashMap::new(),
414        };
415
416        let query = HistoryQuery {
417            file: "src/main.rs".to_string(),
418            anchor: None,
419            limit: 10,
420        };
421
422        let result = build_timeline(&git, &query).unwrap();
423        assert_eq!(result.timeline.len(), 1);
424        assert_eq!(result.stats.commits_in_log, 2);
425    }
426}