Establish OA/SA prototype baseline and first-release work

This commit is contained in:
2026-09-05 17:04:16 +03:00
commit 93ba83d321
109 changed files with 56512 additions and 0 deletions
+710
View File
@@ -0,0 +1,710 @@
use crate::*;
use std::collections::BTreeMap;
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str, parent: Option<Id>) -> Id {
p.add_element(kind, layer, name, parent).unwrap()
}
fn occurrence(
p: &mut Project,
owner: Id,
layer: Layer,
name: &str,
exchange: Id,
sender: Option<Id>,
receiver: Option<Id>,
) -> Id {
let id = add(p, Kind::Occurrence, layer, name, Some(owner));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
exchange: Some(exchange),
sender,
receiver,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
},
precondition: String::new(),
postcondition: String::new(),
};
p.elements
.get_mut(&owner)
.unwrap()
.steps_mut()
.unwrap()
.push(id);
id
}
fn port(p: &mut Project, owner: Id, name: &str, direction: PortDirection, interface: Id) -> Id {
let id = add(p, Kind::Port, Layer::Sa, name, Some(owner));
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Port {
direction,
interface: Some(interface),
};
id
}
fn diagram(
p: &mut Project,
layer: Layer,
projection: ProjectionKind,
name: &str,
elements: &[Id],
) -> Id {
let id = Id::new_v4();
let alias = p.fresh_alias(name, "diagram", id);
p.diagrams.insert(
id,
Diagram {
id,
alias,
name: name.into(),
description: "A focused projection of the shared engineering model.".into(),
layer,
projection,
nodes: BTreeMap::new(),
},
);
for (index, e) in elements.iter().enumerate() {
p.place(
id,
*e,
Position {
x: 60. + (index % 4) as f32 * 280.,
y: 70. + (index / 4) as f32 * 170.,
},
);
}
id
}
fn trace(p: &mut Project, oa: Id, sa: Id, kind: TraceKind) {
let id = Id::new_v4();
let name = format!("{} ↔ {}", p.named(oa), p.named(sa));
let alias = p.fresh_alias(&format!("trace_{}", p.named(oa)), "trace", id);
p.traces.insert(
id,
Trace {
id,
alias,
name,
description: "Reviewed OA/SA correspondence in the coastal rescue example.".into(),
oa,
sa,
kind,
baseline: Some(TraceBaseline {
oa: p.properties(oa).unwrap(),
sa: p.properties(sa).unwrap(),
}),
},
);
}
pub fn coastal_rescue() -> Project {
let mut p = Project::empty();
p.name = "Coastal rescue — operational need to system behavior".into();
p.description="A worked OA/SA example. Compare allocation and functional decomposition views; inspect traces, repeated scenario messages, interface contracts, degraded modes, and external engineering artifacts.".into();
let coordination = add(&mut p, Kind::Entity, Layer::Oa, "Rescue coordination", None);
let vessel = add(&mut p, Kind::Actor, Layer::Oa, "Vessel in distress", None);
let role = add(&mut p, Kind::Role, Layer::Oa, "Incident coordinator", None);
let oa_parent = add(
&mut p,
Kind::Activity,
Layer::Oa,
"Respond to distress",
None,
);
let report = add(
&mut p,
Kind::Activity,
Layer::Oa,
"Report distress",
Some(oa_parent),
);
let assess = add(
&mut p,
Kind::Activity,
Layer::Oa,
"Assess incident",
Some(oa_parent),
);
let oa_cap = add(
&mut p,
Kind::Capability,
Layer::Oa,
"Protect life at sea",
None,
);
let process = add(
&mut p,
Kind::Process,
Layer::Oa,
"Distress to response",
None,
);
let oa_item = add(
&mut p,
Kind::ExchangeItem,
Layer::Oa,
"Distress report",
None,
);
p.relate(RelationKind::Allocation, "Reports distress", vessel, report);
p.relate(
RelationKind::Allocation,
"Assesses incident",
coordination,
assess,
);
p.relate(
RelationKind::RoleRealization,
"Fulfils coordinator role",
coordination,
role,
);
p.relate(
RelationKind::RoleAssignment,
"Coordinates assessment",
role,
assess,
);
let interaction = p.relate(RelationKind::Interaction, "Distress signal", report, assess);
if let RelationBody::Interaction { items, .. } =
&mut p.relations.get_mut(&interaction).unwrap().body
{
items.push(oa_item);
}
p.relate(
RelationKind::CapabilityInvolvement,
"Operational contribution",
assess,
oa_cap,
);
p.relate(
RelationKind::ChainInvolvement,
"Operational realization",
process,
oa_cap,
);
let oa_occurrence = occurrence(
&mut p,
process,
Layer::Oa,
"Distress received",
interaction,
None,
None,
);
let system = add(
&mut p,
Kind::System,
Layer::Sa,
"Rescue coordination system",
None,
);
let actor = add(
&mut p,
Kind::SystemActor,
Layer::Sa,
"Vessel communication service",
None,
);
let sa_parent = add(
&mut p,
Kind::Function,
Layer::Sa,
"Manage distress response",
None,
);
let transmit = add(
&mut p,
Kind::Function,
Layer::Sa,
"Transmit distress report",
Some(sa_parent),
);
let coordinate = add(
&mut p,
Kind::Function,
Layer::Sa,
"Classify incident and alert operator",
Some(sa_parent),
);
let sa_cap = add(
&mut p,
Kind::SystemCapability,
Layer::Sa,
"Coordinate rescue response",
None,
);
let chain = add(
&mut p,
Kind::FunctionalChain,
Layer::Sa,
"Digital distress handling",
None,
);
let mission = add(
&mut p,
Kind::Mission,
Layer::Sa,
"Support timely rescue decisions",
None,
);
p.relate(
RelationKind::Allocation,
"Vessel transmission",
actor,
transmit,
);
p.relate(
RelationKind::Allocation,
"System assessment",
system,
coordinate,
);
p.relate(
RelationKind::CapabilityInvolvement,
"System capability support",
coordinate,
sa_cap,
);
p.relate(
RelationKind::ChainInvolvement,
"Functional chain support",
chain,
sa_cap,
);
p.relate(
RelationKind::MissionCapability,
"Mission capability",
mission,
sa_cap,
);
let information = add(
&mut p,
Kind::ExchangeItem,
Layer::Sa,
"Digital distress report",
None,
);
let material = add(
&mut p,
Kind::ExchangeItem,
Layer::Sa,
"Rescue supply package",
None,
);
*p.elements.get_mut(&material).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Material,
};
let energy = add(
&mut p,
Kind::ExchangeItem,
Layer::Sa,
"Backup electrical energy",
None,
);
*p.elements.get_mut(&energy).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem {
kind: ExchangeItemKind::Energy,
};
let field = add(
&mut p,
Kind::DataField,
Layer::Sa,
"Reported latitude",
Some(information),
);
*p.elements.get_mut(&field).unwrap().shared_mut().unwrap() = SharedBody::DataField {
type_ref: TypeRef::Real,
minimum: 1,
maximum: Some(1),
unit: "degrees north".into(),
};
let interface = add(
&mut p,
Kind::Interface,
Layer::Sa,
"Distress reporting interface",
None,
);
*p.elements
.get_mut(&interface)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Interface {
items: vec![information],
};
let vessel_port = port(
&mut p,
actor,
"Vessel reporting output",
PortDirection::Out,
interface,
);
let system_port = port(
&mut p,
system,
"System reporting input",
PortDirection::In,
interface,
);
let function_out = port(
&mut p,
transmit,
"Report function output",
PortDirection::Out,
interface,
);
let function_in = port(
&mut p,
coordinate,
"Assessment function input",
PortDirection::In,
interface,
);
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Validated distress report",
transmit,
coordinate,
);
p.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange {
source: transmit,
target: coordinate,
source_port: Some(function_out),
target_port: Some(function_in),
items: vec![information],
};
let boundary = p.relate(
RelationKind::BoundaryExchange,
"Distress reporting connection",
vessel_port,
system_port,
);
if let RelationBody::BoundaryExchange { items, .. } =
&mut p.relations.get_mut(&boundary).unwrap().body
{
items.push(information);
}
p.relate(
RelationKind::ExchangeAllocation,
"Report carried by connection",
boundary,
exchange,
);
p.relate(
RelationKind::InterfaceProvision,
"Reporting service provided",
actor,
interface,
);
p.relate(
RelationKind::InterfaceRequirement,
"Reporting service required",
system,
interface,
);
let sa_occurrence = occurrence(
&mut p,
chain,
Layer::Sa,
"Report handled",
exchange,
None,
None,
);
let scenario = add(
&mut p,
Kind::Scenario,
Layer::Sa,
"Repeated distress reports",
None,
);
*p.elements.get_mut(&scenario).unwrap().shared_mut().unwrap() = SharedBody::Scenario {
viewpoint: ScenarioViewpoint::BoundaryInterface,
context: Some(sa_cap),
precondition: "A vessel has detected an emergency and the reporting service is available."
.into(),
postcondition: "The operator has a classified incident with the latest vessel report."
.into(),
steps: vec![],
};
let vessel_line = add(
&mut p,
Kind::Lifeline,
Layer::Sa,
"Vessel service lifeline",
Some(scenario),
);
*p.elements
.get_mut(&vessel_line)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(actor),
};
let system_line = add(
&mut p,
Kind::Lifeline,
Layer::Sa,
"Coordination system lifeline",
Some(scenario),
);
*p.elements
.get_mut(&system_line)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Lifeline {
represented: Some(system),
};
occurrence(
&mut p,
scenario,
Layer::Sa,
"Initial distress report",
exchange,
Some(vessel_line),
Some(system_line),
);
let update = occurrence(
&mut p,
scenario,
Layer::Sa,
"Updated distress report",
exchange,
Some(vessel_line),
Some(system_line),
);
if let Some(SharedBody::Occurrence {
precondition,
postcondition,
..
}) = p.elements.get_mut(&update).unwrap().shared_mut()
{
*precondition = "The vessel has more recent position information.".into();
*postcondition =
"The incident position is refreshed without creating a second incident.".into();
}
let machine = add(
&mut p,
Kind::StateMachine,
Layer::Sa,
"Reporting operation modes",
Some(system),
);
let region = add(
&mut p,
Kind::Region,
Layer::Sa,
"Reporting quality region",
Some(machine),
);
let initial = add(
&mut p,
Kind::Initial,
Layer::Sa,
"Start reporting",
Some(region),
);
let normal = add(
&mut p,
Kind::Mode,
Layer::Sa,
"Normal reporting",
Some(region),
);
let degraded = add(
&mut p,
Kind::Mode,
Layer::Sa,
"Degraded reporting",
Some(region),
);
p.relate(
RelationKind::StateTransition,
"Initialize reporting",
initial,
normal,
);
let degrade = p.relate(
RelationKind::StateTransition,
"Signal degraded",
normal,
degraded,
);
let timeout = add(&mut p, Kind::Event, Layer::Sa, "Report timeout", None);
*p.elements.get_mut(&timeout).unwrap().shared_mut().unwrap() = SharedBody::Event {
body: EventBody::Time {
kind: TimeEventKind::After,
expression: "30 s".into(),
},
};
p.relations.get_mut(&degrade).unwrap().body = RelationBody::StateTransition {
source: normal,
target: degraded,
transition_kind: TransitionKind::External,
triggers: vec![timeout],
effects: vec![],
trigger: "report_timeout".into(),
guard: "seconds_since_last_report > 30".into(),
effect: "Flag the incident and request an operator review.".into(),
};
let restore = p.relate(
RelationKind::StateTransition,
"Signal restored",
degraded,
normal,
);
let report_received = add(
&mut p,
Kind::Event,
Layer::Sa,
"Validated report received",
None,
);
*p.elements
.get_mut(&report_received)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Event {
body: EventBody::Signal {
items: vec![information],
},
};
p.relations.get_mut(&restore).unwrap().body = RelationBody::StateTransition {
source: degraded,
target: normal,
transition_kind: TransitionKind::External,
triggers: vec![report_received],
effects: vec![],
trigger: "validated_report_received".into(),
guard: "position_is_current".into(),
effect: "Clear the degraded-reporting alert.".into(),
};
p.relate(
RelationKind::Availability,
"Assessment in normal mode",
coordinate,
normal,
);
p.relate(
RelationKind::Availability,
"Assessment in degraded mode",
coordinate,
degraded,
);
let action = p.relate(
RelationKind::StateAction,
"Review on degraded entry",
degraded,
coordinate,
);
p.relations.get_mut(&action).unwrap().body = RelationBody::StateAction {
source: degraded,
target: coordinate,
phase: StateActionKind::Entry,
};
let constraint = add(
&mut p,
Kind::Constraint,
Layer::Sa,
"Response time budget",
None,
);
*p.elements
.get_mut(&constraint)
.unwrap()
.shared_mut()
.unwrap() = SharedBody::Constraint {
expression: "incident_alert_latency <= 120 s".into(),
constrained: vec![coordinate, exchange],
};
for (owner, name, uri, revision, relation) in [
(
coordinate,
"Response implementation",
"git+https://example.org/rescue/control?path=src/response.rs",
"design-v2",
ArtifactRelation::Implements,
),
(
system,
"Console enclosure CAD",
"cad://rescue-console/enclosure.step",
"B.3",
ArtifactRelation::References,
),
] {
let id = Id::new_v4();
let alias = p.fresh_alias(name, "artifact", id);
p.artifact_links.insert(id,ArtifactLink{id,alias,name:name.into(),description:"Illustrative external engineering reference; opening a model does not fetch it.".into(),owner,relation,uri:uri.into(),revision:Some(revision.into()),fragment:None});
}
for (oa, sa, kind) in [
(coordination, system, TraceKind::Participant),
(vessel, actor, TraceKind::Participant),
(oa_parent, sa_parent, TraceKind::Refinement),
(report, transmit, TraceKind::Refinement),
(assess, coordinate, TraceKind::Refinement),
(oa_cap, sa_cap, TraceKind::Capability),
(process, chain, TraceKind::Sequence),
(oa_occurrence, sa_occurrence, TraceKind::Scenario),
(interaction, exchange, TraceKind::Exchange),
(oa_item, information, TraceKind::Item),
] {
trace(&mut p, oa, sa, kind);
}
let oa_view = diagram(
&mut p,
Layer::Oa,
ProjectionKind::Allocation,
"OA participants and activities",
&[coordination, vessel, report, assess],
);
let sa_view = diagram(
&mut p,
Layer::Sa,
ProjectionKind::Allocation,
"SA system boundary and functions",
&[system, actor, transmit, coordinate],
);
let functions = diagram(
&mut p,
Layer::Sa,
ProjectionKind::FunctionalDataflow,
"SA functional decomposition",
&[sa_parent, transmit, coordinate],
);
let oa_capabilities = diagram(
&mut p,
Layer::Oa,
ProjectionKind::Capabilities,
"OA capabilities and participants",
&[coordination, vessel, role, oa_cap],
);
let sa_capabilities = diagram(
&mut p,
Layer::Sa,
ProjectionKind::Capabilities,
"SA missions and capabilities",
&[system, actor, mission, sa_cap],
);
let activities = diagram(
&mut p,
Layer::Oa,
ProjectionKind::FunctionalDataflow,
"OA activity dataflow",
&[oa_parent, report, assess],
);
execute_batch(
&p,
&[
Command::Layout(oa_view),
Command::Layout(sa_view),
Command::Layout(functions),
Command::Layout(oa_capabilities),
Command::Layout(sa_capabilities),
Command::Layout(activities),
],
)
.expect("The bundled OA/SA example is structurally valid")
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
//! Framework-independent OA/SA model, transactional commands and editing services.
pub mod language;
pub mod model;
pub mod persistence;
pub mod projection;
pub mod references;
pub mod session;
pub mod transitions;
pub mod validation;
pub use model::*;
pub use session::*;
mod demo;
mod state_validation;
File diff suppressed because it is too large Load Diff
+529
View File
@@ -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)
}
+310
View File
@@ -0,0 +1,310 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectedNode {
pub id: Id,
pub element: Id,
pub position: Position,
pub size: Size,
pub visual_parent: Option<Id>,
pub depth: usize,
pub collapsed: bool,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectedEdge {
pub id: Id,
pub source: Id,
pub target: Id,
pub trace: bool,
pub source_port: Option<Id>,
pub target_port: Option<Id>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ProjectedPort {
pub element: Id,
/// Placement identity of the owning visible function/participant.
pub owner: Id,
pub position: Position,
pub direction: PortDirection,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ProjectedView {
pub nodes: Vec<ProjectedNode>,
pub edges: Vec<ProjectedEdge>,
pub ports: Vec<ProjectedPort>,
}
pub(crate) fn minimum_node_size(p: &Project, element: Id, mut size: Size) -> Size {
let count = [PortDirection::In, PortDirection::Out].into_iter().map(|direction| p.children(element).filter(|port| matches!(port.shared(), Some(SharedBody::Port {direction:d,..}) if *d==direction)).count()).max().unwrap_or(0);
if count > 0 {
size.height = size.height.max(76. + 28. * (count + 1) as f32);
}
size
}
pub(crate) fn parents(p: &Project, d: &Diagram) -> BTreeMap<Id, Option<Id>> {
let placed: BTreeMap<_, _> = d
.nodes
.values()
.filter(|n| {
p.elements
.get(&n.element)
.is_some_and(|e| e.kind() != Kind::Port && d.projection.includes(e.kind()))
})
.map(|n| (n.element, n.id))
.collect();
d.nodes
.values()
.filter(|n| placed.contains_key(&n.element))
.map(|n| {
let mut semantic = p.elements.get(&n.element).and_then(|e| e.parent);
if d.projection == ProjectionKind::Allocation
&& let Some(performer) = crate::validation::allocation_summary(p, n.element)
{
let keep_parent = semantic.is_some_and(|parent| {
crate::validation::allocation_summary(p, parent) == Some(performer)
});
if !keep_parent {
semantic = Some(performer);
}
}
if d.projection == ProjectionKind::Traceability {
return (n.id, None);
}
let mut seen = BTreeSet::new();
let mut parent = None;
while let Some(id) = semantic {
if !seen.insert(id) {
break;
}
if let Some(node) = placed.get(&id) {
parent = Some(*node);
break;
}
semantic = p.elements.get(&id).and_then(|e| e.parent);
}
(n.id, parent)
})
.collect()
}
pub fn descendant_nodes(p: &Project, diagram: Id, node: Id) -> Vec<Id> {
let Some(d) = p.diagrams.get(&diagram) else {
return vec![];
};
let parents = parents(p, d);
parents
.keys()
.copied()
.filter(|candidate| {
let mut current = parents.get(candidate).copied().flatten();
let mut seen = BTreeSet::new();
while let Some(id) = current {
if id == node {
return true;
}
if !seen.insert(id) {
break;
}
current = parents.get(&id).copied().flatten();
}
false
})
.collect()
}
/// One projection drives drawing, hit testing, Fit, SVG, and grouped movement.
pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
let Some(d) = p.diagrams.get(&diagram) else {
return ProjectedView::default();
};
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
let Some(e) = p.elements.get(&n.element) else {
continue;
};
if e.kind() == Kind::Port || !d.projection.includes(e.kind()) {
continue;
}
if d.projection != ProjectionKind::Traceability && e.layer() != d.layer {
continue;
}
let mut current = parents.get(&n.id).copied().flatten();
let mut seen = BTreeSet::new();
let mut depth = 0;
let mut hidden = false;
while let Some(id) = current {
if !seen.insert(id) {
hidden = true;
break;
}
depth += 1;
if d.nodes.get(&id).is_some_and(|n| n.collapsed) {
hidden = true;
}
current = parents.get(&id).copied().flatten();
}
if !hidden {
let size = minimum_node_size(p, e.id, n.size);
nodes.push(ProjectedNode {
id: n.id,
element: n.element,
position: n.position,
size,
visual_parent: parents.get(&n.id).copied().flatten(),
depth,
collapsed: n.collapsed,
});
}
}
nodes.sort_by_key(|n| (n.depth, n.id));
for index in (0..nodes.len()).rev() {
let child = nodes[index].clone();
if let Some(parent) = child
.visual_parent
.and_then(|id| nodes.iter().position(|n| n.id == id))
{
let p = &mut nodes[parent];
let right =
(p.position.x + p.size.width).max(child.position.x + child.size.width + 24.);
let bottom =
(p.position.y + p.size.height).max(child.position.y + child.size.height + 24.);
p.position.x = p.position.x.min(child.position.x - 24.);
// Reserve two readable header lines above the first nested card.
p.position.y = p.position.y.min(child.position.y - 64.);
p.size.width = right - p.position.x;
p.size.height = bottom - p.position.y;
}
}
let visible: BTreeSet<_> = nodes.iter().map(|n| n.id).collect();
let placed: BTreeMap<_, _> = d.nodes.values().map(|n| (n.element, n.id)).collect();
let anchor = |element: Id| {
let owner = p
.elements
.get(&element)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(element);
let mut id = placed.get(&owner).copied();
let mut seen = BTreeSet::new();
while let Some(n) = id {
if visible.contains(&n) {
return Some(n);
}
if !seen.insert(n) {
break;
}
id = parents.get(&n).copied().flatten();
}
None
};
let mut ports = Vec::new();
for node in &nodes {
let mut children: Vec<_> = p
.children(node.element)
.filter(|e| e.kind() == Kind::Port)
.collect();
children.sort_by_key(|e| (&e.alias, e.id));
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
let side: Vec<_> = children.iter().filter(|e| matches!(e.shared(), Some(SharedBody::Port {direction: d, ..}) if *d == direction)).collect();
for (index, port) in side.iter().enumerate() {
let fraction = (index + 1) as f32 / (side.len() + 1) as f32;
let position = match direction {
PortDirection::In => Position {
x: node.position.x,
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
},
PortDirection::Out => Position {
x: node.position.x + node.size.width,
y: node.position.y + 64. + (node.size.height - 76.).max(0.) * fraction,
},
PortDirection::InOut => Position {
x: node.position.x + node.size.width * fraction,
y: node.position.y + node.size.height,
},
};
ports.push(ProjectedPort {
element: port.id,
owner: node.id,
position,
direction,
});
}
}
}
let mut edges = Vec::new();
for r in p.relations.values() {
if d.projection == ProjectionKind::Allocation && r.kind() == RelationKind::Allocation {
continue;
}
let included = match d.projection {
ProjectionKind::Allocation => matches!(
r.kind(),
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
),
ProjectionKind::FunctionalDataflow => matches!(
r.kind(),
RelationKind::Interaction | RelationKind::FunctionalExchange
),
ProjectionKind::Capabilities => matches!(
r.kind(),
RelationKind::CapabilityInclude
| RelationKind::CapabilityExtend
| RelationKind::CapabilityGeneralization
| RelationKind::MissionCapability
| RelationKind::MissionInvolvement
| RelationKind::CapabilityInvolvement
),
_ => true,
};
if !included {
continue;
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& source != target
{
let (source_port, target_port) = match &r.body {
RelationBody::FunctionalExchange {
source_port,
target_port,
..
} => (*source_port, *target_port),
RelationBody::BoundaryExchange { source, target, .. } => {
(Some(*source), Some(*target))
}
_ => (None, None),
};
edges.push(ProjectedEdge {
id: r.id,
source,
target,
trace: false,
source_port: source_port.filter(|id| ports.iter().any(|p| p.element == *id)),
target_port: target_port.filter(|id| ports.iter().any(|p| p.element == *id)),
});
}
}
if d.projection == ProjectionKind::Traceability {
for t in p.traces.values() {
if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa))
&& source != target
{
edges.push(ProjectedEdge {
id: t.id,
source,
target,
trace: true,
source_port: None,
target_port: None,
});
}
}
}
ProjectedView {
nodes,
edges,
ports,
}
}
+380
View File
@@ -0,0 +1,380 @@
use crate::*;
use std::collections::BTreeSet;
/// Every live reference in a typed element, including structural ownership.
pub fn element_references(e: &Element) -> Vec<Id> {
let mut copy = e.clone();
let mut refs = Vec::new();
visit_element_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn relation_references(r: &Relation) -> Vec<Id> {
let mut copy = r.clone();
let mut refs = Vec::new();
visit_relation_refs(&mut copy, |id| refs.push(*id));
refs
}
pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
if let Some(parent) = &mut e.parent {
visit(parent);
}
match &mut e.body {
ElementBody::Oa(OaBody::Process { steps })
| ElementBody::Sa(SaBody::FunctionalChain { steps }) => {
for id in steps {
visit(id);
}
}
_ => {}
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::DataField {
type_ref: TypeRef::Named(id),
..
} => visit(id),
SharedBody::Interface { items } => {
for id in items {
visit(id);
}
}
SharedBody::Event {
body: EventBody::Signal { items },
} => {
for id in items {
visit(id);
}
}
SharedBody::Port {
interface: Some(id),
..
} => visit(id),
SharedBody::Scenario { context, steps, .. } => {
if let Some(id) = context {
visit(id);
}
for id in steps {
visit(id);
}
}
SharedBody::Lifeline {
represented: Some(id),
} => visit(id),
SharedBody::Occurrence { body, .. } => match body {
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
reply_to,
values,
..
} => {
for id in [exchange, sender, receiver, reply_to].into_iter().flatten() {
visit(id);
}
for value in values {
visit(&mut value.field);
}
}
OccurrenceBody::Action { action, lifeline } => {
for id in [action, lifeline].into_iter().flatten() {
visit(id);
}
}
OccurrenceBody::SequenceReference { sequence } => {
if let Some(id) = sequence {
visit(id);
}
}
OccurrenceBody::Control { .. } => {}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
if let Some(id) = scenario {
visit(id);
}
for binding in bindings {
visit(&mut binding.referenced);
visit(&mut binding.local);
}
}
OccurrenceBody::State { state, lifeline } => {
for id in [state, lifeline].into_iter().flatten() {
visit(id);
}
}
OccurrenceBody::Fragment { branches, .. } => {
for id in branches {
visit(id);
}
}
OccurrenceBody::Branch { occurrences, .. } => {
for id in occurrences {
visit(id);
}
}
},
SharedBody::Constraint { constrained, .. } => {
for id in constrained {
visit(id);
}
}
_ => {}
}
}
}
pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
let (mut source, mut target) = r.body.endpoints();
visit(&mut source);
visit(&mut target);
r.body.set_endpoints(source, target);
match &mut r.body {
RelationBody::StateTransition {
triggers, effects, ..
} => {
for id in triggers.iter_mut().chain(effects) {
visit(id);
}
}
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
for id in items {
visit(id);
}
}
RelationBody::FunctionalExchange {
source_port,
target_port,
items,
..
} => {
for id in [source_port, target_port].into_iter().flatten() {
visit(id);
}
for id in items {
visit(id);
}
}
_ => {}
}
}
pub fn remap_references(p: &mut Project, old: Id, new: Id) {
for e in p.elements.values_mut() {
visit_element_refs(e, |id| {
if *id == old {
*id = new;
}
});
}
for r in p.relations.values_mut() {
visit_relation_refs(r, |id| {
if *id == old {
*id = new;
}
});
}
for d in p.diagrams.values_mut() {
for n in d.nodes.values_mut() {
if n.element == old {
n.element = new;
}
}
}
for a in p.artifact_links.values_mut() {
if a.owner == old {
a.owner = new;
}
}
for t in p.traces.values_mut() {
if t.oa == old {
t.oa = new;
}
if t.sa == old {
t.sa = new;
}
}
}
/// IDs removed by a cascade. Cross-layer traced counterparts are never included.
pub fn deletion_impact(p: &Project, id: Id) -> BTreeSet<Id> {
let mut removed = BTreeSet::from([id]);
loop {
let before = removed.len();
for e in p.elements.values() {
let dependent_type = matches!(e.shared(),Some(SharedBody::DataField{type_ref:TypeRef::Named(t),..}) if removed.contains(t));
if e.parent.is_some_and(|parent| removed.contains(&parent)) || dependent_type {
removed.insert(e.id);
}
if matches!(e.shared(),Some(SharedBody::Occurrence{body:OccurrenceBody::Exchange{exchange:Some(r),..},..}) if removed.contains(r))
{
removed.insert(e.id);
}
}
for r in p.relations.values() {
if removed.contains(&r.source()) || removed.contains(&r.target()) {
removed.insert(r.id);
}
}
for d in p.diagrams.values() {
for n in d.nodes.values() {
if removed.contains(&d.id) || removed.contains(&n.element) {
removed.insert(n.id);
}
}
}
for t in p.traces.values() {
if removed.contains(&t.oa) || removed.contains(&t.sa) {
removed.insert(t.id);
}
}
for a in p.artifact_links.values() {
if removed.contains(&a.owner) {
removed.insert(a.id);
}
}
if removed.len() == before {
break;
}
}
removed
}
pub fn delete_cascade(p: &mut Project, id: Id) {
let removed = deletion_impact(p, id);
let removed_names: std::collections::BTreeMap<_, _> = removed
.iter()
.map(|id| (*id, p.named(*id).to_string()))
.collect();
p.elements.retain(|id, _| !removed.contains(id));
p.relations.retain(|id, _| !removed.contains(id));
p.diagrams.retain(|id, _| !removed.contains(id));
p.traces.retain(|id, _| !removed.contains(id));
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.nodes.retain(|id, _| !removed.contains(id));
}
let clear = |v: &mut Option<Id>| {
if v.is_some_and(|id| removed.contains(&id)) {
*v = None;
}
};
for e in p.elements.values_mut() {
if let Some(steps) = e.steps_mut() {
steps.retain(|id| !removed.contains(id));
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::Interface { items } => items.retain(|id| !removed.contains(id)),
SharedBody::Event {
body: EventBody::Signal { items },
} => items.retain(|id| !removed.contains(id)),
SharedBody::Port { interface, .. } => clear(interface),
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
constrained.retain(|id| !removed.contains(id))
}
SharedBody::Occurrence { body, .. } => match body {
OccurrenceBody::Exchange {
exchange,
sender,
receiver,
reply_to,
values,
..
} => {
clear(exchange);
clear(sender);
clear(receiver);
clear(reply_to);
values.retain(|value| !removed.contains(&value.field));
}
OccurrenceBody::Action { action, lifeline } => {
clear(action);
clear(lifeline);
}
OccurrenceBody::SequenceReference { sequence } => clear(sequence),
OccurrenceBody::Control { .. } => {}
OccurrenceBody::ScenarioUse { scenario, bindings } => {
clear(scenario);
bindings.retain(|binding| {
!removed.contains(&binding.referenced)
&& !removed.contains(&binding.local)
});
}
OccurrenceBody::State { state, lifeline } => {
clear(state);
clear(lifeline);
}
OccurrenceBody::Fragment { branches, .. } => {
branches.retain(|id| !removed.contains(id))
}
OccurrenceBody::Branch { .. } => {}
},
_ => {}
}
}
}
for r in p.relations.values_mut() {
match &mut r.body {
RelationBody::StateTransition {
triggers,
effects,
trigger,
..
} => {
let deleted: Vec<_> = triggers
.iter()
.filter(|id| removed.contains(id))
.filter_map(|id| removed_names.get(id).cloned())
.collect();
triggers.retain(|id| !removed.contains(id));
effects.retain(|id| !removed.contains(id));
if !deleted.is_empty() && triggers.is_empty() && trigger.trim().is_empty() {
*trigger = format!(
"Removed trigger: {}. Choose a replacement trigger.",
deleted.join(", ")
);
}
}
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange {
items,
source_port,
target_port,
..
} => {
items.retain(|id| !removed.contains(id));
clear(source_port);
clear(target_port);
}
_ => {}
}
}
// Removing an item or a containing payload field can disconnect a still-live
// record field from an exchange. Its contextual annotation is no longer valid.
let allowed: std::collections::BTreeMap<_, _> = p
.elements
.values()
.filter_map(|e| match e.shared() {
Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
exchange: Some(id), ..
},
..
}) => p
.relations
.get(id)
.map(|r| (e.id, crate::validation::carried_fields(p, r))),
_ => None,
})
.collect();
for (id, fields) in allowed {
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange { values, .. },
..
}) = p.elements.get_mut(&id).and_then(Element::shared_mut)
{
values.retain(|value| fields.contains(&value.field));
}
}
}
+792
View File
@@ -0,0 +1,792 @@
use crate::*;
use serde::{Deserialize, Serialize};
use thiserror::Error;
#[derive(Debug, Clone, Error, Serialize, Deserialize)]
#[error("{0}")]
pub struct ModelError(pub String);
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum Command {
Replace(Project),
Add(Kind),
AddElement {
kind: Kind,
layer: Layer,
name: String,
parent: Option<Id>,
},
CreateElement(Element),
EditElement(Id, Element),
CreateRelation(Relation),
EditRelation(Id, Relation),
CreateDiagram(Diagram),
EditDiagram(Id, Diagram),
CreateArtifact(ArtifactLink),
EditArtifact(Id, ArtifactLink),
CreateTrace(Trace),
EditTrace(Id, Trace),
CreateNode {
diagram: Id,
node: NodePlacement,
},
EditNode {
diagram: Id,
original: Id,
node: NodePlacement,
},
RelocateNode {
node: Id,
diagram: Id,
},
EditProject {
id: Id,
alias: String,
name: String,
description: String,
},
Reparent {
element: Id,
parent: Option<Id>,
},
Reidentify(Id, Id),
Rename(Id, String),
Describe(Id, String),
SetAlias(Id, String),
Connect(RelationKind, String, Id, Id),
Delete(Id),
Batch(Vec<Command>),
AddStep(Id, Id),
RemoveStep(Id, usize),
MoveStep(Id, usize, usize),
SetSteps(Id, Vec<Id>),
MoveNode {
diagram: Id,
node: Id,
position: Position,
with_descendants: bool,
},
Layout(Id),
}
pub struct Session {
project: Project,
past: Vec<Project>,
future: Vec<Project>,
saved: Project,
revision: u64,
}
impl Session {
pub fn try_new(project: Project) -> Result<Self, ModelError> {
validate_candidate(&project)?;
Ok(Self {
saved: project.clone(),
project,
past: vec![],
future: vec![],
revision: 0,
})
}
pub fn new(project: Project) -> Self {
Self::try_new(project).expect("Session requires a valid project")
}
pub fn project(&self) -> &Project {
&self.project
}
pub fn revision(&self) -> u64 {
self.revision
}
pub fn dirty(&self) -> bool {
self.project != self.saved
}
pub fn mark_saved(&mut self, snapshot: Project) {
self.saved = snapshot;
}
pub fn can_undo(&self) -> bool {
!self.past.is_empty()
}
pub fn can_redo(&self) -> bool {
!self.future.is_empty()
}
pub fn undo(&mut self) {
if let Some(p) = self.past.pop() {
self.future.push(std::mem::replace(&mut self.project, p));
self.revision = self.revision.saturating_add(1);
}
}
pub fn redo(&mut self) {
if let Some(p) = self.future.pop() {
self.past.push(std::mem::replace(&mut self.project, p));
self.revision = self.revision.saturating_add(1);
}
}
pub fn execute_many(&mut self, commands: &[Command]) -> Result<(), ModelError> {
let candidate = execute_batch(&self.project, commands)?;
if candidate != self.project {
self.past
.push(std::mem::replace(&mut self.project, candidate));
if self.past.len() > 100 {
self.past.remove(0);
}
self.future.clear();
self.revision = self.revision.saturating_add(1);
}
Ok(())
}
pub fn execute(&mut self, command: Command) -> Result<Option<Id>, ModelError> {
let mut candidate = self.project.clone();
let selected = apply_command(&mut candidate, &command, 0)?;
validate_candidate(&candidate)?;
if candidate != self.project {
self.past
.push(std::mem::replace(&mut self.project, candidate));
if self.past.len() > 100 {
self.past.remove(0);
}
self.future.clear();
self.revision = self.revision.saturating_add(1);
}
Ok(selected)
}
}
pub fn validate_candidate(p: &Project) -> Result<(), ModelError> {
if let Some(i) = validation::validate(p)
.into_iter()
.find(|i| i.severity == validation::Severity::Error)
{
Err(ModelError(format!("{}: {}", i.code, i.message)))
} else {
Ok(())
}
}
pub fn execute_batch(project: &Project, commands: &[Command]) -> Result<Project, ModelError> {
let mut candidate = project.clone();
for command in commands {
apply_command(&mut candidate, command, 0)?;
}
validate_candidate(&candidate)?;
Ok(candidate)
}
fn vacant(p: &Project, id: Id) -> Result<(), ModelError> {
if p.contains(id) {
Err(ModelError(
"This ID already belongs to another object.".into(),
))
} else {
Ok(())
}
}
fn missing() -> ModelError {
ModelError("Object no longer exists.".into())
}
fn detach(p: &mut Project, id: Id) {
for e in p.elements.values_mut() {
if let Some(steps) = e.steps_mut() {
steps.retain(|step| *step != id);
}
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = e.shared_mut()
{
branches.retain(|branch| *branch != id);
}
}
}
fn attach(p: &mut Project, id: Id) {
let Some(e) = p.elements.get(&id) else {
return;
};
if e.kind() != Kind::Occurrence {
return;
}
let parent = e.parent;
let branch = matches!(
e.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
);
if let Some(owner) = parent.and_then(|id| p.elements.get_mut(&id)) {
if let Some(steps) = owner.steps_mut() {
if !steps.contains(&id) {
steps.push(id);
}
} else if branch
&& let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = owner.shared_mut()
&& !branches.contains(&id)
{
branches.push(id);
}
}
}
fn apply_command(p: &mut Project, c: &Command, depth: usize) -> Result<Option<Id>, ModelError> {
if depth > 64 {
return Err(ModelError(
"Command batch nesting exceeds 64 levels.".into(),
));
}
let selected = match c {
Command::Replace(value) => {
*p = value.clone();
None
}
Command::Batch(commands) => {
let mut selected = None;
for command in commands {
let id = apply_command(p, command, depth + 1)?;
if id.is_some() {
selected = id;
}
}
selected
}
Command::Add(kind) => {
let layer = if kind.supports(Layer::Oa) {
Layer::Oa
} else {
Layer::Sa
};
let name = format!("New {kind}");
let e = p
.make_element(*kind, layer, &name, None)
.ok_or_else(|| ModelError("Invalid layer for kind.".into()))?;
let id = e.id;
p.elements.insert(id, e);
let d = p.ensure_diagram(layer);
let slot = p.diagrams[&d].nodes.len();
p.place(
d,
id,
Position {
x: 70. + (slot % 4) as f32 * 270.,
y: 70. + (slot / 4) as f32 * 150.,
},
);
Some(id)
}
Command::AddElement {
kind,
layer,
name,
parent,
} => {
let e = p
.make_element(*kind, *layer, name, *parent)
.ok_or_else(|| ModelError("Invalid layer for kind.".into()))?;
let id = e.id;
p.elements.insert(id, e);
attach(p, id);
Some(id)
}
Command::CreateElement(value) => {
vacant(p, value.id)?;
p.elements.insert(value.id, value.clone());
attach(p, value.id);
Some(value.id)
}
Command::EditElement(old, value) => {
let previous = p.elements.get(old).ok_or_else(missing)?;
let ownership_change =
previous.parent != value.parent || previous.kind() != value.kind();
if ownership_change {
detach(p, *old);
}
let mut e = value.clone();
e.id = *old;
p.elements.insert(*old, e);
reidentify(p, *old, value.id)?;
if ownership_change {
attach(p, value.id);
}
Some(value.id)
}
Command::CreateRelation(value) => {
vacant(p, value.id)?;
p.relations.insert(value.id, value.clone());
Some(value.id)
}
Command::EditRelation(old, value) => {
if !p.relations.contains_key(old) {
return Err(missing());
}
let mut v = value.clone();
v.id = *old;
p.relations.insert(*old, v);
reidentify(p, *old, value.id)?;
Some(value.id)
}
Command::CreateDiagram(value) => {
vacant(p, value.id)?;
p.diagrams.insert(value.id, value.clone());
Some(value.id)
}
Command::EditDiagram(old, value) => {
if !p.diagrams.contains_key(old) {
return Err(missing());
}
let mut v = value.clone();
v.id = *old;
p.diagrams.insert(*old, v);
reidentify(p, *old, value.id)?;
Some(value.id)
}
Command::CreateArtifact(value) => {
vacant(p, value.id)?;
p.artifact_links.insert(value.id, value.clone());
Some(value.id)
}
Command::EditArtifact(old, value) => {
if !p.artifact_links.contains_key(old) {
return Err(missing());
}
let mut v = value.clone();
v.id = *old;
p.artifact_links.insert(*old, v);
reidentify(p, *old, value.id)?;
Some(value.id)
}
Command::CreateTrace(value) => {
vacant(p, value.id)?;
p.traces.insert(value.id, value.clone());
Some(value.id)
}
Command::EditTrace(old, value) => {
if !p.traces.contains_key(old) {
return Err(missing());
}
let mut v = value.clone();
v.id = *old;
p.traces.insert(*old, v);
reidentify(p, *old, value.id)?;
Some(value.id)
}
Command::CreateNode { diagram, node } => {
vacant(p, node.id)?;
p.diagrams
.get_mut(diagram)
.ok_or_else(missing)?
.nodes
.insert(node.id, node.clone());
Some(node.id)
}
Command::EditNode {
diagram,
original,
node,
} => {
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
if !d.nodes.contains_key(original) {
return Err(missing());
}
let mut n = node.clone();
n.id = *original;
d.nodes.insert(*original, n);
reidentify(p, *original, node.id)?;
Some(node.id)
}
Command::RelocateNode { node, diagram } => {
if !p.diagrams.contains_key(diagram) {
return Err(missing());
}
let source = p
.diagrams
.values()
.find(|d| d.nodes.contains_key(node))
.map(|d| d.id)
.ok_or_else(missing)?;
if source != *diagram {
let placement = p
.diagrams
.get_mut(&source)
.unwrap()
.nodes
.remove(node)
.unwrap();
p.diagrams
.get_mut(diagram)
.unwrap()
.nodes
.insert(*node, placement);
}
Some(*node)
}
Command::EditProject {
id,
alias,
name,
description,
} => {
reidentify(p, p.id, *id)?;
p.alias = alias.clone();
p.name = name.clone();
p.description = description.clone();
None
}
Command::Reparent { element, parent } => {
let current_parent = p.elements.get(element).ok_or_else(missing)?.parent;
if current_parent != *parent {
detach(p, *element);
p.elements.get_mut(element).unwrap().parent = *parent;
attach(p, *element);
}
Some(*element)
}
Command::Reidentify(old, new) => {
reidentify(p, *old, *new)?;
Some(*new)
}
Command::Rename(id, value) => {
edit_header(p, *id, Some(value), None, None)?;
Some(*id)
}
Command::Describe(id, value) => {
edit_header(p, *id, None, Some(value), None)?;
Some(*id)
}
Command::SetAlias(id, value) => {
edit_header(p, *id, None, None, Some(value))?;
Some(*id)
}
Command::Connect(kind, name, source, target) => {
Some(p.relate(*kind, name, *source, *target))
}
Command::Delete(id) => {
if *id == p.id {
return Err(ModelError("Use New/Replace to delete a project.".into()));
}
if !p.contains(*id) {
return Err(missing());
}
crate::references::delete_cascade(p, *id);
None
}
Command::AddStep(owner, exchange) => {
let e = p.elements.get(owner).ok_or_else(missing)?;
if e.steps().is_none() {
return Err(ModelError(
"Selected element does not own an ordered sequence.".into(),
));
}
let layer = e.layer();
let name = format!("{} occurrence", p.named(*exchange));
let mut occ = p
.make_element(Kind::Occurrence, layer, &name, Some(*owner))
.unwrap();
*occ.shared_mut().unwrap() = 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(),
};
let id = occ.id;
p.elements.insert(id, occ);
attach(p, id);
Some(*owner)
}
Command::RemoveStep(owner, index) => {
let id = *p
.elements
.get(owner)
.and_then(Element::steps)
.and_then(|s| s.get(*index))
.ok_or_else(|| ModelError("Step index is out of range.".into()))?;
crate::references::delete_cascade(p, id);
Some(*owner)
}
Command::MoveStep(owner, from, to) => {
let steps = p
.elements
.get_mut(owner)
.and_then(Element::steps_mut)
.ok_or_else(missing)?;
if *from >= steps.len() || *to >= steps.len() {
return Err(ModelError("Step index is out of range.".into()));
}
let step = steps.remove(*from);
steps.insert(*to, step);
Some(*owner)
}
Command::SetSteps(owner, value) => {
*p.elements
.get_mut(owner)
.and_then(Element::steps_mut)
.ok_or_else(missing)? = value.clone();
Some(*owner)
}
Command::MoveNode {
diagram,
node,
position,
with_descendants,
} => {
let descendants = if *with_descendants {
crate::projection::descendant_nodes(p, *diagram, *node)
} else {
vec![]
};
let d = p.diagrams.get_mut(diagram).ok_or_else(missing)?;
let n = d.nodes.get_mut(node).ok_or_else(missing)?;
let delta = Position {
x: position.x - n.position.x,
y: position.y - n.position.y,
};
n.position = *position;
for id in descendants {
if let Some(child) = d.nodes.get_mut(&id) {
child.position.x += delta.x;
child.position.y += delta.y;
}
}
Some(*node)
}
Command::Layout(diagram) => {
layout(p, *diagram)?;
Some(*diagram)
}
};
Ok(selected)
}
fn edit_header(
p: &mut Project,
id: Id,
name: Option<&String>,
description: Option<&String>,
alias: Option<&String>,
) -> Result<(), ModelError> {
let assign = |n: &mut String, d: &mut String, a: &mut String| {
if let Some(v) = name {
*n = v.clone();
}
if let Some(v) = description {
*d = v.clone();
}
if let Some(v) = alias {
*a = v.clone();
}
};
if p.id == id {
assign(&mut p.name, &mut p.description, &mut p.alias);
return Ok(());
}
if let Some(v) = p.elements.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.relations.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.diagrams.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.artifact_links.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
if let Some(v) = p.traces.get_mut(&id) {
assign(&mut v.name, &mut v.description, &mut v.alias);
return Ok(());
}
for d in p.diagrams.values_mut() {
if let Some(n) = d.nodes.get_mut(&id) {
if name.is_some() || description.is_some() {
return Err(ModelError(
"Diagram nodes have no independent name/description.".into(),
));
}
if let Some(a) = alias {
n.alias = a.clone();
}
return Ok(());
}
}
Err(missing())
}
pub fn reidentify(p: &mut Project, old: Id, new: Id) -> Result<(), ModelError> {
if !p.contains(old) {
return Err(missing());
}
if old == new {
return Ok(());
}
vacant(p, new)?;
if p.id == old {
p.id = new;
} else if let Some(mut v) = p.elements.remove(&old) {
v.id = new;
p.elements.insert(new, v);
} else if let Some(mut v) = p.relations.remove(&old) {
v.id = new;
p.relations.insert(new, v);
} else if let Some(mut v) = p.diagrams.remove(&old) {
v.id = new;
p.diagrams.insert(new, v);
} else if let Some(mut v) = p.artifact_links.remove(&old) {
v.id = new;
p.artifact_links.insert(new, v);
} else if let Some(mut v) = p.traces.remove(&old) {
v.id = new;
p.traces.insert(new, v);
} else {
for d in p.diagrams.values_mut() {
if let Some(mut n) = d.nodes.remove(&old) {
n.id = new;
d.nodes.insert(new, n);
break;
}
}
}
crate::references::remap_references(p, old, new);
Ok(())
}
fn layout(p: &mut Project, diagram: Id) -> Result<(), ModelError> {
use std::collections::{BTreeMap, BTreeSet};
let d = p.diagrams.get(&diagram).ok_or_else(missing)?;
let parents = crate::projection::parents(p, d);
let mut children: BTreeMap<Option<Id>, Vec<Id>> = BTreeMap::new();
for (id, parent) in &parents {
children.entry(*parent).or_default().push(*id);
}
fn measure(
p: &Project,
id: Id,
d: &Diagram,
children: &BTreeMap<Option<Id>, Vec<Id>>,
sizes: &mut BTreeMap<Id, Size>,
seen: &mut BTreeSet<Id>,
) -> Result<Size, ModelError> {
if !seen.insert(id) {
return Err(ModelError("Diagram containment cycle.".into()));
}
let own = crate::projection::minimum_node_size(p, d.nodes[&id].element, d.nodes[&id].size);
let mut width = own.width;
let mut height = 64.;
let kids = children.get(&Some(id));
if let Some(kids) = kids {
for child in kids {
let size = measure(p, *child, d, children, sizes, seen)?;
width = width.max(size.width + 48.);
height += size.height + 16.;
}
height += 8.;
} else {
height = own.height;
}
let size = Size {
width,
height: height.max(own.height),
};
sizes.insert(id, size);
Ok(size)
}
fn place(
id: Id,
position: Position,
children: &BTreeMap<Option<Id>, Vec<Id>>,
sizes: &BTreeMap<Id, Size>,
positions: &mut BTreeMap<Id, Position>,
) {
positions.insert(id, position);
let mut y = position.y + 64.;
if let Some(kids) = children.get(&Some(id)) {
for child in kids {
place(
*child,
Position {
x: position.x + 24.,
y,
},
children,
sizes,
positions,
);
y += sizes[child].height + 16.;
}
}
}
// Orient containers by their actual exchanges. UUID ordering made otherwise
// identical models swap source/sink sides and sent boundary ports backwards.
let mut remaining: BTreeSet<_> = children
.get(&None)
.cloned()
.unwrap_or_default()
.into_iter()
.collect();
let root_of = |mut node| {
let mut seen = BTreeSet::new();
while let Some(parent) = parents.get(&node).copied().flatten() {
if !seen.insert(node) {
break;
}
node = parent;
}
node
};
let edges: BTreeSet<_> = crate::projection::project_view(p, diagram)
.edges
.iter()
.filter_map(|edge| {
let source = root_of(edge.source);
let target = root_of(edge.target);
(source != target).then_some((source, target))
})
.collect();
let mut roots = Vec::new();
while !remaining.is_empty() {
let next = remaining
.iter()
.filter(|id| {
!edges
.iter()
.any(|(source, target)| target == *id && remaining.contains(source))
})
.min_by_key(|id| (&p.elements[&d.nodes[id].element].alias, **id))
.or_else(|| {
remaining
.iter()
.min_by_key(|id| (&p.elements[&d.nodes[id].element].alias, **id))
})
.copied()
.unwrap();
remaining.remove(&next);
roots.push(next);
}
let mut sizes = BTreeMap::new();
let mut seen = BTreeSet::new();
for id in &roots {
measure(p, *id, d, &children, &mut sizes, &mut seen)?;
}
let mut positions = BTreeMap::new();
let mut x = 60.;
let mut y = 60.;
let mut row_height = 0f32;
for (index, id) in roots.iter().enumerate() {
if index > 0 && index % 4 == 0 {
x = 60.;
y += row_height + 60.;
row_height = 0.;
}
place(*id, Position { x, y }, &children, &sizes, &mut positions);
x += sizes[id].width + 60.;
row_height = row_height.max(sizes[id].height);
}
let d = p.diagrams.get_mut(&diagram).unwrap();
for (id, position) in positions {
let n = d.nodes.get_mut(&id).unwrap();
n.position = position;
n.size = sizes[&id];
}
Ok(())
}
+469
View File
@@ -0,0 +1,469 @@
//! Structural state-machine rules. Expressions describe intent; they are not executed.
use crate::{
validation::{Issue, ancestor_of_kind, error, warning},
*,
};
use std::collections::BTreeSet;
fn ancestors(p: &Project, id: Id) -> Vec<Id> {
let mut result = Vec::new();
let mut current = p.elements.get(&id).and_then(|e| e.parent);
let mut seen = BTreeSet::from([id]);
while let Some(id) = current {
if !seen.insert(id) {
break;
}
result.push(id);
current = p.elements.get(&id).and_then(|e| e.parent);
}
result
}
fn within(p: &Project, id: Id, owner: Id) -> bool {
ancestors(p, id).contains(&owner)
}
fn boundary(p: &Project, vertex: &Element) -> Option<Id> {
vertex
.parent
.and_then(|region| p.elements.get(&region))
.and_then(|region| region.parent)
}
fn first_region(p: &Project, vertex: Id, owner: Id) -> Option<Id> {
ancestors(p, vertex).into_iter().find(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind() == Kind::Region && e.parent == Some(owner))
})
}
fn edges<'a>(
p: &'a Project,
candidate: Option<&'a Relation>,
) -> impl Iterator<Item = &'a Relation> {
p.relations
.values()
.filter(move |r| candidate.is_none_or(|c| c.id != r.id))
.chain(candidate)
.filter(|r| r.kind() == RelationKind::StateTransition)
}
fn guard(r: &Relation) -> &str {
match &r.body {
RelationBody::StateTransition { guard, .. } => guard.trim(),
_ => "",
}
}
fn orthogonal(p: &Project, arms: &[Id]) -> bool {
let Some(first) = arms.first() else {
return true;
};
if arms.len() < 2 {
return true;
}
let Some(owner) = ancestors(p, *first).into_iter().find(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_stable_state() || e.kind() == Kind::StateMachine)
&& arms.iter().all(|arm| within(p, *arm, *id))
}) else {
return false;
};
let regions: Option<BTreeSet<_>> = arms
.iter()
.map(|arm| first_region(p, *arm, owner))
.collect();
regions.is_some_and(|regions| regions.len() == arms.len())
}
pub(crate) fn validate_transition(
p: &Project,
r: &Relation,
a: &Element,
b: &Element,
out: &mut Vec<Issue>,
) {
let RelationBody::StateTransition {
transition_kind,
trigger,
triggers,
guard,
..
} = &r.body
else {
return;
};
let machine = ancestor_of_kind(p, a.id, Kind::StateMachine);
if machine.is_none() || machine != ancestor_of_kind(p, b.id, Kind::StateMachine) {
error(
out,
r.id,
"transition_machine",
"endpoints",
"State transition endpoints belong to one state machine.",
);
}
if !a.kind().is_state() || !b.kind().is_state() {
return;
}
if a.id == b.id && !a.kind().is_stable_state() {
error(
out,
r.id,
"pseudostate_self",
"endpoints",
"Only a State or Mode can have a self-transition.",
);
}
let valid_kind = match transition_kind {
TransitionKind::Internal => a.kind().is_stable_state() && a.id == b.id,
TransitionKind::Local if a.kind() == Kind::EntryPoint => {
boundary(p, a).is_some_and(|owner| within(p, b.id, owner))
}
TransitionKind::Local => {
a.kind().is_stable_state()
&& p.children(a.id).any(|e| e.kind() == Kind::Region)
&& within(p, b.id, a.id)
}
TransitionKind::External => a.kind() != Kind::EntryPoint,
};
if !valid_kind {
error(
out,
r.id,
"transition_kind",
"transition_kind",
"Internal requires one stable state; Local goes from a composite or EntryPoint into its boundary; EntryPoint routing must be Local.",
);
}
if matches!(a.kind(), Kind::Final | Kind::Terminate) || b.kind() == Kind::Initial {
error(
out,
r.id,
"pseudostate_direction",
"endpoints",
"Final/Terminate have no outgoing transitions; Initial has no incoming transition.",
);
}
let triggered = !trigger.trim().is_empty() || !triggers.is_empty();
if !a.kind().is_stable_state() && triggered {
error(
out,
r.id,
if a.kind() == Kind::Initial {
"initial_transition"
} else {
"pseudostate_trigger"
},
"triggers",
"Pseudostate routing cannot wait for an event/work trigger; put the trigger on the incoming stable-state transition.",
);
}
if (matches!(
a.kind(),
Kind::Initial | Kind::Fork | Kind::ShallowHistory | Kind::DeepHistory
) || b.kind() == Kind::Join)
&& (!guard.trim().is_empty() || (b.kind() == Kind::Join && triggered))
{
error(
out,
r.id,
if a.kind() == Kind::Initial {
"initial_transition"
} else {
"pseudostate_segment"
},
"guard",
"Initial/history fallback and Fork output/Join input segments have no guard or trigger.",
);
}
if a.kind() == Kind::Fork && !b.kind().is_stable_state()
|| b.kind() == Kind::Join && !a.kind().is_stable_state()
{
error(
out,
r.id,
"orthogonal_endpoint",
"endpoints",
"Fork outputs and Join inputs connect stable States/Modes in distinct orthogonal regions.",
);
}
if matches!(a.kind(), Kind::ShallowHistory | Kind::DeepHistory) {
let scope = a.parent.is_some_and(|region| {
if a.kind() == Kind::ShallowHistory {
b.parent == Some(region)
} else {
within(p, b.id, region)
}
});
if !b.kind().is_stable_state() || !scope {
error(
out,
r.id,
"history_scope",
"target",
"Shallow history falls back to a direct stable child of its region; deep history to a stable descendant within that region.",
);
}
}
for (point, other, outgoing) in [(a, b, true), (b, a, false)] {
if !matches!(point.kind(), Kind::EntryPoint | Kind::ExitPoint) {
continue;
}
let Some(owner) = boundary(p, point) else {
continue;
};
let needs_inside = (point.kind() == Kind::EntryPoint) == outgoing;
if within(p, other.id, owner) != needs_inside || other.id == owner {
error(
out,
r.id,
"boundary_transition",
"endpoints",
"EntryPoint routes from outside to inside its owner boundary; ExitPoint routes from inside to outside it.",
);
}
}
}
pub(crate) fn validate_vertex(
p: &Project,
e: &Element,
candidate: Option<&Relation>,
out: &mut Vec<Issue>,
) {
if !e.kind().is_state() {
return;
}
let incoming: Vec<_> = edges(p, candidate).filter(|r| r.target() == e.id).collect();
let outgoing: Vec<_> = edges(p, candidate).filter(|r| r.source() == e.id).collect();
let (ins, outs) = (incoming.len(), outgoing.len());
let max_in = matches!(e.kind(), Kind::Choice | Kind::Fork).then_some(1);
let max_out = matches!(
e.kind(),
Kind::Initial | Kind::Join | Kind::ShallowHistory | Kind::DeepHistory | Kind::ExitPoint
)
.then_some(1);
if max_in.is_some_and(|max| ins > max) || max_out.is_some_and(|max| outs > max) {
error(
out,
e.id,
if e.kind() == Kind::Initial {
"initial_transition_count"
} else {
"pseudostate_cardinality"
},
"body",
format!(
"{} has too many {} transitions.",
e.kind(),
if max_in.is_some_and(|max| ins > max) {
"incoming"
} else {
"outgoing"
}
),
);
}
let incomplete = match e.kind() {
Kind::Initial | Kind::ShallowHistory | Kind::DeepHistory => outs == 0,
Kind::Final | Kind::Terminate => ins == 0,
Kind::Choice | Kind::Fork => ins == 0 || outs < 2,
Kind::Join => ins < 2 || outs == 0,
Kind::Junction => ins == 0 || outs == 0,
Kind::EntryPoint => outs == 0,
Kind::ExitPoint => {
ins == 0
|| (outs == 0
&& boundary(p, e)
.and_then(|id| p.elements.get(&id))
.is_some_and(|e| e.kind() != Kind::StateMachine))
}
_ => false,
};
if incomplete {
warning(
out,
e.id,
"incomplete_state_vertex",
"body",
format!(
"Finish the required {} incoming/outgoing routing; incomplete vertices can be saved while wiring.",
e.kind()
),
);
}
if e.kind() == Kind::Choice {
let defaults = outgoing.iter().filter(|r| guard(r) == "else").count();
if defaults > 1 {
error(
out,
e.id,
"choice_default",
"guard",
"Choice has at most one literal else branch.",
);
}
if defaults == 0 {
warning(
out,
e.id,
"missing_choice_default",
"guard",
"Add an else branch, or review and document why no alternative may apply.",
);
}
if outgoing.iter().any(|r| guard(r).is_empty()) {
warning(
out,
e.id,
"missing_choice_guard",
"guard",
"Each Choice alternative needs an explicit guard or literal else.",
);
}
}
if e.kind() == Kind::Junction && outs > 1 && outgoing.iter().any(|r| guard(r).is_empty()) {
warning(
out,
e.id,
"junction_guard",
"guard",
"Review the unguarded Junction alternatives; guard expressions are not interpreted for disjointness.",
);
}
if matches!(e.kind(), Kind::Fork | Kind::Join) {
let arms: Vec<_> = if e.kind() == Kind::Fork {
outgoing.iter().map(|r| r.target()).collect()
} else {
incoming.iter().map(|r| r.source()).collect()
};
if !orthogonal(p, &arms) {
error(
out,
e.id,
"orthogonal_regions",
"endpoints",
"Each Fork/Join arm must occupy a distinct first region below their nearest common composite or machine; deeper nesting does not create concurrency.",
);
}
}
if e.kind() == Kind::EntryPoint
&& let Some(owner) = boundary(p, e)
{
let regions: Vec<_> = outgoing
.iter()
.filter_map(|r| first_region(p, r.target(), owner))
.collect();
let distinct: BTreeSet<_> = regions.iter().copied().collect();
if distinct.len() != regions.len() {
error(
out,
e.id,
"entry_point_cardinality",
"target",
"EntryPoint has at most one outgoing transition per destination region.",
);
}
if p.children(owner)
.filter(|r| r.kind() == Kind::Region)
.any(|r| {
!distinct.contains(&r.id) && !p.children(r.id).any(|v| v.kind() == Kind::Initial)
})
{
warning(
out,
e.id,
"entry_region_default",
"target",
"Provide an EntryPoint route or Initial default entry for every concurrent region in this boundary.",
);
}
}
}
pub(crate) fn validate_graph(p: &Project, out: &mut Vec<Issue>) {
for e in p.elements.values() {
validate_vertex(p, e, None, out);
if e.kind() == Kind::Region {
for (kind, code) in [
(Kind::Initial, "multiple_initial"),
(Kind::ShallowHistory, "multiple_shallow_history"),
(Kind::DeepHistory, "multiple_deep_history"),
] {
if p.children(e.id).filter(|c| c.kind() == kind).count() > 1 {
error(
out,
e.id,
code,
"parent",
format!("A region has at most one {kind}."),
);
}
}
let explicit_entry =
edges(p, None).any(|r| within(p, r.target(), e.id) && !within(p, r.source(), e.id));
if !p
.children(e.id)
.any(|v| matches!(v.kind(), Kind::Initial | Kind::EntryPoint))
&& !explicit_entry
{
warning(
out,
e.id,
"missing_initial",
"parent",
"Add an Initial default entry or an explicit incoming entry path for this region.",
);
}
}
if e.kind() == Kind::StateMachine {
validate_reachability(p, e.id, out);
}
}
}
fn validate_reachability(p: &Project, machine: Id, out: &mut Vec<Issue>) {
let mut pending: Vec<_> = p
.children(machine)
.filter(|e| e.kind() == Kind::Region)
.flat_map(|region| p.children(region.id))
.filter(|e| matches!(e.kind(), Kind::Initial | Kind::EntryPoint))
.map(|e| e.id)
.collect();
let mut reached = BTreeSet::new();
while let Some(id) = pending.pop() {
if !reached.insert(id) {
continue;
}
pending.extend(
edges(p, None)
.filter(|r| r.source() == id)
.map(Relation::target),
);
// Entering a nested vertex activates its composite ancestors. Reaching a
// composite also permits each region's default entry; this deliberately
// over-approximates guards/history instead of pretending to execute them.
pending.extend(ancestors(p, id).into_iter().filter(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_stable_state())
}));
pending.extend(
p.children(id)
.filter(|e| e.kind() == Kind::Region)
.flat_map(|region| p.children(region.id))
.filter(|e| e.kind() == Kind::Initial)
.map(|e| e.id),
);
}
for e in p.elements.values().filter(|e| {
e.kind().is_state() && ancestor_of_kind(p, e.id, Kind::StateMachine) == Some(machine)
}) {
if !reached.contains(&e.id) {
warning(
out,
e.id,
"unreachable_state",
"body",
"No structural path from a machine entry reaches this vertex; review entry, nesting and transitions (guards are not executed).",
);
}
}
}
+945
View File
@@ -0,0 +1,945 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionDirection {
OaToSa,
SaToOa,
}
impl TransitionDirection {
pub fn source_layer(self) -> Layer {
match self {
Self::OaToSa => Layer::Oa,
Self::SaToOa => Layer::Sa,
}
}
pub fn target_layer(self) -> Layer {
match self {
Self::OaToSa => Layer::Sa,
Self::SaToOa => Layer::Oa,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum DependencyScope {
SelectedOnly,
Required,
Subtree,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionField {
Name,
Description,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionMapping {
pub source: Id,
pub target: Option<Id>,
pub target_kind: Option<Kind>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum FieldDecision {
TakeSource,
KeepTarget,
Value(String),
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FieldResolution {
pub source: Id,
pub field: TransitionField,
pub decision: FieldDecision,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionRequest {
pub direction: TransitionDirection,
pub sources: Vec<Id>,
pub scope: DependencyScope,
pub mappings: Vec<TransitionMapping>,
pub fields: Vec<TransitionField>,
pub resolutions: Vec<FieldResolution>,
pub destination_diagram: Option<Id>,
}
impl TransitionRequest {
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
Self {
direction,
sources,
scope: DependencyScope::SelectedOnly,
mappings: vec![],
fields: vec![TransitionField::Name, TransitionField::Description],
resolutions: vec![],
destination_diagram: None,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionAction {
Create,
Update,
Reuse,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct FieldChange {
pub field: TransitionField,
pub before: String,
pub after: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionChange {
pub source: Id,
pub target: Id,
pub action: TransitionAction,
pub fields: Vec<FieldChange>,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionConflict {
pub source: Option<Id>,
pub target: Option<Id>,
pub field: Option<TransitionField>,
pub message: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionPlan {
pub base_fingerprint: String,
pub request: TransitionRequest,
pub changes: Vec<TransitionChange>,
pub conflicts: Vec<TransitionConflict>,
pub warnings: Vec<String>,
pub commands: Vec<Command>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum Drift {
Unreviewed,
Unchanged,
OaChanged,
SaChanged,
BothChanged,
}
pub fn trace_drift(p: &Project, t: &Trace) -> Drift {
let Some(base) = &t.baseline else {
return Drift::Unreviewed;
};
let (Some(oa), Some(sa)) = (p.properties(t.oa), p.properties(t.sa)) else {
return Drift::Unreviewed;
};
match (oa != base.oa, sa != base.sa) {
(false, false) => Drift::Unchanged,
(true, false) => Drift::OaChanged,
(false, true) => Drift::SaChanged,
(true, true) => Drift::BothChanged,
}
}
/// Deterministic change token, not a security hash or persisted revision.
pub fn project_fingerprint(p: &Project) -> String {
let bytes = serde_json::to_vec(p).expect("Project serializes");
let mut hash = 0xcbf29ce484222325u64;
for byte in bytes {
hash ^= byte as u64;
hash = hash.wrapping_mul(0x100000001b3);
}
format!("{hash:016x}")
}
fn conflict(
plan: &mut TransitionPlan,
source: Option<Id>,
target: Option<Id>,
field: Option<TransitionField>,
message: impl Into<String>,
) {
plan.conflicts.push(TransitionConflict {
source,
target,
field,
message: message.into(),
});
}
fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
Some(match (direction, kind) {
(TransitionDirection::OaToSa, Kind::Activity) => Kind::Function,
(TransitionDirection::SaToOa, Kind::Function) => Kind::Activity,
(TransitionDirection::OaToSa, Kind::Capability) => Kind::SystemCapability,
(TransitionDirection::SaToOa, Kind::SystemCapability) => Kind::Capability,
(TransitionDirection::OaToSa, Kind::OperationalMission) => Kind::Mission,
(TransitionDirection::SaToOa, Kind::Mission) => Kind::OperationalMission,
(TransitionDirection::OaToSa, Kind::Process) => Kind::FunctionalChain,
(TransitionDirection::SaToOa, Kind::FunctionalChain) => Kind::Process,
(_, k)
if k.is_state()
|| matches!(
k,
Kind::ExchangeItem
| Kind::DataType
| Kind::DataField
| Kind::Interface
| Kind::Port
| Kind::Scenario
| Kind::Lifeline
| Kind::Occurrence
| Kind::StateMachine
| Kind::Region
| Kind::Event
| Kind::Constraint
) =>
{
k
}
_ => return None,
})
}
fn mapped_body(e: &Element, kind: Kind, layer: Layer) -> Option<ElementBody> {
if let Some(shared) = e.shared()
&& kind == e.kind()
{
let mut shared = shared.clone();
if let SharedBody::Scenario { viewpoint, .. } = &mut shared {
*viewpoint = match (*viewpoint, layer) {
(ScenarioViewpoint::OperationalActivity, Layer::Sa) => {
ScenarioViewpoint::Functional
}
(ScenarioViewpoint::Functional, Layer::Oa) => {
ScenarioViewpoint::OperationalActivity
}
(ScenarioViewpoint::ParticipantInteraction, Layer::Sa) => {
ScenarioViewpoint::BoundaryInterface
}
(ScenarioViewpoint::BoundaryInterface, Layer::Oa) => {
ScenarioViewpoint::ParticipantInteraction
}
(viewpoint, _) => viewpoint,
};
}
return Some(ElementBody::shared_in(layer, shared));
}
let mut body = ElementBody::new(kind, layer)?;
if let (Some(old), Some(new)) = (e.steps(), body.steps_mut()) {
*new = old.clone();
}
Some(body)
}
fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<RelationBody> {
let (source, target) = r.body.endpoints();
Some(match (&r.body, direction) {
(RelationBody::Interaction { items, .. }, TransitionDirection::OaToSa) => {
RelationBody::FunctionalExchange {
source,
target,
source_port: None,
target_port: None,
items: items.clone(),
}
}
(RelationBody::FunctionalExchange { items, .. }, TransitionDirection::SaToOa) => {
RelationBody::Interaction {
source,
target,
items: items.clone(),
}
}
(
RelationBody::Allocation { .. }
| RelationBody::ExchangeAllocation { .. }
| RelationBody::CapabilityInvolvement { .. }
| RelationBody::CapabilityInclude { .. }
| RelationBody::CapabilityExtend { .. }
| RelationBody::CapabilityGeneralization { .. }
| RelationBody::MissionInvolvement { .. }
| RelationBody::MissionCapability { .. }
| RelationBody::SequenceLink { .. }
| RelationBody::ChainInvolvement { .. }
| RelationBody::BoundaryExchange { .. }
| RelationBody::InterfaceProvision { .. }
| RelationBody::InterfaceRequirement { .. }
| RelationBody::StateTransition { .. }
| RelationBody::Availability { .. }
| RelationBody::StateAction { .. },
_,
) => r.body.clone(),
_ => return None,
})
}
fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
match (p.elements.get(&oa), p.elements.get(&sa)) {
(Some(a), Some(b)) => match (a.kind(), b.kind()) {
(Kind::Activity, Kind::Function) => TraceKind::Refinement,
(Kind::Entity | Kind::Actor, Kind::System | Kind::SystemActor) => {
TraceKind::Participant
}
(Kind::Capability, Kind::SystemCapability) => TraceKind::Capability,
(Kind::OperationalMission, Kind::Mission) => TraceKind::Mission,
(Kind::Process, Kind::FunctionalChain) => TraceKind::Sequence,
(Kind::Occurrence, Kind::Occurrence)
if a.parent
.and_then(|id| p.elements.get(&id))
.is_some_and(|owner| owner.kind() == Kind::Process)
&& b.parent
.and_then(|id| p.elements.get(&id))
.is_some_and(|owner| owner.kind() == Kind::FunctionalChain) =>
{
TraceKind::Sequence
}
(
Kind::ExchangeItem
| Kind::DataType
| Kind::DataField
| Kind::Interface
| Kind::Port,
_,
) => TraceKind::Item,
(Kind::Scenario | Kind::Lifeline | Kind::Occurrence, _) => TraceKind::Scenario,
(kind, other)
if kind == other
&& (kind.is_state()
|| matches!(kind, Kind::StateMachine | Kind::Region | Kind::Event)) =>
{
TraceKind::State
}
_ => TraceKind::Manual,
},
_ => {
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
{
TraceKind::Exchange
} else {
TraceKind::Manual
}
}
}
}
fn value(props: &TracedProperties, field: TransitionField) -> &String {
match field {
TransitionField::Name => &props.name,
TransitionField::Description => &props.description,
}
}
fn set(props: &mut TracedProperties, field: TransitionField, value: String) {
match field {
TransitionField::Name => props.name = value,
TransitionField::Description => props.description = value,
}
}
pub fn plan_transition(
project: &Project,
request: &TransitionRequest,
) -> Result<TransitionPlan, ModelError> {
validate_candidate(project)?;
let mut plan = TransitionPlan {
base_fingerprint: project_fingerprint(project),
request: request.clone(),
changes: vec![],
conflicts: vec![],
warnings: vec![],
commands: vec![],
};
let mut selected: BTreeSet<_> = request.sources.iter().copied().collect();
if selected.len() != request.sources.len() {
conflict(
&mut plan,
None,
None,
None,
"Source selection contains duplicate identities.",
);
}
if selected.is_empty() {
conflict(
&mut plan,
None,
None,
None,
"Select at least one source object.",
);
}
if request.scope != DependencyScope::SelectedOnly {
loop {
let before = selected.len();
let existing: Vec<_> = selected.iter().copied().collect();
for id in existing {
if let Some(e) = project.elements.get(&id) {
selected.extend(crate::references::element_references(e));
// Contextual links are owned by the process/chain shared by
// their endpoint involvements, independently of list order.
if matches!(e.kind(), Kind::Process | Kind::FunctionalChain) {
selected.extend(project.relations.values().filter_map(|relation| {
(relation.kind() == RelationKind::SequenceLink
&& project
.elements
.get(&relation.source())
.and_then(|e| e.parent)
== Some(id))
.then_some(relation.id)
}));
}
if e.kind() == Kind::StateMachine && request.scope == DependencyScope::Subtree {
selected.extend(project.relations.values().filter_map(|relation| {
(matches!(
relation.kind(),
RelationKind::StateTransition | RelationKind::StateAction
) && validation::ancestor_of_kind(
project,
relation.source(),
Kind::StateMachine,
) == Some(id))
.then_some(relation.id)
}));
}
if request.scope == DependencyScope::Subtree
|| matches!(e.kind(), Kind::ExchangeItem | Kind::DataType)
{
selected.extend(project.children(id).map(|e| e.id));
}
} else if let Some(r) = project.relations.get(&id) {
selected.extend(crate::references::relation_references(r));
}
}
if selected.len() == before {
break;
}
}
}
let mut choices = BTreeMap::new();
for mapping in &request.mappings {
if choices.insert(mapping.source, mapping).is_some() {
conflict(
&mut plan,
Some(mapping.source),
None,
None,
"Choose one counterpart per source in this plan; run separately for another existing trace.",
);
}
}
let mut targets = BTreeMap::new();
let mut creating = BTreeSet::new();
let mut kinds = BTreeMap::new();
for source in &selected {
if project.layer_of(*source) != Some(request.direction.source_layer()) {
conflict(
&mut plan,
Some(*source),
None,
None,
"Source is not a semantic object in the selected source layer.",
);
continue;
}
let explicit = choices.get(source).copied();
let existing: Vec<_> = project
.traces
.values()
.filter_map(|t| match request.direction {
TransitionDirection::OaToSa if t.oa == *source => Some(t.sa),
TransitionDirection::SaToOa if t.sa == *source => Some(t.oa),
_ => None,
})
.collect::<BTreeSet<_>>()
.into_iter()
.collect();
let target = if let Some(mapping) = explicit {
mapping.target
} else if existing.len() == 1 {
Some(existing[0])
} else if existing.len() > 1 {
conflict(
&mut plan,
Some(*source),
None,
None,
"Several traced counterparts exist; select the target explicitly.",
);
continue;
} else {
None
};
if let Some(target) = target {
if project.layer_of(target) != Some(request.direction.target_layer()) {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Selected target must exist in the opposite layer.",
);
continue;
}
if project.elements.contains_key(source) != project.elements.contains_key(&target) {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Elements map to elements and relationships map to relationships.",
);
continue;
}
if !compatible_existing(project, *source, target, request.direction) {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"These kinds have no automatic transition mapping; use a manual trace.",
);
continue;
}
targets.insert(*source, target);
continue;
}
if let Some(e) = project.elements.get(source) {
let kind = explicit
.and_then(|m| m.target_kind)
.or_else(|| inferred_kind(e.kind(), request.direction));
let Some(kind) = kind else {
conflict(
&mut plan,
Some(*source),
None,
None,
"Choose a counterpart kind explicitly; participant/system boundary decisions are never inferred.",
);
continue;
};
if !kind.supports(request.direction.target_layer())
|| !compatible_kinds(e.kind(), kind, request.direction)
{
conflict(
&mut plan,
Some(*source),
None,
None,
"Counterpart kind is not valid for this automatic transition.",
);
continue;
}
kinds.insert(*source, kind);
} else if let Some(r) = project.relations.get(source)
&& mapped_relation(r, request.direction).is_none()
{
conflict(
&mut plan,
Some(*source),
None,
None,
"This relation has no automatic cross-layer mapping; use an explicit manual trace.",
);
continue;
}
targets.insert(*source, Id::new_v4());
creating.insert(*source);
}
// Explicit mapping choices may provide dependencies without selecting updates.
for mapping in &request.mappings {
if let Some(target) = mapping.target
&& project.layer_of(mapping.source) == Some(request.direction.source_layer())
&& project.layer_of(target) == Some(request.direction.target_layer())
{
targets.entry(mapping.source).or_insert(target);
}
}
let mut reused = BTreeMap::new();
for source in &selected {
if let Some(target) = targets.get(source)
&& let Some(previous) = reused.insert(*target, *source)
{
let different = match (project.properties(previous), project.properties(*source)) {
(Some(a), Some(b)) => request
.fields
.iter()
.any(|field| value(&a, *field) != value(&b, *field)),
_ => true,
};
if different {
conflict(
&mut plan,
Some(*source),
Some(*target),
None,
"Several selected sources propose different values for one target. Reconcile that target explicitly in separate previews.",
);
}
}
}
let mut working = project.clone();
for source in selected.iter().filter(|id| creating.contains(id)) {
let Some(target) = targets.get(source).copied() else {
continue;
};
if let Some(e) = project.elements.get(source) {
let Some(body) = mapped_body(e, kinds[source], request.direction.target_layer()) else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Unsupported typed body conversion.",
);
continue;
};
let mut created = e.clone();
created.id = target;
created.body = body;
created.alias = working.fresh_alias(
&format!(
"{}_{}",
request.direction.target_layer().to_string().to_lowercase(),
e.alias
),
"element",
target,
);
crate::references::visit_element_refs(&mut created, |id| {
if let Some(mapped) = targets.get(id) {
*id = *mapped;
}
});
for dependency in crate::references::element_references(&created) {
if !targets.values().any(|id| *id == dependency)
&& working.layer_of(dependency) != Some(request.direction.target_layer())
{
conflict(
&mut plan,
Some(*source),
Some(target),
None,
format!(
"Dependency {dependency} needs an explicit counterpart or a broader dependency scope."
),
);
}
}
if !request.fields.contains(&TransitionField::Description) {
created.description.clear();
}
working.elements.insert(target, created.clone());
plan.commands.push(Command::CreateElement(created));
} else if let Some(r) = project.relations.get(source) {
let Some(body) = mapped_relation(r, request.direction) else {
continue;
};
let mut created = r.clone();
created.id = target;
created.body = body;
created.alias = working.fresh_alias(
&format!(
"{}_{}",
request.direction.target_layer().to_string().to_lowercase(),
r.alias
),
"relation",
target,
);
crate::references::visit_relation_refs(&mut created, |id| {
if let Some(mapped) = targets.get(id) {
*id = *mapped;
}
});
for dependency in crate::references::relation_references(&created) {
if !targets.values().any(|id| *id == dependency)
&& working.layer_of(dependency) != Some(request.direction.target_layer())
{
conflict(
&mut plan,
Some(*source),
Some(target),
None,
format!(
"Relationship dependency {dependency} needs a counterpart mapping."
),
);
}
}
if !request.fields.contains(&TransitionField::Description) {
created.description.clear();
}
working.relations.insert(target, created.clone());
plan.commands.push(Command::CreateRelation(created));
}
}
for source in &selected {
let Some(target) = targets.get(source).copied() else {
continue;
};
let (Some(source_props), Some(mut target_props)) =
(project.properties(*source), working.properties(target))
else {
continue;
};
let (oa, sa) = match request.direction {
TransitionDirection::OaToSa => (*source, target),
TransitionDirection::SaToOa => (target, *source),
};
let existing_trace = project.traces.values().find(|t| t.oa == oa && t.sa == sa);
let mut changes = vec![];
let mut reviewed_fields = vec![];
for field in &request.fields {
let before = value(&target_props, *field).clone();
let incoming = value(&source_props, *field).clone();
if before == incoming {
reviewed_fields.push(*field);
continue;
}
let decision = request
.resolutions
.iter()
.find(|r| r.source == *source && r.field == *field)
.map(|r| &r.decision);
let changed = existing_trace
.and_then(|t| t.baseline.as_ref())
.map(|base| {
let (origin, destination) = match request.direction {
TransitionDirection::OaToSa => (&base.oa, &base.sa),
TransitionDirection::SaToOa => (&base.sa, &base.oa),
};
(
value(origin, *field) != &incoming,
value(destination, *field) != &before,
)
});
let after = match decision {
Some(FieldDecision::TakeSource) => incoming,
Some(FieldDecision::KeepTarget) => before.clone(),
Some(FieldDecision::Value(v)) => v.clone(),
None if creating.contains(source) => incoming,
// A reviewed pair may deliberately have different values. No
// source edit means no propagation, including when the target
// has a local edit that can later propagate in reverse.
None if matches!(changed, Some((false, _))) => continue,
None if matches!(changed, Some((true, false))) => incoming,
None => {
conflict(
&mut plan,
Some(*source),
Some(target),
Some(*field),
"Both sides changed or no reviewed baseline exists. Choose take source, keep target, or a merged value.",
);
continue;
}
};
reviewed_fields.push(*field);
if after != before {
set(&mut target_props, *field, after.clone());
changes.push(FieldChange {
field: *field,
before,
after,
});
}
}
if !changes.is_empty() {
if let Some(e) = working.elements.get_mut(&target) {
e.name = target_props.name.clone();
e.description = target_props.description.clone();
plan.commands.push(Command::EditElement(target, e.clone()));
} else if let Some(r) = working.relations.get_mut(&target) {
r.name = target_props.name.clone();
r.description = target_props.description.clone();
plan.commands.push(Command::EditRelation(target, r.clone()));
}
}
plan.changes.push(TransitionChange {
source: *source,
target,
action: if creating.contains(source) {
TransitionAction::Create
} else if changes.is_empty() {
TransitionAction::Reuse
} else {
TransitionAction::Update
},
fields: changes,
});
let current_oa = working.properties(oa).unwrap();
let current_sa = working.properties(sa).unwrap();
let mut baseline =
existing_trace
.and_then(|t| t.baseline.clone())
.unwrap_or(TraceBaseline {
oa: current_oa.clone(),
sa: current_sa.clone(),
});
for field in &reviewed_fields {
set(&mut baseline.oa, *field, value(&current_oa, *field).clone());
set(&mut baseline.sa, *field, value(&current_sa, *field).clone());
}
if let Some(old) = existing_trace {
let mut trace = old.clone();
trace.baseline = Some(baseline);
if &trace != old {
working.traces.insert(trace.id, trace.clone());
plan.commands.push(Command::EditTrace(trace.id, trace));
}
} else {
let id = Id::new_v4();
let name = format!("{} ↔ {}", working.named(oa), working.named(sa));
let alias = working.fresh_alias(
&format!(
"trace_{}_{}",
working
.elements
.get(&oa)
.map(|e| e.alias.as_str())
.unwrap_or("exchange"),
working
.elements
.get(&sa)
.map(|e| e.alias.as_str())
.unwrap_or("exchange")
),
"trace",
id,
);
let trace = Trace {
id,
alias,
name,
description: String::new(),
oa,
sa,
kind: trace_kind(&working, oa, sa),
baseline: Some(baseline),
};
working.traces.insert(id, trace.clone());
plan.commands.push(Command::CreateTrace(trace));
}
if let Some(diagram) = request.destination_diagram
&& working.elements.contains_key(&target)
{
match working.diagrams.get(&diagram) {
Some(d)
if d.layer == request.direction.target_layer()
|| d.projection == ProjectionKind::Traceability =>
{
if !d.nodes.values().any(|n| n.element == target) {
let index = d.nodes.len();
let id = Id::new_v4();
let alias = working.fresh_alias(
&format!("view_{}", working.named(target)),
"node",
id,
);
let node = NodePlacement {
id,
alias,
element: target,
position: Position {
x: 60. + (index % 4) as f32 * 270.,
y: 60. + (index / 4) as f32 * 160.,
},
size: Size {
width: 224.,
height: 94.,
},
collapsed: false,
};
working
.diagrams
.get_mut(&diagram)
.unwrap()
.nodes
.insert(id, node.clone());
plan.commands.push(Command::CreateNode { diagram, node });
}
}
_ => conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Destination diagram is missing or belongs to the wrong layer.",
),
}
}
}
// Final source-order-independent batch execution also applies occurrence ownership rules.
match execute_batch(project, &plan.commands) {
Ok(candidate) => {
for issue in validation::validate(&candidate) {
if issue.severity == validation::Severity::Warning {
plan.warnings.push(issue.message);
}
}
}
Err(err) => conflict(
&mut plan,
None,
None,
None,
format!("Candidate validation: {err}"),
),
}
Ok(plan)
}
pub fn apply_transition(project: &Project, plan: &TransitionPlan) -> Result<Project, ModelError> {
if project_fingerprint(project) != plan.base_fingerprint {
return Err(ModelError(
"Transition preview is stale; create a new preview.".into(),
));
}
if !plan.conflicts.is_empty() {
return Err(ModelError(
"Resolve transition conflicts before applying.".into(),
));
}
execute_batch(project, &plan.commands)
}
/// Supported explicit element counterparts, in stable kind-catalog order.
pub fn counterpart_kinds(source: Kind, direction: TransitionDirection) -> Vec<Kind> {
let mut kinds: Vec<_> = Kind::ALL
.into_iter()
.filter(|target| {
target.supports(direction.target_layer())
&& compatible_kinds(source, *target, direction)
})
.collect();
// Offer the semantic counterpart first; alternative engineering mappings
// remain explicit choices and existing traces remain reusable.
let preferred = inferred_kind(source, direction);
kinds.sort_by_key(|kind| Some(*kind) != preferred);
kinds
}
fn compatible_kinds(source: Kind, target: Kind, direction: TransitionDirection) -> bool {
if matches!(
(direction, source, target),
(
TransitionDirection::OaToSa,
Kind::Entity | Kind::Actor,
Kind::System | Kind::SystemActor
) | (
TransitionDirection::SaToOa,
Kind::System | Kind::SystemActor,
Kind::Entity | Kind::Actor
) | (TransitionDirection::OaToSa, Kind::Capability, Kind::Mission)
| (TransitionDirection::SaToOa, Kind::Mission, Kind::Capability)
) {
return true;
}
inferred_kind(source, direction) == Some(target)
}
fn compatible_existing(
p: &Project,
source: Id,
target: Id,
direction: TransitionDirection,
) -> bool {
match (p.elements.get(&source), p.elements.get(&target)) {
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b)) => {
mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
}
_ => false,
},
}
}
File diff suppressed because it is too large Load Diff