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

1097 lines
41 KiB
Rust

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 layer == Layer::Oa
&& crate::ports::port_contract(e).is_some_and(|contract| *contract != PortContract::Legacy)
{
return None;
}
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: Id::nil(),
target_port: Id::nil(),
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,
}
}
/// SA port implementation details have no fabricated OA counterpart. Propagate
/// omissions through dependent records, except the FE whose ports are removed
/// by its explicit Interaction mapping.
fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
let mut omitted: BTreeSet<_> = p
.elements
.values()
.filter(|e| {
e.layer() == Layer::Sa
&& crate::ports::port_contract(e).is_some_and(|c| *c != PortContract::Legacy)
})
.map(|e| e.id)
.collect();
loop {
let before = omitted.len();
for r in p
.relations
.values()
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
{
if crate::references::relation_references(r)
.iter()
.any(|id| omitted.contains(id))
{
omitted.insert(r.id);
}
}
for e in p.elements.values().filter(|e| e.layer() == Layer::Sa) {
if crate::references::element_references(e)
.iter()
.any(|id| omitted.contains(id))
{
omitted.insert(e.id);
}
}
if before == omitted.len() {
return omitted;
}
}
}
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();
let omitted = if request.direction == TransitionDirection::SaToOa {
sa_port_omissions(project)
} else {
BTreeSet::new()
};
let mut reported_omissions = BTreeSet::new();
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 omitted.contains(&id) {
continue;
}
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) {
if request.direction == TransitionDirection::SaToOa
&& let RelationBody::FunctionalExchange {
source,
target,
source_port,
target_port,
items,
} = &r.body
{
// An Interaction needs the work/items, never either SA
// endpoint port, including an unresolved legacy one.
selected.extend([*source, *target]);
selected.extend(items);
for port in [*source_port, *target_port] {
if reported_omissions.insert(port) {
plan.warnings.push(format!("SA-only omission: functional endpoint {} ({port}) is not copied into the portless OA interaction; the original is preserved.", project.named(port)));
}
}
} else {
selected.extend(crate::references::relation_references(r));
}
}
}
if selected.len() == before {
break;
}
}
}
selected.retain(|id| {
if omitted.contains(id) {
if reported_omissions.insert(*id) { plan.warnings.push(format!("SA-only omission: {} ({id}) has no OA port counterpart; the original is preserved.", project.named(*id))); }
false
} else { true }
});
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();
let ordered_creations: Vec<_> = selected
.iter()
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
.chain(
selected
.iter()
.filter(|id| creating.contains(id) && project.relations.contains_key(id)),
)
.copied()
.collect();
for source in &ordered_creations {
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 dependency.is_nil()
&& request.direction == TransitionDirection::OaToSa
&& r.kind() == RelationKind::Interaction
{
continue;
}
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();
}
if request.direction == TransitionDirection::OaToSa
&& let RelationBody::FunctionalExchange {
source,
target: destination,
items,
..
} = &created.body
{
let request = FunctionalExchangeRequest {
id: created.id,
alias: created.alias.clone(),
name: created.name.clone(),
description: created.description.clone(),
source: *source,
target: *destination,
source_port: PortChoice::Auto,
target_port: PortChoice::Auto,
items: items.clone(),
};
match crate::ports::functional_exchange_commands(&working, &request) {
Ok(commands) => {
for command in &commands {
match command {
Command::CreateElement(e) => {
working.elements.insert(e.id, e.clone());
}
Command::CreateRelation(r) => {
working.relations.insert(r.id, r.clone());
}
_ => unreachable!("functional creation only creates objects"),
}
}
plan.commands.extend(commands);
}
Err(err) => conflict(
&mut plan,
Some(*source),
Some(target),
None,
format!("Functional endpoint creation: {err}"),
),
}
} else {
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,
port_anchors: Vec::new(),
};
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> {
prepare_transition(project, plan).map(|prepared| prepared.project)
}
/// Prepare a reviewed transition once, retaining identity correspondence for
/// adapters that update accepted graphical and authored source state together.
pub fn prepare_transition(
project: &Project,
plan: &TransitionPlan,
) -> Result<PreparedCommand, 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(),
));
}
prepare_command(project, &Command::Batch(plan.commands.clone()))
}
/// 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,
},
}
}