Skip to main content

chronicle/schema/
migrate.rs

1use super::{v1, v2};
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        },
120        decisions,
121        markers,
122        effort,
123        provenance,
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130    use crate::schema::common::{AstAnchor, LineRange};
131    use crate::schema::v1;
132
133    fn make_v1_annotation() -> v1::Annotation {
134        v1::Annotation {
135            schema: "chronicle/v1".to_string(),
136            commit: "abc123".to_string(),
137            timestamp: "2025-01-01T00:00:00Z".to_string(),
138            task: Some("TASK-42".to_string()),
139            summary: "Add reconnect logic".to_string(),
140            context_level: v1::ContextLevel::Enhanced,
141            regions: vec![v1::RegionAnnotation {
142                file: "src/mqtt/reconnect.rs".to_string(),
143                ast_anchor: AstAnchor {
144                    unit_type: "function".to_string(),
145                    name: "attempt_reconnect".to_string(),
146                    signature: Some("fn attempt_reconnect(&mut self)".to_string()),
147                },
148                lines: LineRange { start: 10, end: 30 },
149                intent: "Handle reconnection with exponential backoff".to_string(),
150                reasoning: Some("Broker rate-limits rapid reconnects".to_string()),
151                constraints: vec![v1::Constraint {
152                    text: "Must not exceed 60s backoff".to_string(),
153                    source: v1::ConstraintSource::Author,
154                }],
155                semantic_dependencies: vec![v1::SemanticDependency {
156                    file: "src/tls/session.rs".to_string(),
157                    anchor: "TlsSessionCache::max_sessions".to_string(),
158                    nature: "assumes max_sessions is 4".to_string(),
159                }],
160                related_annotations: vec![],
161                tags: vec!["mqtt".to_string()],
162                risk_notes: Some("Backoff timer is not persisted across restarts".to_string()),
163                corrections: vec![],
164            }],
165            cross_cutting: vec![v1::CrossCuttingConcern {
166                description: "All reconnect paths must use exponential backoff".to_string(),
167                regions: vec![v1::CrossCuttingRegionRef {
168                    file: "src/mqtt/reconnect.rs".to_string(),
169                    anchor: "attempt_reconnect".to_string(),
170                }],
171                tags: vec![],
172            }],
173            provenance: v1::Provenance {
174                operation: v1::ProvenanceOperation::Initial,
175                derived_from: vec![],
176                original_annotations_preserved: false,
177                synthesis_notes: None,
178            },
179        }
180    }
181
182    #[test]
183    fn test_v1_to_v2_basic() {
184        let v1_ann = make_v1_annotation();
185        let v2_ann = v1_to_v2(v1_ann);
186
187        assert_eq!(v2_ann.schema, "chronicle/v2");
188        assert_eq!(v2_ann.commit, "abc123");
189        assert_eq!(v2_ann.timestamp, "2025-01-01T00:00:00Z");
190        assert_eq!(v2_ann.narrative.summary, "Add reconnect logic");
191        assert_eq!(
192            v2_ann.narrative.files_changed,
193            vec!["src/mqtt/reconnect.rs"]
194        );
195    }
196
197    #[test]
198    fn test_v1_to_v2_markers() {
199        let v1_ann = make_v1_annotation();
200        let v2_ann = v1_to_v2(v1_ann);
201
202        // Should have 3 markers: 1 contract, 1 hazard, 1 dependency
203        assert_eq!(v2_ann.markers.len(), 3);
204
205        // Contract from constraint
206        assert!(matches!(
207            &v2_ann.markers[0].kind,
208            v2::MarkerKind::Contract { description, .. } if description == "Must not exceed 60s backoff"
209        ));
210
211        // Hazard from risk_notes
212        assert!(matches!(
213            &v2_ann.markers[1].kind,
214            v2::MarkerKind::Hazard { description } if description.contains("not persisted")
215        ));
216
217        // Dependency from semantic_dependencies
218        assert!(matches!(
219            &v2_ann.markers[2].kind,
220            v2::MarkerKind::Dependency { target_file, target_anchor, assumption }
221                if target_file == "src/tls/session.rs"
222                && target_anchor == "TlsSessionCache::max_sessions"
223                && assumption == "assumes max_sessions is 4"
224        ));
225    }
226
227    #[test]
228    fn test_v1_to_v2_decisions() {
229        let v1_ann = make_v1_annotation();
230        let v2_ann = v1_to_v2(v1_ann);
231
232        // Cross-cutting concern becomes a decision
233        assert_eq!(v2_ann.decisions.len(), 1);
234        assert_eq!(
235            v2_ann.decisions[0].what,
236            "All reconnect paths must use exponential backoff"
237        );
238    }
239
240    #[test]
241    fn test_v1_to_v2_effort() {
242        let v1_ann = make_v1_annotation();
243        let v2_ann = v1_to_v2(v1_ann);
244
245        // Task becomes effort link
246        let effort = v2_ann.effort.unwrap();
247        assert_eq!(effort.id, "TASK-42");
248    }
249
250    #[test]
251    fn test_v1_to_v2_provenance() {
252        let v1_ann = make_v1_annotation();
253        let v2_ann = v1_to_v2(v1_ann);
254
255        assert_eq!(v2_ann.provenance.source, v2::ProvenanceSource::MigratedV1);
256    }
257
258    #[test]
259    fn test_v1_to_v2_validates() {
260        let v1_ann = make_v1_annotation();
261        let v2_ann = v1_to_v2(v1_ann);
262
263        assert!(v2_ann.validate().is_ok());
264    }
265
266    #[test]
267    fn test_v1_to_v2_empty_regions() {
268        let v1_ann = v1::Annotation {
269            schema: "chronicle/v1".to_string(),
270            commit: "abc123".to_string(),
271            timestamp: "2025-01-01T00:00:00Z".to_string(),
272            task: None,
273            summary: "Simple commit".to_string(),
274            context_level: v1::ContextLevel::Inferred,
275            regions: vec![],
276            cross_cutting: vec![],
277            provenance: v1::Provenance {
278                operation: v1::ProvenanceOperation::Initial,
279                derived_from: vec![],
280                original_annotations_preserved: false,
281                synthesis_notes: None,
282            },
283        };
284        let v2_ann = v1_to_v2(v1_ann);
285
286        assert!(v2_ann.markers.is_empty());
287        assert!(v2_ann.decisions.is_empty());
288        assert!(v2_ann.effort.is_none());
289        assert!(v2_ann.validate().is_ok());
290    }
291}