1use super::{v1, v2};
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 },
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 assert_eq!(v2_ann.markers.len(), 3);
204
205 assert!(matches!(
207 &v2_ann.markers[0].kind,
208 v2::MarkerKind::Contract { description, .. } if description == "Must not exceed 60s backoff"
209 ));
210
211 assert!(matches!(
213 &v2_ann.markers[1].kind,
214 v2::MarkerKind::Hazard { description } if description.contains("not persisted")
215 ));
216
217 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 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 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}