use crate::*; use serde::{Deserialize, Serialize}; use std::{io::Write, path::Path}; use thiserror::Error; #[path = "migration_v2/mod.rs"] pub(crate) mod v2; pub(crate) use v2::adapter::migrate as migrate_v2_limited; #[path = "migration_v3/mod.rs"] pub(crate) mod v3; #[path = "migration_v4.rs"] pub(crate) mod v4; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct MigrationReport { pub from_schema: u32, pub to_schema: u32, pub preserved_ports: Vec, pub generated_endpoints: Vec, pub orientation_defaults: Vec, pub required_resolutions: Vec, #[serde(default)] pub semantic_defaults: Vec, #[serde(default)] pub generated_interface_allocations: Vec, #[serde(default)] pub unresolved_message_values: Vec, } impl MigrationReport { pub(crate) fn data_upgrade(from_schema: u32) -> Self { Self { from_schema, to_schema: SCHEMA_VERSION, preserved_ports: vec![], generated_endpoints: vec![], orientation_defaults: vec![], required_resolutions: vec![], semantic_defaults: vec![], generated_interface_allocations: vec![], unresolved_message_values: vec![], } } } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct SemanticDefault { pub object: Id, pub field: String, pub description: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GeneratedInterfaceAllocation { pub id: Id, pub source: Id, pub target: Id, pub alias: String, pub order: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct UnresolvedMessageValue { pub occurrence: Id, pub field: Id, pub reason: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct PreservedPort { pub id: Id, pub alias: String, pub required_resolution: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct GeneratedEndpoint { pub exchange: Id, pub side: String, pub port: Id, pub owner: Id, pub alias: String, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct OrientationDefault { pub exchange: Id, pub oriented: bool, } #[derive(Debug, Clone)] pub struct DecodeResult { pub project: Project, pub migration: Option, } #[derive(Debug, Error)] pub enum FileError { #[error("File operation failed: {0}")] Io(#[from] std::io::Error), #[error("Invalid project JSON: {0}")] Json(#[from] serde_json::Error), #[error("Invalid Arcadia text: {0}")] Text(String), #[error("Invalid project: {0}")] Invalid(String), #[error("Invalid project: {message}")] Model { message: String, issues: Vec, }, #[error( "This file changed outside Arcadia Studio. Save as a new file, or reopen it to review the external changes." )] Conflict, #[error("Model admission limit: {0}")] Limit(String), } pub fn decode(bytes: &[u8]) -> Result { decode_with_report(bytes).map(|decoded| decoded.project) } pub fn decode_with_report(bytes: &[u8]) -> Result { decode_with_report_limited(bytes, crate::language::ParseLimits::UNLIMITED) } pub fn decode_limited( bytes: &[u8], limits: crate::language::ParseLimits, ) -> Result { decode_with_report_limited(bytes, limits).map(|decoded| decoded.project) } /// Admission budgets cover the original graph and all generated migration data. /// Decoding never rewrites a file or advances a hosted revision. pub fn decode_with_report_limited( bytes: &[u8], limits: crate::language::ParseLimits, ) -> Result { if bytes.len() > limits.max_source_bytes { return Err(FileError::Limit(format!( "Input exceeds {} bytes.", limits.max_source_bytes ))); } let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') { let header: serde_json::Value = serde_json::from_slice(bytes)?; match header .get("schema_version") .and_then(serde_json::Value::as_u64) { Some(1) => { let old = serde_json::from_slice(bytes)?; check_v1_limits(&old, limits).map_err(FileError::Limit)?; migrate_v1_with_report_limited(old, limits)? } Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?, Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?, Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?, Some(5) => DecodeResult { project: serde_json::from_slice(bytes)?, migration: None, }, _ => { return Err(FileError::Invalid( "Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(), )); } } } else { let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?; crate::language::parse_with_report_limited(text, limits).map_err(|diagnostics| { let detail = diagnostics .iter() .map(ToString::to_string) .collect::>() .join("\n"); if diagnostics .iter() .any(|d| matches!(d.code.as_str(), "model_limit" | "source_limit")) { FileError::Limit(detail) } else { FileError::Text(detail) } })? }; limits .check_model(&decoded.project) .map_err(FileError::Limit)?; validate_project(&decoded.project)?; Ok(decoded) } pub(crate) fn migrate_v1_with_report_limited( old: v1::Project, limits: crate::language::ParseLimits, ) -> Result { check_v1_limits(&old, limits).map_err(FileError::Limit)?; let normalized = normalize_v1_to_v2(old, limits).map_err(FileError::Invalid)?; let mut decoded = migrate_v2_limited(normalized, limits)?; if let Some(report) = &mut decoded.migration { report.from_schema = 1; } Ok(decoded) } /// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and /// coordinates survive; every newly required identity is deterministic. fn normalize_v1_to_v2( mut old: v1::Project, limits: crate::language::ParseLimits, ) -> Result { use v2::model::*; check_v1_limits(&old, limits)?; if let Some(issue) = v1::validation::validate(&old).into_iter().next() { return Err(issue.message); } if old.aliases.is_empty() { old.aliases = crate::language::legacy::parse(&crate::language::legacy::format(&old)) .map_err(|e| { e.into_iter() .map(|d| d.to_string()) .collect::>() .join("\n") })? .aliases; } let mut p = Project::empty(); p.id = old.id; p.name = old.name.clone(); p.alias = "v1_project".into(); let used = old .aliases .values() .cloned() .collect::>(); while used.contains(&p.alias) { p.alias.push('_'); } for e in old.elements.values() { let body = match e.kind { v1::Kind::Entity => OaBody::Entity, v1::Kind::Actor => OaBody::Actor, v1::Kind::Activity => OaBody::Activity, v1::Kind::Capability => OaBody::Capability, v1::Kind::Process => OaBody::Process { steps: vec![] }, }; p.elements.insert( e.id, Element { id: e.id, alias: old.aliases[&e.id].clone(), name: e.name.clone(), description: e.description.clone(), parent: None, body: ElementBody::Oa(body), }, ); } for r in old.relations.values() { let kind = match r.kind { v1::RelationKind::Allocation => RelationKind::Allocation, v1::RelationKind::Interaction => RelationKind::Interaction, v1::RelationKind::Contribution => RelationKind::CapabilityInvolvement, v1::RelationKind::Realization => RelationKind::ChainInvolvement, }; p.relations.insert( r.id, Relation { id: r.id, alias: old.aliases[&r.id].clone(), name: r.name.clone(), description: String::new(), body: RelationBody::new(kind, r.source, r.target), }, ); } // V1 allowed aliases later reserved by V2. Preserve all valid aliases first; // deterministically suffix only the conflicting names in stable UUID order. let invalid = p .identities() .into_iter() .filter(|(_, alias)| !valid_alias(alias)) .map(|(id, alias)| (id, alias.to_owned())) .collect::>(); for (id, alias) in invalid { let replacement = p.fresh_alias(&std::format!("{alias}_v1"), "legacy", id); if let Some(e) = p.elements.get_mut(&id) { e.alias = replacement; } else if let Some(r) = p.relations.get_mut(&id) { r.alias = replacement; } } let diagram_id = migration_id(&p, "diagram:oa"); let diagram_alias = p.fresh_alias("OA overview", "diagram", diagram_id); p.diagrams.insert( diagram_id, Diagram { id: diagram_id, alias: diagram_alias, name: "OA overview".into(), description: String::new(), layer: Layer::Oa, projection: ProjectionKind::Overview, nodes: std::collections::BTreeMap::new(), }, ); for e in old.elements.values() { let id = migration_id(&p, &std::format!("node:{}", e.id)); let alias = p.fresh_alias(&std::format!("{}_view", old.aliases[&e.id]), "node", id); let node = NodePlacement { id, alias, element: e.id, position: Position { x: e.position.x, y: e.position.y, }, size: Size { width: 224., height: 94., }, collapsed: false, }; p.diagrams .get_mut(&diagram_id) .unwrap() .nodes .insert(id, node); } for e in old .elements .values() .filter(|e| e.kind == v1::Kind::Process) { let mut steps = Vec::new(); for (index, exchange) in e.steps.iter().enumerate() { let id = migration_id(&p, &std::format!("occurrence:{}:{index}", e.id)); let alias = p.fresh_alias( &std::format!("{}_step_{}", old.aliases[&e.id], index + 1), "occurrence", id, ); let name = old.relations[exchange].name.clone(); p.elements.insert( id, Element { id, alias, name, description: String::new(), parent: Some(e.id), body: ElementBody::shared_in( Layer::Oa, SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange: Some(*exchange), sender: None, receiver: None, message_kind: MessageKind::Asynchronous, reply_to: None, values: vec![], }, precondition: String::new(), postcondition: String::new(), }, ), }, ); steps.push(id); } *p.elements.get_mut(&e.id).unwrap().steps_mut().unwrap() = steps; } if let Some(issue) = v2::validation::validate(&p) .into_iter() .find(|i| i.severity == v2::validation::Severity::Error) { return Err(std::format!( "V1 migration cannot satisfy V2 validation: {}", issue.message )); } Ok(p) } pub(crate) fn check_v1_limits( old: &v1::Project, limits: crate::language::ParseLimits, ) -> Result<(), String> { let steps = old.elements.values().map(|e| e.steps.len()).sum::(); let graph = old .elements .len() .saturating_add(old.relations.len()) .saturating_add(steps); // Migration adds one diagram, a placement per old element, and one stable // occurrence per process step. Count those before validating the old graph. let records = graph.saturating_add(old.elements.len()).saturating_add(2); limits.check_counts(graph, records, steps) } fn migration_id(p: &v2::model::Project, role: &str) -> Id { let mut suffix = 0; loop { let id = Id::new_v5( &p.id, std::format!("arcadia-studio:v1:{role}:{suffix}").as_bytes(), ); if !p.contains(id) { return id; } suffix += 1; } } pub(crate) mod v1 { pub use crate::{Id, Position}; use serde::{Deserialize, Deserializer}; use std::collections::BTreeMap; #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum Kind { Entity, Actor, Activity, Capability, Process, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)] pub enum RelationKind { Allocation, Interaction, Contribution, Realization, } #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Element { pub id: Id, pub kind: Kind, pub name: String, pub description: String, pub position: Position, pub steps: Vec, } #[derive(Debug, Clone, PartialEq, Deserialize)] #[serde(deny_unknown_fields)] pub struct Relation { pub id: Id, pub kind: RelationKind, pub name: String, pub source: Id, pub target: Id, } #[derive(Debug, Clone, PartialEq)] pub struct Project { pub schema_version: u32, pub id: Id, pub name: String, pub elements: BTreeMap, pub relations: BTreeMap, pub aliases: BTreeMap, } impl<'de> Deserialize<'de> for Project { fn deserialize>(d: D) -> Result { #[derive(Deserialize)] #[serde(deny_unknown_fields)] struct Raw { schema_version: u32, id: Id, name: String, #[serde(deserialize_with = "crate::unique_id_map")] elements: BTreeMap, #[serde(deserialize_with = "crate::unique_id_map")] relations: BTreeMap, } let r = Raw::deserialize(d)?; Ok(Self { schema_version: r.schema_version, id: r.id, name: r.name, elements: r.elements, relations: r.relations, aliases: BTreeMap::new(), }) } } pub mod validation { use super::*; #[derive(PartialEq, Eq)] pub enum Severity { Error, } pub struct Issue { pub severity: Severity, pub element: Option, pub message: String, } fn issue(id: Option, message: impl Into) -> Issue { Issue { severity: Severity::Error, element: id, message: message.into(), } } fn string_ok(s: &str) -> bool { s.chars().all(|c| { matches!(c, '\t' | '\n' | '\r') || matches!(c as u32,0x20..=0xd7ff|0xe000..=0xfffd|0x10000..=0x10ffff) }) } pub fn validate(p: &Project) -> Vec { let mut out = Vec::new(); if p.schema_version != 1 { out.push(issue(None, "Unsupported schema version; expected 1.")); } if !string_ok(&p.name) { out.push(issue( None, "Project name contains a character forbidden by XML 1.0.", )); } let mut ids = std::collections::BTreeSet::from([p.id]); let mut allocated = std::collections::BTreeSet::new(); for (id, e) in &p.elements { if *id != e.id || !ids.insert(e.id) { out.push(issue( Some(e.id), "Inconsistent or duplicate element identity.", )); } if !string_ok(&e.name) || !string_ok(&e.description) { out.push(issue( Some(e.id), "Element text contains a character forbidden by XML 1.0.", )); } if !e.position.x.is_finite() || !e.position.y.is_finite() || e.position.x.abs() > crate::MAX_COORDINATE || e.position.y.abs() > crate::MAX_COORDINATE { out.push(issue( Some(e.id), "Coordinates must be finite and between -1000000 and 1000000.", )); } if e.kind != Kind::Process && !e.steps.is_empty() { out.push(issue(Some(e.id), "Only processes can contain steps.")); } for step in &e.steps { if !p .relations .get(step) .is_some_and(|r| r.kind == RelationKind::Interaction) { out.push(issue( Some(e.id), "Every process step must reference an existing interaction.", )); } } } for (id, r) in &p.relations { if *id != r.id || !ids.insert(r.id) { out.push(issue( Some(r.id), "Inconsistent or duplicate relation identity.", )); } if !string_ok(&r.name) { out.push(issue( Some(r.id), "Relation text contains a character forbidden by XML 1.0.", )); } let permitted = p .elements .get(&r.source) .zip(p.elements.get(&r.target)) .is_some_and(|(s, t)| match r.kind { RelationKind::Allocation => { matches!(s.kind, Kind::Entity | Kind::Actor) && t.kind == Kind::Activity } RelationKind::Interaction => { s.kind == Kind::Activity && t.kind == Kind::Activity } RelationKind::Contribution => { s.kind == Kind::Activity && t.kind == Kind::Capability } RelationKind::Realization => { s.kind == Kind::Process && t.kind == Kind::Capability } }); if !permitted || r.source == r.target { out.push(issue( Some(r.id), std::format!("Invalid {:?} endpoints.", r.kind), )); } if r.kind == RelationKind::Allocation && !allocated.insert(r.target) { out.push(issue( Some(r.id), "An activity may have at most one direct allocation.", )); } } out } } } fn validate_project(p: &Project) -> Result<(), FileError> { if let Some(i) = validation::validate(p) .into_iter() .find(|i| i.severity == validation::Severity::Error) { return Err(FileError::Model { message: i.message.clone(), issues: vec![i], }); } Ok(()) } pub fn load(path: &Path) -> Result { load_with_report(path).map(|decoded| decoded.project) } pub fn load_with_report(path: &Path) -> Result { decode_with_report(&std::fs::read(path)?) } /// Serialize to a sibling temporary file, flush, then atomically replace the destination. pub fn save(path: &Path, p: &Project) -> Result<(), FileError> { save_checked(path, p, None).map(|_| ()) } /// Return the exact bytes written. When supplied, `expected` is the last read or /// saved file content; an external change/deletion is rejected before replacement. /// This detects ordinary editor/Git conflicts; it is not a filesystem CAS lock. pub fn save_checked( path: &Path, p: &Project, expected: Option<&[u8]>, ) -> Result, FileError> { validate_project(p)?; let mut bytes = if path .extension() .is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia")) { crate::language::format(p).into_bytes() } else { serde_json::to_vec_pretty(p)? }; if !bytes.ends_with(b"\n") { bytes.push(b'\n'); } let parent = path .parent() .filter(|p| !p.as_os_str().is_empty()) .unwrap_or(Path::new(".")); let mut temp = tempfile::NamedTempFile::new_in(parent)?; temp.write_all(&bytes)?; temp.as_file().sync_all()?; if let Some(expected) = expected { match std::fs::read(path) { Ok(current) if current == expected => {} Ok(_) => return Err(FileError::Conflict), Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(FileError::Conflict), Err(e) => return Err(FileError::Io(e)), } } temp.persist(path).map_err(|e| FileError::Io(e.error))?; Ok(bytes) }