954 lines
33 KiB
Rust
954 lines
33 KiB
Rust
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<PreservedPort>,
|
||
pub generated_endpoints: Vec<GeneratedEndpoint>,
|
||
pub orientation_defaults: Vec<OrientationDefault>,
|
||
pub required_resolutions: Vec<Id>,
|
||
#[serde(default)]
|
||
pub semantic_defaults: Vec<SemanticDefault>,
|
||
#[serde(default)]
|
||
pub generated_interface_allocations: Vec<GeneratedInterfaceAllocation>,
|
||
#[serde(default)]
|
||
pub unresolved_message_values: Vec<UnresolvedMessageValue>,
|
||
}
|
||
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<MigrationReport>,
|
||
}
|
||
#[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<validation::Issue>,
|
||
},
|
||
#[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<Project, FileError> {
|
||
decode_with_report(bytes).map(|decoded| decoded.project)
|
||
}
|
||
pub fn decode_with_report(bytes: &[u8]) -> Result<DecodeResult, FileError> {
|
||
decode_with_report_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
||
}
|
||
pub fn decode_limited(
|
||
bytes: &[u8],
|
||
limits: crate::language::ParseLimits,
|
||
) -> Result<Project, FileError> {
|
||
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<DecodeResult, FileError> {
|
||
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)?;
|
||
if header
|
||
.get("schema_version")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.is_some_and(|v| v <= 6)
|
||
{
|
||
reject_source7_fields(&header).map_err(FileError::Invalid)?;
|
||
}
|
||
if header
|
||
.get("schema_version")
|
||
.and_then(serde_json::Value::as_u64)
|
||
.is_some_and(|v| v <= 5)
|
||
&& let Some(diagrams) = header
|
||
.get("diagrams")
|
||
.and_then(serde_json::Value::as_object)
|
||
{
|
||
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
|
||
for key in diagram.keys() {
|
||
if key == "view_options" {
|
||
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
|
||
}
|
||
if !matches!(
|
||
key.as_str(),
|
||
"id" | "alias"
|
||
| "name"
|
||
| "description"
|
||
| "layer"
|
||
| "projection"
|
||
| "nodes"
|
||
| "routes"
|
||
) {
|
||
return Err(FileError::Invalid(format!(
|
||
"Unknown diagram field '{key}' in the frozen pre6 contract."
|
||
)));
|
||
}
|
||
}
|
||
}
|
||
}
|
||
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) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
|
||
Some(6) => migrate_v6(serde_json::from_slice(bytes)?, limits)?,
|
||
Some(7) => DecodeResult {
|
||
project: serde_json::from_slice(bytes)?,
|
||
migration: None,
|
||
},
|
||
_ => {
|
||
return Err(FileError::Invalid(
|
||
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6 or 7.".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::<Vec<_>>()
|
||
.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)
|
||
}
|
||
|
||
/// Versions 4 and 5 share unchanged typed records, but their constructor set
|
||
/// remains frozen. Apply this before normalizing the advertised version.
|
||
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
|
||
matches!(
|
||
kind,
|
||
RelationKind::InterfaceItemAllocation
|
||
| RelationKind::DataAssociation
|
||
| RelationKind::Allocation
|
||
| RelationKind::PortAllocation
|
||
| RelationKind::Interaction
|
||
| RelationKind::CapabilityInvolvement
|
||
| RelationKind::ChainInvolvement
|
||
| RelationKind::RoleRealization
|
||
| RelationKind::RoleAssignment
|
||
| RelationKind::FunctionalExchange
|
||
| RelationKind::BoundaryExchange
|
||
| RelationKind::MissionCapability
|
||
| RelationKind::CapabilityInclude
|
||
| RelationKind::CapabilityExtend
|
||
| RelationKind::CapabilityGeneralization
|
||
| RelationKind::MissionInvolvement
|
||
| RelationKind::SequenceLink
|
||
| RelationKind::StateTransition
|
||
| RelationKind::Availability
|
||
| RelationKind::ExchangeAllocation
|
||
| RelationKind::InterfaceProvision
|
||
| RelationKind::InterfaceRequirement
|
||
| RelationKind::StateAction
|
||
)
|
||
}
|
||
pub(crate) fn pre6_element_kind(kind: Kind) -> bool {
|
||
matches!(
|
||
kind,
|
||
Kind::Entity
|
||
| Kind::Actor
|
||
| Kind::Role
|
||
| Kind::Activity
|
||
| Kind::Capability
|
||
| Kind::OperationalMission
|
||
| Kind::Process
|
||
| Kind::System
|
||
| Kind::SystemActor
|
||
| Kind::Function
|
||
| Kind::SystemCapability
|
||
| Kind::Mission
|
||
| Kind::FunctionalChain
|
||
| Kind::ExchangeItem
|
||
| Kind::DataType
|
||
| Kind::DataField
|
||
| Kind::Interface
|
||
| Kind::Port
|
||
| Kind::Scenario
|
||
| Kind::Lifeline
|
||
| Kind::Occurrence
|
||
| Kind::StateMachine
|
||
| Kind::Region
|
||
| Kind::State
|
||
| Kind::Mode
|
||
| Kind::Initial
|
||
| Kind::Final
|
||
| Kind::Choice
|
||
| Kind::Junction
|
||
| Kind::Fork
|
||
| Kind::Join
|
||
| Kind::ShallowHistory
|
||
| Kind::DeepHistory
|
||
| Kind::EntryPoint
|
||
| Kind::ExitPoint
|
||
| Kind::Terminate
|
||
| Kind::Event
|
||
| Kind::Constraint
|
||
)
|
||
}
|
||
pub(crate) fn check_pre6_relations<'a>(
|
||
relations: impl Iterator<Item = &'a Relation>,
|
||
) -> Result<(), FileError> {
|
||
for relation in relations {
|
||
if !pre6_relation_kind(relation.kind()) {
|
||
return Err(FileError::Invalid(format!(
|
||
"{:?} requires schema/source 6.",
|
||
relation.kind()
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
pub(crate) fn check_pre6_elements<'a>(
|
||
elements: impl Iterator<Item = &'a Element>,
|
||
) -> Result<(), FileError> {
|
||
for element in elements {
|
||
if !pre6_element_kind(element.kind()) {
|
||
return Err(FileError::Invalid(format!(
|
||
"{:?} requires schema/source 6.",
|
||
element.kind()
|
||
)));
|
||
}
|
||
}
|
||
Ok(())
|
||
}
|
||
pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), FileError> {
|
||
if baseline.facets.iter().any(|f| {
|
||
matches!(
|
||
f,
|
||
FacetReview::Category(_)
|
||
| FacetReview::Member {
|
||
collection: ReferenceCollection::CategoryExchanges,
|
||
..
|
||
}
|
||
)
|
||
}) {
|
||
return Err(FileError::Invalid(
|
||
"Exchange category semantic facts require schema/source 6.".into(),
|
||
));
|
||
}
|
||
Ok(())
|
||
}
|
||
/// Frozen sources 3–6 admit opposite-layer placements and visibility references
|
||
/// only in Traceability. Check before migration discards the advertised version;
|
||
/// current ordinary projections may retain these records as latent presentation.
|
||
pub(crate) fn check_pre7_diagram_admission(project: &Project) -> Result<(), FileError> {
|
||
let mut issues = Vec::new();
|
||
for diagram in project.diagrams.values() {
|
||
let option_issues = if diagram.projection == ProjectionKind::Traceability {
|
||
crate::view_options::validate_options_cross_layer(project, &diagram.view_options)
|
||
} else {
|
||
crate::view_options::validate_options(project, diagram.layer, &diagram.view_options)
|
||
};
|
||
for issue in option_issues {
|
||
validation::error(
|
||
&mut issues,
|
||
diagram.id,
|
||
"view_options",
|
||
issue.field,
|
||
issue.message,
|
||
);
|
||
}
|
||
for node in diagram.nodes.values() {
|
||
if !project.elements.get(&node.element).is_some_and(|element| {
|
||
diagram.projection == ProjectionKind::Traceability
|
||
|| element.layer() == diagram.layer
|
||
}) {
|
||
validation::error(
|
||
&mut issues,
|
||
node.id,
|
||
"diagram_layer",
|
||
"element",
|
||
"Diagram nodes reference existing elements in the selected layer.",
|
||
);
|
||
}
|
||
}
|
||
}
|
||
if let Some(issue) = issues.into_iter().next() {
|
||
return Err(FileError::Model {
|
||
message: issue.message.clone(),
|
||
issues: vec![issue],
|
||
});
|
||
}
|
||
Ok(())
|
||
}
|
||
|
||
pub(crate) fn migrate_v5(
|
||
mut project: Project,
|
||
limits: crate::language::ParseLimits,
|
||
) -> Result<DecodeResult, FileError> {
|
||
if project.schema_version != 5 {
|
||
return Err(FileError::Invalid("Expected schema_version 5.".into()));
|
||
}
|
||
check_pre7_diagram_admission(&project)?;
|
||
check_pre6_relations(project.relations.values())?;
|
||
check_pre6_elements(project.elements.values())?;
|
||
for baseline in project
|
||
.traces
|
||
.values()
|
||
.filter_map(|t| t.baseline.as_ref().and_then(|b| b.semantic.as_ref()))
|
||
{
|
||
check_pre6_baseline(baseline)?;
|
||
}
|
||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||
project.schema_version = SCHEMA_VERSION;
|
||
Ok(DecodeResult {
|
||
project,
|
||
migration: Some(MigrationReport::data_upgrade(5)),
|
||
})
|
||
}
|
||
|
||
/// Reject explicit new fields before serde defaults can erase their presence.
|
||
/// This is also applied to source's resolved structured values.
|
||
pub(crate) fn reject_source7_fields(value: &serde_json::Value) -> Result<(), String> {
|
||
match value {
|
||
serde_json::Value::Object(fields) => {
|
||
if fields.contains_key("ContextualLink") {
|
||
return Err("ContextualLink requires schema/source 7.".into());
|
||
}
|
||
if fields.get("Relation").and_then(serde_json::Value::as_str) == Some("ContextualLink")
|
||
{
|
||
return Err("ContextualLink view selectors require schema/source 7.".into());
|
||
}
|
||
if fields.keys().any(|key| {
|
||
matches!(
|
||
key.as_str(),
|
||
"Operation"
|
||
| "ContextOwner"
|
||
| "ContextSource"
|
||
| "ContextTarget"
|
||
| "ContextPurpose"
|
||
)
|
||
}) {
|
||
return Err(
|
||
"Operation/contextual-link semantic facets require schema/source 7.".into(),
|
||
);
|
||
}
|
||
if fields
|
||
.get("Exchange")
|
||
.and_then(serde_json::Value::as_object)
|
||
.is_some_and(|body| body.contains_key("operation"))
|
||
{
|
||
return Err(
|
||
"Exchange.operation requires schema/source 7, including explicit null.".into(),
|
||
);
|
||
}
|
||
for nested in fields.values() {
|
||
reject_source7_fields(nested)?;
|
||
}
|
||
}
|
||
serde_json::Value::Array(values) => {
|
||
for nested in values {
|
||
reject_source7_fields(nested)?;
|
||
}
|
||
}
|
||
_ => {}
|
||
}
|
||
Ok(())
|
||
}
|
||
pub(crate) fn migrate_v6(
|
||
mut project: Project,
|
||
limits: crate::language::ParseLimits,
|
||
) -> Result<DecodeResult, FileError> {
|
||
if project.schema_version != 6 {
|
||
return Err(FileError::Invalid("Expected schema_version 6.".into()));
|
||
}
|
||
check_pre7_diagram_admission(&project)?;
|
||
reject_source7_fields(&serde_json::to_value(&project)?).map_err(FileError::Invalid)?;
|
||
limits.check_model(&project).map_err(FileError::Limit)?;
|
||
project.schema_version = SCHEMA_VERSION;
|
||
Ok(DecodeResult {
|
||
project,
|
||
migration: Some(MigrationReport::data_upgrade(6)),
|
||
})
|
||
}
|
||
|
||
pub(crate) fn migrate_v1_with_report_limited(
|
||
old: v1::Project,
|
||
limits: crate::language::ParseLimits,
|
||
) -> Result<DecodeResult, FileError> {
|
||
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<v2::model::Project, String> {
|
||
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::<Vec<_>>()
|
||
.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::<std::collections::BTreeSet<_>>();
|
||
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::<Vec<_>>();
|
||
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::<usize>();
|
||
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<Id>,
|
||
}
|
||
#[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<Id, Element>,
|
||
pub relations: BTreeMap<Id, Relation>,
|
||
pub aliases: BTreeMap<Id, String>,
|
||
}
|
||
impl<'de> Deserialize<'de> for Project {
|
||
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||
#[derive(Deserialize)]
|
||
#[serde(deny_unknown_fields)]
|
||
struct Raw {
|
||
schema_version: u32,
|
||
id: Id,
|
||
name: String,
|
||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||
elements: BTreeMap<Id, Element>,
|
||
#[serde(deserialize_with = "crate::unique_id_map")]
|
||
relations: BTreeMap<Id, Relation>,
|
||
}
|
||
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<Id>,
|
||
pub message: String,
|
||
}
|
||
fn issue(id: Option<Id>, message: impl Into<String>) -> 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<Issue> {
|
||
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<Project, FileError> {
|
||
load_with_report(path).map(|decoded| decoded.project)
|
||
}
|
||
pub fn load_with_report(path: &Path) -> Result<DecodeResult, FileError> {
|
||
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<Vec<u8>, 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)
|
||
}
|