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