1use std::path::Path;
2
3use chrono::{DateTime, Utc};
4use serde::{Deserialize, Serialize};
5
6use crate::error::chronicle_error::{IoSnafu, JsonSnafu};
7use crate::error::Result;
8use crate::git::GitOps;
9use crate::schema::v1::{
10 self, ContextLevel, CrossCuttingConcern, Provenance, ProvenanceOperation, RegionAnnotation,
11};
12use crate::schema::{self, v3};
13type Annotation = v1::Annotation;
14use snafu::ResultExt;
15
16const PENDING_SQUASH_EXPIRY_SECS: i64 = 60;
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct PendingSquash {
23 pub source_commits: Vec<String>,
24 #[serde(skip_serializing_if = "Option::is_none")]
25 pub source_ref: Option<String>,
26 pub timestamp: DateTime<Utc>,
27}
28
29#[derive(Debug, Clone)]
31pub struct SquashSynthesisContext {
32 pub squash_commit: String,
34 pub diff: String,
36 pub source_annotations: Vec<Annotation>,
38 pub source_messages: Vec<(String, String)>,
40 pub squash_message: String,
42}
43
44#[derive(Debug, Clone)]
46pub struct AmendMigrationContext {
47 pub new_commit: String,
49 pub new_diff: String,
51 pub old_annotation: Annotation,
53 pub new_message: String,
55}
56
57fn pending_squash_path(git_dir: &Path) -> std::path::PathBuf {
58 git_dir.join("chronicle").join("pending-squash.json")
59}
60
61pub fn write_pending_squash(git_dir: &Path, pending: &PendingSquash) -> Result<()> {
63 let path = pending_squash_path(git_dir);
64 if let Some(parent) = path.parent() {
65 std::fs::create_dir_all(parent).context(IoSnafu)?;
66 }
67 let json = serde_json::to_string_pretty(pending).context(JsonSnafu)?;
68 std::fs::write(&path, json).context(IoSnafu)?;
69 Ok(())
70}
71
72pub fn read_pending_squash(git_dir: &Path) -> Result<Option<PendingSquash>> {
75 let path = pending_squash_path(git_dir);
76 if !path.exists() {
77 return Ok(None);
78 }
79
80 let content = std::fs::read_to_string(&path).context(IoSnafu)?;
81 let pending: PendingSquash = match serde_json::from_str(&content) {
82 Ok(p) => p,
83 Err(e) => {
84 tracing::warn!("Invalid pending-squash.json, deleting: {e}");
85 let _ = std::fs::remove_file(&path);
86 return Ok(None);
87 }
88 };
89
90 let age = Utc::now() - pending.timestamp;
91 if age.num_seconds() > PENDING_SQUASH_EXPIRY_SECS {
92 tracing::warn!(
93 "Stale pending-squash.json ({}s old), deleting",
94 age.num_seconds()
95 );
96 std::fs::remove_file(&path).context(IoSnafu)?;
97 return Ok(None);
98 }
99
100 Ok(Some(pending))
101}
102
103pub fn delete_pending_squash(git_dir: &Path) -> Result<()> {
105 let path = pending_squash_path(git_dir);
106 if path.exists() {
107 std::fs::remove_file(&path).context(IoSnafu)?;
108 }
109 Ok(())
110}
111
112pub fn synthesize_squash_annotation(ctx: &SquashSynthesisContext) -> Annotation {
118 let mut all_regions: Vec<RegionAnnotation> = Vec::new();
119 let mut all_cross_cutting: Vec<CrossCuttingConcern> = Vec::new();
120 let mut source_shas: Vec<String> = Vec::new();
121 let has_annotations = !ctx.source_annotations.is_empty();
122
123 for ann in &ctx.source_annotations {
124 source_shas.push(ann.commit.clone());
125
126 for region in &ann.regions {
128 let already_exists = all_regions
129 .iter()
130 .any(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name);
131 if already_exists {
132 if let Some(existing) = all_regions
134 .iter_mut()
135 .find(|r| r.file == region.file && r.ast_anchor.name == region.ast_anchor.name)
136 {
137 for constraint in ®ion.constraints {
139 if !existing
140 .constraints
141 .iter()
142 .any(|c| c.text == constraint.text)
143 {
144 existing.constraints.push(constraint.clone());
145 }
146 }
147 for dep in ®ion.semantic_dependencies {
149 if !existing
150 .semantic_dependencies
151 .iter()
152 .any(|d| d.file == dep.file && d.anchor == dep.anchor)
153 {
154 existing.semantic_dependencies.push(dep.clone());
155 }
156 }
157 if let Some(new_reasoning) = ®ion.reasoning {
159 if let Some(ref mut existing_reasoning) = existing.reasoning {
160 existing_reasoning.push_str("\n\n");
161 existing_reasoning.push_str(new_reasoning);
162 } else {
163 existing.reasoning = Some(new_reasoning.clone());
164 }
165 }
166 existing.lines.start = existing.lines.start.min(region.lines.start);
168 existing.lines.end = existing.lines.end.max(region.lines.end);
169 }
170 } else {
171 all_regions.push(region.clone());
172 }
173 }
174
175 for cc in &ann.cross_cutting {
177 if !all_cross_cutting
178 .iter()
179 .any(|c| c.description == cc.description)
180 {
181 all_cross_cutting.push(cc.clone());
182 }
183 }
184 }
185
186 for (sha, _) in &ctx.source_messages {
188 if !source_shas.contains(sha) {
189 source_shas.push(sha.clone());
190 }
191 }
192
193 let annotations_count = ctx.source_annotations.len();
194 let total_sources = ctx.source_messages.len();
195 let all_had_annotations = annotations_count == total_sources && total_sources > 0;
196
197 let synthesis_notes = if has_annotations {
198 Some(format!(
199 "Synthesized from {} commits ({} of {} had annotations).",
200 total_sources, annotations_count, total_sources,
201 ))
202 } else {
203 Some(format!(
204 "Synthesized from {} commits (none had annotations).",
205 total_sources,
206 ))
207 };
208
209 Annotation {
210 schema: "chronicle/v1".to_string(),
211 commit: ctx.squash_commit.clone(),
212 timestamp: Utc::now().to_rfc3339(),
213 task: None,
214 summary: ctx.squash_message.clone(),
215 context_level: if has_annotations {
216 ContextLevel::Enhanced
217 } else {
218 ContextLevel::Inferred
219 },
220 regions: all_regions,
221 cross_cutting: all_cross_cutting,
222 provenance: Provenance {
223 operation: ProvenanceOperation::Squash,
224 derived_from: source_shas,
225 original_annotations_preserved: all_had_annotations,
226 synthesis_notes,
227 },
228 }
229}
230
231pub fn migrate_amend_annotation(ctx: &AmendMigrationContext) -> Annotation {
236 let mut new_annotation = ctx.old_annotation.clone();
237 new_annotation.commit = ctx.new_commit.clone();
238 new_annotation.timestamp = Utc::now().to_rfc3339();
239
240 let is_message_only = ctx.new_diff.trim().is_empty();
241
242 new_annotation.provenance = Provenance {
243 operation: ProvenanceOperation::Amend,
244 derived_from: vec![ctx.old_annotation.commit.clone()],
245 original_annotations_preserved: true,
246 synthesis_notes: if is_message_only {
247 Some("Message-only amend; annotation unchanged.".to_string())
248 } else {
249 Some("Migrated from amend. Regions preserved from original annotation.".to_string())
250 },
251 };
252
253 if is_message_only {
255 new_annotation.summary = ctx.new_message.clone();
256 }
257
258 new_annotation
259}
260
261pub fn collect_source_annotations(
263 git_ops: &dyn GitOps,
264 source_shas: &[String],
265) -> Vec<(String, Option<Annotation>)> {
266 source_shas
267 .iter()
268 .map(|sha| {
269 let annotation = git_ops
270 .note_read(sha)
271 .ok()
272 .flatten()
273 .and_then(|json| serde_json::from_str::<Annotation>(&json).ok());
274 (sha.clone(), annotation)
275 })
276 .collect()
277}
278
279pub fn collect_source_messages(
281 git_ops: &dyn GitOps,
282 source_shas: &[String],
283) -> Vec<(String, String)> {
284 source_shas
285 .iter()
286 .filter_map(|sha| {
287 git_ops
288 .commit_info(sha)
289 .ok()
290 .map(|info| (sha.clone(), info.message))
291 })
292 .collect()
293}
294
295#[derive(Debug, Clone)]
301pub struct SquashSynthesisContextV3 {
302 pub squash_commit: String,
304 pub squash_message: String,
306 pub source_annotations: Vec<v3::Annotation>,
308 pub source_messages: Vec<(String, String)>,
310}
311
312pub fn synthesize_squash_annotation_v3(ctx: &SquashSynthesisContextV3) -> v3::Annotation {
317 let mut all_wisdom: Vec<v3::WisdomEntry> = Vec::new();
318 let mut source_shas: Vec<String> = Vec::new();
319
320 for ann in &ctx.source_annotations {
321 source_shas.push(ann.commit.clone());
322
323 for entry in &ann.wisdom {
324 let already_exists = all_wisdom
325 .iter()
326 .any(|w| w.category == entry.category && w.content == entry.content);
327 if !already_exists {
328 all_wisdom.push(entry.clone());
329 }
330 }
331 }
332
333 for (sha, _) in &ctx.source_messages {
335 if !source_shas.contains(sha) {
336 source_shas.push(sha.clone());
337 }
338 }
339
340 let annotations_count = ctx.source_annotations.len();
341 let total_sources = ctx.source_messages.len();
342
343 let mut notes_parts: Vec<String> = Vec::new();
345 if !ctx.source_annotations.is_empty() {
346 notes_parts.push(format!(
347 "Synthesized from {} commits ({} of {} had annotations).",
348 total_sources, annotations_count, total_sources,
349 ));
350 } else {
351 notes_parts.push(format!(
352 "Synthesized from {} commits (none had annotations).",
353 total_sources,
354 ));
355 }
356
357 for ann in &ctx.source_annotations {
359 let short_sha = if ann.commit.len() > 8 {
360 &ann.commit[..8]
361 } else {
362 &ann.commit
363 };
364 notes_parts.push(format!("{}: {}", short_sha, ann.summary));
365 }
366
367 v3::Annotation {
368 schema: "chronicle/v3".to_string(),
369 commit: ctx.squash_commit.clone(),
370 timestamp: Utc::now().to_rfc3339(),
371 summary: ctx.squash_message.clone(),
372 wisdom: all_wisdom,
373 provenance: v3::Provenance {
374 source: v3::ProvenanceSource::Squash,
375 author: None,
376 derived_from: source_shas,
377 notes: Some(notes_parts.join("\n")),
378 },
379 }
380}
381
382pub fn collect_source_annotations_v3(
385 git_ops: &dyn GitOps,
386 source_shas: &[String],
387) -> Vec<(String, Option<v3::Annotation>)> {
388 source_shas
389 .iter()
390 .map(|sha| {
391 let annotation = git_ops
392 .note_read(sha)
393 .ok()
394 .flatten()
395 .and_then(|json| schema::parse_annotation(&json).ok());
396 (sha.clone(), annotation)
397 })
398 .collect()
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use crate::schema::common::{AstAnchor, LineRange};
405 use crate::schema::v1::{
406 Constraint, ConstraintSource, CrossCuttingConcern, CrossCuttingRegionRef,
407 SemanticDependency,
408 };
409
410 fn make_test_annotation(commit: &str, file: &str, anchor: &str) -> Annotation {
411 Annotation {
412 schema: "chronicle/v1".to_string(),
413 commit: commit.to_string(),
414 timestamp: Utc::now().to_rfc3339(),
415 task: None,
416 summary: format!("Commit {commit}"),
417 context_level: ContextLevel::Inferred,
418 regions: vec![RegionAnnotation {
419 file: file.to_string(),
420 ast_anchor: AstAnchor {
421 unit_type: "function".to_string(),
422 name: anchor.to_string(),
423 signature: None,
424 },
425 lines: LineRange { start: 1, end: 10 },
426 intent: format!("Modified {anchor}"),
427 reasoning: Some(format!("Reasoning for {anchor} in {commit}")),
428 constraints: vec![Constraint {
429 text: format!("Constraint from {commit}"),
430 source: ConstraintSource::Inferred,
431 }],
432 semantic_dependencies: vec![SemanticDependency {
433 file: "other.rs".to_string(),
434 anchor: "helper".to_string(),
435 nature: "calls".to_string(),
436 }],
437 related_annotations: Vec::new(),
438 tags: Vec::new(),
439 risk_notes: None,
440 corrections: vec![],
441 }],
442 cross_cutting: vec![CrossCuttingConcern {
443 description: format!("Cross-cutting from {commit}"),
444 regions: vec![CrossCuttingRegionRef {
445 file: file.to_string(),
446 anchor: anchor.to_string(),
447 }],
448 tags: Vec::new(),
449 }],
450 provenance: Provenance {
451 operation: ProvenanceOperation::Initial,
452 derived_from: Vec::new(),
453 original_annotations_preserved: false,
454 synthesis_notes: None,
455 },
456 }
457 }
458
459 #[test]
460 fn test_pending_squash_roundtrip() {
461 let dir = tempfile::tempdir().unwrap();
462 let git_dir = dir.path();
463 std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
464
465 let pending = PendingSquash {
466 source_commits: vec!["abc123".to_string(), "def456".to_string()],
467 source_ref: Some("feature-branch".to_string()),
468 timestamp: Utc::now(),
469 };
470
471 write_pending_squash(git_dir, &pending).unwrap();
472 let read_back = read_pending_squash(git_dir).unwrap().unwrap();
473
474 assert_eq!(read_back.source_commits, pending.source_commits);
475 assert_eq!(read_back.source_ref, pending.source_ref);
476 }
477
478 #[test]
479 fn test_pending_squash_missing_file() {
480 let dir = tempfile::tempdir().unwrap();
481 let result = read_pending_squash(dir.path()).unwrap();
482 assert!(result.is_none());
483 }
484
485 #[test]
486 fn test_pending_squash_stale_file() {
487 let dir = tempfile::tempdir().unwrap();
488 let git_dir = dir.path();
489 std::fs::create_dir_all(git_dir.join("chronicle")).unwrap();
490
491 let pending = PendingSquash {
492 source_commits: vec!["abc123".to_string()],
493 source_ref: None,
494 timestamp: Utc::now() - chrono::Duration::seconds(120),
495 };
496
497 write_pending_squash(git_dir, &pending).unwrap();
498 let result = read_pending_squash(git_dir).unwrap();
499 assert!(result.is_none());
500 assert!(!pending_squash_path(git_dir).exists());
502 }
503
504 #[test]
505 fn test_pending_squash_invalid_json() {
506 let dir = tempfile::tempdir().unwrap();
507 let git_dir = dir.path();
508 let chronicle_dir = git_dir.join("chronicle");
509 std::fs::create_dir_all(&chronicle_dir).unwrap();
510 std::fs::write(chronicle_dir.join("pending-squash.json"), "not json").unwrap();
511
512 let result = read_pending_squash(git_dir).unwrap();
513 assert!(result.is_none());
514 assert!(!pending_squash_path(git_dir).exists());
516 }
517
518 #[test]
519 fn test_delete_pending_squash() {
520 let dir = tempfile::tempdir().unwrap();
521 let git_dir = dir.path();
522
523 let pending = PendingSquash {
524 source_commits: vec!["abc123".to_string()],
525 source_ref: None,
526 timestamp: Utc::now(),
527 };
528
529 write_pending_squash(git_dir, &pending).unwrap();
530 assert!(pending_squash_path(git_dir).exists());
531
532 delete_pending_squash(git_dir).unwrap();
533 assert!(!pending_squash_path(git_dir).exists());
534 }
535
536 #[test]
537 fn test_delete_pending_squash_missing_file() {
538 let dir = tempfile::tempdir().unwrap();
539 delete_pending_squash(dir.path()).unwrap();
541 }
542
543 #[test]
544 fn test_synthesize_squash_distinct_regions() {
545 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
546 let ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
547 let ann3 = make_test_annotation("ghi789", "src/baz.rs", "baz_fn");
548
549 let ctx = SquashSynthesisContext {
550 squash_commit: "squash001".to_string(),
551 diff: "some diff".to_string(),
552 source_annotations: vec![ann1, ann2, ann3],
553 source_messages: vec![
554 ("abc123".to_string(), "Commit abc".to_string()),
555 ("def456".to_string(), "Commit def".to_string()),
556 ("ghi789".to_string(), "Commit ghi".to_string()),
557 ],
558 squash_message: "Squash merge".to_string(),
559 };
560
561 let result = synthesize_squash_annotation(&ctx);
562
563 assert_eq!(result.commit, "squash001");
564 assert_eq!(result.regions.len(), 3);
565 assert_eq!(result.cross_cutting.len(), 3);
566 assert_eq!(result.provenance.operation, ProvenanceOperation::Squash);
567 assert_eq!(result.provenance.derived_from.len(), 3);
568 assert!(result.provenance.original_annotations_preserved);
569 }
570
571 #[test]
572 fn test_synthesize_squash_overlapping_regions() {
573 let ann1 = make_test_annotation("abc123", "src/foo.rs", "connect");
574 let mut ann2 = make_test_annotation("def456", "src/foo.rs", "connect");
575 ann2.regions[0].constraints[0].text = "Constraint from def456".to_string();
577 ann2.regions[0].lines = LineRange { start: 5, end: 20 };
578
579 let ctx = SquashSynthesisContext {
580 squash_commit: "squash001".to_string(),
581 diff: "some diff".to_string(),
582 source_annotations: vec![ann1, ann2],
583 source_messages: vec![
584 ("abc123".to_string(), "First".to_string()),
585 ("def456".to_string(), "Second".to_string()),
586 ],
587 squash_message: "Squash merge".to_string(),
588 };
589
590 let result = synthesize_squash_annotation(&ctx);
591
592 assert_eq!(result.regions.len(), 1);
594 assert_eq!(result.regions[0].constraints.len(), 2);
596 assert_eq!(result.regions[0].lines.start, 1);
598 assert_eq!(result.regions[0].lines.end, 20);
599 assert!(result.regions[0]
601 .reasoning
602 .as_ref()
603 .unwrap()
604 .contains("abc123"));
605 assert!(result.regions[0]
606 .reasoning
607 .as_ref()
608 .unwrap()
609 .contains("def456"));
610 }
611
612 #[test]
613 fn test_synthesize_squash_partial_annotations() {
614 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
615
616 let ctx = SquashSynthesisContext {
617 squash_commit: "squash001".to_string(),
618 diff: "some diff".to_string(),
619 source_annotations: vec![ann1],
620 source_messages: vec![
621 ("abc123".to_string(), "First".to_string()),
622 ("def456".to_string(), "Second".to_string()),
623 ("ghi789".to_string(), "Third".to_string()),
624 ],
625 squash_message: "Squash merge".to_string(),
626 };
627
628 let result = synthesize_squash_annotation(&ctx);
629
630 assert!(!result.provenance.original_annotations_preserved);
631 assert!(result
632 .provenance
633 .synthesis_notes
634 .as_ref()
635 .unwrap()
636 .contains("1 of 3"));
637 }
638
639 #[test]
640 fn test_synthesize_squash_no_annotations() {
641 let ctx = SquashSynthesisContext {
642 squash_commit: "squash001".to_string(),
643 diff: "some diff".to_string(),
644 source_annotations: vec![],
645 source_messages: vec![
646 ("abc123".to_string(), "First".to_string()),
647 ("def456".to_string(), "Second".to_string()),
648 ],
649 squash_message: "Squash merge".to_string(),
650 };
651
652 let result = synthesize_squash_annotation(&ctx);
653
654 assert_eq!(result.context_level, ContextLevel::Inferred);
655 assert!(result.regions.is_empty());
656 assert!(!result.provenance.original_annotations_preserved);
657 }
658
659 #[test]
660 fn test_synthesize_preserves_cross_cutting() {
661 let ann1 = make_test_annotation("abc123", "src/foo.rs", "foo_fn");
662 let mut ann2 = make_test_annotation("def456", "src/bar.rs", "bar_fn");
663 ann2.cross_cutting.push(CrossCuttingConcern {
665 description: "Another concern".to_string(),
666 regions: vec![CrossCuttingRegionRef {
667 file: "src/bar.rs".to_string(),
668 anchor: "bar_fn".to_string(),
669 }],
670 tags: Vec::new(),
671 });
672
673 let ctx = SquashSynthesisContext {
674 squash_commit: "squash001".to_string(),
675 diff: "some diff".to_string(),
676 source_annotations: vec![ann1, ann2],
677 source_messages: vec![
678 ("abc123".to_string(), "First".to_string()),
679 ("def456".to_string(), "Second".to_string()),
680 ],
681 squash_message: "Squash merge".to_string(),
682 };
683
684 let result = synthesize_squash_annotation(&ctx);
685 assert_eq!(result.cross_cutting.len(), 3);
687 }
688
689 #[test]
690 fn test_migrate_amend_message_only() {
691 let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
692
693 let ctx = AmendMigrationContext {
694 new_commit: "new_sha".to_string(),
695 new_diff: "".to_string(), old_annotation: old_ann,
697 new_message: "Updated commit message".to_string(),
698 };
699
700 let result = migrate_amend_annotation(&ctx);
701
702 assert_eq!(result.commit, "new_sha");
703 assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
704 assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
705 assert!(result.provenance.original_annotations_preserved);
706 assert!(result
707 .provenance
708 .synthesis_notes
709 .as_ref()
710 .unwrap()
711 .contains("Message-only"));
712 assert_eq!(result.summary, "Updated commit message");
713 assert_eq!(result.regions.len(), 1);
715 }
716
717 #[test]
718 fn test_migrate_amend_with_code_changes() {
719 let old_ann = make_test_annotation("old_sha", "src/foo.rs", "foo_fn");
720
721 let ctx = AmendMigrationContext {
722 new_commit: "new_sha".to_string(),
723 new_diff: "+some new code\n-some old code\n".to_string(),
724 old_annotation: old_ann,
725 new_message: "Updated commit".to_string(),
726 };
727
728 let result = migrate_amend_annotation(&ctx);
729
730 assert_eq!(result.commit, "new_sha");
731 assert_eq!(result.provenance.operation, ProvenanceOperation::Amend);
732 assert_eq!(result.provenance.derived_from, vec!["old_sha".to_string()]);
733 assert!(result
734 .provenance
735 .synthesis_notes
736 .as_ref()
737 .unwrap()
738 .contains("Migrated from amend"));
739 assert_eq!(result.regions.len(), 1);
741 }
742
743 fn make_v3_annotation(commit: &str, wisdom: Vec<v3::WisdomEntry>) -> v3::Annotation {
748 v3::Annotation {
749 schema: "chronicle/v3".to_string(),
750 commit: commit.to_string(),
751 timestamp: Utc::now().to_rfc3339(),
752 summary: format!("Commit {commit}"),
753 wisdom,
754 provenance: v3::Provenance {
755 source: v3::ProvenanceSource::Live,
756 author: None,
757 derived_from: Vec::new(),
758 notes: None,
759 },
760 }
761 }
762
763 fn wisdom(category: v3::WisdomCategory, content: &str) -> v3::WisdomEntry {
764 v3::WisdomEntry {
765 category,
766 content: content.to_string(),
767 file: None,
768 lines: None,
769 }
770 }
771
772 fn wisdom_with_file(
773 category: v3::WisdomCategory,
774 content: &str,
775 file: &str,
776 ) -> v3::WisdomEntry {
777 v3::WisdomEntry {
778 category,
779 content: content.to_string(),
780 file: Some(file.to_string()),
781 lines: None,
782 }
783 }
784
785 #[test]
786 fn test_synthesize_squash_v3_merges_wisdom() {
787 let ann1 = make_v3_annotation(
788 "abc123",
789 vec![
790 wisdom(v3::WisdomCategory::DeadEnd, "Tried approach X"),
791 wisdom_with_file(
792 v3::WisdomCategory::Gotcha,
793 "Must validate input",
794 "src/input.rs",
795 ),
796 ],
797 );
798 let ann2 = make_v3_annotation(
799 "def456",
800 vec![
801 wisdom(v3::WisdomCategory::Insight, "HashMap is O(1)"),
802 wisdom_with_file(
803 v3::WisdomCategory::Gotcha,
804 "Connection can timeout",
805 "src/net.rs",
806 ),
807 ],
808 );
809
810 let ctx = SquashSynthesisContextV3 {
811 squash_commit: "squash001".to_string(),
812 squash_message: "Squash merge PR #42".to_string(),
813 source_annotations: vec![ann1, ann2],
814 source_messages: vec![
815 ("abc123".to_string(), "First commit".to_string()),
816 ("def456".to_string(), "Second commit".to_string()),
817 ],
818 };
819
820 let result = synthesize_squash_annotation_v3(&ctx);
821
822 assert_eq!(result.schema, "chronicle/v3");
823 assert_eq!(result.commit, "squash001");
824 assert_eq!(result.summary, "Squash merge PR #42");
825 assert_eq!(result.wisdom.len(), 4);
826 assert!(result
827 .wisdom
828 .iter()
829 .any(|w| w.category == v3::WisdomCategory::DeadEnd && w.content == "Tried approach X"));
830 assert!(result
831 .wisdom
832 .iter()
833 .any(|w| w.category == v3::WisdomCategory::Gotcha
834 && w.content == "Must validate input"
835 && w.file.as_deref() == Some("src/input.rs")));
836 }
837
838 #[test]
839 fn test_synthesize_squash_v3_deduplicates_wisdom() {
840 let shared_wisdom = wisdom(v3::WisdomCategory::Gotcha, "Must validate input");
842 let ann1 = make_v3_annotation("abc123", vec![shared_wisdom.clone()]);
843 let ann2 = make_v3_annotation(
844 "def456",
845 vec![
846 shared_wisdom,
847 wisdom(v3::WisdomCategory::Insight, "Unique to ann2"),
848 ],
849 );
850
851 let ctx = SquashSynthesisContextV3 {
852 squash_commit: "squash001".to_string(),
853 squash_message: "Squash".to_string(),
854 source_annotations: vec![ann1, ann2],
855 source_messages: vec![
856 ("abc123".to_string(), "First".to_string()),
857 ("def456".to_string(), "Second".to_string()),
858 ],
859 };
860
861 let result = synthesize_squash_annotation_v3(&ctx);
862
863 assert_eq!(result.wisdom.len(), 2);
865 let gotcha_count = result
866 .wisdom
867 .iter()
868 .filter(|w| {
869 w.category == v3::WisdomCategory::Gotcha && w.content == "Must validate input"
870 })
871 .count();
872 assert_eq!(gotcha_count, 1);
873 }
874
875 #[test]
876 fn test_synthesize_squash_v3_no_annotations() {
877 let ctx = SquashSynthesisContextV3 {
878 squash_commit: "squash001".to_string(),
879 squash_message: "Squash merge".to_string(),
880 source_annotations: vec![],
881 source_messages: vec![
882 ("abc123".to_string(), "First".to_string()),
883 ("def456".to_string(), "Second".to_string()),
884 ],
885 };
886
887 let result = synthesize_squash_annotation_v3(&ctx);
888
889 assert_eq!(result.schema, "chronicle/v3");
890 assert!(result.wisdom.is_empty());
891 assert_eq!(result.provenance.source, v3::ProvenanceSource::Squash);
892 assert_eq!(result.provenance.derived_from.len(), 2);
893 assert!(result
894 .provenance
895 .notes
896 .as_ref()
897 .unwrap()
898 .contains("none had annotations"));
899 }
900
901 #[test]
902 fn test_synthesize_squash_v3_partial_annotations() {
903 let ann1 = make_v3_annotation(
904 "abc123",
905 vec![wisdom(v3::WisdomCategory::Insight, "Only this one")],
906 );
907
908 let ctx = SquashSynthesisContextV3 {
909 squash_commit: "squash001".to_string(),
910 squash_message: "Squash merge".to_string(),
911 source_annotations: vec![ann1],
912 source_messages: vec![
913 ("abc123".to_string(), "First".to_string()),
914 ("def456".to_string(), "Second".to_string()),
915 ("ghi789".to_string(), "Third".to_string()),
916 ],
917 };
918
919 let result = synthesize_squash_annotation_v3(&ctx);
920
921 assert_eq!(result.wisdom.len(), 1);
922 assert!(result.provenance.notes.as_ref().unwrap().contains("1 of 3"));
923 assert_eq!(result.provenance.derived_from.len(), 3);
925 }
926
927 #[test]
928 fn test_synthesize_squash_v3_provenance() {
929 let ann1 = make_v3_annotation(
930 "abc12345678",
931 vec![wisdom(v3::WisdomCategory::DeadEnd, "Dead end")],
932 );
933 let ann2 = make_v3_annotation(
934 "def45678901",
935 vec![wisdom(v3::WisdomCategory::Insight, "Insight")],
936 );
937
938 let ctx = SquashSynthesisContextV3 {
939 squash_commit: "squash001".to_string(),
940 squash_message: "Squash merge".to_string(),
941 source_annotations: vec![ann1, ann2],
942 source_messages: vec![
943 ("abc12345678".to_string(), "First".to_string()),
944 ("def45678901".to_string(), "Second".to_string()),
945 ],
946 };
947
948 let result = synthesize_squash_annotation_v3(&ctx);
949
950 assert_eq!(result.provenance.source, v3::ProvenanceSource::Squash);
951 assert_eq!(
952 result.provenance.derived_from,
953 vec!["abc12345678".to_string(), "def45678901".to_string()]
954 );
955 let notes = result.provenance.notes.unwrap();
956 assert!(notes.contains("Synthesized from 2 commits"));
957 assert!(notes.contains("2 of 2 had annotations"));
958 assert!(notes.contains("abc12345: Commit abc12345678"));
960 assert!(notes.contains("def45678: Commit def45678901"));
961 }
962
963 #[test]
964 fn test_collect_source_annotations_v3_handles_all_versions() {
965 use crate::error::GitError;
966 use crate::git::diff::FileDiff;
967 use crate::git::CommitInfo;
968 use std::collections::HashMap;
969
970 struct MockGitOpsForNotes {
971 notes: HashMap<String, String>,
972 }
973
974 impl GitOps for MockGitOpsForNotes {
975 fn diff(&self, _: &str) -> std::result::Result<Vec<FileDiff>, GitError> {
976 Ok(vec![])
977 }
978 fn note_read(&self, commit: &str) -> std::result::Result<Option<String>, GitError> {
979 Ok(self.notes.get(commit).cloned())
980 }
981 fn note_write(&self, _: &str, _: &str) -> std::result::Result<(), GitError> {
982 Ok(())
983 }
984 fn note_exists(&self, _: &str) -> std::result::Result<bool, GitError> {
985 Ok(false)
986 }
987 fn file_at_commit(
988 &self,
989 _: &std::path::Path,
990 _: &str,
991 ) -> std::result::Result<String, GitError> {
992 Ok(String::new())
993 }
994 fn commit_info(&self, _: &str) -> std::result::Result<CommitInfo, GitError> {
995 Ok(CommitInfo {
996 sha: String::new(),
997 message: String::new(),
998 author_name: String::new(),
999 author_email: String::new(),
1000 timestamp: String::new(),
1001 parent_shas: Vec::new(),
1002 })
1003 }
1004 fn resolve_ref(&self, _: &str) -> std::result::Result<String, GitError> {
1005 Ok(String::new())
1006 }
1007 fn config_get(&self, _: &str) -> std::result::Result<Option<String>, GitError> {
1008 Ok(None)
1009 }
1010 fn config_set(&self, _: &str, _: &str) -> std::result::Result<(), GitError> {
1011 Ok(())
1012 }
1013 fn log_for_file(&self, _: &str) -> std::result::Result<Vec<String>, GitError> {
1014 Ok(vec![])
1015 }
1016 fn list_annotated_commits(&self, _: u32) -> std::result::Result<Vec<String>, GitError> {
1017 Ok(vec![])
1018 }
1019 }
1020
1021 let v1_json = r#"{
1022 "schema": "chronicle/v1",
1023 "commit": "v1sha",
1024 "timestamp": "2025-01-01T00:00:00Z",
1025 "summary": "V1 annotation",
1026 "context_level": "enhanced",
1027 "regions": [],
1028 "provenance": {
1029 "operation": "initial",
1030 "derived_from": [],
1031 "original_annotations_preserved": false
1032 }
1033 }"#;
1034
1035 let v3_json = r#"{
1036 "schema": "chronicle/v3",
1037 "commit": "v3sha",
1038 "timestamp": "2025-06-01T00:00:00Z",
1039 "summary": "V3 annotation",
1040 "wisdom": [{"category": "gotcha", "content": "Watch out"}],
1041 "provenance": {"source": "live"}
1042 }"#;
1043
1044 let mut notes = HashMap::new();
1045 notes.insert("v1sha".to_string(), v1_json.to_string());
1046 notes.insert("v3sha".to_string(), v3_json.to_string());
1047 let mock = MockGitOpsForNotes { notes };
1050 let shas = vec![
1051 "v1sha".to_string(),
1052 "v3sha".to_string(),
1053 "no_ann_sha".to_string(),
1054 ];
1055
1056 let results = collect_source_annotations_v3(&mock, &shas);
1057
1058 assert_eq!(results.len(), 3);
1059
1060 let (sha, ann) = &results[0];
1062 assert_eq!(sha, "v1sha");
1063 let ann = ann.as_ref().unwrap();
1064 assert_eq!(ann.schema, "chronicle/v3");
1065 assert_eq!(ann.summary, "V1 annotation");
1066
1067 let (sha, ann) = &results[1];
1069 assert_eq!(sha, "v3sha");
1070 let ann = ann.as_ref().unwrap();
1071 assert_eq!(ann.schema, "chronicle/v3");
1072 assert_eq!(ann.wisdom.len(), 1);
1073
1074 let (sha, ann) = &results[2];
1076 assert_eq!(sha, "no_ann_sha");
1077 assert!(ann.is_none());
1078 }
1079}