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
+396 -137
View File
@@ -14,6 +14,7 @@ use iced::{
use oa_core::*;
use serde_json::{Value, json};
use std::collections::{BTreeMap, BTreeSet};
mod port_fields;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RecordKind {
@@ -37,6 +38,7 @@ pub struct Draft {
baseline: Value,
pub inputs: BTreeMap<String, Input>,
pub exact_strings: bool,
port_contract_drafts: BTreeMap<String, Value>,
}
impl Draft {
@@ -72,6 +74,7 @@ impl Draft {
value,
inputs: BTreeMap::new(),
exact_strings: false,
port_contract_drafts: BTreeMap::new(),
};
draft.rebuild();
Some(draft)
@@ -179,6 +182,17 @@ impl Draft {
}
pub fn shape(&mut self, path: &str, value: Value) -> Result<(), String> {
let mut candidate = self.materialize()?;
let value = if path.ends_with("/Port/contract") {
port_fields::switch_contract(
candidate
.pointer(path)
.ok_or("Property no longer exists.")?,
value,
&mut self.port_contract_drafts,
)
} else {
value
};
*candidate
.pointer_mut(path)
.ok_or("Property no longer exists.")? = value;
@@ -301,6 +315,15 @@ fn friendly(name: &str) -> String {
"do_activity" => "Do behavior (description)".into(),
"source_port" => "Output port".into(),
"target_port" => "Input port".into(),
"contract" => "Port contract".into(),
"FunctionInput" => "Function input".into(),
"FunctionOutput" => "Function output".into(),
"Component" => "Component port".into(),
"incoming_items" => "Incoming exchange items".into(),
"outgoing_items" => "Outgoing exchange items".into(),
"orientation" => "Flow orientation".into(),
"oriented" => "Directed component exchange".into(),
"Unset" => "Not specified".into(),
"minimum" => "Minimum count".into(),
"maximum" => "Maximum count".into(),
"type_ref" => "Value type".into(),
@@ -378,7 +401,33 @@ fn reference(path: &str) -> bool {
| "diagram"
) || matches!(
parent,
"items" | "steps" | "branches" | "occurrences" | "constrained" | "triggers" | "effects"
"items"
| "incoming_items"
| "outgoing_items"
| "steps"
| "branches"
| "occurrences"
| "constrained"
| "triggers"
| "effects"
)
}
fn optional_reference(path: &str) -> bool {
matches!(
path.rsplit('/').next().unwrap_or_default(),
"parent"
| "interface"
| "context"
| "represented"
| "exchange"
| "sender"
| "receiver"
| "action"
| "sequence"
| "scenario"
| "reply_to"
| "lifeline"
| "state"
)
}
fn multiline(path: &str) -> bool {
@@ -411,7 +460,9 @@ fn free_string(path: &str) -> bool {
// incompatible. Applying the complete draft remains the core validator's job.
fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id) -> Vec<Choice> {
let identities = project.identities().into_iter().collect::<BTreeMap<_, _>>();
let mut objects = reference_ids(draft, project, path)
let compatible = reference_ids(draft, project, path);
let mut objects = compatible
.clone()
.into_iter()
.chain((!current.is_nil()).then_some(current))
.collect::<BTreeSet<_>>()
@@ -430,21 +481,31 @@ fn reference_choices(draft: &Draft, project: &Project, path: &str, current: Id)
})
})
.unwrap_or_else(|| project.named(id).to_owned());
let label = identities
let mut label = identities
.get(&id)
.map(|alias| format!("{name} · @{alias}"))
.unwrap_or_else(|| format!("Missing reference · {id}"));
if identities.contains_key(&id) && !compatible.contains(&id) {
label = format!("Current choice needs review · {label}");
}
Choice { id, label }
})
.collect::<Vec<_>>();
objects.sort_by(|a, b| a.label.cmp(&b.label));
objects.insert(
0,
Choice {
id: Id::nil(),
label: "Not assigned".into(),
},
);
if optional_reference(path) || current.is_nil() {
objects.insert(
0,
Choice {
id: Id::nil(),
label: if optional_reference(path) {
"Not assigned"
} else {
"Choose a required reference"
}
.into(),
},
);
}
objects
}
@@ -500,6 +561,9 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
if matches!(field, "source" | "target")
&& let Some(r) = &relation
{
if let Some(choices) = port_fields::relation_endpoints(project, r, field, draft.original) {
return choices;
}
// can_connect treats allocations/initial exits as new links, so exclude
// the relation being edited before asking it for endpoint choices.
let mut context = project.clone();
@@ -584,100 +648,257 @@ fn reference_ids(draft: &Draft, project: &Project, path: &str) -> Vec<Id> {
.rev()
.nth(1)
.and_then(|s| s.parse::<usize>().ok());
project.elements.values().filter(|candidate| {
if field == "element" && let RecordKind::Node(diagram) = draft.kind {
let diagram = value.get("diagram").and_then(Value::as_str)
.and_then(|id| id.parse().ok()).unwrap_or(diagram);
return project.diagrams.get(&diagram).is_some_and(|d|
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
&& !d.nodes.values().any(|n| n.id != draft.original && n.element == candidate.id));
}
if !same_layer(candidate) { return false; }
match field {
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original || candidate.id == child.id
|| !validation::can_parent(project, child.kind(), child.layer(), Some(candidate.id)) {
return false;
project
.elements
.values()
.filter(|candidate| {
if field == "element"
&& let RecordKind::Node(diagram) = draft.kind
{
let diagram = value
.get("diagram")
.and_then(Value::as_str)
.and_then(|id| id.parse().ok())
.unwrap_or(diagram);
return project.diagrams.get(&diagram).is_some_and(|d| {
(d.projection == ProjectionKind::Traceability || d.layer == candidate.layer())
&& !d
.nodes
.values()
.any(|n| n.id != draft.original && n.element == candidate.id)
});
}
if !same_layer(candidate) {
return false;
}
match field {
"parent" => element.as_ref().is_some_and(|child| {
if candidate.id == draft.original
|| candidate.id == child.id
|| !validation::can_parent(
project,
child.kind(),
child.layer(),
Some(candidate.id),
)
{
return false;
}
let mut parent = candidate.parent;
let mut seen = BTreeSet::new();
while let Some(id) = parent {
if id == draft.original || id == child.id || !seen.insert(id) {
return false;
}
parent = project.elements.get(&id).and_then(|e| e.parent);
}
match child.shared() {
Some(SharedBody::Port { contract, .. }) => match contract {
PortContract::FunctionInput { .. }
| PortContract::FunctionOutput { .. } => {
child.layer() == Layer::Sa && candidate.kind() == Kind::Function
}
PortContract::Component { .. } => {
child.layer() == Layer::Sa
&& matches!(candidate.kind(), Kind::System | Kind::SystemActor)
}
PortContract::Legacy => {
candidate.kind().is_work() || candidate.kind().is_performer()
}
},
Some(SharedBody::Occurrence { body, .. }) => match candidate.shared() {
Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { .. },
..
}) => matches!(body, OccurrenceBody::Branch { .. }),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
}) => !matches!(body, OccurrenceBody::Branch { .. }),
_ if matches!(
candidate.kind(),
Kind::Process | Kind::FunctionalChain
) =>
{
matches!(
body,
OccurrenceBody::Exchange { .. }
| OccurrenceBody::Action { .. }
| OccurrenceBody::Control { .. }
| OccurrenceBody::SequenceReference { .. }
)
}
_ => !matches!(body, OccurrenceBody::Branch { .. }),
},
_ => true,
}
}),
"Named" => candidate.kind() == Kind::DataType,
"interface" => candidate.kind() == Kind::Interface,
"context" => {
candidate.kind().is_sequence()
|| candidate.kind().is_work()
|| candidate.kind().is_performer()
|| matches!(
candidate.kind(),
Kind::Capability
| Kind::SystemCapability
| Kind::Mission
| Kind::OperationalMission
)
}
let mut parent = candidate.parent;
let mut seen = BTreeSet::new();
while let Some(id) = parent {
if id == draft.original || id == child.id || !seen.insert(id) { return false; }
parent = project.elements.get(&id).and_then(|e| e.parent);
"represented" => {
candidate.kind().is_performer()
|| candidate.kind().is_work()
|| candidate.kind() == Kind::Role
}
match child.shared() {
Some(SharedBody::Occurrence { body, .. }) => match candidate.shared() {
Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { .. }, .. }) => matches!(body, OccurrenceBody::Branch { .. }),
Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) => !matches!(body, OccurrenceBody::Branch { .. }),
_ if matches!(candidate.kind(), Kind::Process | Kind::FunctionalChain) => matches!(body, OccurrenceBody::Exchange { .. } | OccurrenceBody::Action { .. } | OccurrenceBody::Control { .. } | OccurrenceBody::SequenceReference { .. }),
_ => !matches!(body, OccurrenceBody::Branch { .. }),
},
"action" => candidate.kind().is_work(),
"sequence" => element
.as_ref()
.and_then(|e| e.parent)
.and_then(|id| project.elements.get(&id))
.is_some_and(|owner| {
candidate.kind() == owner.kind() && candidate.id != owner.id
}),
"scenario" => {
candidate.kind() == Kind::Scenario
&& scenario.is_some_and(|owner| {
let viewpoint =
|id| match project.elements.get(&id).and_then(Element::shared) {
Some(SharedBody::Scenario { viewpoint, .. }) => {
Some(*viewpoint)
}
_ => None,
};
let a = viewpoint(owner);
let b = viewpoint(candidate.id);
(a == b
|| a == Some(ScenarioViewpoint::MixedLegacy)
|| b == Some(ScenarioViewpoint::MixedLegacy))
&& !scenario_reaches(project, candidate.id, owner)
})
}
"reply_to" => {
candidate.id != draft.original
&& scenario.is_some()
&& validation::ancestor_of_kind(project, candidate.id, Kind::Scenario)
== scenario
&& matches!(
candidate.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
..
},
..
})
)
}
"referenced" | "local" => element.as_ref().is_some_and(|e| {
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::ScenarioUse {
scenario: target,
bindings,
},
..
}) = e.shared()
else {
return false;
};
if candidate.kind() != Kind::Lifeline
|| candidate.parent
!= if field == "referenced" {
*target
} else {
scenario
}
|| candidate.parent.is_none()
{
return false;
}
if bindings.iter().enumerate().any(|(i, b)| {
Some(i) != binding_row
&& candidate.id
== if field == "referenced" {
b.referenced
} else {
b.local
}
}) {
return false;
}
let other = binding_row.and_then(|i| bindings.get(i)).map(|b| {
if field == "referenced" {
b.local
} else {
b.referenced
}
});
let represented = |id| match project.elements.get(&id).and_then(Element::shared)
{
Some(SharedBody::Lifeline { represented }) => *represented,
_ => None,
};
match (represented(candidate.id), other.and_then(represented)) {
(Some(a), Some(b)) => {
validation::semantic_representation_compatible(
project,
if field == "local" { a } else { b },
if field == "local" { b } else { a },
) != Some(false)
}
_ => true,
}
}),
"state" => matches!(candidate.kind(), Kind::State | Kind::Mode),
"sender" | "receiver" | "lifeline" => element.as_ref().is_some_and(|e| {
let scenario = e
.parent
.and_then(|id| validation::ancestor_of_kind(project, id, Kind::Scenario));
candidate.kind() == Kind::Lifeline
&& candidate.parent == scenario
&& lifeline_matches(project, e, candidate, field)
}),
"source_port" | "target_port" => relation.as_ref().is_some_and(|r| {
let source = field == "source_port";
candidate.layer() == Layer::Sa
&& candidate.parent == Some(if source { r.source() } else { r.target() })
&& match port_contract(candidate) {
Some(PortContract::FunctionOutput { outgoing_items }) if source => {
items_satisfy(project, r.body.items(), outgoing_items)
}
Some(PortContract::FunctionInput { incoming_items }) if !source => {
items_satisfy(project, r.body.items(), incoming_items)
}
_ => false,
}
}),
_ => match list {
"items" | "incoming_items" | "outgoing_items" => {
candidate.kind() == Kind::ExchangeItem
}
"triggers" => candidate.kind().is_work() || candidate.kind() == Kind::Event,
"effects" => candidate.kind().is_work(),
"branches" => {
owned(candidate)
&& matches!(
candidate.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
}
"steps" | "occurrences" => {
owned(candidate) && candidate.kind() == Kind::Occurrence
}
_ => true,
}
}),
"Named" => candidate.kind() == Kind::DataType,
"interface" => candidate.kind() == Kind::Interface,
"context" => candidate.kind().is_sequence() || candidate.kind().is_work()
|| candidate.kind().is_performer()
|| matches!(candidate.kind(), Kind::Capability | Kind::SystemCapability | Kind::Mission | Kind::OperationalMission),
"represented" => candidate.kind().is_performer() || candidate.kind().is_work() || candidate.kind() == Kind::Role,
"action" => candidate.kind().is_work(),
"sequence" => element.as_ref().and_then(|e| e.parent).and_then(|id| project.elements.get(&id))
.is_some_and(|owner| candidate.kind() == owner.kind() && candidate.id != owner.id),
"scenario" => candidate.kind() == Kind::Scenario && scenario.is_some_and(|owner| {
let viewpoint = |id| match project.elements.get(&id).and_then(Element::shared) {
Some(SharedBody::Scenario { viewpoint, .. }) => Some(*viewpoint),
_ => None,
};
let a = viewpoint(owner);
let b = viewpoint(candidate.id);
(a == b || a == Some(ScenarioViewpoint::MixedLegacy) || b == Some(ScenarioViewpoint::MixedLegacy))
&& !scenario_reaches(project, candidate.id, owner)
}),
"reply_to" => candidate.id != draft.original
&& scenario.is_some()
&& validation::ancestor_of_kind(project, candidate.id, Kind::Scenario) == scenario
&& matches!(candidate.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Call, .. }, .. })),
"referenced" | "local" => element.as_ref().is_some_and(|e| {
let Some(SharedBody::Occurrence { body: OccurrenceBody::ScenarioUse { scenario: target, bindings }, .. }) = e.shared() else { return false; };
if candidate.kind() != Kind::Lifeline
|| candidate.parent != if field == "referenced" { *target } else { scenario }
|| candidate.parent.is_none() { return false; }
if bindings.iter().enumerate().any(|(i,b)| Some(i) != binding_row
&& candidate.id == if field == "referenced" { b.referenced } else { b.local }) { return false; }
let other = binding_row.and_then(|i| bindings.get(i)).map(|b| if field == "referenced" { b.local } else { b.referenced });
let represented = |id| match project.elements.get(&id).and_then(Element::shared) {
Some(SharedBody::Lifeline { represented }) => *represented,
_ => None,
};
match (represented(candidate.id), other.and_then(represented)) {
(Some(a), Some(b)) => validation::semantic_representation_compatible(project,
if field == "local" { a } else { b }, if field == "local" { b } else { a }) != Some(false),
_ => true,
}
}),
"state" => matches!(candidate.kind(), Kind::State | Kind::Mode),
"sender" | "receiver" | "lifeline" => element.as_ref().is_some_and(|e| {
let scenario = e.parent.and_then(|id| validation::ancestor_of_kind(project, id, Kind::Scenario));
candidate.kind() == Kind::Lifeline && candidate.parent == scenario
&& lifeline_matches(project, e, candidate, field)
}),
"source_port" | "target_port" => relation.as_ref().is_some_and(|r| {
let source = field == "source_port";
candidate.parent == Some(if source { r.source() } else { r.target() })
&& matches!(candidate.shared(), Some(SharedBody::Port { direction, .. })
if if source { *direction != PortDirection::In } else { *direction != PortDirection::Out })
}),
_ => match list {
"items" => candidate.kind() == Kind::ExchangeItem,
"triggers" => candidate.kind().is_work() || candidate.kind() == Kind::Event,
"effects" => candidate.kind().is_work(),
"branches" => owned(candidate) && matches!(candidate.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. })),
"steps" | "occurrences" => owned(candidate) && candidate.kind() == Kind::Occurrence,
_ => true,
},
}
}).map(|e| e.id).collect()
},
}
})
.map(|e| e.id)
.collect()
}
fn lifeline_matches(project: &Project, occurrence: &Element, line: &Element, field: &str) -> bool {
@@ -774,7 +995,6 @@ fn variants(path: &str, value: &Value, kind: RecordKind) -> Vec<(String, Value)>
"FunctionalDataflow",
"Capabilities",
],
"direction" => &["In", "Out", "InOut"],
"operator" => &["Alternative", "Parallel", "Loop", "Optional"],
"viewpoint" if path.contains("/Oa/") => &[
"OperationalActivity",
@@ -929,9 +1149,15 @@ pub fn view<'a>(draft: &'a Draft, project: &'a Project) -> View<'a, Message> {
&& let Some(shared) = body.shared()
{
let shared = shared.clone();
let layers = if matches!(&shared, SharedBody::Port { contract, .. } if *contract != PortContract::Legacy)
{
vec![Layer::Sa]
} else {
vec![Layer::Oa, Layer::Sa]
};
content = content.push(column![
text("Engineering perspective").size(11).color(style::MUTED),
pick_list([Layer::Oa, Layer::Sa], Some(body.layer()), move |layer|
pick_list(layers, Some(body.layer()), move |layer|
Message::FieldVariant("/body".into(), json!(ElementBody::shared_in(layer, shared.clone()))))
.text_size(12).padding(8).width(Fill),
text("Changing perspective keeps references; incompatible references must be updated before Apply.")
@@ -985,11 +1211,37 @@ fn property<'a>(
skip_variant: bool,
) -> View<'a, Message> {
let label_view = || text(label.to_owned()).size(11).color(style::MUTED);
if path.ends_with("/Port/legacy") {
let mut content = column![].spacing(4);
if !value.is_null() {
content = content.push(
text("Preserved generic association")
.size(12)
.color(style::INK),
);
for line in port_fields::legacy_lines(value, project) {
content = content.push(text(line).size(11).color(style::MUTED));
}
}
return content.into();
}
let enum_choices = if skip_variant {
vec![]
} else {
variants(path, value, draft.kind)
port_fields::variants(draft, project, path, value)
.unwrap_or_else(|| variants(path, value, draft.kind))
};
if path.ends_with("/Port/contract") && !skip_variant && enum_choices.is_empty() {
let mut content = column![
label_view(),
text("Choose a System Analysis function or component owner to edit this port's typed contract. The current contract remains in the draft.")
.size(11).color(style::MUTED)
].spacing(5);
if value.is_object() {
content = content.push(property(draft, project, value, path, "", true));
}
return content.into();
}
if !enum_choices.is_empty() {
let labels = enum_choices
.iter()
@@ -1001,6 +1253,16 @@ fn property<'a>(
.map(|(name, _)| name.clone());
let owned_path = path.to_string();
let choices = enum_choices.clone();
let retained = selected.is_none().then(|| {
format!(
"Current value retained for review: {}",
value
.as_str()
.or_else(|| value.as_object()?.keys().next().map(String::as_str))
.map(friendly)
.unwrap_or_else(|| "Unrecognized value".into())
)
});
let picker = pick_list(labels, selected, move |selected| {
Message::FieldVariant(
owned_path.clone(),
@@ -1016,6 +1278,9 @@ fn property<'a>(
.padding(8)
.width(Fill);
let mut out = column![label_view(), picker].spacing(5);
if let Some(retained) = retained {
out = out.push(text(retained).size(11).color(style::MUTED));
}
if value.is_object() {
out = out.push(property(draft, project, value, path, "", true));
}
@@ -1031,26 +1296,8 @@ fn property<'a>(
let choices = reference_choices(draft, project, path, current);
let selected = choices.iter().find(|c| c.id == current).cloned();
let owned_path = path.to_string();
let optional = value.is_null()
|| matches!(
path.rsplit('/').next().unwrap_or_default(),
"parent"
| "interface"
| "context"
| "represented"
| "exchange"
| "sender"
| "receiver"
| "action"
| "sequence"
| "scenario"
| "reply_to"
| "lifeline"
| "state"
| "source_port"
| "target_port"
);
return column![
let optional = optional_reference(path);
let mut content = column![
label_view(),
pick_list(choices, selected, move |choice| Message::FieldShape(
owned_path.clone(),
@@ -1064,8 +1311,17 @@ fn property<'a>(
.padding(8)
.width(Fill)
]
.spacing(5)
.into();
.spacing(5);
if let Some(raw) = raw
&& raw.parse::<Id>().is_err()
{
content = content.push(
text(format!("Invalid reference retained in the draft: {raw}"))
.size(11)
.color(style::MUTED),
);
}
return content.into();
}
match value {
Value::Object(map) => {
@@ -1251,21 +1507,20 @@ mod tests {
let wrong_layer = p
.add_element(Kind::ExchangeItem, Layer::Oa, "Operational item", None)
.unwrap();
let interface = p
.add_element(Kind::Interface, Layer::Sa, "Contract", None)
.unwrap();
let output = p
.add_element(Kind::Port, Layer::Sa, "Output", Some(function))
.unwrap();
let input = p
.add_element(Kind::Port, Layer::Sa, "Input", Some(receiver))
.unwrap();
let exchange = p.relate(
RelationKind::FunctionalExchange,
"Message",
function,
receiver,
);
let RelationBody::FunctionalExchange {
source_port: output,
target_port: input,
..
} = p.relations[&exchange].body
else {
panic!("Expected functional exchange");
};
let draft = Draft::from_project(&p, exchange).unwrap();
let choices = ids(&draft, &p, "/body/FunctionalExchange/items/0");
assert_eq!(choices, vec![item]);
@@ -1289,8 +1544,12 @@ mod tests {
);
let port = Draft::from_project(&p, output).unwrap();
assert_eq!(
ids(&port, &p, "/body/Sa/Shared/Port/interface"),
vec![interface]
ids(
&port,
&p,
"/body/Sa/Shared/Port/contract/FunctionOutput/outgoing_items/0"
),
vec![item]
);
}