Files
ARCADIA-Studio/crates/oa-desktop/src/workflow.rs
T

1612 lines
65 KiB
Rust

//! Explicit, reviewable relationship and cross-perspective operations.
use crate::{
app::{Choice, Message},
style,
views::short,
};
use iced::{
Element, Fill,
widget::{
Space, button, checkbox, column, pick_list, row, scrollable, text, text_editor, text_input,
},
};
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
#[derive(Debug, Clone)]
pub enum Event {
Port(crate::port_workflow::Event),
ExchangeItem(Id, bool),
Endpoint(bool, EndpointChoice),
Oriented(bool),
Cancel,
Apply,
RelationName(String),
RelationKind(RelationKind),
Condition(text_editor::Action),
Source(Choice),
Target(Choice),
Scope(String),
MappingTarget(Id, Choice),
MappingKind(Id, Kind),
Resolve(Id, TransitionField, FieldDecision),
Preview,
}
pub enum Workflow {
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
name: String,
source: Choice,
target: Option<Choice>,
},
Transition(Box<Transition>),
}
pub struct Relationship {
id: Id,
pub name: String,
pub kind: RelationKind,
pub source: Option<Choice>,
pub target: Option<Choice>,
pub condition: text_editor::Content,
layer: Layer,
sources: Vec<Choice>,
targets: Vec<Choice>,
items: Vec<Id>,
source_port: EndpointChoice,
target_port: EndpointChoice,
oriented: bool,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EndpointChoice {
pub choice: PortChoice,
pub label: String,
}
impl std::fmt::Display for EndpointChoice {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.label)
}
}
pub(crate) fn automatic_port() -> EndpointChoice {
EndpointChoice {
choice: PortChoice::Auto,
label: "Automatic · reuse only if unique".into(),
}
}
fn endpoint_choices(
p: &Project,
owner: Option<Id>,
outgoing: bool,
items: &[Id],
) -> Vec<EndpointChoice> {
let mut choices = vec![
automatic_port(),
EndpointChoice {
choice: PortChoice::Create,
label: "Create a new port".into(),
},
];
choices.extend(
p.elements
.values()
.filter(|e| e.parent == owner && e.layer() == Layer::Sa)
.filter(|e| match port_contract(e) {
Some(PortContract::FunctionInput { incoming_items }) if !outgoing => {
items_satisfy(p, items, incoming_items)
}
Some(PortContract::FunctionOutput { outgoing_items }) if outgoing => {
items_satisfy(p, items, outgoing_items)
}
_ => false,
})
.map(|e| EndpointChoice {
choice: PortChoice::Reuse(e.id),
label: format!("Reuse {} · @{}", e.name, e.alias),
}),
);
choices
}
impl Relationship {
fn accepts_source(&self, p: &Project, id: Id) -> bool {
if p.layer_of(id) != Some(self.layer) {
return false;
}
if matches!(
self.kind,
RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement
) {
return validation::can_create_interface_role(p, id);
}
if self.kind == RelationKind::ExchangeAllocation {
return p
.relations
.get(&id)
.is_some_and(|r| r.kind() == RelationKind::BoundaryExchange);
}
p.elements.get(&id).is_some_and(|e| {
Kind::ALL
.into_iter()
.any(|target| target.supports(self.layer) && self.kind.permits(e.kind(), target))
})
}
fn refresh_choices(&mut self, p: &Project) {
self.sources = p
.elements
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| self.accepts_source(p, *id))
.map(|id| choice(p, id))
.collect();
self.sources.sort_by(|a, b| a.label.cmp(&b.label));
self.targets = p
.elements
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| self.accepts_target(p, *id))
.map(|id| choice(p, id))
.collect();
self.targets.sort_by(|a, b| a.label.cmp(&b.label));
}
fn accepts_target(&self, p: &Project, target: Id) -> bool {
let Some(source) = self.source.as_ref().map(|source| source.id) else {
return false;
};
if self.kind == RelationKind::BoundaryExchange {
validation::can_connect_boundary(p, source, target, self.oriented)
} else {
validation::can_connect(p, self.kind, source, target)
}
}
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
if !self.name.trim().is_empty() {
return self.name.clone();
}
let verb = match self.kind {
RelationKind::CapabilityInclude => "includes",
RelationKind::CapabilityExtend => "extends",
RelationKind::CapabilityGeneralization => "specializes",
RelationKind::MissionCapability => "exploits",
RelationKind::MissionInvolvement | RelationKind::CapabilityInvolvement => {
"participates in"
}
RelationKind::Allocation => "performs",
_ => return format!("{}: {} → {}", self.kind, p.named(source), p.named(target)),
};
format!("{} {verb} {}", p.named(source), p.named(target))
}
fn default_orientation(&mut self, p: &Project) {
self.oriented = !self
.source
.as_ref()
.and_then(|v| p.elements.get(&v.id))
.is_some_and(|e| {
matches!(
port_contract(e),
Some(PortContract::Component {
kind: ComponentPortKind::Standard,
..
})
)
});
}
fn commands(&self, p: &Project) -> Result<Vec<Command>, String> {
let a = self.source.as_ref().ok_or("Choose a source.")?.id;
let b = self.target.as_ref().ok_or("Choose a target.")?.id;
if !self.accepts_source(p, a) {
return Err("Choose an eligible source for this relationship.".into());
}
let id = self.id;
let name = self.engineering_name(p, a, b);
if self.kind == RelationKind::FunctionalExchange {
return plan_functional_exchange(
p,
&FunctionalExchangeRequest {
id,
alias: p.fresh_alias(&name, "exchange", id),
name,
description: String::new(),
source: a,
target: b,
source_port: self.source_port.choice,
target_port: self.target_port.choice,
items: self.items.clone(),
},
)
.map_err(|e| e.to_string());
}
let mut body = RelationBody::new(self.kind, a, b);
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
*condition = self.condition.text();
}
if let RelationBody::BoundaryExchange { oriented, .. } = &mut body {
*oriented = self.oriented;
}
let commands = vec![Command::CreateRelation(Relation {
id,
alias: p.fresh_alias(&name, "relation", id),
name,
description: String::new(),
body,
})];
execute_batch(p, &commands)
.map(|_| commands)
.map_err(|e| e.to_string())
}
}
pub struct Transition {
pub request: TransitionRequest,
pub plan: TransitionPlan,
}
impl Workflow {
pub fn manual_trace(p: &Project, selected: Id) -> Result<Self, String> {
if p.layer_of(selected).is_none()
|| !(p.elements.contains_key(&selected) || p.relations.contains_key(&selected))
{
return Err("Select an OA or SA element or relationship to trace.".into());
}
Ok(Self::ManualTrace {
name: "Engineering correspondence".into(),
source: choice(p, selected),
target: None,
})
}
pub fn relationship(p: &Project, selected: Id, layer: Layer) -> Self {
if let Some(PortContract::Component { orientation, .. }) =
p.elements.get(&selected).and_then(port_contract)
{
return Self::relationship_kind(
p,
selected,
layer,
RelationKind::BoundaryExchange,
*orientation == ComponentPortOrientation::In,
);
}
let kind = match p.elements.get(&selected).map(oa_core::Element::kind) {
Some(Kind::OperationalMission | Kind::Mission) => RelationKind::MissionCapability,
Some(Kind::Capability | Kind::SystemCapability) => RelationKind::CapabilityInclude,
Some(Kind::Role) => RelationKind::RoleAssignment,
Some(Kind::Process | Kind::FunctionalChain) => RelationKind::ChainInvolvement,
Some(kind) if kind.is_performer() => RelationKind::Allocation,
Some(kind) if kind.is_state() => RelationKind::StateTransition,
_ if layer == Layer::Oa => RelationKind::Interaction,
_ => RelationKind::FunctionalExchange,
};
Self::relationship_kind(p, selected, layer, kind, false)
}
pub fn relationship_kind(
p: &Project,
selected: Id,
layer: Layer,
kind: RelationKind,
incoming: bool,
) -> Self {
let mut draft = Relationship {
id: Id::new_v4(),
name: String::new(),
kind,
source: (!incoming).then(|| choice(p, selected)),
target: incoming.then(|| choice(p, selected)),
condition: text_editor::Content::new(),
layer,
sources: vec![],
targets: vec![],
items: vec![],
source_port: automatic_port(),
target_port: automatic_port(),
oriented: true,
};
if draft
.source
.as_ref()
.is_some_and(|s| !draft.accepts_source(p, s.id))
{
draft.source = None;
}
draft.default_orientation(p);
draft.refresh_choices(p);
Self::Relation(Box::new(draft))
}
pub fn transition(p: &Project, selected: Id) -> Result<Self, String> {
let layer = p
.layer_of(selected)
.ok_or("Select an element or relationship in OA or SA.")?;
let direction = if layer == Layer::Oa {
TransitionDirection::OaToSa
} else {
TransitionDirection::SaToOa
};
let mut request = TransitionRequest::new(direction, vec![selected]);
request.scope = DependencyScope::Required;
let plan = plan_transition(p, &request).map_err(|e| e.to_string())?;
Ok(Self::Transition(Box::new(Transition { request, plan })))
}
/// Return a complete accepted candidate only on explicit Apply. Intermediate
/// choices and previews never mutate the Session.
pub fn update(&mut self, p: &Project, event: Event) -> Result<Option<Project>, String> {
match self {
Self::Ports(state) => return state.update(p, event),
Self::ManualTrace {
name,
source,
target,
} => match event {
Event::RelationName(value) => *name = value,
Event::Target(value) => *target = Some(value),
Event::Apply => {
let target = target
.as_ref()
.ok_or("Choose a counterpart in the other perspective.")?
.id;
if name.trim().is_empty() {
return Err("Give the trace an engineering name.".into());
}
let (oa, sa) = if p.layer_of(source.id) == Some(Layer::Oa) {
(source.id, target)
} else {
(target, source.id)
};
let id = Id::new_v4();
let trace = Trace {
id,
alias: p.fresh_alias("correspondence", "trace", id),
name: name.clone(),
description: String::new(),
oa,
sa,
kind: TraceKind::Manual,
baseline: None,
};
return execute_batch(p, &[Command::CreateTrace(trace)])
.map(Some)
.map_err(|e| e.to_string());
}
_ => {}
},
Self::Relation(d) => match event {
Event::Oriented(value) => {
d.oriented = value;
d.refresh_choices(p);
}
Event::ExchangeItem(id, enabled) => {
if enabled && !d.items.contains(&id) {
d.items.push(id);
}
if !enabled {
d.items.retain(|item| *item != id);
}
}
Event::Endpoint(source, choice) => {
if source {
d.source_port = choice;
} else {
d.target_port = choice;
}
}
Event::RelationName(name) => d.name = name,
Event::RelationKind(kind) => {
d.kind = kind;
d.target = None;
if d.source
.as_ref()
.is_some_and(|s| !d.accepts_source(p, s.id))
{
d.source = None;
}
d.default_orientation(p);
d.refresh_choices(p);
}
Event::Source(source) => {
d.source = Some(source);
d.default_orientation(p);
if d.target
.as_ref()
.is_some_and(|target| !d.accepts_target(p, target.id))
{
d.target = None;
}
d.refresh_choices(p);
}
Event::Target(target) => d.target = Some(target),
Event::Condition(action) => d.condition.perform(action),
Event::Apply => {
return execute_batch(p, &d.commands(p)?)
.map(Some)
.map_err(|e| e.to_string());
}
_ => {}
},
Self::Transition(d) => {
match event {
Event::Scope(scope) => {
d.request.scope = match scope.as_str() {
"Required references" => DependencyScope::Required,
"Whole subtree" => DependencyScope::Subtree,
_ => DependencyScope::SelectedOnly,
}
}
Event::MappingTarget(source, target) => {
let target = (!target.id.is_nil()).then_some(target.id);
let previous = d
.request
.mappings
.iter()
.find(|m| m.source == source)
.map(|m| m.target)
.unwrap_or_else(|| {
d.plan
.changes
.iter()
.find(|c| c.source == source)
.map(|c| c.target)
.or_else(|| {
d.plan
.conflicts
.iter()
.find(|c| c.source == Some(source))
.and_then(|c| c.target)
})
// Planned new identities are not an existing counterpart.
.filter(|id| p.contains(*id))
});
if previous != target {
d.request.resolutions.retain(|r| r.source != source);
}
if let Some(mapping) =
d.request.mappings.iter_mut().find(|m| m.source == source)
{
mapping.target = target;
} else {
d.request.mappings.push(TransitionMapping {
source,
target,
target_kind: None,
});
}
}
Event::MappingKind(source, kind) => {
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let planned_target = d
.plan
.changes
.iter()
.find(|c| c.source == source)
.map(|c| c.target);
let previous = mapping
.and_then(|m| m.target_kind)
.or_else(|| {
planned_target.and_then(|target| {
p.elements.get(&target).map(|e| e.kind()).or_else(|| {
d.plan.commands.iter().find_map(|command| match command {
Command::CreateElement(e) if e.id == target => {
Some(e.kind())
}
_ => None,
})
})
})
})
.or_else(|| {
p.elements.get(&source).and_then(|e| {
let kinds = counterpart_kinds(e.kind(), d.request.direction);
(kinds.len() == 1).then(|| kinds[0])
})
});
// Adding a kind mapping explicitly requests creation. If
// it replaces an inferred existing target, that is also
// a new review context even when its kind is identical.
let replaces_existing = mapping.is_none()
&& planned_target.is_some_and(|target| p.contains(target));
if previous != Some(kind) || replaces_existing {
d.request.resolutions.retain(|r| r.source != source);
}
if let Some(mapping) =
d.request.mappings.iter_mut().find(|m| m.source == source)
{
mapping.target_kind = Some(kind);
} else {
d.request.mappings.push(TransitionMapping {
source,
target: None,
target_kind: Some(kind),
});
}
}
Event::Resolve(source, field, decision) => {
d.request
.resolutions
.retain(|r| r.source != source || r.field != field);
d.request.resolutions.push(FieldResolution {
source,
field,
decision,
});
}
Event::Apply => {
let mut candidate =
apply_transition(p, &d.plan).map_err(|e| e.to_string())?;
let layer = d.request.direction.target_layer();
let diagram = d
.request
.destination_diagram
.or_else(|| {
candidate
.diagrams
.values()
.find(|d| d.layer == layer)
.map(|d| d.id)
})
.unwrap_or_else(|| candidate.ensure_diagram(layer));
for change in &d.plan.changes {
if candidate.elements.contains_key(&change.target) {
let index = candidate.diagrams[&diagram].nodes.len();
candidate.place(
diagram,
change.target,
Position {
x: 60. + (index % 3) as f32 * 280.,
y: 60. + (index / 3) as f32 * 150.,
},
);
}
}
return execute_batch(&candidate, &[])
.map(Some)
.map_err(|e| e.to_string());
}
_ => {}
}
d.plan = plan_transition(p, &d.request).map_err(|e| e.to_string())?;
}
}
Ok(None)
}
pub fn title(&self) -> &'static str {
match self {
Self::Ports(state) => state.title(),
Self::ManualTrace { .. } => "Link existing counterparts",
Self::Relation(_) => "Create relationship",
Self::Transition(t) => {
if t.request.direction == TransitionDirection::OaToSa {
"Operational → System"
} else {
"System → Operational"
}
}
}
}
pub fn view<'a>(&'a self, p: &'a Project) -> Element<'a, Message> {
if let Self::Ports(state) = self {
return state.view(p);
}
let mut content = column![text(self.title()).size(23)].spacing(14);
let relation_plan = if let Self::Relation(d) = self {
Some(d.commands(p))
} else {
None
};
match self {
Self::Ports(_) => unreachable!("Port dialog returned above"),
Self::ManualTrace {
name,
source,
target,
} => {
let mut choices = p
.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id)
.is_some_and(|layer| Some(layer) != p.layer_of(source.id))
})
.map(|id| choice(p, *id))
.collect::<Vec<_>>();
choices.sort_by(|a, b| a.label.cmp(&b.label));
content = content.push(text("Record an explicit engineering correspondence between existing records. This link preserves both records and does not propagate properties.").size(13).color(style::MUTED))
.push(text(format!("From: {}",source.label)).size(14))
.push(text_input("Trace name",name).padding(10).on_input(|v|Message::Workflow(Event::RelationName(v))))
.push(pick_list(choices,target.clone(),|v|Message::Workflow(Event::Target(v))).placeholder("Choose counterpart in the other perspective").width(Fill).padding(10).text_size(13));
}
Self::Relation(d) => {
let kinds = RelationKind::ALL
.into_iter()
.filter(|kind| {
*kind == RelationKind::ExchangeAllocation
|| Kind::ALL.into_iter().any(|source| {
source.supports(d.layer)
&& Kind::ALL.into_iter().any(|target| {
target.supports(d.layer) && kind.permits(source, target)
})
})
})
.collect::<Vec<_>>();
let (source_label, target_label) = match d.kind {
RelationKind::CapabilityInclude => {
("Including capability", "Included capability")
}
RelationKind::CapabilityExtend => {
("Extending capability", "Extended capability")
}
RelationKind::CapabilityGeneralization => {
("Specific capability", "General capability")
}
RelationKind::MissionCapability => ("Mission", "Exploited capability"),
RelationKind::MissionInvolvement => ("Participant", "Mission"),
RelationKind::CapabilityInvolvement => ("Participant or work", "Capability"),
_ => ("Source", "Target"),
};
content = content
.push(
text(if d.kind == RelationKind::BoundaryExchange {
"Component port ↔ component port"
} else {
d.kind.hint()
})
.size(13)
.color(style::MUTED),
)
.push(
pick_list(kinds, Some(d.kind), |v| {
Message::Workflow(Event::RelationKind(v))
})
.text_size(13)
.padding(9)
.width(Fill),
)
.push(text(source_label).size(12))
.push(
pick_list(d.sources.as_slice(), d.source.as_ref(), |v| {
Message::Workflow(Event::Source(v))
})
.placeholder("Choose source")
.text_size(13)
.padding(9)
.width(Fill),
)
.push(text(target_label).size(12))
.push(
pick_list(d.targets.as_slice(), d.target.as_ref(), |v| {
Message::Workflow(Event::Target(v))
})
.placeholder("Choose a compatible target")
.text_size(13)
.padding(9)
.width(Fill),
);
if d.source.is_some() && d.targets.is_empty() {
content = content.push(
text(if d.kind == RelationKind::BoundaryExchange {
"No compatible component port on another participant. Select the other system or actor, add a port of the same kind (Standard or Flow), then connect it here. Flow ports also need compatible input/output orientations."
} else {
"No compatible target yet. Create the needed model element, then connect it here."
})
.size(12)
.color(style::MUTED),
);
}
if d.kind == RelationKind::CapabilityExtend {
content = content.push(text("Extension condition").size(12)).push(
text_editor(&d.condition)
.placeholder("When does the extending capability add behavior?")
.on_action(|action| Message::Workflow(Event::Condition(action)))
.height(72)
.padding(9),
);
}
if d.kind == RelationKind::BoundaryExchange {
content = content.push(
checkbox(d.oriented)
.label("Oriented component exchange")
.on_toggle(|v| Message::Workflow(Event::Oriented(v))),
);
}
if d.kind == RelationKind::FunctionalExchange {
content = content.push(text("Function ports and carried items").size(14))
.push(text("Automatic uses one compatible typed port, or creates a new one. Creating this exchange and its ports is one undoable change.").size(12));
for source in [true, false] {
let owner = if source { &d.source } else { &d.target };
let selected = if source {
&d.source_port
} else {
&d.target_port
};
let mut options =
endpoint_choices(p, owner.as_ref().map(|v| v.id), source, &d.items);
if !options.contains(selected) {
options.push(selected.clone());
}
content = content
.push(text(if source { "Output port" } else { "Input port" }).size(12))
.push(
pick_list(options, Some(selected.clone()), move |v| {
Message::Workflow(Event::Endpoint(source, v))
})
.padding(8)
.text_size(12)
.width(Fill),
);
}
for item in p
.elements
.values()
.filter(|e| e.layer() == d.layer && e.kind() == Kind::ExchangeItem)
{
let id = item.id;
content = content.push(
checkbox(d.items.contains(&id))
.label(format!("{} · @{}", item.name, item.alias))
.on_toggle(move |enabled| {
Message::Workflow(Event::ExchangeItem(id, enabled))
}),
);
}
}
content = content.push(
text_input("Name (generated from endpoints when blank)", &d.name)
.on_input(|v| Message::Workflow(Event::RelationName(v)))
.padding(10)
.size(13),
);
if let (Some(source), Some(target)) = (&d.source, &d.target) {
content = content.push(
text(d.engineering_name(p, source.id, target.id))
.size(13)
.color(style::TEAL),
);
}
}
Self::Transition(d) => {
content=content.push(text("Create or reconcile traced counterparts. Parent and performer remain independent. Descriptive updates are reviewed here; structural changes are created only for new counterparts.").size(12).color(style::MUTED));
let scope = match d.request.scope {
DependencyScope::SelectedOnly => "Selected only",
DependencyScope::Required => "Required references",
DependencyScope::Subtree => "Whole subtree",
}
.to_string();
content = content.push(
row![
text("Include").size(12),
pick_list(
vec![
"Selected only".to_string(),
"Required references".to_string(),
"Whole subtree".to_string()
],
Some(scope),
|v| Message::Workflow(Event::Scope(v))
)
.text_size(12)
.padding(8),
Space::new().width(Fill),
button(text("Refresh preview").size(12))
.style(style::nav)
.on_press(Message::Workflow(Event::Preview))
]
.spacing(10)
.align_y(iced::Center),
);
let sources = d
.request
.sources
.iter()
.copied()
.chain(d.plan.changes.iter().map(|c| c.source))
.chain(d.plan.conflicts.iter().filter_map(|c| c.source))
.collect::<BTreeSet<_>>();
for source in sources {
let mapping = d.request.mappings.iter().find(|m| m.source == source);
let existing = p
.traces
.values()
.filter_map(|t| {
if d.request.direction == TransitionDirection::OaToSa && t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
.collect::<Vec<_>>();
let target = mapping.map(|m| m.target).unwrap_or_else(|| {
if existing.len() == 1 {
Some(existing[0])
} else {
None
}
});
let mut targets = vec![Choice {
id: Id::nil(),
label: "Create new counterpart".into(),
}];
targets.extend(
p.elements
.keys()
.chain(p.relations.keys())
.filter(|id| {
p.layer_of(**id) == Some(d.request.direction.target_layer())
&& p.elements.contains_key(&source)
== p.elements.contains_key(id)
})
.map(|id| choice(p, *id)),
);
let selected = targets
.iter()
.find(|c| Some(c.id) == target.or(Some(Id::nil())))
.cloned();
let mut item = column![
text(format!(
"{} · {}",
short(p.named(source), 70),
p.elements
.get(&source)
.map(|e| e.kind().label())
.unwrap_or("Relationship")
))
.size(14),
pick_list(targets, selected, move |v| Message::Workflow(
Event::MappingTarget(source, v)
))
.text_size(12)
.padding(8)
.width(Fill)
]
.spacing(7);
if target.is_none()
&& let Some(e) = p.elements.get(&source)
{
let kinds = counterpart_kinds(e.kind(), d.request.direction);
if !kinds.is_empty() {
let selected = mapping.and_then(|m| m.target_kind).or_else(|| {
if kinds.len() == 1 {
Some(kinds[0])
} else {
None
}
});
item = item.push(
pick_list(kinds, selected, move |kind| {
Message::Workflow(Event::MappingKind(source, kind))
})
.placeholder("Choose counterpart kind / system boundary")
.text_size(12)
.padding(8)
.width(Fill),
);
}
}
content = content.push(
iced::widget::container(item)
.padding(12)
.style(style::subtle),
);
}
let mut changes = column![].spacing(8);
for change in &d.plan.changes {
changes = changes.push(
text(format!("{:?} · {}", change.action, p.named(change.source)))
.size(12)
.color(style::TEAL),
);
for field in &change.fields {
changes = changes.push(
text(format!(
"{:?}: {} → {}",
field.field,
short(&field.before, 90),
short(&field.after, 90)
))
.size(12),
);
}
}
content = content.push(changes);
let mut fields = d
.plan
.conflicts
.iter()
.filter_map(|c| Some((c.source?, c.field?)))
.chain(d.request.resolutions.iter().map(|r| (r.source, r.field)))
.fold(Vec::new(), |mut out, field| {
if !out.contains(&field) {
out.push(field);
}
out
});
// Keep property widgets in the same order while a resolution
// changes the preview, so continuous typing retains focus.
fields.sort_by_key(|(source, field)| (*source, *field as u8));
let mut general_conflicts = column![].spacing(8);
for conflict in &d.plan.conflicts {
if conflict.field.is_none() {
general_conflicts = general_conflicts.push(
text(&conflict.message)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
}
content = content.push(general_conflicts);
for (source, field) in fields {
let target = d
.plan
.conflicts
.iter()
.find(|c| c.source == Some(source) && c.field == Some(field))
.and_then(|c| c.target)
.or_else(|| {
d.request
.mappings
.iter()
.find(|m| m.source == source)
.and_then(|m| m.target)
})
.or_else(|| {
d.plan
.changes
.iter()
.find(|c| c.source == source)
.map(|c| c.target)
})
.or_else(|| {
p.traces.values().find_map(|t| {
if d.request.direction == TransitionDirection::OaToSa
&& t.oa == source
{
Some(t.sa)
} else if d.request.direction == TransitionDirection::SaToOa
&& t.sa == source
{
Some(t.oa)
} else {
None
}
})
});
let value = |id| {
p.properties(id)
.map(|v| match field {
TransitionField::Name => v.name,
TransitionField::Description => v.description,
})
.unwrap_or_default()
};
let resolution = d
.request
.resolutions
.iter()
.find(|r| r.source == source && r.field == field);
let merged = resolution
.and_then(|r| {
if let FieldDecision::Value(v) = &r.decision {
Some(v.as_str())
} else {
None
}
})
.unwrap_or("");
let status = resolution
.map(|r| match &r.decision {
FieldDecision::KeepTarget => "Keep target",
FieldDecision::TakeSource => "Take source",
FieldDecision::Value(_) => "Use merged text",
})
.unwrap_or("Choose how to resolve this conflict");
let card = column![
text(format!(
"{:?} · {} → {}",
field,
p.named(source),
target.map(|id| p.named(id)).unwrap_or("New counterpart")
))
.size(14),
text(format!(
"Current target: {}",
target.map(value).unwrap_or_default()
))
.size(12),
text(format!("Proposed source: {}", value(source))).size(12),
text(status).size(12).color(style::TEAL),
row![
button(text("Keep target").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(Event::Resolve(
source,
field,
FieldDecision::KeepTarget
))),
button(text("Take source").size(12))
.padding(8)
.style(style::nav)
.on_press(Message::Workflow(Event::Resolve(
source,
field,
FieldDecision::TakeSource
)))
]
.spacing(8),
text_input("Or enter merged text", merged)
.padding(9)
.size(12)
.on_input(move |v| Message::Workflow(Event::Resolve(
source,
field,
FieldDecision::Value(v)
)))
]
.spacing(8);
content = content.push(
iced::widget::container(card)
.padding(12)
.style(style::subtle),
);
}
for warning in &d.plan.warnings {
content = content.push(
text(format!("Modeling warning · {warning}"))
.size(12)
.color(style::MUTED),
);
}
content = content.push(
text(format!(
"{} changes · {} unresolved conflicts · {} modeling warnings",
d.plan.changes.len(),
d.plan.conflicts.len(),
d.plan.warnings.len()
))
.size(12),
);
}
}
let enabled = match self {
Self::Ports(_) => false,
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(_) => relation_plan.as_ref().is_some_and(Result::is_ok),
Self::Transition(d) => d.plan.conflicts.is_empty(),
};
if let Some(Err(error)) = relation_plan {
content = content.push(
text(error)
.size(12)
.color(iced::Color::from_rgb8(178, 54, 58)),
);
}
column![
scrollable(content.padding(iced::Padding {
right: 14.,
..Default::default()
}))
.height(480),
row![
Space::new().width(Fill),
button(text("Cancel").size(12))
.padding(10)
.style(style::nav)
.on_press(Message::Workflow(Event::Cancel)),
button(text("Apply reviewed change").size(12))
.padding(10)
.on_press_maybe(enabled.then_some(Message::Workflow(Event::Apply)))
]
.spacing(10)
]
.spacing(18)
.into()
}
}
fn choice(p: &Project, id: Id) -> Choice {
if let Some(element) = p.elements.get(&id)
&& let Some(contract) = port_contract(element)
{
let contract = match contract {
PortContract::FunctionInput { .. } => "Input".into(),
PortContract::FunctionOutput { .. } => "Output".into(),
PortContract::Component {
kind: ComponentPortKind::Standard,
..
} => "Standard".into(),
PortContract::Component { orientation, .. } => format!("Flow {orientation:?}"),
PortContract::Legacy => "Legacy".into(),
};
return Choice {
id,
label: format!(
"{} / {} · {} · @{}",
short(element.parent.map_or("Unowned", |owner| p.named(owner)), 18),
short(&element.name, 20),
contract,
element.alias
),
};
}
Choice {
id,
label: format!(
"{} · {}",
p.named(id),
p.layer_of(id).map(|l| l.to_string()).unwrap_or_default()
),
}
}
#[cfg(test)]
mod port_authoring_tests {
use super::*;
#[test]
fn boundary_picker_matches_exact_commands_across_contracts_and_ownership() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let function = p
.add_element(Kind::Function, Layer::Sa, "Internal work", None)
.unwrap();
let mut endpoints = vec![];
let mut standard = vec![];
for owner in [system, actor] {
for orientation in [
ComponentPortOrientation::Unset,
ComponentPortOrientation::In,
ComponentPortOrientation::Out,
ComponentPortOrientation::InOut,
] {
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Flow",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
},
)
.unwrap();
p.elements.insert(id, port);
endpoints.push(id);
}
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Standard",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(id, port);
endpoints.push(id);
standard.push(id);
for direction in [PortDirection::In, PortDirection::Out, PortDirection::InOut] {
let id = Id::new_v4();
let mut port = make_port(&p, id, owner, "Legacy", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction,
interface: None,
});
}
p.elements.insert(id, port);
endpoints.push(id);
}
}
for contract in [
PortContract::FunctionInput {
incoming_items: vec![],
},
PortContract::FunctionOutput {
outgoing_items: vec![],
},
] {
let id = Id::new_v4();
let port = make_port(&p, id, function, "Unrelated function port", contract).unwrap();
p.elements.insert(id, port);
endpoints.push(id);
}
let interface = p
.add_element(Kind::Interface, Layer::Sa, "Telemetry", None)
.unwrap();
p.relate(
RelationKind::InterfaceRequirement,
"Requires telemetry",
standard[0],
interface,
);
p.relate(
RelationKind::InterfaceProvision,
"Provides telemetry",
standard[1],
interface,
);
let missing_contract = Id::new_v4();
let port = make_port(
&p,
missing_contract,
actor,
"Missing provision",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(missing_contract, port);
endpoints.extend([missing_contract, system, function, Id::new_v4()]);
assert!(execute_batch(&p, &[]).is_ok());
for source in &endpoints {
let Workflow::Relation(mut draft) = Workflow::relationship_kind(
&p,
*source,
Layer::Sa,
RelationKind::BoundaryExchange,
false,
) else {
unreachable!()
};
// Include invalid sources as well as those shown by the UI.
draft.source = Some(choice(&p, *source));
for oriented in [false, true] {
draft.oriented = oriented;
for target in &endpoints {
let exact = Relation {
id: Id::new_v4(),
alias: "boundary_probe".into(),
name: "Boundary probe".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source: *source,
target: *target,
oriented,
items: vec![],
},
};
let accepted = execute_batch(&p, &[Command::CreateRelation(exact)]).is_ok();
assert_eq!(
draft.accepts_target(&p, *target),
accepted,
"source {source}, target {target}, oriented {oriented}"
);
}
}
}
}
#[test]
fn generic_component_port_connection_keeps_its_endpoint_and_direction() {
for (kind, orientation) in [
(ComponentPortKind::Flow, ComponentPortOrientation::In),
(ComponentPortKind::Flow, ComponentPortOrientation::Out),
(ComponentPortKind::Flow, ComponentPortOrientation::InOut),
(ComponentPortKind::Flow, ComponentPortOrientation::Unset),
(ComponentPortKind::Standard, ComponentPortOrientation::Unset),
] {
let mut p = Project::empty();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let incoming = orientation == ComponentPortOrientation::In;
let other_orientation = if kind == ComponentPortKind::Standard {
ComponentPortOrientation::Unset
} else if incoming {
ComponentPortOrientation::Out
} else {
ComponentPortOrientation::In
};
let mut ports = vec![];
for (owner, orientation) in [(actor, orientation), (system, other_orientation)] {
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Data",
PortContract::Component { kind, orientation },
)
.unwrap();
p.elements.insert(id, port);
ports.push(id);
}
let before = p.clone();
let mut workflow = Workflow::relationship(&p, ports[0], Layer::Sa);
let Workflow::Relation(draft) = &workflow else {
unreachable!()
};
assert_eq!(draft.kind, RelationKind::BoundaryExchange);
if incoming {
assert!(draft.source.is_none());
assert_eq!(draft.target.as_ref().unwrap().id, ports[0]);
} else {
assert_eq!(draft.source.as_ref().unwrap().id, ports[0]);
assert!(draft.target.is_none());
}
assert_eq!(draft.oriented, kind == ComponentPortKind::Flow);
workflow
.update(
&p,
if incoming {
Event::Source(choice(&p, ports[1]))
} else {
Event::Target(choice(&p, ports[1]))
},
)
.unwrap();
let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap();
assert_eq!(p, before, "The modal never mutates its input model");
let (expected_source, expected_target) = if incoming {
(ports[1], ports[0])
} else {
(ports[0], ports[1])
};
assert!(accepted.relations.values().any(|relation| matches!(relation.body, RelationBody::BoundaryExchange { source, target, oriented, .. } if source == expected_source && target == expected_target && oriented == (kind == ComponentPortKind::Flow))));
}
}
#[test]
fn duplicate_port_names_show_owner_contract_and_unique_alias() {
let mut p = Project::empty();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Operator", None)
.unwrap();
let system = p
.add_element(Kind::System, Layer::Sa, "Rescue", None)
.unwrap();
let mut labels = BTreeSet::new();
for owner in [actor, actor, system] {
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Data",
PortContract::Component {
kind: ComponentPortKind::Flow,
orientation: ComponentPortOrientation::Out,
},
)
.unwrap();
p.elements.insert(id, port);
let label = choice(&p, id).label;
assert!(label.contains(&format!("{} / Data", p.named(owner))));
assert!(label.contains("Flow Out"));
assert!(label.contains(&format!("@{}", p.elements[&id].alias)));
assert!(
labels.insert(label),
"Duplicate names must still identify distinct ports"
);
}
}
#[test]
fn boundary_picker_recomputes_for_unoriented_reverse_endpoints_without_relaxing_contracts() {
let mut p = Project::empty();
let system = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let actor = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let mut create = |owner, name, contract| {
let id = Id::new_v4();
let port = make_port(&p, id, owner, name, contract).unwrap();
p.elements.insert(id, port);
id
};
let flow = |orientation| PortContract::Component {
kind: ComponentPortKind::Flow,
orientation,
};
let input = create(system, "Receive", flow(ComponentPortOrientation::In));
let output = create(actor, "Send", flow(ComponentPortOrientation::Out));
let incompatible = create(actor, "Other input", flow(ComponentPortOrientation::In));
let same_owner = create(system, "Local output", flow(ComponentPortOrientation::Out));
let standard = create(
actor,
"Standard",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
);
let legacy = create(actor, "Preserved output", PortContract::Legacy);
if let Some(SharedBody::Port { legacy, .. }) =
p.elements.get_mut(&legacy).unwrap().shared_mut()
{
*legacy = Some(LegacyPortContract {
direction: PortDirection::Out,
interface: None,
});
}
let before = p.clone();
let mut workflow = Workflow::relationship_kind(
&p,
input,
Layer::Sa,
RelationKind::BoundaryExchange,
false,
);
let Workflow::Relation(draft) = &workflow else {
unreachable!()
};
assert!(!draft.targets.iter().any(|choice| choice.id == output));
workflow.update(&p, Event::Oriented(false)).unwrap();
let Workflow::Relation(draft) = &workflow else {
unreachable!()
};
assert!(draft.targets.iter().any(|choice| choice.id == output));
for invalid in [input, incompatible, same_owner, standard, legacy] {
assert!(
!draft.targets.iter().any(|choice| choice.id == invalid),
"Rejected endpoint {invalid}"
);
}
workflow
.update(&p, Event::Target(choice(&p, output)))
.unwrap();
let Workflow::Relation(draft) = &workflow else {
unreachable!()
};
assert!(draft.commands(&p).is_ok());
workflow.update(&p, Event::Oriented(true)).unwrap();
let Workflow::Relation(draft) = &workflow else {
unreachable!()
};
assert_eq!(
draft.target.as_ref().unwrap().id,
output,
"Keep the selected identity available for correction"
);
assert!(!draft.targets.iter().any(|choice| choice.id == output));
assert!(
draft.commands(&p).is_err(),
"The actual Apply gate rejects the incompatible draft"
);
assert!(workflow.update(&p, Event::Apply).is_err());
assert_eq!(p, before);
workflow.update(&p, Event::Oriented(false)).unwrap();
let accepted = workflow.update(&p, Event::Apply).unwrap().unwrap();
assert!(accepted.relations.values().any(|r| matches!(r.body, RelationBody::BoundaryExchange { source, target, oriented: false, .. } if source == input && target == output)));
}
#[test]
fn fresh_functions_are_actual_picker_targets_and_plan_enables_apply() {
let mut p = Project::empty();
let a = p
.add_element(Kind::Function, Layer::Sa, "Source", None)
.unwrap();
let b = p
.add_element(Kind::Function, Layer::Sa, "Target", None)
.unwrap();
let oa = p
.add_element(Kind::Activity, Layer::Oa, "Outside layer", None)
.unwrap();
let mut w =
Workflow::relationship_kind(&p, a, Layer::Sa, RelationKind::FunctionalExchange, false);
let Workflow::Relation(d) = &w else {
unreachable!()
};
assert!(d.targets.iter().any(|v| v.id == b));
assert!(!d.targets.iter().any(|v| v.id == a || v.id == oa));
assert!(d.commands(&p).is_err());
w.update(&p, Event::Target(choice(&p, b))).unwrap();
let Workflow::Relation(d) = &w else {
unreachable!()
};
assert!(
d.commands(&p).is_ok(),
"The same plan enables the actual Apply control"
);
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
assert_eq!(
accepted
.elements
.values()
.filter(|e| e.kind() == Kind::Port)
.count(),
2
);
let invalid = Relation {
id: Id::new_v4(),
alias: "unbound".into(),
name: "Unbound".into(),
description: String::new(),
body: RelationBody::new(RelationKind::FunctionalExchange, a, b),
};
assert!(
execute_batch(&p, &[Command::CreateRelation(invalid)]).is_err(),
"Exact records remain strict"
);
}
#[test]
fn ordinary_standard_connections_default_unoriented_and_new_roles_exclude_legacy() {
let mut p = Project::empty();
let a = p
.add_element(Kind::System, Layer::Sa, "System", None)
.unwrap();
let b = p
.add_element(Kind::SystemActor, Layer::Sa, "Actor", None)
.unwrap();
let f = p
.add_element(Kind::Function, Layer::Sa, "Function", None)
.unwrap();
let interface = p
.add_element(Kind::Interface, Layer::Sa, "Interface", None)
.unwrap();
let mut ids = vec![];
for owner in [a, b] {
let id = Id::new_v4();
let port = make_port(
&p,
id,
owner,
"Standard",
PortContract::Component {
kind: ComponentPortKind::Standard,
orientation: ComponentPortOrientation::Unset,
},
)
.unwrap();
p.elements.insert(id, port);
ids.push(id);
}
let legacy = Id::new_v4();
let mut port = make_port(&p, legacy, f, "Preserved", PortContract::Legacy).unwrap();
if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() {
*legacy = Some(LegacyPortContract {
direction: PortDirection::In,
interface: Some(interface),
});
}
p.elements.insert(legacy, port);
let mut w = Workflow::relationship_kind(
&p,
ids[0],
Layer::Sa,
RelationKind::BoundaryExchange,
false,
);
w.update(&p, Event::Target(choice(&p, ids[1]))).unwrap();
let accepted = w.update(&p, Event::Apply).unwrap().unwrap();
assert!(accepted.relations.values().any(|r| matches!(
r.body,
RelationBody::BoundaryExchange {
oriented: false,
..
}
)));
for kind in [
RelationKind::InterfaceProvision,
RelationKind::InterfaceRequirement,
] {
let w = Workflow::relationship_kind(&p, legacy, Layer::Sa, kind, false);
let Workflow::Relation(d) = w else {
unreachable!()
};
assert!(d.source.is_none());
assert!(!d.sources.iter().any(|v| v.id == legacy));
assert!(d.sources.iter().any(|v| v.id == ids[0]));
}
}
}