1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v3};
4
5#[derive(Debug, Clone)]
7pub struct HistoryQuery {
8 pub file: String,
9 pub anchor: Option<String>,
10 pub limit: u32,
11}
12
13#[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#[derive(Debug, Clone, serde::Serialize)]
32pub struct HistoryStats {
33 pub commits_in_log: u32,
34 pub annotations_found: u32,
35}
36
37#[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#[derive(Debug, Clone, serde::Serialize)]
48pub struct QueryEcho {
49 pub file: String,
50 pub anchor: Option<String>,
51}
52
53pub 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(¬e) {
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 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 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 entries.reverse();
116
117 let annotations_found = entries.len() as u32;
118
119 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 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 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 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 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}