//! Contextual port authoring. Plans remain ordinary core commands and commit once. use crate::{ app::{Choice, Message}, port_preview, style, workflow, }; use iced::{ Element as View, Fill, widget::{button, checkbox, column, pick_list, row, scrollable, text, text_input}, }; use oa_core::*; use std::collections::BTreeSet; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Action { Input, Output, Flow, Standard, Generate, Resolve, Split, Synchronize, } impl Action { pub fn label(self) -> &'static str { match self { Self::Input => "Create function input port", Self::Output => "Create function output port", Self::Flow => "Create Flow component port", Self::Standard => "Create Standard component port", Self::Generate => "Generate component exchange", Self::Resolve => "Resolve legacy port contract", Self::Split => "Split legacy input/output port", Self::Synchronize => "Synchronize port items", } } } impl std::fmt::Display for Action { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(self.label()) } } pub fn actions(p: &Project, id: Id) -> Vec { if p.layer_of(id) != Some(Layer::Sa) { return vec![]; } if p.relations .get(&id) .is_some_and(|r| r.kind() == RelationKind::FunctionalExchange) { return vec![Action::Generate]; } let Some(e) = p.elements.get(&id) else { return vec![]; }; match e.kind() { Kind::Function => vec![Action::Input, Action::Output], Kind::System | Kind::SystemActor => vec![Action::Flow, Action::Standard], Kind::Port => match e.shared() { Some(SharedBody::Port { contract: PortContract::Legacy, legacy: Some(old), }) => { let mut out = vec![Action::Resolve]; if old.direction == PortDirection::InOut && e.parent .and_then(|id| p.elements.get(&id)) .is_some_and(|e| e.kind() == Kind::Function) { out.push(Action::Split); } out } Some(SharedBody::Port { contract: PortContract::FunctionInput { .. } | PortContract::FunctionOutput { .. }, .. }) => vec![Action::Synchronize], _ => vec![], }, _ => vec![], } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ContractKind { Input, Output, Flow, Standard, } impl std::fmt::Display for ContractKind { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(match self { Self::Input => "Function input", Self::Output => "Function output", Self::Flow => "Component · Flow", Self::Standard => "Component · Standard", }) } } #[derive(Debug, Clone)] pub enum Event { Name(String), Kind(ContractKind), Orientation(String), Oriented(bool), Boundary(Choice), Item(Id, bool, bool), Primary(bool), Endpoint(bool, workflow::EndpointChoice), } pub struct State { action: Action, subject: Id, id: Id, baseline: Project, name: String, kind: ContractKind, orientation: ComponentPortOrientation, oriented: bool, boundary: Option, source_port: workflow::EndpointChoice, target_port: workflow::EndpointChoice, incoming: BTreeSet, outgoing: BTreeSet, primary_input: bool, preview: Result, String>, } impl State { pub fn new(p: &Project, subject: Id, action: Action) -> Result { if !actions(p, subject).contains(&action) { return Err("This port action is unavailable for the selected element.".into()); } let e = p.elements.get(&subject); let old = e.and_then(|e| e.shared()).and_then(|body| match body { SharedBody::Port { legacy, .. } => legacy.as_ref(), _ => None, }); let owner = e.and_then(|e| e.parent).and_then(|id| p.elements.get(&id)); let kind = match action { Action::Input => ContractKind::Input, Action::Output => ContractKind::Output, Action::Standard => ContractKind::Standard, Action::Resolve | Action::Split | Action::Synchronize if owner.is_some_and(|e| e.kind() == Kind::Function) => { if matches!( e.and_then(port_contract), Some(PortContract::FunctionInput { .. }) ) || (matches!(e.and_then(port_contract), Some(PortContract::Legacy)) && old.is_some_and(|old| old.direction == PortDirection::In)) { ContractKind::Input } else { ContractKind::Output } } _ => ContractKind::Flow, }; let mut incoming = BTreeSet::new(); let mut outgoing = BTreeSet::new(); if let Some(contract) = e.and_then(port_contract) { match contract { PortContract::FunctionInput { incoming_items } => incoming.extend(incoming_items), PortContract::FunctionOutput { outgoing_items } => outgoing.extend(outgoing_items), _ => {} } } if let Some(SharedBody::Interface { items }) = old .and_then(|old| old.interface) .and_then(|id| p.elements.get(&id)) .and_then(|e| e.shared()) { incoming.extend(items); outgoing.extend(items); } for r in p.relations.values() { if let RelationBody::FunctionalExchange { source_port, target_port, items, .. } = &r.body { if *source_port == subject { outgoing.extend(items); } if *target_port == subject { incoming.extend(items); } } } let mut state = Self { action, subject, id: Id::new_v4(), baseline: p.clone(), name: if action == Action::Generate { format!("{} implementation", p.named(subject)) } else { format!("{kind} port") }, kind, orientation: old.map_or(ComponentPortOrientation::Unset, |old| match old.direction { PortDirection::In => ComponentPortOrientation::In, PortDirection::Out => ComponentPortOrientation::Out, PortDirection::InOut => ComponentPortOrientation::InOut, }), oriented: true, boundary: None, source_port: workflow::automatic_port(), target_port: workflow::automatic_port(), incoming, outgoing, primary_input: true, preview: Ok(vec![]), }; state.preview = state.plan(p); Ok(state) } pub fn title(&self) -> &'static str { self.action.label() } pub fn focus(&self) -> Id { match self.action { Action::Generate => self .boundary .as_ref() .filter(|v| !v.id.is_nil()) .map_or(self.id, |v| v.id), Action::Resolve | Action::Split | Action::Synchronize => self.subject, _ => self.id, } } fn contract(&self) -> PortContract { match self.kind { ContractKind::Input => PortContract::FunctionInput { incoming_items: self.incoming.iter().copied().collect(), }, ContractKind::Output => PortContract::FunctionOutput { outgoing_items: self.outgoing.iter().copied().collect(), }, ContractKind::Flow => PortContract::Component { kind: ComponentPortKind::Flow, orientation: self.orientation, }, ContractKind::Standard => PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }, } } fn plan(&self, p: &Project) -> Result, String> { let result = match self.action { Action::Generate => plan_component_exchange( p, &ComponentExchangeRequest { functional_exchange: self.subject, boundary: if let Some(existing) = &self.boundary && !existing.id.is_nil() { ComponentExchangeChoice::Reuse(existing.id) } else { ComponentExchangeChoice::Create { id: self.id, alias: p.fresh_alias(&self.name, "component_exchange", self.id), name: self.name.clone(), description: String::new(), port_kind: if self.kind == ContractKind::Standard { ComponentPortKind::Standard } else { ComponentPortKind::Flow }, oriented: self.oriented, source_port: self.source_port.choice, target_port: self.target_port.choice, } }, }, ), Action::Resolve => plan_legacy_port_resolution( p, &LegacyPortResolution { port: self.subject, contract: self.contract(), }, ), Action::Split => plan_legacy_work_port_split( p, &LegacyWorkPortSplitRequest { port: self.subject, primary: if self.primary_input { FunctionPortSide::Input } else { FunctionPortSide::Output }, incoming_items: self.incoming.iter().copied().collect(), outgoing_items: self.outgoing.iter().copied().collect(), }, ), Action::Synchronize => plan_function_port_items( p, self.subject, if self.kind == ContractKind::Input { &self.incoming } else { &self.outgoing } .iter() .copied() .collect(), ), _ => make_port(p, self.id, self.subject, &self.name, self.contract()).and_then(|e| { let commands = vec![Command::CreateElement(e)]; execute_batch(p, &commands).map(|_| commands) }), }; result.map_err(|e| e.to_string()) } pub fn update( &mut self, p: &Project, event: workflow::Event, ) -> Result, String> { if p != &self.baseline { return Err("The model changed after this preview. Reopen the port action to review current facts.".into()); } match event { workflow::Event::Apply => { return execute_batch(p, self.preview.as_ref().map_err(Clone::clone)?) .map(Some) .map_err(|e| e.to_string()); } workflow::Event::Port(event) => match event { Event::Name(v) => self.name = v, Event::Kind(v) => { self.kind = v; if v == ContractKind::Standard { self.oriented = false; } } Event::Orientation(v) => { self.orientation = match v.as_str() { "In" => ComponentPortOrientation::In, "Out" => ComponentPortOrientation::Out, "InOut" => ComponentPortOrientation::InOut, _ => ComponentPortOrientation::Unset, } } Event::Oriented(v) => self.oriented = v, Event::Boundary(v) => self.boundary = Some(v), Event::Primary(v) => self.primary_input = v, Event::Endpoint(source, choice) => { if source { self.source_port = choice; } else { self.target_port = choice; } } Event::Item(id, input, enabled) => { let items = if input { &mut self.incoming } else { &mut self.outgoing }; if enabled { items.insert(id); } else { items.remove(&id); } } }, _ => {} } self.preview = self.plan(p); Ok(None) } pub fn view<'a>(&'a self, p: &'a Project) -> View<'a, Message> { let reusing = self.action == Action::Generate && self.boundary.as_ref().is_some_and(|v| !v.id.is_nil()); let mut body = column![ text(self.title()).size(23), text(p.named(self.subject)).size(15) ] .spacing(12) .width(Fill); if matches!( self.action, Action::Input | Action::Output | Action::Flow | Action::Standard | Action::Generate ) && !reusing { body = body.push( text_input("Port or exchange name", &self.name) .padding(9) .on_input(|v| event(Event::Name(v))), ); } if matches!(self.action, Action::Resolve | Action::Generate) && !reusing { let kinds = if self.kind == ContractKind::Input || self.kind == ContractKind::Output { vec![ContractKind::Input, ContractKind::Output] } else { vec![ContractKind::Standard, ContractKind::Flow] }; body = body.push( pick_list(kinds, Some(self.kind), |v| event(Event::Kind(v))) .padding(9) .width(Fill), ); } if self.kind == ContractKind::Flow && self.action != Action::Generate { body = body.push(text("Flow orientation").size(12)).push( pick_list( vec![ "Unset".to_string(), "In".into(), "Out".into(), "InOut".into(), ], Some(format!("{:?}", self.orientation)), |v| event(Event::Orientation(v)), ) .padding(9), ); } if self.action == Action::Generate { let mut choices = vec![Choice { id: Id::nil(), label: "Create a component exchange".into(), }]; choices.extend( p.relations .values() .filter(|r| { r.kind() == RelationKind::BoundaryExchange && p.layer_of(r.id) == Some(Layer::Sa) }) .map(|r| Choice { id: r.id, label: format!("Reuse {}", r.name), }), ); body = body.push(pick_list(choices, self.boundary.clone(), |v| event(Event::Boundary(v))).placeholder("Create a component exchange").padding(9).width(Fill)) .push(text("Uses actual function allocations. Compatible ports are reused only when unique; otherwise new ports are created. The preview includes both port allocations and exchange allocation.").size(12)); if !reusing { body = body.push( checkbox(self.oriented) .label("Orient the new component exchange") .on_toggle(|v| event(Event::Oriented(v))), ); for source in [true, false] { let selected = if source { &self.source_port } else { &self.target_port }; let mut choices = self.component_port_choices(p, source); if !choices.contains(selected) { choices.push(selected.clone()); } body = body .push( text(if source { "Source component port" } else { "Target component port" }) .size(12), ) .push( pick_list(choices, Some(selected.clone()), move |v| { event(Event::Endpoint(source, v)) }) .padding(9) .text_size(12) .width(Fill), ); } } } if matches!( self.action, Action::Resolve | Action::Split | Action::Synchronize ) { body = body.push(text("Item suggestions combine existing declarations, incident exchanges and any preserved interface membership. Review the effective contract below.").size(12)); } if self.action == Action::Split { body = body.push( checkbox(self.primary_input) .label("Keep the original identity as the input port") .on_toggle(|v| event(Event::Primary(v))), ); } for input in [true, false] { if self.action != Action::Split && (input != (self.kind == ContractKind::Input) || !matches!(self.kind, ContractKind::Input | ContractKind::Output)) { continue; } let selected = if input { &self.incoming } else { &self.outgoing }; body = body.push( text(if input { "Incoming exchange items" } else { "Outgoing exchange items" }) .size(14), ); let items: Vec<_> = p .elements .values() .filter(|e| e.layer() == Layer::Sa && e.kind() == Kind::ExchangeItem) .collect(); if items.is_empty() { body = body.push( text("No exchange items yet. An empty contract carries no items.").size(12), ); } for item in items { let id = item.id; body = body.push( checkbox(selected.contains(&id)) .label(format!("{} · @{}", item.name, item.alias)) .on_toggle(move |enabled| event(Event::Item(id, input, enabled))), ); } } if matches!(self.action, Action::Resolve | Action::Split) { body = body.push(text("The original identity, prose, artifacts, constraints, traces and preserved legacy association stay on the primary port. Only functional-exchange endpoints are rerouted by splitting.").size(12)); } if matches!( self.action, Action::Resolve | Action::Split | Action::Synchronize ) { for description in port_preview::port_context_rows(p, self.subject) { body = body.push( text(description) .size(13) .width(Fill) .wrapping(text::Wrapping::WordOrGlyph), ); } } body = body.push(text("Proposed model changes").size(15)); match &self.preview { Ok(commands) => { for description in port_preview::change_rows(p, commands) { body = body.push( text(description) .size(13) .width(Fill) .wrapping(text::Wrapping::WordOrGlyph), ); } } Err(error) => { body = body.push( text(error) .size(12) .color(iced::Color::from_rgb8(178, 54, 58)), ) } } column![ scrollable(body).height(480), row![ button(text("Cancel")) .style(style::nav) .on_press(Message::Workflow(workflow::Event::Cancel)), button(text("Apply reviewed change")).on_press_maybe( self.preview .as_ref() .is_ok_and(|v| !v.is_empty()) .then_some(Message::Workflow(workflow::Event::Apply)) ) ] .spacing(10) ] .spacing(16) .into() } fn component_port_choices(&self, p: &Project, source: bool) -> Vec { let mut choices = vec![ workflow::automatic_port(), workflow::EndpointChoice { choice: PortChoice::Create, label: "Create a new component port".into(), }, ]; let Some(fe) = p.relations.get(&self.subject) else { return choices; }; let owner = actual_performer(p, if source { fe.source() } else { fe.target() }); let desired = if self.kind == ContractKind::Standard { ComponentPortKind::Standard } else { ComponentPortKind::Flow }; choices.extend(p.elements.values().filter(|e| e.parent == owner && e.layer() == Layer::Sa).filter(|e| matches!(port_contract(e), Some(contract @ PortContract::Component { kind, .. }) if *kind == desired && component_allows(contract, source))).map(|e| workflow::EndpointChoice { choice: PortChoice::Reuse(e.id), label: format!("Reuse {} · @{}", e.name, e.alias) })); choices } } fn event(event: Event) -> Message { Message::Workflow(workflow::Event::Port(event)) } #[cfg(test)] mod tests { use super::*; use crate::app::App; fn send(app: &mut App, message: Message) { let _ = app.update(message); } fn fixture() -> (Project, Id, Id, Id) { let mut p = Project::empty(); let system = p .add_element(Kind::System, Layer::Sa, "Rescue system", None) .unwrap(); let actor = p .add_element(Kind::SystemActor, Layer::Sa, "Operator", None) .unwrap(); let output = p .add_element(Kind::Function, Layer::Sa, "Report incident", None) .unwrap(); let input = p .add_element(Kind::Function, Layer::Sa, "Assess incident", None) .unwrap(); let item = p .add_element(Kind::ExchangeItem, Layer::Sa, "Incident report", None) .unwrap(); p.relate(RelationKind::Allocation, "Reports", actor, output); p.relate(RelationKind::Allocation, "Assesses", system, input); let diagram = p.ensure_diagram(Layer::Sa); p.diagrams.get_mut(&diagram).unwrap().projection = ProjectionKind::Allocation; for id in [actor, system, output, input] { p.place(diagram, id, Position { x: 60., y: 60. }); } let p = execute_batch(&p, &[Command::Layout(diagram)]).unwrap(); (p, output, input, item) } #[test] fn port_creation_keeps_owner_diagram_and_unfolds_in_the_same_undo() { for folded in [false, true] { let (mut p, output, _, _) = fixture(); let diagram = p .diagrams .values() .find(|d| d.layer == Layer::Sa) .unwrap() .id; let performer = actual_performer(&p, output).unwrap(); let container = p.diagrams[&diagram] .nodes .values() .find(|n| n.element == performer) .unwrap() .id; p.diagrams .get_mut(&diagram) .unwrap() .nodes .get_mut(&container) .unwrap() .collapsed = folded; let other = Id::new_v4(); p.diagrams.insert( other, Diagram { id: other, alias: "empty_overview".into(), name: "Empty overview".into(), description: String::new(), layer: Layer::Sa, projection: ProjectionKind::Overview, nodes: Default::default(), }, ); let mut app = App::with_project(p.clone()); send(&mut app, Message::Select(output)); assert_eq!(app.active_diagram, Some(diagram)); send(&mut app, Message::PortAction(Action::Output)); send(&mut app, Message::Workflow(workflow::Event::Apply)); assert!(app.workflow.is_none(), "{}", app.status); assert_eq!(app.active_diagram, Some(diagram)); assert!( projection::project_view(app.project(), diagram) .ports .iter() .any(|port| port.element == app.selected) ); send(&mut app, Message::Undo); assert_eq!( app.project(), &p, "Port creation and owner reveal are one undo step" ); } } #[test] fn synchronization_uses_active_contract_instead_of_preserved_direction() { let (mut p, output, _, item) = fixture(); let id = Id::new_v4(); let mut port = make_port( &p, id, output, "Resolved output", PortContract::FunctionOutput { outgoing_items: vec![item], }, ) .unwrap(); if let Some(SharedBody::Port { legacy, .. }) = port.shared_mut() { *legacy = Some(LegacyPortContract { direction: PortDirection::In, interface: None, }); } p.elements.insert(id, port); let mut state = State::new(&p, id, Action::Synchronize).unwrap(); assert_eq!(state.kind, ContractKind::Output); assert_eq!(state.outgoing, BTreeSet::from([item])); assert!( state.preview.as_ref().unwrap().is_empty(), "Unchanged sync disables Apply" ); let candidate = state.update(&p, workflow::Event::Apply).unwrap().unwrap(); assert_eq!( candidate, p, "Opening and applying sync must not erase the active contract" ); } #[test] fn graphical_function_exchange_and_component_generation_commit_and_undo_whole_contracts() { let (p, output, input, item) = fixture(); let mut app = App::with_project(p.clone()); send(&mut app, Message::Select(output)); send( &mut app, Message::AddRelationKind(RelationKind::FunctionalExchange, false), ); send( &mut app, Message::Workflow(workflow::Event::Target(Choice { id: input, label: "Assess incident".into(), })), ); send( &mut app, Message::Workflow(workflow::Event::ExchangeItem(item, true)), ); assert_eq!(app.project(), &p); send(&mut app, Message::Workflow(workflow::Event::Apply)); assert!(app.workflow.is_none(), "{}", app.status); let exchange = app.selected; let RelationBody::FunctionalExchange { source_port, target_port, items, .. } = &app.project().relations[&exchange].body else { panic!("FE selected"); }; assert_eq!(items, &[item]); assert_eq!(app.project().elements[source_port].parent, Some(output)); assert_eq!(app.project().elements[target_port].parent, Some(input)); assert_eq!( port_contract(&app.project().elements[source_port]), Some(&PortContract::FunctionOutput { outgoing_items: vec![item] }) ); let before_generation = app.project().clone(); send(&mut app, Message::PortAction(Action::Generate)); send(&mut app, event(Event::Kind(ContractKind::Standard))); assert_eq!(app.project(), &before_generation); send(&mut app, Message::Workflow(workflow::Event::Apply)); assert!(app.workflow.is_none(), "{}", app.status); let boundary = app.selected; assert!(matches!( app.project().relations[&boundary].body, RelationBody::BoundaryExchange { oriented: false, .. } )); assert_eq!( app.project() .relations .values() .filter(|r| r.kind() == RelationKind::PortAllocation) .count(), 2 ); assert_eq!( app.project() .relations .values() .filter(|r| r.kind() == RelationKind::ExchangeAllocation) .count(), 1 ); assert_eq!( language::parse(&language::format(app.project())).unwrap(), *app.project() ); let generated = app.project().clone(); send(&mut app, Message::Undo); assert_eq!(app.project(), &before_generation); send(&mut app, Message::Undo); assert_eq!(app.project(), &p); send(&mut app, Message::Redo); send(&mut app, Message::Redo); assert_eq!(app.project(), &generated); send(&mut app, Message::Select(exchange)); send(&mut app, Message::PortAction(Action::Generate)); send( &mut app, event(Event::Boundary(Choice { id: boundary, label: "Reuse implementation".into(), })), ); send(&mut app, Message::Workflow(workflow::Event::Apply)); assert_eq!( app.project(), &generated, "Repeated generation duplicates no facts" ); } #[test] fn creating_typed_port_and_rejecting_stale_preview_preserve_drafts_and_identity() { let (p, output, _, item) = fixture(); let mut app = App::with_project(p.clone()); send(&mut app, Message::Select(output)); send(&mut app, Message::PortAction(Action::Output)); send(&mut app, event(Event::Name("Incident output".into()))); send(&mut app, event(Event::Item(item, false, true))); send(&mut app, Message::Workflow(workflow::Event::Apply)); let port = app.selected; assert_eq!(app.project().elements[&port].parent, Some(output)); assert_eq!( port_contract(&app.project().elements[&port]), Some(&PortContract::FunctionOutput { outgoing_items: vec![item] }) ); assert!(matches!( app.draft.as_ref().unwrap().kind, crate::forms::RecordKind::Element )); send(&mut app, Message::Undo); assert_eq!(app.project(), &p); let mut draft = State::new(&p, output, Action::Output).unwrap(); let changed = execute_batch(&p, &[Command::Rename(output, "Changed elsewhere".into())]).unwrap(); assert!( draft .update(&changed, workflow::Event::Apply) .unwrap_err() .contains("model changed") ); assert!(!changed.elements.contains_key(&draft.focus())); assert!(actions(&p, p.id).is_empty()); } #[test] fn legacy_resolution_preserves_original_record_and_applies_as_one_undo() { let decoded = persistence::decode_with_report(include_bytes!( "../../oa-core/tests/fixtures/v2-coastal-rescue.json" )) .unwrap(); let original = decoded.project; let port = original .elements .values() .find(|e| { e.layer() == Layer::Sa && actions(&original, e.id).contains(&Action::Resolve) && e.parent .and_then(|id| original.elements.get(&id)) .is_some_and(|owner| owner.kind() == Kind::Function) }) .unwrap() .id; let old = original.elements[&port].clone(); let mut app = App::with_project(original.clone()); send(&mut app, Message::Select(port)); send(&mut app, Message::PortAction(Action::Resolve)); send(&mut app, Message::Workflow(workflow::Event::Apply)); assert!(app.workflow.is_none(), "{}", app.status); let resolved = &app.project().elements[&port]; assert_ne!(port_contract(resolved), Some(&PortContract::Legacy)); assert_eq!( ( &resolved.alias, &resolved.name, &resolved.description, resolved.parent ), (&old.alias, &old.name, &old.description, old.parent) ); let legacy = |e: &oa_core::Element| match e.shared().unwrap() { SharedBody::Port { legacy, .. } => legacy.clone(), _ => unreachable!(), }; assert_eq!(legacy(resolved), legacy(&old)); assert_eq!( language::parse(&language::format(app.project())).unwrap(), *app.project() ); send(&mut app, Message::Undo); assert_eq!(app.project(), &original); } }