Establish OA/SA prototype baseline and first-release work

This commit is contained in:
2026-09-05 17:04:16 +03:00
commit 93ba83d321
109 changed files with 56512 additions and 0 deletions
+900
View File
@@ -0,0 +1,900 @@
//! Explicit, reviewable relationship and cross-perspective operations.
use crate::{
app::{Choice, Message},
style,
views::short,
};
use iced::{
Element, Fill,
widget::{Space, button, column, pick_list, row, scrollable, text, text_editor, text_input},
};
use oa_core::{transitions::*, *};
use std::collections::BTreeSet;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct RelationAction {
pub label: &'static str,
pub kind: RelationKind,
pub incoming: bool,
}
impl std::fmt::Display for RelationAction {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label)
}
}
#[derive(Debug, Clone)]
pub enum Event {
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 {
Relation(Relationship),
ManualTrace {
name: String,
source: Choice,
target: Option<Choice>,
},
Transition(Box<Transition>),
}
pub struct Relationship {
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>,
}
impl Relationship {
fn accepts_source(&self, p: &Project, id: Id) -> bool {
if p.layer_of(id) != Some(self.layer) {
return false;
}
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.source
.as_ref()
.is_some_and(|source| validation::can_connect(p, self.kind, source.id, *id))
})
.map(|id| choice(p, id))
.collect();
self.targets.sort_by(|a, b| a.label.cmp(&b.label));
}
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))
}
}
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 {
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 {
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![],
};
if draft
.source
.as_ref()
.is_some_and(|s| !draft.accepts_source(p, s.id))
{
draft.source = None;
}
draft.refresh_choices(p);
Self::Relation(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::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::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.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.target = None;
}
d.refresh_choices(p);
}
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)])
.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::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> {
let mut content = column![text(self.title()).size(23)].spacing(14);
match self {
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(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("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),
);
}
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::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::Transition(d) => d.plan.conflicts.is_empty(),
};
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 {
Choice {
id,
label: format!(
"{} · {}",
p.named(id),
p.layer_of(id).map(|l| l.to_string()).unwrap_or_default()
),
}
}