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

954 lines
33 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}