Skip to main content

chronicle/schema/
migrate.rs

1use super::{v1, v2, v3};
2
3/// Migrate a v1 annotation to v2 (canonical) format.
4pub 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        // Track files for the narrative
10        if !files_changed.contains(&region.file) {
11            files_changed.push(region.file.clone());
12        }
13
14        // Convert constraints -> Contract markers
15        for constraint in &region.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        // Convert risk_notes -> Hazard markers
31        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        // Convert semantic_dependencies -> Dependency markers
43        for dep in &region.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    // Build narrative from commit summary + region intents/reasoning
58    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        // For multi-region v1 annotations, join with the first being the main summary
71        summary_parts[0].clone()
72    };
73
74    // Convert cross-cutting concerns to decisions
75    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    // Convert provenance
95    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    // Build effort link from task if present
103    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
128/// Migrate a v2 annotation to v3 format.
129///
130/// Implements all 10 migration rules from the v3 spec (features/22-schema-v3.md):
131/// 1. summary <- v2.narrative.summary
132/// 2. rejected_alternatives -> dead_end wisdom entries
133/// 3. motivation -> insight wisdom entry
134/// 4. follow_up -> unfinished_thread wisdom entry
135/// 5. sentiments -> wisdom entries (category by feeling keyword)
136/// 6. decisions -> insight wisdom entries
137/// 7. markers -> wisdom entries (category per marker kind)
138/// 8. provenance carries through
139/// 9. effort dropped
140/// 10. files_changed dropped
141pub fn v2_to_v3(ann: v2::Annotation) -> v3::Annotation {
142    let mut wisdom = Vec::new();
143
144    // Rule 2: rejected_alternatives -> dead_end entries
145    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    // Rule 3: motivation -> insight entry (if present)
160    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    // Rule 4: follow_up -> unfinished_thread entry (if present)
170    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    // Rule 5: sentiments -> wisdom entries
180    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    // Rule 6: decisions -> insight wisdom entries
201    for decision in &ann.decisions {
202        let file = decision.scope.first().map(|s| {
203            // Scope entries can be "src/foo.rs:bar_fn" — extract just the file part
204            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    // Rule 7: markers -> wisdom entries
215    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                // TestCoverage is dropped per spec, but we still convert for completeness
248                (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    // Rule 8: provenance carries through unchanged
261    // Rule 9: effort dropped
262    // Rule 10: files_changed dropped
263
264    v3::Annotation {
265        schema: "chronicle/v3".to_string(),
266        commit: ann.commit,
267        timestamp: ann.timestamp,
268        summary: ann.narrative.summary, // Rule 1
269        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        // Should have 3 markers: 1 contract, 1 hazard, 1 dependency
350        assert_eq!(v2_ann.markers.len(), 3);
351
352        // Contract from constraint
353        assert!(matches!(
354            &v2_ann.markers[0].kind,
355            v2::MarkerKind::Contract { description, .. } if description == "Must not exceed 60s backoff"
356        ));
357
358        // Hazard from risk_notes
359        assert!(matches!(
360            &v2_ann.markers[1].kind,
361            v2::MarkerKind::Hazard { description } if description.contains("not persisted")
362        ));
363
364        // Dependency from semantic_dependencies
365        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        // Cross-cutting concern becomes a decision
380        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        // Task becomes effort link
393        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    // -----------------------------------------------------------------------
440    // v2 -> v3 migration tests
441    // -----------------------------------------------------------------------
442
443    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        // Empty reason: just the approach text
612        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        // worry -> gotcha
650        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        // doubt -> unfinished_thread
659        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        // confidence -> insight
668        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        // file from first scope element (strip anchor part)
694        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        // Contract -> gotcha
703        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        // Hazard -> gotcha
720        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        // Dependency -> insight
729        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        // Unstable -> unfinished_thread
741        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        // Security -> gotcha
750        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        // Performance -> gotcha
759        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        // Deprecated -> unfinished_thread
768        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        // TechDebt -> unfinished_thread
777        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        // Rule 8: provenance carries through unchanged
792        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        // Rule 9: effort is dropped — it shouldn't appear in v3 at all
800        // (v3::Annotation has no effort field, so this is structural)
801        let v2_ann = make_v2_annotation();
802        assert!(v2_ann.effort.is_some()); // sanity check
803        let _v3_ann = v2_to_v3(v2_ann);
804        // If it compiles, effort is dropped
805    }
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        // v1 -> v2 -> v3 chain
848        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        // Should have wisdom from markers (contract, hazard, dependency) and the decision
855        assert!(!v3_ann.wisdom.is_empty());
856        // Provenance should be MigratedV1 (from the v1->v2 step)
857        assert_eq!(v3_ann.provenance.source, v2::ProvenanceSource::MigratedV1);
858        assert!(v3_ann.validate().is_ok());
859    }
860}