Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -0,0 +1,529 @@
|
||||
use crate::*;
|
||||
use std::{io::Write, path::Path};
|
||||
use thiserror::Error;
|
||||
#[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(
|
||||
"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_limited(bytes, crate::language::ParseLimits::UNLIMITED)
|
||||
}
|
||||
|
||||
/// Apply admission budgets before validating a candidate or its migrated form.
|
||||
pub fn decode_limited(
|
||||
bytes: &[u8],
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<Project, FileError> {
|
||||
if bytes.len() > limits.max_source_bytes {
|
||||
return Err(FileError::Limit(format!(
|
||||
"Input exceeds {} bytes.",
|
||||
limits.max_source_bytes
|
||||
)));
|
||||
}
|
||||
let p: Project = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
|
||||
let header: serde_json::Value = serde_json::from_slice(bytes)?;
|
||||
match header
|
||||
.get("schema_version")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
{
|
||||
Some(1) => {
|
||||
let old = serde_json::from_slice(bytes)?;
|
||||
check_v1_limits(&old, limits).map_err(FileError::Limit)?;
|
||||
migrate_v1_limited(old, limits).map_err(FileError::Invalid)?
|
||||
}
|
||||
Some(2) => serde_json::from_slice(bytes)?,
|
||||
_ => {
|
||||
return Err(FileError::Invalid(
|
||||
"Unsupported or missing schema_version; expected 1 or 2.".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
let text = std::str::from_utf8(bytes).map_err(|e| FileError::Text(e.to_string()))?;
|
||||
crate::language::parse_limited(text, limits).map_err(|diagnostics| {
|
||||
let detail = diagnostics
|
||||
.iter()
|
||||
.map(ToString::to_string)
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
if diagnostics.iter().any(|diagnostic| {
|
||||
matches!(diagnostic.code.as_str(), "model_limit" | "source_limit")
|
||||
}) {
|
||||
FileError::Limit(detail)
|
||||
} else {
|
||||
FileError::Text(detail)
|
||||
}
|
||||
})?
|
||||
};
|
||||
limits.check_model(&p).map_err(FileError::Limit)?;
|
||||
validate_project(&p)?;
|
||||
Ok(p)
|
||||
}
|
||||
|
||||
/// Explicit V1 adapter. Old identities, labels, descriptions, endpoints and
|
||||
/// coordinates survive; every newly required identity is deterministic.
|
||||
pub(crate) fn migrate_v1_limited(
|
||||
mut old: v1::Project,
|
||||
limits: crate::language::ParseLimits,
|
||||
) -> Result<Project, String> {
|
||||
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: e.position,
|
||||
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) = validation::validate(&p)
|
||||
.into_iter()
|
||||
.find(|i| i.severity == 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: &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::Invalid(i.message));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
pub fn load(path: &Path) -> Result<Project, FileError> {
|
||||
decode(&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)
|
||||
}
|
||||
Reference in New Issue
Block a user