1use crate::error::GitError;
2use crate::git::GitOps;
3use crate::schema::annotation::Annotation;
4
5#[derive(Debug, Clone)]
7pub struct HistoryQuery {
8 pub file: String,
9 pub anchor: Option<String>,
10 pub limit: u32,
11 pub follow_related: bool,
12}
13
14#[derive(Debug, Clone, serde::Serialize)]
16pub struct TimelineEntry {
17 pub commit: String,
18 pub timestamp: String,
19 pub commit_message: String,
20 pub context_level: String,
21 pub provenance: String,
22 pub intent: String,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub reasoning: Option<String>,
25 #[serde(default, skip_serializing_if = "Vec::is_empty")]
26 pub constraints: Vec<String>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub risk_notes: Option<String>,
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub related_context: Vec<RelatedContext>,
31}
32
33#[derive(Debug, Clone, serde::Serialize)]
35pub struct RelatedContext {
36 pub commit: String,
37 pub anchor: String,
38 pub relationship: String,
39 #[serde(skip_serializing_if = "Option::is_none")]
40 pub intent: Option<String>,
41}
42
43#[derive(Debug, Clone, serde::Serialize)]
45pub struct HistoryStats {
46 pub commits_in_log: u32,
47 pub annotations_found: u32,
48 pub related_followed: u32,
49}
50
51#[derive(Debug, Clone, serde::Serialize)]
53pub struct HistoryOutput {
54 pub schema: String,
55 pub query: QueryEcho,
56 pub timeline: Vec<TimelineEntry>,
57 pub stats: HistoryStats,
58}
59
60#[derive(Debug, Clone, serde::Serialize)]
62pub struct QueryEcho {
63 pub file: String,
64 pub anchor: Option<String>,
65}
66
67pub fn build_timeline(git: &dyn GitOps, query: &HistoryQuery) -> Result<HistoryOutput, GitError> {
75 let shas = git.log_for_file(&query.file)?;
76 let commits_in_log = shas.len() as u32;
77
78 let mut entries: Vec<TimelineEntry> = Vec::new();
79 let mut related_followed: u32 = 0;
80
81 for sha in &shas {
82 let note = match git.note_read(sha)? {
83 Some(n) => n,
84 None => continue,
85 };
86
87 let annotation: Annotation = match serde_json::from_str(¬e) {
88 Ok(a) => a,
89 Err(_) => continue,
90 };
91
92 let commit_msg = git
93 .commit_info(sha)
94 .map(|ci| ci.message.clone())
95 .unwrap_or_default();
96
97 for region in &annotation.regions {
98 if !file_matches(®ion.file, &query.file) {
99 continue;
100 }
101 if let Some(ref anchor_name) = query.anchor {
102 if !anchor_matches(®ion.ast_anchor.name, anchor_name) {
103 continue;
104 }
105 }
106
107 let mut related_context = Vec::new();
108 if query.follow_related {
109 for rel in ®ion.related_annotations {
110 if let Ok(Some(rel_note)) = git.note_read(&rel.commit) {
111 if let Ok(rel_ann) = serde_json::from_str::<Annotation>(&rel_note) {
112 let rel_intent = rel_ann
113 .regions
114 .iter()
115 .find(|r| anchor_matches(&r.ast_anchor.name, &rel.anchor))
116 .map(|r| r.intent.clone());
117 related_context.push(RelatedContext {
118 commit: rel.commit.clone(),
119 anchor: rel.anchor.clone(),
120 relationship: rel.relationship.clone(),
121 intent: rel_intent,
122 });
123 related_followed += 1;
124 }
125 }
126 }
127 }
128
129 let constraints: Vec<String> =
130 region.constraints.iter().map(|c| c.text.clone()).collect();
131
132 entries.push(TimelineEntry {
133 commit: sha.clone(),
134 timestamp: annotation.timestamp.clone(),
135 commit_message: commit_msg.clone(),
136 context_level: format!("{:?}", annotation.context_level).to_lowercase(),
137 provenance: format!("{:?}", annotation.provenance.operation).to_lowercase(),
138 intent: region.intent.clone(),
139 reasoning: region.reasoning.clone(),
140 constraints,
141 risk_notes: region.risk_notes.clone(),
142 related_context,
143 });
144 }
145 }
146
147 entries.reverse();
149
150 let annotations_found = entries.len() as u32;
151
152 if entries.len() > query.limit as usize {
154 let start = entries.len() - query.limit as usize;
155 entries = entries.split_off(start);
156 }
157
158 Ok(HistoryOutput {
159 schema: "chronicle-history/v1".to_string(),
160 query: QueryEcho {
161 file: query.file.clone(),
162 anchor: query.anchor.clone(),
163 },
164 timeline: entries,
165 stats: HistoryStats {
166 commits_in_log,
167 annotations_found,
168 related_followed,
169 },
170 })
171}
172
173fn file_matches(a: &str, b: &str) -> bool {
174 fn norm(s: &str) -> &str {
175 s.strip_prefix("./").unwrap_or(s)
176 }
177 norm(a) == norm(b)
178}
179
180fn anchor_matches(region_anchor: &str, query_anchor: &str) -> bool {
181 if region_anchor == query_anchor {
182 return true;
183 }
184 let region_short = region_anchor.rsplit("::").next().unwrap_or(region_anchor);
185 let query_short = query_anchor.rsplit("::").next().unwrap_or(query_anchor);
186 region_short == query_anchor || region_anchor == query_short || region_short == query_short
187}
188
189#[cfg(test)]
190mod tests {
191 use super::*;
192 use crate::schema::annotation::*;
193
194 struct MockGitOps {
195 file_log: Vec<String>,
196 notes: std::collections::HashMap<String, String>,
197 commit_messages: std::collections::HashMap<String, String>,
198 }
199
200 impl GitOps for MockGitOps {
201 fn diff(&self, _commit: &str) -> Result<Vec<crate::git::FileDiff>, GitError> {
202 Ok(vec![])
203 }
204 fn note_read(&self, commit: &str) -> Result<Option<String>, GitError> {
205 Ok(self.notes.get(commit).cloned())
206 }
207 fn note_write(&self, _commit: &str, _content: &str) -> Result<(), GitError> {
208 Ok(())
209 }
210 fn note_exists(&self, commit: &str) -> Result<bool, GitError> {
211 Ok(self.notes.contains_key(commit))
212 }
213 fn file_at_commit(
214 &self,
215 _path: &std::path::Path,
216 _commit: &str,
217 ) -> Result<String, GitError> {
218 Ok(String::new())
219 }
220 fn commit_info(&self, commit: &str) -> Result<crate::git::CommitInfo, GitError> {
221 Ok(crate::git::CommitInfo {
222 sha: commit.to_string(),
223 message: self
224 .commit_messages
225 .get(commit)
226 .cloned()
227 .unwrap_or_default(),
228 author_name: "test".to_string(),
229 author_email: "test@test.com".to_string(),
230 timestamp: "2025-01-01T00:00:00Z".to_string(),
231 parent_shas: vec![],
232 })
233 }
234 fn resolve_ref(&self, _refspec: &str) -> Result<String, GitError> {
235 Ok("abc123".to_string())
236 }
237 fn config_get(&self, _key: &str) -> Result<Option<String>, GitError> {
238 Ok(None)
239 }
240 fn config_set(&self, _key: &str, _value: &str) -> Result<(), GitError> {
241 Ok(())
242 }
243 fn log_for_file(&self, _path: &str) -> Result<Vec<String>, GitError> {
244 Ok(self.file_log.clone())
245 }
246 fn list_annotated_commits(&self, _limit: u32) -> Result<Vec<String>, GitError> {
247 Ok(vec![])
248 }
249 }
250
251 fn make_annotation(
252 commit: &str,
253 timestamp: &str,
254 regions: Vec<RegionAnnotation>,
255 ) -> Annotation {
256 Annotation {
257 schema: "chronicle/v1".to_string(),
258 commit: commit.to_string(),
259 timestamp: timestamp.to_string(),
260 task: None,
261 summary: "test".to_string(),
262 context_level: ContextLevel::Enhanced,
263 regions,
264 cross_cutting: vec![],
265 provenance: Provenance {
266 operation: ProvenanceOperation::Initial,
267 derived_from: vec![],
268 original_annotations_preserved: false,
269 synthesis_notes: None,
270 },
271 }
272 }
273
274 fn make_region(
275 file: &str,
276 anchor: &str,
277 intent: &str,
278 related: Vec<RelatedAnnotation>,
279 ) -> RegionAnnotation {
280 RegionAnnotation {
281 file: file.to_string(),
282 ast_anchor: AstAnchor {
283 unit_type: "fn".to_string(),
284 name: anchor.to_string(),
285 signature: None,
286 },
287 lines: LineRange { start: 1, end: 10 },
288 intent: intent.to_string(),
289 reasoning: None,
290 constraints: vec![],
291 semantic_dependencies: vec![],
292 related_annotations: related,
293 tags: vec![],
294 risk_notes: None,
295 corrections: vec![],
296 }
297 }
298
299 #[test]
300 fn test_single_commit_history() {
301 let ann = make_annotation(
302 "commit1",
303 "2025-01-01T00:00:00Z",
304 vec![make_region("src/main.rs", "main", "entry point", vec![])],
305 );
306
307 let mut notes = std::collections::HashMap::new();
308 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
309 let mut msgs = std::collections::HashMap::new();
310 msgs.insert("commit1".to_string(), "initial commit".to_string());
311
312 let git = MockGitOps {
313 file_log: vec!["commit1".to_string()],
314 notes,
315 commit_messages: msgs,
316 };
317
318 let query = HistoryQuery {
319 file: "src/main.rs".to_string(),
320 anchor: Some("main".to_string()),
321 limit: 10,
322 follow_related: true,
323 };
324
325 let result = build_timeline(&git, &query).unwrap();
326 assert_eq!(result.timeline.len(), 1);
327 assert_eq!(result.timeline[0].intent, "entry point");
328 assert_eq!(result.timeline[0].commit_message, "initial commit");
329 }
330
331 #[test]
332 fn test_multi_commit_chronological_order() {
333 let ann1 = make_annotation(
334 "commit1",
335 "2025-01-01T00:00:00Z",
336 vec![make_region("src/main.rs", "main", "v1 entry", vec![])],
337 );
338 let ann2 = make_annotation(
339 "commit2",
340 "2025-01-02T00:00:00Z",
341 vec![make_region("src/main.rs", "main", "v2 entry", vec![])],
342 );
343 let ann3 = make_annotation(
344 "commit3",
345 "2025-01-03T00:00:00Z",
346 vec![make_region("src/main.rs", "main", "v3 entry", vec![])],
347 );
348
349 let mut notes = std::collections::HashMap::new();
350 notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
351 notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
352 notes.insert("commit3".to_string(), serde_json::to_string(&ann3).unwrap());
353
354 let git = MockGitOps {
355 file_log: vec![
357 "commit3".to_string(),
358 "commit2".to_string(),
359 "commit1".to_string(),
360 ],
361 notes,
362 commit_messages: std::collections::HashMap::new(),
363 };
364
365 let query = HistoryQuery {
366 file: "src/main.rs".to_string(),
367 anchor: Some("main".to_string()),
368 limit: 10,
369 follow_related: false,
370 };
371
372 let result = build_timeline(&git, &query).unwrap();
373 assert_eq!(result.timeline.len(), 3);
374 assert_eq!(result.timeline[0].intent, "v1 entry");
376 assert_eq!(result.timeline[1].intent, "v2 entry");
377 assert_eq!(result.timeline[2].intent, "v3 entry");
378 }
379
380 #[test]
381 fn test_limit_respected() {
382 let ann1 = make_annotation(
383 "commit1",
384 "2025-01-01T00:00:00Z",
385 vec![make_region("src/main.rs", "main", "v1", vec![])],
386 );
387 let ann2 = make_annotation(
388 "commit2",
389 "2025-01-02T00:00:00Z",
390 vec![make_region("src/main.rs", "main", "v2", vec![])],
391 );
392 let ann3 = make_annotation(
393 "commit3",
394 "2025-01-03T00:00:00Z",
395 vec![make_region("src/main.rs", "main", "v3", vec![])],
396 );
397
398 let mut notes = std::collections::HashMap::new();
399 notes.insert("commit1".to_string(), serde_json::to_string(&ann1).unwrap());
400 notes.insert("commit2".to_string(), serde_json::to_string(&ann2).unwrap());
401 notes.insert("commit3".to_string(), serde_json::to_string(&ann3).unwrap());
402
403 let git = MockGitOps {
404 file_log: vec![
405 "commit3".to_string(),
406 "commit2".to_string(),
407 "commit1".to_string(),
408 ],
409 notes,
410 commit_messages: std::collections::HashMap::new(),
411 };
412
413 let query = HistoryQuery {
414 file: "src/main.rs".to_string(),
415 anchor: Some("main".to_string()),
416 limit: 2,
417 follow_related: false,
418 };
419
420 let result = build_timeline(&git, &query).unwrap();
421 assert_eq!(result.timeline.len(), 2);
423 assert_eq!(result.timeline[0].intent, "v2");
424 assert_eq!(result.timeline[1].intent, "v3");
425 assert_eq!(result.stats.annotations_found, 3);
426 }
427
428 #[test]
429 fn test_follow_related() {
430 let related_ann = make_annotation(
431 "related_commit",
432 "2025-01-01T00:00:00Z",
433 vec![make_region(
434 "src/tls.rs",
435 "TlsSessionCache::new",
436 "session cache init",
437 vec![],
438 )],
439 );
440
441 let main_ann = make_annotation(
442 "commit1",
443 "2025-01-02T00:00:00Z",
444 vec![make_region(
445 "src/main.rs",
446 "main",
447 "entry point",
448 vec![RelatedAnnotation {
449 commit: "related_commit".to_string(),
450 anchor: "TlsSessionCache::new".to_string(),
451 relationship: "depends on session cache".to_string(),
452 }],
453 )],
454 );
455
456 let mut notes = std::collections::HashMap::new();
457 notes.insert(
458 "commit1".to_string(),
459 serde_json::to_string(&main_ann).unwrap(),
460 );
461 notes.insert(
462 "related_commit".to_string(),
463 serde_json::to_string(&related_ann).unwrap(),
464 );
465
466 let git = MockGitOps {
467 file_log: vec!["commit1".to_string()],
468 notes,
469 commit_messages: std::collections::HashMap::new(),
470 };
471
472 let query = HistoryQuery {
473 file: "src/main.rs".to_string(),
474 anchor: Some("main".to_string()),
475 limit: 10,
476 follow_related: true,
477 };
478
479 let result = build_timeline(&git, &query).unwrap();
480 assert_eq!(result.timeline.len(), 1);
481 assert_eq!(result.timeline[0].related_context.len(), 1);
482 assert_eq!(
483 result.timeline[0].related_context[0].anchor,
484 "TlsSessionCache::new"
485 );
486 assert_eq!(
487 result.timeline[0].related_context[0].intent,
488 Some("session cache init".to_string())
489 );
490 assert_eq!(result.stats.related_followed, 1);
491 }
492
493 #[test]
494 fn test_follow_related_disabled() {
495 let main_ann = make_annotation(
496 "commit1",
497 "2025-01-02T00:00:00Z",
498 vec![make_region(
499 "src/main.rs",
500 "main",
501 "entry point",
502 vec![RelatedAnnotation {
503 commit: "related_commit".to_string(),
504 anchor: "TlsSessionCache::new".to_string(),
505 relationship: "depends on session cache".to_string(),
506 }],
507 )],
508 );
509
510 let mut notes = std::collections::HashMap::new();
511 notes.insert(
512 "commit1".to_string(),
513 serde_json::to_string(&main_ann).unwrap(),
514 );
515
516 let git = MockGitOps {
517 file_log: vec!["commit1".to_string()],
518 notes,
519 commit_messages: std::collections::HashMap::new(),
520 };
521
522 let query = HistoryQuery {
523 file: "src/main.rs".to_string(),
524 anchor: Some("main".to_string()),
525 limit: 10,
526 follow_related: false,
527 };
528
529 let result = build_timeline(&git, &query).unwrap();
530 assert_eq!(result.timeline.len(), 1);
531 assert!(result.timeline[0].related_context.is_empty());
532 assert_eq!(result.stats.related_followed, 0);
533 }
534
535 #[test]
536 fn test_commit_without_annotation_skipped() {
537 let ann = make_annotation(
538 "commit1",
539 "2025-01-01T00:00:00Z",
540 vec![make_region("src/main.rs", "main", "v1", vec![])],
541 );
542
543 let mut notes = std::collections::HashMap::new();
544 notes.insert("commit1".to_string(), serde_json::to_string(&ann).unwrap());
545 let git = MockGitOps {
548 file_log: vec!["commit2".to_string(), "commit1".to_string()],
549 notes,
550 commit_messages: std::collections::HashMap::new(),
551 };
552
553 let query = HistoryQuery {
554 file: "src/main.rs".to_string(),
555 anchor: Some("main".to_string()),
556 limit: 10,
557 follow_related: false,
558 };
559
560 let result = build_timeline(&git, &query).unwrap();
561 assert_eq!(result.timeline.len(), 1);
562 assert_eq!(result.stats.commits_in_log, 2);
563 }
564}