Files
ARCADIA-Studio/crates/oa-core/src/persistence.rs
T

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)
}