1use crate::metadata::StepMetadata;
2use chrono::{DateTime, Utc};
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use uuid::Uuid;
6
7pub const SCHEMA_VERSION: &str = "1.0";
9
10fn default_schema_version() -> String {
11 SCHEMA_VERSION.to_string()
12}
13
14fn parse_schema_version(version: &str) -> Option<(u64, u64)> {
15 let trimmed = version.trim();
16 if trimmed.is_empty() {
17 return None;
18 }
19
20 let mut parts = trimmed.split('.');
21 let major = parts.next()?.parse().ok()?;
22 let minor = parts.next().unwrap_or("0").parse().ok()?;
23 Some((major, minor))
24}
25
26pub fn is_supported_schema_version(version: &str) -> bool {
30 let Some((major, _)) = parse_schema_version(version) else {
31 return false;
32 };
33 let Some((supported_major, _)) = parse_schema_version(SCHEMA_VERSION) else {
34 return false;
35 };
36 major == supported_major
37}
38
39#[derive(Debug, Clone, Serialize, Deserialize)]
44pub struct Schematic {
45 #[serde(default = "default_schema_version")]
47 pub schema_version: String,
48 pub id: String,
50 pub name: String,
52 #[serde(skip_serializing_if = "Option::is_none")]
54 pub description: Option<String>,
55 #[serde(skip_serializing_if = "Option::is_none")]
57 pub generated_at: Option<DateTime<Utc>>,
58 pub nodes: Vec<Node>,
60 pub edges: Vec<Edge>,
62}
63
64impl Default for Schematic {
65 fn default() -> Self {
66 Self {
67 schema_version: SCHEMA_VERSION.to_string(),
68 id: Uuid::new_v4().to_string(),
69 name: String::new(),
70 description: None,
71 generated_at: Some(Utc::now()),
72 nodes: Vec::new(),
73 edges: Vec::new(),
74 }
75 }
76}
77
78impl Schematic {
79 pub fn new(name: impl Into<String>) -> Self {
80 Self {
81 name: name.into(),
82 ..Default::default()
83 }
84 }
85
86 pub fn is_supported_schema_version(&self) -> bool {
87 is_supported_schema_version(&self.schema_version)
88 }
89
90 pub fn with_id(name: impl Into<String>, id: impl Into<String>) -> Self {
92 Self {
93 id: id.into(),
94 name: name.into(),
95 ..Default::default()
96 }
97 }
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct SourceLocation {
103 pub file: String,
105 pub line: u32,
107 #[serde(skip_serializing_if = "Option::is_none")]
109 pub column: Option<u32>,
110}
111
112impl SourceLocation {
113 pub fn new(file: impl Into<String>, line: u32) -> Self {
114 Self {
115 file: file.into(),
116 line,
117 column: None,
118 }
119 }
120
121 pub fn with_column(file: impl Into<String>, line: u32, column: u32) -> Self {
122 Self {
123 file: file.into(),
124 line,
125 column: Some(column),
126 }
127 }
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize)]
131pub struct Position {
132 pub x: f32,
133 pub y: f32,
134}
135
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub struct Node {
138 pub id: String, pub kind: NodeKind,
140 pub label: String,
141 #[serde(skip_serializing_if = "Option::is_none")]
142 pub description: Option<String>,
143 pub input_type: String,
144 pub output_type: String, pub resource_type: String,
146 pub metadata: StepMetadata,
147 #[serde(skip_serializing_if = "Option::is_none")]
149 pub bus_capability: Option<BusCapabilitySchema>,
150 #[serde(skip_serializing_if = "Option::is_none")]
152 pub source_location: Option<SourceLocation>,
153 #[serde(skip_serializing_if = "Option::is_none")]
155 pub position: Option<Position>,
156 #[serde(skip_serializing_if = "Option::is_none")]
159 pub compensation_node_id: Option<String>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub input_schema: Option<serde_json::Value>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub output_schema: Option<serde_json::Value>,
168}
169
170#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
171pub struct BusCapabilitySchema {
172 #[serde(skip_serializing_if = "Vec::is_empty", default)]
173 pub allow: Vec<String>,
174 #[serde(skip_serializing_if = "Vec::is_empty", default)]
175 pub deny: Vec<String>,
176}
177
178#[derive(Debug, Clone, Serialize, Deserialize)]
179pub enum NodeKind {
180 Ingress, Atom, Synapse, Egress, Subgraph(Box<Schematic>), FanOut, FanIn, }
188
189#[derive(Debug, Clone, Serialize, Deserialize)]
190pub enum EdgeType {
191 Linear, Branch(String), Jump, Fault, Parallel, }
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct Edge {
200 pub from: String,
201 pub to: String,
202 pub kind: EdgeType,
203 pub label: Option<String>,
204}
205
206#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
208pub enum MigrationStrategy {
209 Fail,
211 CompleteOnOldVersion,
213 MigrateActiveNode {
215 old_node_id: String,
216 new_node_id: String,
217 },
218 FallbackToNode(String),
220 ResumeFromStart,
222}
223
224pub trait SchemaMigrationMapper: Send + Sync {
229 fn map_state(&self, old_state: &serde_json::Value) -> anyhow::Result<serde_json::Value>;
231}
232
233#[derive(Clone, Serialize, Deserialize)]
235pub struct SnapshotMigration {
236 pub name: Option<String>,
238 pub from_version: String,
240 pub to_version: String,
242 pub default_strategy: MigrationStrategy,
244 #[serde(skip_serializing_if = "HashMap::is_empty", default)]
246 pub node_mapping: HashMap<String, MigrationStrategy>,
247 #[serde(skip)]
249 pub payload_mapper: Option<std::sync::Arc<dyn SchemaMigrationMapper>>,
250}
251
252impl std::fmt::Debug for SnapshotMigration {
253 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
254 f.debug_struct("SnapshotMigration")
255 .field("name", &self.name)
256 .field("from_version", &self.from_version)
257 .field("to_version", &self.to_version)
258 .field("default_strategy", &self.default_strategy)
259 .field("node_mapping", &self.node_mapping)
260 .field(
261 "payload_mapper",
262 &self.payload_mapper.as_ref().map(|_| ".."),
263 )
264 .finish()
265 }
266}
267
268#[derive(Debug, Clone, Default, Serialize, Deserialize)]
272pub struct MigrationRegistry {
273 pub circuit_id: String,
274 pub migrations: Vec<SnapshotMigration>,
275}
276
277impl MigrationRegistry {
278 pub fn new(circuit_id: impl Into<String>) -> Self {
279 Self {
280 circuit_id: circuit_id.into(),
281 migrations: Vec::new(),
282 }
283 }
284
285 pub fn register(&mut self, migration: SnapshotMigration) {
286 self.migrations.push(migration);
287 }
288
289 pub fn find_migration(&self, from: &str, to: &str) -> Option<&SnapshotMigration> {
291 self.migrations
292 .iter()
293 .find(|m| m.from_version == from && m.to_version == to)
294 }
295
296 pub fn find_migration_path(&self, from: &str, to: &str) -> Option<Vec<&SnapshotMigration>> {
301 if from == to {
302 return Some(Vec::new());
303 }
304 if let Some(direct) = self.find_migration(from, to) {
306 return Some(vec![direct]);
307 }
308 let mut queue: std::collections::VecDeque<(String, Vec<usize>)> =
310 std::collections::VecDeque::new();
311 let mut visited: std::collections::HashSet<String> = std::collections::HashSet::new();
312 visited.insert(from.to_string());
313 for (i, m) in self.migrations.iter().enumerate() {
314 if m.from_version == from {
315 visited.insert(m.to_version.clone());
316 if m.to_version == to {
317 return Some(vec![&self.migrations[i]]);
318 }
319 queue.push_back((m.to_version.clone(), vec![i]));
320 }
321 }
322 while let Some((current, path)) = queue.pop_front() {
323 for (i, m) in self.migrations.iter().enumerate() {
324 if m.from_version == current && !visited.contains(&m.to_version) {
325 let mut new_path = path.clone();
326 new_path.push(i);
327 if m.to_version == to {
328 return Some(new_path.iter().map(|&idx| &self.migrations[idx]).collect());
329 }
330 visited.insert(m.to_version.clone());
331 queue.push_back((m.to_version.clone(), new_path));
332 }
333 }
334 }
335 None
336 }
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342
343 #[test]
344 fn test_schematic_default_has_version_and_id() {
345 let schematic = Schematic::new("Test Circuit");
346 assert_eq!(schematic.schema_version, SCHEMA_VERSION);
347 assert!(schematic.is_supported_schema_version());
348 assert!(!schematic.id.is_empty());
349 assert!(schematic.generated_at.is_some());
350 }
351
352 #[test]
353 fn test_schematic_serialization_with_new_fields() {
354 let schematic = Schematic::new("Test");
355 let json = serde_json::to_string_pretty(&schematic).unwrap();
356
357 assert!(json.contains("schema_version"));
358 assert!(json.contains("\"1.0\""));
359 assert!(json.contains("generated_at"));
360 }
361
362 #[test]
363 fn test_source_location_optional_in_json() {
364 let schematic = Schematic::new("Test");
365 let json = serde_json::to_string(&schematic).unwrap();
366
367 assert!(!json.contains("description"));
369 }
370
371 #[test]
372 fn test_source_location_creation() {
373 let loc = SourceLocation::new("src/main.rs", 42);
374 assert_eq!(loc.file, "src/main.rs");
375 assert_eq!(loc.line, 42);
376 assert!(loc.column.is_none());
377
378 let loc_with_col = SourceLocation::with_column("src/lib.rs", 10, 5);
379 assert_eq!(loc_with_col.column, Some(5));
380 }
381
382 #[test]
383 fn test_schema_version_defaults_when_missing_in_json() {
384 let json = r#"{
385 "id": "test-id",
386 "name": "Legacy Schematic",
387 "nodes": [],
388 "edges": []
389 }"#;
390 let schematic: Schematic = serde_json::from_str(json).unwrap();
391 assert_eq!(schematic.schema_version, SCHEMA_VERSION);
392 assert!(schematic.is_supported_schema_version());
393 }
394
395 #[test]
396 fn test_supported_schema_version_major_compatibility() {
397 assert!(is_supported_schema_version("1"));
398 assert!(is_supported_schema_version("1.0"));
399 assert!(is_supported_schema_version("1.1"));
400 assert!(is_supported_schema_version("1.0.9"));
401 assert!(!is_supported_schema_version("2.0"));
402 assert!(!is_supported_schema_version(""));
403 assert!(!is_supported_schema_version("invalid"));
404 }
405
406 #[test]
407 fn test_migration_registry_lookup() {
408 let mut registry = MigrationRegistry::new("test-circuit");
409 let migration = SnapshotMigration {
410 name: Some("v1 to v2".to_string()),
411 from_version: "1.0".to_string(),
412 to_version: "2.0".to_string(),
413 default_strategy: MigrationStrategy::Fail,
414 node_mapping: HashMap::new(),
415 payload_mapper: None,
416 };
417 registry.register(migration);
418
419 let found = registry.find_migration("1.0", "2.0");
420 assert!(found.is_some());
421 assert_eq!(found.unwrap().name, Some("v1 to v2".to_string()));
422
423 let not_found = registry.find_migration("1.0", "3.0");
424 assert!(not_found.is_none());
425 }
426
427 #[test]
428 fn test_node_deserializes_without_schema_fields() {
429 let json = r#"{
431 "id": "node-1",
432 "kind": "Atom",
433 "label": "OldNode",
434 "input_type": "i32",
435 "output_type": "i32",
436 "resource_type": "()",
437 "metadata": {
438 "id": "00000000-0000-0000-0000-000000000000",
439 "label": "OldNode",
440 "description": null,
441 "inputs": [],
442 "outputs": []
443 }
444 }"#;
445 let node: Node = serde_json::from_str(json).unwrap();
446 assert_eq!(node.label, "OldNode");
447 assert!(node.input_schema.is_none());
448 assert!(node.output_schema.is_none());
449 }
450
451 #[test]
452 fn test_node_serializes_schema_fields_when_present() {
453 let node = Node {
454 id: "node-s".to_string(),
455 kind: NodeKind::Atom,
456 label: "WithSchema".to_string(),
457 description: None,
458 input_type: "MyInput".to_string(),
459 output_type: "MyOutput".to_string(),
460 resource_type: "()".to_string(),
461 metadata: StepMetadata::default(),
462 bus_capability: None,
463 source_location: None,
464 position: None,
465 compensation_node_id: None,
466 input_schema: Some(serde_json::json!({"type": "object"})),
467 output_schema: Some(serde_json::json!({"type": "string"})),
468 };
469 let json = serde_json::to_value(&node).unwrap();
470 assert_eq!(json["input_schema"], serde_json::json!({"type": "object"}));
471 assert_eq!(json["output_schema"], serde_json::json!({"type": "string"}));
472 }
473
474 #[test]
475 fn test_node_omits_schema_fields_when_none() {
476 let node = Node {
477 id: "node-n".to_string(),
478 kind: NodeKind::Atom,
479 label: "NoSchema".to_string(),
480 description: None,
481 input_type: "i32".to_string(),
482 output_type: "i32".to_string(),
483 resource_type: "()".to_string(),
484 metadata: StepMetadata::default(),
485 bus_capability: None,
486 source_location: None,
487 position: None,
488 compensation_node_id: None,
489 input_schema: None,
490 output_schema: None,
491 };
492 let json = serde_json::to_value(&node).unwrap();
493 let obj = json.as_object().unwrap();
494 assert!(!obj.contains_key("input_schema"));
495 assert!(!obj.contains_key("output_schema"));
496 }
497
498 #[test]
499 fn test_schematic_with_schema_nodes_roundtrip() {
500 let mut schematic = Schematic::new("SchemaTest");
501 schematic.nodes.push(Node {
502 id: "n1".to_string(),
503 kind: NodeKind::Atom,
504 label: "Step1".to_string(),
505 description: None,
506 input_type: "Request".to_string(),
507 output_type: "Response".to_string(),
508 resource_type: "()".to_string(),
509 metadata: StepMetadata::default(),
510 bus_capability: None,
511 source_location: None,
512 position: None,
513 compensation_node_id: None,
514 input_schema: Some(serde_json::json!({"type": "object", "required": ["name"]})),
515 output_schema: None,
516 });
517
518 let json = serde_json::to_string(&schematic).unwrap();
519 let deserialized: Schematic = serde_json::from_str(&json).unwrap();
520
521 assert_eq!(deserialized.nodes.len(), 1);
522 assert!(deserialized.nodes[0].input_schema.is_some());
523 assert!(deserialized.nodes[0].output_schema.is_none());
524 assert_eq!(
525 deserialized.nodes[0].input_schema.as_ref().unwrap()["required"][0],
526 "name"
527 );
528 }
529
530 #[test]
531 fn test_legacy_schematic_json_deserializes() {
532 let json = r#"{
534 "schema_version": "1.0",
535 "id": "legacy-1",
536 "name": "LegacyCircuit",
537 "nodes": [{
538 "id": "n1",
539 "kind": "Ingress",
540 "label": "Start",
541 "input_type": "String",
542 "output_type": "String",
543 "resource_type": "()",
544 "metadata": {
545 "id": "00000000-0000-0000-0000-000000000000",
546 "label": "Start",
547 "description": null,
548 "inputs": [],
549 "outputs": []
550 }
551 }],
552 "edges": []
553 }"#;
554 let schematic: Schematic = serde_json::from_str(json).unwrap();
555 assert_eq!(schematic.name, "LegacyCircuit");
556 assert_eq!(schematic.nodes.len(), 1);
557 assert!(schematic.nodes[0].input_schema.is_none());
558 assert!(schematic.nodes[0].output_schema.is_none());
559 }
560
561 #[test]
562 fn test_multi_hop_migration_path() {
563 let mut registry = MigrationRegistry::new("test-circuit");
564 registry.register(SnapshotMigration {
565 name: Some("v1→v2".to_string()),
566 from_version: "1.0".to_string(),
567 to_version: "2.0".to_string(),
568 default_strategy: MigrationStrategy::ResumeFromStart,
569 node_mapping: HashMap::new(),
570 payload_mapper: None,
571 });
572 registry.register(SnapshotMigration {
573 name: Some("v2→v3".to_string()),
574 from_version: "2.0".to_string(),
575 to_version: "3.0".to_string(),
576 default_strategy: MigrationStrategy::ResumeFromStart,
577 node_mapping: HashMap::new(),
578 payload_mapper: None,
579 });
580
581 let path = registry.find_migration_path("1.0", "2.0").unwrap();
583 assert_eq!(path.len(), 1);
584
585 let path = registry.find_migration_path("1.0", "3.0").unwrap();
587 assert_eq!(path.len(), 2);
588 assert_eq!(path[0].from_version, "1.0");
589 assert_eq!(path[0].to_version, "2.0");
590 assert_eq!(path[1].from_version, "2.0");
591 assert_eq!(path[1].to_version, "3.0");
592
593 let path = registry.find_migration_path("1.0", "1.0").unwrap();
595 assert!(path.is_empty());
596
597 let path = registry.find_migration_path("1.0", "4.0");
599 assert!(path.is_none());
600 }
601}