Integrate typed port authoring and reviewable exchange workflows

This commit is contained in:
2026-09-05 20:00:21 +03:00
parent 2dd17aa693
commit fa096cefd3
21 changed files with 6449 additions and 2154 deletions
+477 -37
View File
@@ -6,7 +6,9 @@ use crate::{
};
use iced::{
Element, Fill,
widget::{Space, button, column, pick_list, row, scrollable, text, text_editor, text_input},
widget::{
Space, button, checkbox, column, pick_list, row, scrollable, text, text_editor, text_input,
},
};
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
@@ -25,6 +27,10 @@ impl std::fmt::Display for RelationAction {
#[derive(Debug, Clone)]
pub enum Event {
Port(crate::port_workflow::Event),
ExchangeItem(Id, bool),
Endpoint(bool, EndpointChoice),
Oriented(bool),
Cancel,
Apply,
RelationName(String),
@@ -39,7 +45,8 @@ pub enum Event {
Preview,
}
pub enum Workflow {
Relation(Relationship),
Ports(Box<crate::port_workflow::State>),
Relation(Box<Relationship>),
ManualTrace {
name: String,
source: Choice,
@@ -48,6 +55,7 @@ pub enum Workflow {
Transition(Box<Transition>),
}
pub struct Relationship {
id: Id,
pub name: String,
pub kind: RelationKind,
pub source: Option<Choice>,
@@ -56,6 +64,60 @@ pub struct Relationship {
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 {
@@ -63,6 +125,12 @@ impl Relationship {
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
@@ -91,16 +159,45 @@ impl Relationship {
.keys()
.chain(p.relations.keys())
.copied()
.filter(|id| {
self.source
.as_ref()
.is_some_and(|source| validation::can_connect(p, self.kind, source.id, *id))
})
.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;
};
let directed = validation::can_connect(p, self.kind, source, target);
if self.kind != RelationKind::BoundaryExchange || self.oriented || directed {
return directed;
}
if ![source, target].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|element| element.kind() == Kind::Port)
}) {
return false;
}
// An unoriented connection may use the reverse stored endpoint order.
// Ask the exact core rules, including preserved Legacy restrictions;
// reversing can_connect alone would incorrectly relax those rules.
let candidate = Relation {
id: self.id,
alias: p.fresh_alias("connection", "relation", self.id),
name: "Connection".into(),
description: String::new(),
body: RelationBody::BoundaryExchange {
source,
target,
oriented: false,
items: vec![],
},
};
execute_batch(p, &[Command::CreateRelation(candidate)]).is_ok()
}
fn engineering_name(&self, p: &Project, source: Id, target: Id) -> String {
if !self.name.trim().is_empty() {
return self.name.clone();
@@ -118,6 +215,64 @@ impl Relationship {
};
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,
@@ -158,6 +313,7 @@ impl Workflow {
incoming: bool,
) -> Self {
let mut draft = Relationship {
id: Id::new_v4(),
name: String::new(),
kind,
source: (!incoming).then(|| choice(p, selected)),
@@ -166,6 +322,10 @@ impl Workflow {
layer,
sources: vec![],
targets: vec![],
items: vec![],
source_port: automatic_port(),
target_port: automatic_port(),
oriented: true,
};
if draft
.source
@@ -174,8 +334,9 @@ impl Workflow {
{
draft.source = None;
}
draft.default_orientation(p);
draft.refresh_choices(p);
Self::Relation(draft)
Self::Relation(Box::new(draft))
}
pub fn transition(p: &Project, selected: Id) -> Result<Self, String> {
let layer = p
@@ -195,6 +356,7 @@ impl Workflow {
/// 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,
@@ -233,6 +395,25 @@ impl Workflow {
_ => {}
},
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;
@@ -243,18 +424,16 @@ impl Workflow {
{
d.source = None;
}
d.default_orientation(p);
d.refresh_choices(p);
}
Event::Source(source) => {
d.source = Some(source);
if d.target.as_ref().is_some_and(|target| {
!validation::can_connect(
p,
d.kind,
d.source.as_ref().unwrap().id,
target.id,
)
}) {
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);
@@ -262,22 +441,7 @@ impl Workflow {
Event::Target(target) => d.target = Some(target),
Event::Condition(action) => d.condition.perform(action),
Event::Apply => {
let a = d.source.as_ref().ok_or("Choose a source.")?.id;
let b = d.target.as_ref().ok_or("Choose a target.")?.id;
let id = Id::new_v4();
let name = d.engineering_name(p, a, b);
let mut body = RelationBody::new(d.kind, a, b);
if let RelationBody::CapabilityExtend { condition, .. } = &mut body {
*condition = d.condition.text();
}
let relation = Relation {
id,
alias: p.fresh_alias(&name, "relation", id),
name,
description: String::new(),
body,
};
return execute_batch(p, &[Command::CreateRelation(relation)])
return execute_batch(p, &d.commands(p)?)
.map(Some)
.map_err(|e| e.to_string());
}
@@ -430,6 +594,7 @@ impl Workflow {
}
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) => {
@@ -442,8 +607,17 @@ impl Workflow {
}
}
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,
@@ -535,6 +709,54 @@ impl Workflow {
.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)))
@@ -858,14 +1080,18 @@ impl Workflow {
}
}
let enabled = match self {
Self::Ports(_) => false,
Self::ManualTrace { name, target, .. } => target.is_some() && !name.trim().is_empty(),
Self::Relation(d) => d
.source
.as_ref()
.zip(d.target.as_ref())
.is_some_and(|(a, b)| validation::can_connect(p, d.kind, a.id, b.id)),
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.,
@@ -898,3 +1124,217 @@ fn choice(p: &Project, id: Id) -> Choice {
),
}
}
#[cfg(test)]
mod port_authoring_tests {
use super::*;
#[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]));
}
}
}