Rust checks / check (ubuntu-latest) (push) Canceled after 0s
Rust checks / check (windows-latest) (push) Canceled after 0s
Preserve current core, source, REST, desktop, recovery, documentation and validation work, including AGENTS.md and the development summary. This is a work-in-progress checkpoint: the new compact palette has outstanding compilation errors and native acceptance; earlier passing gates retain their documented scope.
1349 lines
51 KiB
Rust
1349 lines
51 KiB
Rust
use crate::*;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::collections::{BTreeMap, BTreeSet};
|
|
mod communication;
|
|
pub(crate) use communication::review_endpoint_fact;
|
|
|
|
#[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,
|
|
}
|
|
/// Technical endpoint choices are scoped to one OA exchange. Participant
|
|
/// correspondence remains in `mappings`, never in these port identities.
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub enum CommunicationPortChoice {
|
|
Reuse {
|
|
port: Id,
|
|
},
|
|
Create {
|
|
id: Id,
|
|
alias: String,
|
|
name: String,
|
|
description: String,
|
|
kind: ComponentPortKind,
|
|
orientation: ComponentPortOrientation,
|
|
},
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct CommunicationEndpointDecision {
|
|
pub source: Id,
|
|
pub source_port: CommunicationPortChoice,
|
|
pub target_port: CommunicationPortChoice,
|
|
}
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(deny_unknown_fields)]
|
|
pub struct OperationOmission {
|
|
pub source: Id,
|
|
pub rationale: String,
|
|
}
|
|
#[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>,
|
|
#[serde(default)]
|
|
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
|
|
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
|
pub operation_omissions: Vec<OperationOmission>,
|
|
}
|
|
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,
|
|
communication_endpoints: vec![],
|
|
operation_omissions: vec![],
|
|
}
|
|
}
|
|
}
|
|
#[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::ExchangeCategory
|
|
| 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() || (kind.is_state() && e.kind().is_state()))
|
|
{
|
|
let mut shared = shared.clone();
|
|
if let SharedBody::State {
|
|
kind: state_kind, ..
|
|
} = &mut shared
|
|
{
|
|
*state_kind = kind.state_kind()?;
|
|
}
|
|
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::CommunicationMean {
|
|
oriented, items, ..
|
|
},
|
|
TransitionDirection::OaToSa,
|
|
) => RelationBody::BoundaryExchange {
|
|
source,
|
|
target,
|
|
oriented: *oriented,
|
|
items: items.clone(),
|
|
},
|
|
(
|
|
RelationBody::BoundaryExchange {
|
|
oriented, items, ..
|
|
},
|
|
TransitionDirection::SaToOa,
|
|
) => RelationBody::CommunicationMean {
|
|
source,
|
|
target,
|
|
oriented: *oriented,
|
|
items: items.clone(),
|
|
},
|
|
(
|
|
RelationBody::Allocation { .. }
|
|
| RelationBody::InterfaceItemAllocation { .. }
|
|
| RelationBody::DataAssociation { .. }
|
|
| RelationBody::ExchangeAllocation { .. }
|
|
| RelationBody::CapabilityInvolvement { .. }
|
|
| RelationBody::CapabilityInclude { .. }
|
|
| RelationBody::CapabilityExtend { .. }
|
|
| RelationBody::CapabilityGeneralization { .. }
|
|
| RelationBody::MissionInvolvement { .. }
|
|
| RelationBody::MissionCapability { .. }
|
|
| RelationBody::SequenceLink { .. }
|
|
| RelationBody::ContextualLink { .. }
|
|
| 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 exchange_counterparts(a.kind(), b.kind()))
|
|
{
|
|
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| {
|
|
!matches!(
|
|
r.kind(),
|
|
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
|
|
)
|
|
}) {
|
|
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 mut omitted = if request.direction == TransitionDirection::SaToOa {
|
|
sa_port_omissions(project)
|
|
} else {
|
|
BTreeSet::new()
|
|
};
|
|
// An explicit existing Legacy counterpart can be compared and reviewed
|
|
// without inventing an OA typed port. Only this port is retained: dependent
|
|
// SA implementation records and new counterpart requests stay omitted.
|
|
for mapping in &request.mappings {
|
|
if project
|
|
.elements
|
|
.get(&mapping.source)
|
|
.is_some_and(|e| e.layer() == Layer::Sa && crate::ports::port_contract(e).is_some())
|
|
&& mapping
|
|
.target
|
|
.and_then(|target| project.elements.get(&target))
|
|
.is_some_and(|e| {
|
|
e.layer() == Layer::Oa
|
|
&& crate::ports::port_contract(e) == Some(&PortContract::Legacy)
|
|
})
|
|
{
|
|
omitted.remove(&mapping.source);
|
|
}
|
|
}
|
|
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) {
|
|
let mut dependencies = e.clone();
|
|
if request.direction == TransitionDirection::SaToOa
|
|
&& let Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange { operation, .. },
|
|
..
|
|
}) = dependencies.shared_mut()
|
|
{
|
|
*operation = None;
|
|
}
|
|
selected.extend(crate::references::element_references(&dependencies));
|
|
// Allocations are owned by interfaces. Independent data
|
|
// associations travel only when both endpoint types are
|
|
// already required; they do not expand the dictionary.
|
|
if matches!(e.kind(), Kind::Interface | Kind::DataType) {
|
|
let contracts: Vec<_> = project
|
|
.relations
|
|
.values()
|
|
.filter_map(|r| {
|
|
((r.kind() == RelationKind::InterfaceItemAllocation
|
|
&& r.source() == id)
|
|
|| (r.kind() == RelationKind::DataAssociation
|
|
&& selected.contains(&r.source())
|
|
&& selected.contains(&r.target())))
|
|
.then_some(r.id)
|
|
})
|
|
.collect();
|
|
selected.extend(contracts);
|
|
}
|
|
// 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.contextual_owner() == Some(id)
|
|
|| (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
|
|
&& r.kind() == RelationKind::BoundaryExchange
|
|
{
|
|
selected.extend(
|
|
[r.source(), r.target()].into_iter().filter_map(|port| {
|
|
project.elements.get(&port).and_then(|e| e.parent)
|
|
}),
|
|
);
|
|
selected.extend(r.body.items());
|
|
} else 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 operation_omissions = BTreeSet::new();
|
|
for decision in &request.operation_omissions {
|
|
if request.direction != TransitionDirection::SaToOa
|
|
|| !selected.contains(&decision.source)
|
|
|| !matches!(
|
|
project
|
|
.elements
|
|
.get(&decision.source)
|
|
.and_then(Element::shared),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange {
|
|
operation: Some(_),
|
|
message_kind: MessageKind::Call | MessageKind::Asynchronous,
|
|
..
|
|
},
|
|
..
|
|
})
|
|
)
|
|
|| decision.rationale.trim().is_empty()
|
|
|| !validation::valid_text(&decision.rationale)
|
|
|| !operation_omissions.insert(decision.source)
|
|
{
|
|
conflict(
|
|
&mut plan,
|
|
Some(decision.source),
|
|
None,
|
|
None,
|
|
"Operation omission needs one selected SA invocation, SA→OA direction, and a nonempty rationale; irrelevant or duplicate decisions reject.",
|
|
);
|
|
} else {
|
|
plan.warnings.push(format!(
|
|
"Reviewed operation omission: {} ({}): {}. Source contract is preserved.",
|
|
project.named(decision.source),
|
|
decision.source,
|
|
decision.rationale
|
|
));
|
|
}
|
|
}
|
|
if request.direction == TransitionDirection::SaToOa {
|
|
for source in &selected {
|
|
if matches!(
|
|
project.elements.get(source).and_then(Element::shared),
|
|
Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange {
|
|
operation: Some(_),
|
|
..
|
|
},
|
|
..
|
|
})
|
|
) && !operation_omissions.contains(source)
|
|
{
|
|
conflict(
|
|
&mut plan,
|
|
Some(*source),
|
|
None,
|
|
None,
|
|
"This technical operation has no OA operation slot. Review an explicit per-invocation omission with rationale; Reply derives the Call's decision.",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
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 communication_pairs =
|
|
communication::prepare_ports(project, &mut working, &targets, &mut plan);
|
|
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;
|
|
if request.direction == TransitionDirection::SaToOa
|
|
&& operation_omissions.contains(source)
|
|
&& let Some(SharedBody::Occurrence {
|
|
body: OccurrenceBody::Exchange { operation, .. },
|
|
..
|
|
}) = created.shared_mut()
|
|
{
|
|
*operation = None;
|
|
}
|
|
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;
|
|
// SA technical ports project through their reviewed owners; never
|
|
// place a port→participant pair in the global correspondence map.
|
|
if request.direction == TransitionDirection::SaToOa
|
|
&& r.kind() == RelationKind::BoundaryExchange
|
|
{
|
|
let owners = [r.source(), r.target()]
|
|
.map(|port| project.elements.get(&port).and_then(|e| e.parent));
|
|
if let [Some(a), Some(b)] = owners {
|
|
created.body.set_endpoints(a, b);
|
|
} else {
|
|
conflict(
|
|
&mut plan,
|
|
Some(*source),
|
|
Some(target),
|
|
None,
|
|
"Boundary endpoints need participant owners before communication transition.",
|
|
);
|
|
}
|
|
}
|
|
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;
|
|
}
|
|
});
|
|
if request.direction == TransitionDirection::OaToSa
|
|
&& r.kind() == RelationKind::CommunicationMean
|
|
{
|
|
if let Some((a, b)) = communication_pairs.get(source) {
|
|
created.body.set_endpoints(*a, *b);
|
|
} else {
|
|
conflict(
|
|
&mut plan,
|
|
Some(*source),
|
|
Some(target),
|
|
None,
|
|
"Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.",
|
|
);
|
|
}
|
|
}
|
|
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(),
|
|
semantic: None,
|
|
});
|
|
for field in &reviewed_fields {
|
|
set(&mut baseline.oa, *field, value(¤t_oa, *field).clone());
|
|
set(&mut baseline.sa, *field, value(¤t_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 {
|
|
// State kind is a reviewed structural facet of one state family. An existing
|
|
// counterpart remains selectable after either side changes its vertex kind.
|
|
if source.is_state() && target.is_state() {
|
|
return true;
|
|
}
|
|
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)) => {
|
|
(a.kind() == RelationKind::BoundaryExchange
|
|
&& b.kind() == RelationKind::BoundaryExchange)
|
|
|| mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
|
|
}
|
|
_ => false,
|
|
},
|
|
}
|
|
}
|
|
|
|
pub(crate) fn exchange_counterparts(oa: RelationKind, sa: RelationKind) -> bool {
|
|
matches!(
|
|
(oa, sa),
|
|
(RelationKind::Interaction, RelationKind::FunctionalExchange)
|
|
| (
|
|
RelationKind::CommunicationMean,
|
|
RelationKind::BoundaryExchange
|
|
)
|
|
)
|
|
}
|