Establish OA/SA prototype baseline and first-release work
This commit is contained in:
@@ -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(¤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,
|
||||
};
|
||||
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,
|
||||
},
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user