1use super::{v1, v2, v3};
2
3pub fn v1_to_v2(ann: v1::Annotation) -> v2::Annotation {
5 let mut markers = Vec::new();
6 let mut files_changed: Vec<String> = Vec::new();
7
8 for region in &ann.regions {
9 if !files_changed.contains(®ion.file) {
11 files_changed.push(region.file.clone());
12 }
13
14 for constraint in ®ion.constraints {
16 markers.push(v2::CodeMarker {
17 file: region.file.clone(),
18 anchor: Some(region.ast_anchor.clone()),
19 lines: Some(region.lines),
20 kind: v2::MarkerKind::Contract {
21 description: constraint.text.clone(),
22 source: match constraint.source {
23 v1::ConstraintSource::Author => v2::ContractSource::Author,
24 v1::ConstraintSource::Inferred => v2::ContractSource::Inferred,
25 },
26 },
27 });
28 }
29
30 if let Some(ref risk) = region.risk_notes {
32 markers.push(v2::CodeMarker {
33 file: region.file.clone(),
34 anchor: Some(region.ast_anchor.clone()),
35 lines: Some(region.lines),
36 kind: v2::MarkerKind::Hazard {
37 description: risk.clone(),
38 },
39 });
40 }
41
42 for dep in ®ion.semantic_dependencies {
44 markers.push(v2::CodeMarker {
45 file: region.file.clone(),
46 anchor: Some(region.ast_anchor.clone()),
47 lines: Some(region.lines),
48 kind: v2::MarkerKind::Dependency {
49 target_file: dep.file.clone(),
50 target_anchor: dep.anchor.clone(),
51 assumption: dep.nature.clone(),
52 },
53 });
54 }
55 }
56
57 let mut summary_parts = vec![ann.summary.clone()];
59 for region in &ann.regions {
60 if let Some(ref reasoning) = region.reasoning {
61 summary_parts.push(format!(
62 "{} ({}): {}",
63 region.file, region.ast_anchor.name, reasoning
64 ));
65 }
66 }
67 let summary = if summary_parts.len() == 1 {
68 summary_parts.into_iter().next().unwrap()
69 } else {
70 summary_parts[0].clone()
72 };
73
74 let decisions: Vec<v2::Decision> = ann
76 .cross_cutting
77 .iter()
78 .map(|cc| {
79 let scope: Vec<String> = cc
80 .regions
81 .iter()
82 .map(|r| format!("{}:{}", r.file, r.anchor))
83 .collect();
84 v2::Decision {
85 what: cc.description.clone(),
86 why: "Migrated from v1 cross-cutting concern".to_string(),
87 stability: v2::Stability::Permanent,
88 revisit_when: None,
89 scope,
90 }
91 })
92 .collect();
93
94 let provenance = v2::Provenance {
96 source: v2::ProvenanceSource::MigratedV1,
97 author: None,
98 derived_from: ann.provenance.derived_from,
99 notes: ann.provenance.synthesis_notes,
100 };
101
102 let effort = ann.task.map(|task| v2::EffortLink {
104 id: task.clone(),
105 description: task,
106 phase: v2::EffortPhase::InProgress,
107 });
108
109 v2::Annotation {
110 schema: "chronicle/v2".to_string(),
111 commit: ann.commit,
112 timestamp: ann.timestamp,
113 narrative: v2::Narrative {
114 summary,
115 motivation: None,
116 rejected_alternatives: Vec::new(),
117 follow_up: None,
118 files_changed,
119 sentiments: Vec::new(),
120 },
121 decisions,
122 markers,
123 effort,
124 provenance,
125 }
126}
127
128pub fn v2_to_v3(ann: v2::Annotation) -> v3::Annotation {
142 let mut wisdom = Vec::new();
143
144 for ra in &ann.narrative.rejected_alternatives {
146 let content = if ra.reason.is_empty() {
147 ra.approach.clone()
148 } else {
149 format!("{}: {}", ra.approach, ra.reason)
150 };
151 wisdom.push(v3::WisdomEntry {
152 category: v3::WisdomCategory::DeadEnd,
153 content,
154 file: None,
155 lines: None,
156 });
157 }
158
159 if let Some(motivation) = &ann.narrative.motivation {
161 wisdom.push(v3::WisdomEntry {
162 category: v3::WisdomCategory::Insight,
163 content: motivation.clone(),
164 file: None,
165 lines: None,
166 });
167 }
168
169 if let Some(follow_up) = &ann.narrative.follow_up {
171 wisdom.push(v3::WisdomEntry {
172 category: v3::WisdomCategory::UnfinishedThread,
173 content: follow_up.clone(),
174 file: None,
175 lines: None,
176 });
177 }
178
179 for sentiment in &ann.narrative.sentiments {
181 let feeling_lower = sentiment.feeling.to_lowercase();
182 let category = if feeling_lower.contains("worry")
183 || feeling_lower.contains("unease")
184 || feeling_lower.contains("concern")
185 {
186 v3::WisdomCategory::Gotcha
187 } else if feeling_lower.contains("uncertain") || feeling_lower.contains("doubt") {
188 v3::WisdomCategory::UnfinishedThread
189 } else {
190 v3::WisdomCategory::Insight
191 };
192 wisdom.push(v3::WisdomEntry {
193 category,
194 content: format!("{}: {}", sentiment.feeling, sentiment.detail),
195 file: None,
196 lines: None,
197 });
198 }
199
200 for decision in &ann.decisions {
202 let file = decision.scope.first().map(|s| {
203 s.split(':').next().unwrap_or(s).to_string()
205 });
206 wisdom.push(v3::WisdomEntry {
207 category: v3::WisdomCategory::Insight,
208 content: format!("{}: {}", decision.what, decision.why),
209 file,
210 lines: None,
211 });
212 }
213
214 for marker in &ann.markers {
216 let (category, content) = match &marker.kind {
217 v2::MarkerKind::Contract { description, .. } => {
218 (v3::WisdomCategory::Gotcha, description.clone())
219 }
220 v2::MarkerKind::Hazard { description } => {
221 (v3::WisdomCategory::Gotcha, description.clone())
222 }
223 v2::MarkerKind::Dependency {
224 target_file,
225 target_anchor,
226 assumption,
227 } => (
228 v3::WisdomCategory::Insight,
229 format!("Depends on {target_file}:{target_anchor} \u{2014} {assumption}"),
230 ),
231 v2::MarkerKind::Unstable { description, .. } => {
232 (v3::WisdomCategory::UnfinishedThread, description.clone())
233 }
234 v2::MarkerKind::Security { description } => {
235 (v3::WisdomCategory::Gotcha, description.clone())
236 }
237 v2::MarkerKind::Performance { description } => {
238 (v3::WisdomCategory::Gotcha, description.clone())
239 }
240 v2::MarkerKind::Deprecated { description, .. } => {
241 (v3::WisdomCategory::UnfinishedThread, description.clone())
242 }
243 v2::MarkerKind::TechDebt { description } => {
244 (v3::WisdomCategory::UnfinishedThread, description.clone())
245 }
246 v2::MarkerKind::TestCoverage { description } => {
247 (v3::WisdomCategory::Insight, description.clone())
249 }
250 };
251
252 wisdom.push(v3::WisdomEntry {
253 category,
254 content,
255 file: Some(marker.file.clone()),
256 lines: marker.lines,
257 });
258 }
259
260 v3::Annotation {
265 schema: "chronicle/v3".to_string(),
266 commit: ann.commit,
267 timestamp: ann.timestamp,
268 summary: ann.narrative.summary, wisdom,
270 provenance: ann.provenance,
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::schema::common::{AstAnchor, LineRange};
278 use crate::schema::v1;
279
280 fn make_v1_annotation() -> v1::Annotation {
281 v1::Annotation {
282 schema: "chronicle/v1".to_string(),
283 commit: "abc123".to_string(),
284 timestamp: "2025-01-01T00:00:00Z".to_string(),
285 task: Some("TASK-42".to_string()),
286 summary: "Add reconnect logic".to_string(),
287 context_level: v1::ContextLevel::Enhanced,
288 regions: vec![v1::RegionAnnotation {
289 file: "src/mqtt/reconnect.rs".to_string(),
290 ast_anchor: AstAnchor {
291 unit_type: "function".to_string(),
292 name: "attempt_reconnect".to_string(),
293 signature: Some("fn attempt_reconnect(&mut self)".to_string()),
294 },
295 lines: LineRange { start: 10, end: 30 },
296 intent: "Handle reconnection with exponential backoff".to_string(),
297 reasoning: Some("Broker rate-limits rapid reconnects".to_string()),
298 constraints: vec![v1::Constraint {
299 text: "Must not exceed 60s backoff".to_string(),
300 source: v1::ConstraintSource::Author,
301 }],
302 semantic_dependencies: vec![v1::SemanticDependency {
303 file: "src/tls/session.rs".to_string(),
304 anchor: "TlsSessionCache::max_sessions".to_string(),
305 nature: "assumes max_sessions is 4".to_string(),
306 }],
307 related_annotations: vec![],
308 tags: vec!["mqtt".to_string()],
309 risk_notes: Some("Backoff timer is not persisted across restarts".to_string()),
310 corrections: vec![],
311 }],
312 cross_cutting: vec![v1::CrossCuttingConcern {
313 description: "All reconnect paths must use exponential backoff".to_string(),
314 regions: vec![v1::CrossCuttingRegionRef {
315 file: "src/mqtt/reconnect.rs".to_string(),
316 anchor: "attempt_reconnect".to_string(),
317 }],
318 tags: vec![],
319 }],
320 provenance: v1::Provenance {
321 operation: v1::ProvenanceOperation::Initial,
322 derived_from: vec![],
323 original_annotations_preserved: false,
324 synthesis_notes: None,
325 },
326 }
327 }
328
329 #[test]
330 fn test_v1_to_v2_basic() {
331 let v1_ann = make_v1_annotation();
332 let v2_ann = v1_to_v2(v1_ann);
333
334 assert_eq!(v2_ann.schema, "chronicle/v2");
335 assert_eq!(v2_ann.commit, "abc123");
336 assert_eq!(v2_ann.timestamp, "2025-01-01T00:00:00Z");
337 assert_eq!(v2_ann.narrative.summary, "Add reconnect logic");
338 assert_eq!(
339 v2_ann.narrative.files_changed,
340 vec!["src/mqtt/reconnect.rs"]
341 );
342 }
343
344 #[test]
345 fn test_v1_to_v2_markers() {
346 let v1_ann = make_v1_annotation();
347 let v2_ann = v1_to_v2(v1_ann);
348
349 assert_eq!(v2_ann.markers.len(), 3);
351
352 assert!(matches!(
354 &v2_ann.markers[0].kind,
355 v2::MarkerKind::Contract { description, .. } if description == "Must not exceed 60s backoff"
356 ));
357
358 assert!(matches!(
360 &v2_ann.markers[1].kind,
361 v2::MarkerKind::Hazard { description } if description.contains("not persisted")
362 ));
363
364 assert!(matches!(
366 &v2_ann.markers[2].kind,
367 v2::MarkerKind::Dependency { target_file, target_anchor, assumption }
368 if target_file == "src/tls/session.rs"
369 && target_anchor == "TlsSessionCache::max_sessions"
370 && assumption == "assumes max_sessions is 4"
371 ));
372 }
373
374 #[test]
375 fn test_v1_to_v2_decisions() {
376 let v1_ann = make_v1_annotation();
377 let v2_ann = v1_to_v2(v1_ann);
378
379 assert_eq!(v2_ann.decisions.len(), 1);
381 assert_eq!(
382 v2_ann.decisions[0].what,
383 "All reconnect paths must use exponential backoff"
384 );
385 }
386
387 #[test]
388 fn test_v1_to_v2_effort() {
389 let v1_ann = make_v1_annotation();
390 let v2_ann = v1_to_v2(v1_ann);
391
392 let effort = v2_ann.effort.unwrap();
394 assert_eq!(effort.id, "TASK-42");
395 }
396
397 #[test]
398 fn test_v1_to_v2_provenance() {
399 let v1_ann = make_v1_annotation();
400 let v2_ann = v1_to_v2(v1_ann);
401
402 assert_eq!(v2_ann.provenance.source, v2::ProvenanceSource::MigratedV1);
403 }
404
405 #[test]
406 fn test_v1_to_v2_validates() {
407 let v1_ann = make_v1_annotation();
408 let v2_ann = v1_to_v2(v1_ann);
409
410 assert!(v2_ann.validate().is_ok());
411 }
412
413 #[test]
414 fn test_v1_to_v2_empty_regions() {
415 let v1_ann = v1::Annotation {
416 schema: "chronicle/v1".to_string(),
417 commit: "abc123".to_string(),
418 timestamp: "2025-01-01T00:00:00Z".to_string(),
419 task: None,
420 summary: "Simple commit".to_string(),
421 context_level: v1::ContextLevel::Inferred,
422 regions: vec![],
423 cross_cutting: vec![],
424 provenance: v1::Provenance {
425 operation: v1::ProvenanceOperation::Initial,
426 derived_from: vec![],
427 original_annotations_preserved: false,
428 synthesis_notes: None,
429 },
430 };
431 let v2_ann = v1_to_v2(v1_ann);
432
433 assert!(v2_ann.markers.is_empty());
434 assert!(v2_ann.decisions.is_empty());
435 assert!(v2_ann.effort.is_none());
436 assert!(v2_ann.validate().is_ok());
437 }
438
439 fn make_v2_annotation() -> v2::Annotation {
444 v2::Annotation {
445 schema: "chronicle/v2".to_string(),
446 commit: "def456".to_string(),
447 timestamp: "2025-06-01T12:00:00Z".to_string(),
448 narrative: v2::Narrative {
449 summary: "Switch to exponential backoff for reconnect".to_string(),
450 motivation: Some("Linear backoff caused thundering herd".to_string()),
451 rejected_alternatives: vec![
452 v2::RejectedAlternative {
453 approach: "Fixed delay".to_string(),
454 reason: "Too aggressive under load".to_string(),
455 },
456 v2::RejectedAlternative {
457 approach: "No delay".to_string(),
458 reason: "".to_string(),
459 },
460 ],
461 follow_up: Some("Need to add jitter to the backoff".to_string()),
462 files_changed: vec!["src/reconnect.rs".to_string()],
463 sentiments: vec![
464 v2::Sentiment {
465 feeling: "worry".to_string(),
466 detail: "Pool size heuristic is fragile".to_string(),
467 },
468 v2::Sentiment {
469 feeling: "doubt".to_string(),
470 detail: "Not sure this is the right abstraction".to_string(),
471 },
472 v2::Sentiment {
473 feeling: "confidence".to_string(),
474 detail: "The core logic is solid".to_string(),
475 },
476 ],
477 },
478 decisions: vec![v2::Decision {
479 what: "Use HashMap for cache".to_string(),
480 why: "O(1) lookups on the hot path".to_string(),
481 stability: v2::Stability::Provisional,
482 revisit_when: Some("If we need ordering".to_string()),
483 scope: vec!["src/cache.rs:Cache".to_string()],
484 }],
485 markers: vec![
486 v2::CodeMarker {
487 file: "src/reconnect.rs".to_string(),
488 anchor: Some(AstAnchor {
489 unit_type: "function".to_string(),
490 name: "attempt_reconnect".to_string(),
491 signature: None,
492 }),
493 lines: Some(LineRange { start: 10, end: 30 }),
494 kind: v2::MarkerKind::Contract {
495 description: "Must not exceed 60s backoff".to_string(),
496 source: v2::ContractSource::Author,
497 },
498 },
499 v2::CodeMarker {
500 file: "src/reconnect.rs".to_string(),
501 anchor: None,
502 lines: Some(LineRange { start: 40, end: 50 }),
503 kind: v2::MarkerKind::Hazard {
504 description: "Timer not persisted across restarts".to_string(),
505 },
506 },
507 v2::CodeMarker {
508 file: "src/reconnect.rs".to_string(),
509 anchor: None,
510 lines: None,
511 kind: v2::MarkerKind::Dependency {
512 target_file: "src/tls/session.rs".to_string(),
513 target_anchor: "max_sessions".to_string(),
514 assumption: "assumes max_sessions is 4".to_string(),
515 },
516 },
517 v2::CodeMarker {
518 file: "src/config.rs".to_string(),
519 anchor: None,
520 lines: None,
521 kind: v2::MarkerKind::Unstable {
522 description: "Config format may change".to_string(),
523 revisit_when: "after v2 ships".to_string(),
524 },
525 },
526 v2::CodeMarker {
527 file: "src/auth.rs".to_string(),
528 anchor: None,
529 lines: None,
530 kind: v2::MarkerKind::Security {
531 description: "JWT validation must check expiry".to_string(),
532 },
533 },
534 v2::CodeMarker {
535 file: "src/hot.rs".to_string(),
536 anchor: None,
537 lines: None,
538 kind: v2::MarkerKind::Performance {
539 description: "Hot loop, avoid allocations".to_string(),
540 },
541 },
542 v2::CodeMarker {
543 file: "src/old.rs".to_string(),
544 anchor: None,
545 lines: None,
546 kind: v2::MarkerKind::Deprecated {
547 description: "Use new_api instead".to_string(),
548 replacement: Some("src/new_api.rs".to_string()),
549 },
550 },
551 v2::CodeMarker {
552 file: "src/hack.rs".to_string(),
553 anchor: None,
554 lines: None,
555 kind: v2::MarkerKind::TechDebt {
556 description: "Needs refactor after v2 ships".to_string(),
557 },
558 },
559 v2::CodeMarker {
560 file: "src/lib.rs".to_string(),
561 anchor: None,
562 lines: None,
563 kind: v2::MarkerKind::TestCoverage {
564 description: "Missing edge case tests".to_string(),
565 },
566 },
567 ],
568 effort: Some(v2::EffortLink {
569 id: "schema-v2".to_string(),
570 description: "Schema v2 redesign".to_string(),
571 phase: v2::EffortPhase::InProgress,
572 }),
573 provenance: v2::Provenance {
574 source: v2::ProvenanceSource::Live,
575 author: Some("claude-code".to_string()),
576 derived_from: vec![],
577 notes: Some("test note".to_string()),
578 },
579 }
580 }
581
582 #[test]
583 fn test_v2_to_v3_summary() {
584 let v2_ann = make_v2_annotation();
585 let v3_ann = v2_to_v3(v2_ann);
586
587 assert_eq!(v3_ann.schema, "chronicle/v3");
588 assert_eq!(v3_ann.commit, "def456");
589 assert_eq!(v3_ann.timestamp, "2025-06-01T12:00:00Z");
590 assert_eq!(
591 v3_ann.summary,
592 "Switch to exponential backoff for reconnect"
593 );
594 }
595
596 #[test]
597 fn test_v2_to_v3_rejected_alternatives() {
598 let v2_ann = make_v2_annotation();
599 let v3_ann = v2_to_v3(v2_ann);
600
601 let dead_ends: Vec<_> = v3_ann
602 .wisdom
603 .iter()
604 .filter(|w| w.category == v3::WisdomCategory::DeadEnd)
605 .collect();
606 assert_eq!(dead_ends.len(), 2);
607 assert_eq!(
608 dead_ends[0].content,
609 "Fixed delay: Too aggressive under load"
610 );
611 assert_eq!(dead_ends[1].content, "No delay");
613 assert!(dead_ends[0].file.is_none());
614 }
615
616 #[test]
617 fn test_v2_to_v3_motivation() {
618 let v2_ann = make_v2_annotation();
619 let v3_ann = v2_to_v3(v2_ann);
620
621 let insights: Vec<_> = v3_ann
622 .wisdom
623 .iter()
624 .filter(|w| w.content == "Linear backoff caused thundering herd")
625 .collect();
626 assert_eq!(insights.len(), 1);
627 assert_eq!(insights[0].category, v3::WisdomCategory::Insight);
628 }
629
630 #[test]
631 fn test_v2_to_v3_follow_up() {
632 let v2_ann = make_v2_annotation();
633 let v3_ann = v2_to_v3(v2_ann);
634
635 let threads: Vec<_> = v3_ann
636 .wisdom
637 .iter()
638 .filter(|w| w.content == "Need to add jitter to the backoff")
639 .collect();
640 assert_eq!(threads.len(), 1);
641 assert_eq!(threads[0].category, v3::WisdomCategory::UnfinishedThread);
642 }
643
644 #[test]
645 fn test_v2_to_v3_sentiments() {
646 let v2_ann = make_v2_annotation();
647 let v3_ann = v2_to_v3(v2_ann);
648
649 let worry: Vec<_> = v3_ann
651 .wisdom
652 .iter()
653 .filter(|w| w.content.starts_with("worry:"))
654 .collect();
655 assert_eq!(worry.len(), 1);
656 assert_eq!(worry[0].category, v3::WisdomCategory::Gotcha);
657
658 let doubt: Vec<_> = v3_ann
660 .wisdom
661 .iter()
662 .filter(|w| w.content.starts_with("doubt:"))
663 .collect();
664 assert_eq!(doubt.len(), 1);
665 assert_eq!(doubt[0].category, v3::WisdomCategory::UnfinishedThread);
666
667 let conf: Vec<_> = v3_ann
669 .wisdom
670 .iter()
671 .filter(|w| w.content.starts_with("confidence:"))
672 .collect();
673 assert_eq!(conf.len(), 1);
674 assert_eq!(conf[0].category, v3::WisdomCategory::Insight);
675 }
676
677 #[test]
678 fn test_v2_to_v3_decisions() {
679 let v2_ann = make_v2_annotation();
680 let v3_ann = v2_to_v3(v2_ann);
681
682 let decision_entries: Vec<_> = v3_ann
683 .wisdom
684 .iter()
685 .filter(|w| w.content.contains("Use HashMap for cache"))
686 .collect();
687 assert_eq!(decision_entries.len(), 1);
688 assert_eq!(decision_entries[0].category, v3::WisdomCategory::Insight);
689 assert_eq!(
690 decision_entries[0].content,
691 "Use HashMap for cache: O(1) lookups on the hot path"
692 );
693 assert_eq!(decision_entries[0].file.as_deref(), Some("src/cache.rs"));
695 }
696
697 #[test]
698 fn test_v2_to_v3_markers() {
699 let v2_ann = make_v2_annotation();
700 let v3_ann = v2_to_v3(v2_ann);
701
702 let contract_entries: Vec<_> = v3_ann
704 .wisdom
705 .iter()
706 .filter(|w| w.content == "Must not exceed 60s backoff")
707 .collect();
708 assert_eq!(contract_entries.len(), 1);
709 assert_eq!(contract_entries[0].category, v3::WisdomCategory::Gotcha);
710 assert_eq!(
711 contract_entries[0].file.as_deref(),
712 Some("src/reconnect.rs")
713 );
714 assert_eq!(
715 contract_entries[0].lines,
716 Some(LineRange { start: 10, end: 30 })
717 );
718
719 let hazard_entries: Vec<_> = v3_ann
721 .wisdom
722 .iter()
723 .filter(|w| w.content == "Timer not persisted across restarts")
724 .collect();
725 assert_eq!(hazard_entries.len(), 1);
726 assert_eq!(hazard_entries[0].category, v3::WisdomCategory::Gotcha);
727
728 let dep_entries: Vec<_> = v3_ann
730 .wisdom
731 .iter()
732 .filter(|w| w.content.starts_with("Depends on"))
733 .collect();
734 assert_eq!(dep_entries.len(), 1);
735 assert_eq!(dep_entries[0].category, v3::WisdomCategory::Insight);
736 assert!(dep_entries[0]
737 .content
738 .contains("src/tls/session.rs:max_sessions"));
739
740 let unstable: Vec<_> = v3_ann
742 .wisdom
743 .iter()
744 .filter(|w| w.content == "Config format may change")
745 .collect();
746 assert_eq!(unstable.len(), 1);
747 assert_eq!(unstable[0].category, v3::WisdomCategory::UnfinishedThread);
748
749 let security: Vec<_> = v3_ann
751 .wisdom
752 .iter()
753 .filter(|w| w.content.contains("JWT validation"))
754 .collect();
755 assert_eq!(security.len(), 1);
756 assert_eq!(security[0].category, v3::WisdomCategory::Gotcha);
757
758 let perf: Vec<_> = v3_ann
760 .wisdom
761 .iter()
762 .filter(|w| w.content.contains("Hot loop"))
763 .collect();
764 assert_eq!(perf.len(), 1);
765 assert_eq!(perf[0].category, v3::WisdomCategory::Gotcha);
766
767 let deprecated: Vec<_> = v3_ann
769 .wisdom
770 .iter()
771 .filter(|w| w.content == "Use new_api instead")
772 .collect();
773 assert_eq!(deprecated.len(), 1);
774 assert_eq!(deprecated[0].category, v3::WisdomCategory::UnfinishedThread);
775
776 let tech_debt: Vec<_> = v3_ann
778 .wisdom
779 .iter()
780 .filter(|w| w.content == "Needs refactor after v2 ships")
781 .collect();
782 assert_eq!(tech_debt.len(), 1);
783 assert_eq!(tech_debt[0].category, v3::WisdomCategory::UnfinishedThread);
784 }
785
786 #[test]
787 fn test_v2_to_v3_provenance_preserved() {
788 let v2_ann = make_v2_annotation();
789 let v3_ann = v2_to_v3(v2_ann);
790
791 assert_eq!(v3_ann.provenance.source, v2::ProvenanceSource::Live);
793 assert_eq!(v3_ann.provenance.author.as_deref(), Some("claude-code"));
794 assert_eq!(v3_ann.provenance.notes.as_deref(), Some("test note"));
795 }
796
797 #[test]
798 fn test_v2_to_v3_effort_dropped() {
799 let v2_ann = make_v2_annotation();
802 assert!(v2_ann.effort.is_some()); let _v3_ann = v2_to_v3(v2_ann);
804 }
806
807 #[test]
808 fn test_v2_to_v3_validates() {
809 let v2_ann = make_v2_annotation();
810 let v3_ann = v2_to_v3(v2_ann);
811 assert!(v3_ann.validate().is_ok());
812 }
813
814 #[test]
815 fn test_v2_to_v3_empty_annotation() {
816 let v2_ann = v2::Annotation {
817 schema: "chronicle/v2".to_string(),
818 commit: "abc123".to_string(),
819 timestamp: "2025-01-01T00:00:00Z".to_string(),
820 narrative: v2::Narrative {
821 summary: "Simple change".to_string(),
822 motivation: None,
823 rejected_alternatives: vec![],
824 follow_up: None,
825 files_changed: vec![],
826 sentiments: vec![],
827 },
828 decisions: vec![],
829 markers: vec![],
830 effort: None,
831 provenance: v2::Provenance {
832 source: v2::ProvenanceSource::Live,
833 author: None,
834 derived_from: vec![],
835 notes: None,
836 },
837 };
838
839 let v3_ann = v2_to_v3(v2_ann);
840 assert_eq!(v3_ann.summary, "Simple change");
841 assert!(v3_ann.wisdom.is_empty());
842 assert!(v3_ann.validate().is_ok());
843 }
844
845 #[test]
846 fn test_v1_to_v3_chained_migration() {
847 let v1_ann = make_v1_annotation();
849 let v2_ann = v1_to_v2(v1_ann);
850 let v3_ann = v2_to_v3(v2_ann);
851
852 assert_eq!(v3_ann.schema, "chronicle/v3");
853 assert_eq!(v3_ann.summary, "Add reconnect logic");
854 assert!(!v3_ann.wisdom.is_empty());
856 assert_eq!(v3_ann.provenance.source, v2::ProvenanceSource::MigratedV1);
858 assert!(v3_ann.validate().is_ok());
859 }
860}