1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::{self, v2};
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 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 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 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 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 entries.reverse();
179
180 let annotations_found = entries.len() as u32;
181
182 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 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 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 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 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}