//! Contextual controls for the closed port model. Draft-only preservation never //! changes the persisted contract or substitutes for candidate validation. use super::*; fn constructor(value: &Value) -> Option<&str> { value .as_str() .or_else(|| value.as_object()?.keys().next().map(String::as_str)) } pub(super) fn switch_contract( current: &Value, proposed: Value, saved: &mut BTreeMap, ) -> Value { let (Some(previous), Some(next)) = (constructor(current), constructor(&proposed)) else { return proposed; }; if previous == next { return proposed; } saved.insert(previous.into(), current.clone()); if let Some(existing) = saved.get(next) { return existing.clone(); } // Direction changes retain the item declaration; switching constructors // must not silently replace an authored payload with an empty template. let items = current .pointer("/FunctionInput/incoming_items") .or_else(|| current.pointer("/FunctionOutput/outgoing_items")); match (next, items) { ("FunctionInput", Some(items)) => json!({"FunctionInput":{"incoming_items":items}}), ("FunctionOutput", Some(items)) => json!({"FunctionOutput":{"outgoing_items":items}}), _ => proposed, } } pub(super) fn variants( draft: &Draft, project: &Project, path: &str, _value: &Value, ) -> Option> { if path.ends_with("/Port/contract") { let current = draft.context_value(); let sa = current.pointer("/body/Sa").is_some(); if !sa { return Some(if draft.semantic_groups { vec![("Legacy generic port".into(), json!("Legacy"))] } else { vec![] }); } let owner = current .get("parent") .and_then(Value::as_str) .and_then(|id| id.parse::().ok()) .and_then(|id| project.elements.get(&id)); let mut choices = vec![]; if owner.is_none_or(|owner| owner.kind() == Kind::Function) { choices.push(( "Function input".into(), json!({"FunctionInput":{"incoming_items":[]}}), )); choices.push(( "Function output".into(), json!({"FunctionOutput":{"outgoing_items":[]}}), )); } if owner.is_none_or(|owner| matches!(owner.kind(), Kind::System | Kind::SystemActor)) { choices.push(( "Component port".into(), json!({"Component":{"kind":"Standard","orientation":"Unset"}}), )); } if draft.semantic_groups { choices.push(("Legacy generic port".into(), json!("Legacy"))); } return Some(choices); } if path.ends_with("/Port/contract/Component/kind") { return Some( ["Standard", "Flow"] .into_iter() .map(|kind| (kind.into(), json!(kind))) .collect(), ); } if path.ends_with("/Port/contract/Component/orientation") { let current = draft.context_value(); let kind_path = path.strip_suffix("orientation").unwrap().to_owned() + "kind"; let orientations: &[&str] = if current.pointer(&kind_path).and_then(Value::as_str) == Some("Standard") { &["Unset"] } else { &["Unset", "In", "Out", "InOut"] }; return Some( orientations .iter() .map(|orientation| { let label = if *orientation == "Unset" && orientations.len() == 1 { "Not applicable to Standard".into() } else { friendly(orientation) }; (label, json!(orientation)) }) .collect(), ); } None } pub(super) fn legacy_lines(value: &Value, project: &Project) -> Vec { let direction = value .get("direction") .and_then(Value::as_str) .map(friendly) .unwrap_or_else(|| "Unspecified".into()); let interface = value .get("interface") .and_then(Value::as_str) .and_then(|id| id.parse::().ok()); let association = interface .map(|id| { project .elements .get(&id) .map(|e| format!("{} · @{}", e.name, e.alias)) .unwrap_or_else(|| format!("Missing interface · {id}")) }) .unwrap_or_else(|| "No interface was associated".into()); vec![format!("Original direction: {direction}"), format!("Original interface: {association}"), "Retained from the generic model; this association does not declare a provided or required interface.".into()] } fn component_items_fit(project: &Project, port: Id, items: &[Id]) -> bool { let interfaces: Vec<_> = project .relations .values() .filter(|role| { matches!( role.kind(), RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement ) && role.source() == port }) .map(Relation::target) .collect(); if interfaces.is_empty() { return true; } let declared: Vec<_> = interfaces .iter() .flat_map(|id| data::interface_items(project, *id)) .collect(); items_satisfy(project, items, &declared) } fn boundary_pair(project: &Project, relation: &Relation, source: Id, target: Id) -> bool { let RelationBody::BoundaryExchange { oriented, items, .. } = &relation.body else { return false; }; let (Some(a), Some(b)) = (project.elements.get(&source), project.elements.get(&target)) else { return false; }; if source == target || a.layer() != b.layer() || a.parent == b.parent || [a, b].iter().any(|port| { !port .parent .and_then(|id| project.elements.get(&id)) .is_some_and(|owner| owner.kind().is_performer()) }) { return false; } let (Some(ac), Some(bc)) = (port_contract(a), port_contract(b)) else { return false; }; let direction = |e: &Element, contract: &PortContract, outgoing| match contract { PortContract::Component { .. } => !*oriented || component_allows(contract, outgoing), PortContract::Legacy => { matches!(e.shared(), Some(SharedBody::Port { legacy: Some(LegacyPortContract { direction, .. }), .. }) if if outgoing { *direction != PortDirection::In } else { *direction != PortDirection::Out }) } _ => false, }; if !direction(a, ac, true) || !direction(b, bc, false) { return false; } if let (PortContract::Component { kind: ak, .. }, PortContract::Component { kind: bk, .. }) = (ac, bc) { if ak != bk { return false; } if !*oriented && *ak == ComponentPortKind::Flow && !((component_allows(ac, true) && component_allows(bc, false)) || (component_allows(ac, false) && component_allows(bc, true))) { return false; } } component_items_fit(project, source, items) && component_items_fit(project, target, items) } pub(super) fn relation_endpoints( project: &Project, relation: &Relation, field: &str, original: Id, ) -> Option> { if !matches!( relation.kind(), RelationKind::FunctionalExchange | RelationKind::BoundaryExchange | RelationKind::PortAllocation | RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement ) { return None; } let source_field = field == "source"; let other = if source_field { relation.target() } else { relation.source() }; if relation.kind() == RelationKind::FunctionalExchange { return Some( project .elements .values() .filter(|e| e.kind() == Kind::Function && e.layer() == Layer::Sa && e.id != other) .map(|e| e.id) .collect(), ); } let eligible = |e: &Element, source: bool| match relation.kind() { RelationKind::BoundaryExchange => { e.layer() == Layer::Sa && matches!(port_contract(e), Some(PortContract::Component { .. })) } RelationKind::PortAllocation => { e.layer() == Layer::Sa && if source { matches!(port_contract(e), Some(PortContract::Component { .. })) } else { port_contract(e).is_some_and(PortContract::is_function) } } RelationKind::InterfaceProvision | RelationKind::InterfaceRequirement => { if source { validation::can_create_interface_role(project, e.id) } else { e.kind() == Kind::Interface } } _ => false, }; let mut context = project.clone(); context.relations.remove(&original); let permits = |candidate: Id, peer: Id| { let (source, target) = if source_field { (candidate, peer) } else { (peer, candidate) }; if relation.kind() == RelationKind::BoundaryExchange { boundary_pair(project, relation, source, target) } else { validation::can_connect(&context, relation.kind(), source, target) } }; Some( project .elements .values() .filter(|e| eligible(e, source_field)) .filter(|e| { if project.elements.contains_key(&other) { permits(e.id, other) } else { project .elements .values() .filter(|peer| eligible(peer, !source_field)) .any(|peer| permits(e.id, peer.id)) } }) .map(|e| e.id) .collect(), ) } #[cfg(test)] mod tests { use super::*; const CONTRACT: &str = "/body/Sa/Shared/Port/contract"; fn add(project: &mut Project, kind: Kind, name: &str) -> Id { project.add_element(kind, Layer::Sa, name, None).unwrap() } fn port(project: &mut Project, owner: Id, name: &str, contract: PortContract) -> Id { let id = Id::new_v4(); let element = make_port(project, id, owner, name, contract).unwrap(); project.elements.insert(id, element); id } fn component( project: &mut Project, owner: Id, kind: ComponentPortKind, orientation: ComponentPortOrientation, ) -> Id { port( project, owner, "Boundary port", PortContract::Component { kind, orientation }, ) } fn function_port(project: &mut Project, owner: Id, output: bool, items: Vec) -> Id { port( project, owner, "Function port", if output { PortContract::FunctionOutput { outgoing_items: items, } } else { PortContract::FunctionInput { incoming_items: items, } }, ) } fn legacy(project: &mut Project, owner: Id, interface: Option) -> Id { let id = port(project, owner, "Preserved port", PortContract::Legacy); let layer = project.elements[&id].layer(); project.elements.get_mut(&id).unwrap().body = ElementBody::shared_in( layer, SharedBody::Port { contract: PortContract::Legacy, legacy: Some(LegacyPortContract { direction: PortDirection::InOut, interface, }), }, ); id } fn choice_values(draft: &Draft, project: &Project, path: &str) -> Vec { variants(draft, project, path, draft.value.pointer(path).unwrap()) .unwrap() .into_iter() .map(|(_, value)| value) .collect() } #[test] fn switching_function_direction_preserves_invalid_items_and_each_authored_payload() { let mut project = Project::empty(); let function = add(&mut project, Kind::Function, "Send"); let item = add(&mut project, Kind::ExchangeItem, "Payload"); let output = function_port(&mut project, function, true, vec![item]); let mut draft = Draft::from_project(&project, output).unwrap(); let output_item = format!("{CONTRACT}/FunctionOutput/outgoing_items/0"); let input_items = format!("{CONTRACT}/FunctionInput/incoming_items"); draft.input(&output_item, "unfinished reference".into()); draft .shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}})) .unwrap(); assert_eq!( draft.materialize().unwrap().pointer(&input_items).unwrap(), &json!(["unfinished reference"]) ); assert!(draft.command().is_err()); draft.shape(&input_items, json!([item])).unwrap(); draft .shape(CONTRACT, json!({"FunctionOutput":{"outgoing_items":[]}})) .unwrap(); assert_eq!(draft.inputs[&output_item].raw, "unfinished reference"); draft .shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}})) .unwrap(); assert_eq!( draft.materialize().unwrap().pointer(&input_items).unwrap(), &json!([item]) ); assert_eq!( execute_batch(&project, &[draft.command().unwrap()]) .unwrap() .elements[&output] .shared(), Some(&SharedBody::Port { contract: PortContract::FunctionInput { incoming_items: vec![item] }, legacy: None, }) ); assert!(draft.dirty()); assert!( port_contract(&project.elements[&output]) .unwrap() .is_function() ); } #[test] fn failed_shape_keeps_incomplete_text_and_saved_contract_payloads() { let mut project = Project::empty(); let function = add(&mut project, Kind::Function, "Send"); let output = function_port(&mut project, function, true, vec![]); let mut draft = Draft::from_project(&project, output).unwrap(); draft.set_exact(true).unwrap(); draft.input("/description", "\"unfinished escape\\".into()); let value = draft.value.clone(); assert!( draft .shape(CONTRACT, json!({"FunctionInput":{"incoming_items":[]}})) .is_err() ); assert_eq!(draft.value, value); assert_eq!(draft.inputs["/description"].raw, "\"unfinished escape\\"); assert!(draft.port_contract_drafts.is_empty()); } #[test] fn contract_choices_follow_owner_and_never_offer_legacy_or_oa_typed_ports() { let mut project = Project::empty(); let function = add(&mut project, Kind::Function, "Send"); let system = add(&mut project, Kind::System, "System"); let output = function_port(&mut project, function, true, vec![]); let boundary = component( &mut project, system, ComponentPortKind::Standard, ComponentPortOrientation::Unset, ); let activity = project .add_element(Kind::Activity, Layer::Oa, "Operate", None) .unwrap(); let retained = legacy(&mut project, activity, None); let function_draft = Draft::from_project(&project, output).unwrap(); let component_draft = Draft::from_project(&project, boundary).unwrap(); let choices = choice_values(&function_draft, &project, CONTRACT); assert_eq!( choices, vec![ json!({"FunctionInput":{"incoming_items":[]}}), json!({"FunctionOutput":{"outgoing_items":[]}}) ] ); assert_eq!( choice_values(&component_draft, &project, CONTRACT), vec![json!({"Component":{"kind":"Standard","orientation":"Unset"}})] ); assert!( choice_values( &Draft::from_project(&project, retained).unwrap(), &project, "/body/Oa/Shared/Port/contract" ) .is_empty() ); assert_eq!( reference_ids(&function_draft, &project, "/parent"), vec![function] ); assert_eq!( reference_ids(&component_draft, &project, "/parent"), vec![system] ); } #[test] fn standard_switch_retains_incompatible_orientation_until_explicit_repair() { let mut project = Project::empty(); let system = add(&mut project, Kind::System, "System"); let boundary = component( &mut project, system, ComponentPortKind::Flow, ComponentPortOrientation::Out, ); let mut draft = Draft::from_project(&project, boundary).unwrap(); let kind = format!("{CONTRACT}/Component/kind"); let orientation = format!("{CONTRACT}/Component/orientation"); assert!(choice_values(&draft, &project, &orientation).contains(&json!("InOut"))); draft.shape(&kind, json!("Standard")).unwrap(); assert_eq!( draft.materialize().unwrap().pointer(&orientation), Some(&json!("Out")) ); assert_eq!( choice_values(&draft, &project, &orientation), vec![json!("Unset")] ); let mut session = Session::new(project.clone()); assert!(session.execute(draft.command().unwrap()).is_err()); assert_eq!(session.project(), &project); assert!(draft.dirty()); draft.shape(&orientation, json!("Unset")).unwrap(); session.execute(draft.command().unwrap()).unwrap(); assert_eq!( port_contract(&session.project().elements[&boundary]), Some(&PortContract::Component { kind: ComponentPortKind::Standard, orientation: ComponentPortOrientation::Unset, }) ); session.undo(); assert_eq!(session.project(), &project); } #[test] fn functional_endpoints_require_owner_direction_and_items_but_retain_bad_current_choice() { let mut project = Project::empty(); let source = add(&mut project, Kind::Function, "Send"); let target = add(&mut project, Kind::Function, "Receive"); let alternative = add(&mut project, Kind::Function, "Alternative sender"); let item = add(&mut project, Kind::ExchangeItem, "Message"); let output = function_port(&mut project, source, true, vec![item]); let input = function_port(&mut project, target, false, vec![item]); let wrong_direction = function_port(&mut project, source, false, vec![item]); function_port(&mut project, source, true, vec![]); function_port(&mut project, target, true, vec![item]); let replacement = function_port(&mut project, alternative, true, vec![item]); let retained = legacy(&mut project, source, None); let exchange = project.relate(RelationKind::FunctionalExchange, "Message", source, target); project.relations.get_mut(&exchange).unwrap().body = RelationBody::FunctionalExchange { source, target, source_port: output, target_port: input, items: vec![item], }; let mut draft = Draft::from_project(&project, exchange).unwrap(); let source_path = "/body/FunctionalExchange/source_port"; assert_eq!(reference_ids(&draft, &project, source_path), vec![output]); assert_eq!( reference_ids(&draft, &project, "/body/FunctionalExchange/target_port"), vec![input] ); assert!( !reference_choices(&draft, &project, source_path, output) .iter() .any(|choice| choice.id.is_nil()) ); for invalid in [wrong_direction, retained] { let choices = reference_choices(&draft, &project, source_path, invalid); assert!(choices.iter().any(|choice| choice.id == invalid && choice.label.starts_with("Current choice needs review"))); } let owners = reference_ids(&draft, &project, "/body/FunctionalExchange/source"); assert!(owners.contains(&source) && owners.contains(&alternative)); assert!(!owners.contains(&target)); draft .shape("/body/FunctionalExchange/source", json!(alternative)) .unwrap(); assert_eq!( reference_ids(&draft, &project, source_path), vec![replacement] ); assert_eq!( draft.materialize().unwrap().pointer(source_path), Some(&json!(output)) ); assert!(execute_batch(&project, &[draft.command().unwrap()]).is_err()); draft.shape(source_path, json!(replacement)).unwrap(); assert!(execute_batch(&project, &[draft.command().unwrap()]).is_ok()); } #[test] fn port_allocation_choices_use_actual_owner_direction_and_ignore_edited_relation() { let mut project = Project::empty(); let system = add(&mut project, Kind::System, "System"); let actor = add(&mut project, Kind::SystemActor, "Partner"); let function = add(&mut project, Kind::Function, "Send"); let unallocated = add(&mut project, Kind::Function, "Unallocated"); project.relate(RelationKind::Allocation, "Performs", system, function); assert_eq!(actual_performer(&project, unallocated), None); let output = function_port(&mut project, function, true, vec![]); function_port(&mut project, function, false, vec![]); function_port(&mut project, unallocated, true, vec![]); let first = component( &mut project, system, ComponentPortKind::Flow, ComponentPortOrientation::Out, ); let second = component( &mut project, system, ComponentPortKind::Standard, ComponentPortOrientation::Unset, ); component( &mut project, system, ComponentPortKind::Flow, ComponentPortOrientation::In, ); component( &mut project, actor, ComponentPortKind::Flow, ComponentPortOrientation::Out, ); legacy(&mut project, system, None); let allocation = project.relate( RelationKind::PortAllocation, "Allocated port", first, output, ); let draft = Draft::from_project(&project, allocation).unwrap(); let sources = reference_ids(&draft, &project, "/body/PortAllocation/source"); assert_eq!( sources.into_iter().collect::>(), BTreeSet::from([first, second]) ); assert_eq!( reference_ids(&draft, &project, "/body/PortAllocation/target"), vec![output] ); project.relate( RelationKind::PortAllocation, "Other allocated port", second, output, ); assert_eq!( reference_ids(&draft, &project, "/body/PortAllocation/source"), vec![first] ); } #[test] fn boundary_choices_honor_draft_direction_same_kind_and_direct_interface_items() { let mut project = Project::empty(); let system = add(&mut project, Kind::System, "System"); let actor = add(&mut project, Kind::SystemActor, "Partner"); let input = component( &mut project, system, ComponentPortKind::Flow, ComponentPortOrientation::In, ); let output = component( &mut project, actor, ComponentPortKind::Flow, ComponentPortOrientation::Out, ); component( &mut project, actor, ComponentPortKind::Flow, ComponentPortOrientation::In, ); component( &mut project, actor, ComponentPortKind::Standard, ComponentPortOrientation::Unset, ); legacy(&mut project, actor, None); let item = add(&mut project, Kind::ExchangeItem, "Message"); let interface = add(&mut project, Kind::Interface, "Empty contract"); // A participant declaration must not become the port's declaration. project.relate( RelationKind::InterfaceRequirement, "Participant requires", actor, interface, ); let exchange = project.relate(RelationKind::BoundaryExchange, "Boundary", input, output); project.relations.get_mut(&exchange).unwrap().body = RelationBody::BoundaryExchange { source: input, target: output, oriented: false, items: vec![item], }; let mut draft = Draft::from_project(&project, exchange).unwrap(); let target = "/body/BoundaryExchange/target"; assert_eq!(reference_ids(&draft, &project, target), vec![output]); draft .shape("/body/BoundaryExchange/oriented", json!(true)) .unwrap(); assert!(reference_ids(&draft, &project, target).is_empty()); assert_eq!( draft.materialize().unwrap().pointer(target), Some(&json!(output)) ); draft .shape("/body/BoundaryExchange/oriented", json!(false)) .unwrap(); project.relate( RelationKind::InterfaceProvision, "Port provides", output, interface, ); assert!(reference_ids(&draft, &project, target).is_empty()); project.relate( RelationKind::InterfaceItemAllocation, "Allocated item", interface, item, ); assert_eq!(reference_ids(&draft, &project, target), vec![output]); } #[test] fn interface_roles_offer_participants_and_component_ports_independent_of_orientation() { let mut project = Project::empty(); let system = add(&mut project, Kind::System, "System"); let function = add(&mut project, Kind::Function, "Send"); let function_port = function_port(&mut project, function, true, vec![]); let interface = add(&mut project, Kind::Interface, "Service"); let input = component( &mut project, system, ComponentPortKind::Flow, ComponentPortOrientation::In, ); let output = component( &mut project, system, ComponentPortKind::Flow, ComponentPortOrientation::Out, ); let preserved = legacy(&mut project, system, Some(interface)); for kind in [ RelationKind::InterfaceProvision, RelationKind::InterfaceRequirement, ] { let role = project.relate(kind, "Role", input, interface); let draft = Draft::from_project(&project, role).unwrap(); let path = format!("/body/{kind:?}/source"); let choices = reference_ids(&draft, &project, &path); assert_eq!( choices.into_iter().collect::>(), BTreeSet::from([system, input, output]) ); for invalid in [function_port, preserved] { assert!( reference_choices(&draft, &project, &path, invalid) .iter() .any(|choice| choice.id == invalid && choice.label.starts_with("Current choice needs review")) ); } assert_eq!( reference_ids(&draft, &project, &format!("/body/{kind:?}/target")), vec![interface] ); } let draft = Draft::from_project(&project, preserved).unwrap(); let lines = legacy_lines( draft.value.pointer("/body/Sa/Shared/Port/legacy").unwrap(), &project, ); assert!(lines[0].contains("Input / output")); assert!(lines[1].contains("Service")); assert!(lines[2].contains("does not declare a provided or required interface")); assert_eq!( execute_batch(&project, &[draft.command().unwrap()]).unwrap(), project ); } #[test] fn all_demo_records_roundtrip_through_graphical_and_escaped_string_drafts() { let project = Project::demo(); let mut kinds = BTreeSet::new(); for (id, _) in project.identities() { let mut draft = Draft::from_project(&project, id).unwrap(); kinds.insert( format!("{:?}", draft.kind) .split('(') .next() .unwrap() .to_owned(), ); assert!(!draft.dirty()); assert_eq!( execute_batch(&project, &[draft.command().unwrap()]).unwrap(), project, "Normal draft {id}" ); draft.set_exact(true).unwrap(); assert!(!draft.dirty()); assert_eq!( execute_batch(&project, &[draft.command().unwrap()]).unwrap(), project, "Escaped draft {id}" ); draft.set_exact(false).unwrap(); assert!(!draft.dirty()); } assert_eq!( kinds, BTreeSet::from( [ "Project", "Element", "Relation", "Diagram", "Node", "Artifact", "Trace" ] .map(str::to_owned) ) ); } }