1020 lines
35 KiB
Rust
1020 lines
35 KiB
Rust
use crate::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{io::Write, path::Path};
|
|
use thiserror::Error;
|
|
mod json_codec;
|
|
#[cfg(test)]
|
|
#[path = "persistence/source_save_tests.rs"]
|
|
mod source_save_tests;
|
|
#[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;
|
|
#[path = "migration_v7.rs"]
|
|
pub(crate) mod v7;
|
|
|
|
#[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> {
|
|
decode_with_report_profile(
|
|
bytes,
|
|
limits,
|
|
json_codec::Profile::with_byte_limit(limits.max_source_bytes),
|
|
json_codec::DEPTH_128_VALIDATED,
|
|
)
|
|
}
|
|
fn decode_with_report_profile(
|
|
bytes: &[u8],
|
|
limits: crate::language::ParseLimits,
|
|
profile: json_codec::Profile,
|
|
depth_128: bool,
|
|
) -> 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'{') {
|
|
json_codec::preflight(bytes, profile)?;
|
|
let version = json_codec::version(bytes)?;
|
|
if version.is_some_and(|version| version <= 6) {
|
|
let header = json_codec::legacy(bytes)?;
|
|
let result = check_legacy_header(&header, version);
|
|
json_codec::discard_value(header);
|
|
result?;
|
|
}
|
|
match version {
|
|
Some(1) => {
|
|
let old = json_codec::legacy(bytes)?;
|
|
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
|
migrate_v1_with_report_limited(old, limits)?
|
|
}
|
|
Some(2) => migrate_v2_limited(json_codec::legacy(bytes)?, limits)?,
|
|
Some(3) => v3::migrate(json_codec::legacy(bytes)?, limits)?,
|
|
Some(4) => v4::migrate(json_codec::legacy(bytes)?, limits)?,
|
|
Some(5) => migrate_v5(
|
|
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
|
limits,
|
|
)?,
|
|
Some(6) => migrate_v6(
|
|
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
|
limits,
|
|
)?,
|
|
Some(7) => migrate_v7(
|
|
json_codec::legacy::<v7::Project7>(bytes)?.into_current()?,
|
|
limits,
|
|
)?,
|
|
Some(8) => DecodeResult {
|
|
project: json_codec::current(bytes, profile, depth_128)?,
|
|
migration: None,
|
|
},
|
|
_ => {
|
|
return Err(FileError::Invalid(
|
|
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5, 6, 7 or 8."
|
|
.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)
|
|
}
|
|
|
|
fn check_legacy_header(header: &serde_json::Value, version: Option<u64>) -> Result<(), FileError> {
|
|
reject_source7_fields(header).map_err(FileError::Invalid)?;
|
|
if version.is_some_and(|version| version <= 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."
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
/// 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(())
|
|
}
|
|
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()));
|
|
}
|
|
reject_pre8_project(&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> {
|
|
let mut pending = vec![value];
|
|
while let Some(value) = pending.pop() {
|
|
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(),
|
|
);
|
|
}
|
|
pending.extend(fields.values().rev());
|
|
}
|
|
serde_json::Value::Array(values) => {
|
|
pending.extend(values.iter().rev());
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
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()));
|
|
}
|
|
reject_pre8_project(&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_v7(
|
|
mut project: Project,
|
|
limits: crate::language::ParseLimits,
|
|
) -> Result<DecodeResult, FileError> {
|
|
if project.schema_version != 7 {
|
|
return Err(FileError::Invalid("Expected schema_version 7.".into()));
|
|
}
|
|
reject_pre8_project(&project)?;
|
|
limits.check_model(&project).map_err(FileError::Limit)?;
|
|
project.schema_version = SCHEMA_VERSION;
|
|
Ok(DecodeResult {
|
|
project,
|
|
migration: Some(MigrationReport::data_upgrade(7)),
|
|
})
|
|
}
|
|
fn reject_pre8_project(project: &Project) -> Result<(), FileError> {
|
|
if !project.definitions.is_empty() {
|
|
return Err(FileError::Invalid(
|
|
"Definitions require schema/source 8.".into(),
|
|
));
|
|
}
|
|
v7::reject_elements(project.elements.values())
|
|
}
|
|
|
|
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> {
|
|
json_codec::document_stack(|| validate_project_inner(p))
|
|
}
|
|
fn validate_project_inner(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(&json_codec::read_bounded(
|
|
path,
|
|
json_codec::MAX_DOCUMENT_BYTES,
|
|
)?)
|
|
}
|
|
/// 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> {
|
|
save_checked_profile(
|
|
path,
|
|
p,
|
|
expected,
|
|
json_codec::Profile::DOCUMENT,
|
|
json_codec::DEPTH_128_VALIDATED,
|
|
)
|
|
}
|
|
fn save_checked_profile(
|
|
path: &Path,
|
|
p: &Project,
|
|
expected: Option<&[u8]>,
|
|
profile: json_codec::Profile,
|
|
depth_128: bool,
|
|
) -> Result<Vec<u8>, FileError> {
|
|
save_checked_profile_with_source(path, p, expected, profile, depth_128, |project| {
|
|
crate::language::format(project).into_bytes()
|
|
})
|
|
}
|
|
// Keep the source writer replaceable only at this private seam so regressions
|
|
// can prove that malformed or lossy output never reaches filesystem staging.
|
|
fn save_checked_profile_with_source(
|
|
path: &Path,
|
|
p: &Project,
|
|
expected: Option<&[u8]>,
|
|
profile: json_codec::Profile,
|
|
depth_128: bool,
|
|
source: impl FnOnce(&Project) -> Vec<u8>,
|
|
) -> Result<Vec<u8>, FileError> {
|
|
validate_project(p)?;
|
|
let is_source = path
|
|
.extension()
|
|
.is_some_and(|ext| ext.eq_ignore_ascii_case("arcadia"));
|
|
let mut bytes = if is_source {
|
|
source(p)
|
|
} else {
|
|
json_codec::encode(p, profile, depth_128)?
|
|
};
|
|
if !bytes.ends_with(b"\n") {
|
|
bytes.push(b'\n');
|
|
}
|
|
if is_source {
|
|
// Check exactly the final bytes, including their newline, through the
|
|
// public reader before creating a temp file or replacing a destination.
|
|
let decoded = decode_with_report_limited(
|
|
&bytes,
|
|
crate::language::ParseLimits {
|
|
max_source_bytes: profile.bytes,
|
|
..crate::language::ParseLimits::UNLIMITED
|
|
},
|
|
)?;
|
|
if decoded.project != *p {
|
|
return Err(FileError::Invalid(
|
|
"Generated source does not reproduce the complete requested project.".into(),
|
|
));
|
|
}
|
|
if decoded.migration.is_some() {
|
|
return Err(FileError::Invalid(
|
|
"Generated source unexpectedly requires migration.".into(),
|
|
));
|
|
}
|
|
}
|
|
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 json_codec::matches_file(path, expected) {
|
|
Ok(true) => {}
|
|
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)
|
|
}
|