//! Authored Scenario presentation. Structural admission never changes event order. use crate::{ Diagram, Id, Kind, Layer, MAX_COORDINATE, ModelError, OccurrenceBody, OccurrenceEndpoint, Project, SharedBody, validation, }; use serde::{Deserialize, Serialize}; use std::collections::BTreeSet; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub enum DiagramLayout { Scenario(ScenarioLayout), } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)] #[serde(deny_unknown_fields)] pub struct ScenarioLayout { #[serde(default)] pub lanes: Vec, #[serde(default)] pub messages: Vec, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScenarioLanePosition { pub lifeline: Id, pub x: f32, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub struct ScenarioMessagePosition { pub address: OccurrenceEndpoint, /// Actual send endpoint, not the renderer's receive-oriented lifecycle row. pub send_y: f32, } impl DiagramLayout { /// Visit every explicit reference, including unsupported paths in a candidate. /// The caller owns remap preflight and final atomic validation. pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) { match self { Self::Scenario(layout) => { for lane in &mut layout.lanes { visit(&mut lane.lifeline); } for message in &mut layout.messages { for reference in &mut message.address.references { visit(reference); } visit(&mut message.address.occurrence); } } } } } /// Proposed aggregate accounting contribution; the shared admission owner applies it. /// One payload plus one record per lane/message. No graph objects are introduced. pub fn record_count(diagram: &Diagram) -> usize { match &diagram.layout { None => 0, Some(DiagramLayout::Scenario(layout)) => 1usize .saturating_add(layout.lanes.len()) .saturating_add(layout.messages.len()), } } fn coordinate(value: f32) -> bool { value.is_finite() && value.abs() <= MAX_COORDINATE } fn owner(p: &Project, diagram: &Diagram) -> Result { let scenario = diagram .projection .scenario() .ok_or_else(|| ModelError("Scenario layout requires a Scenario projection.".into()))?; if !p .elements .get(&scenario) .is_some_and(|element| element.kind() == Kind::Scenario && element.layer() == diagram.layer) { return Err(ModelError( "Scenario projection requires an existing Scenario in the diagram layer.".into(), )); } Ok(scenario) } fn lane_member(p: &Project, owner: Id, layer: Layer, lifeline: Id) -> bool { p.elements.get(&lifeline).is_some_and(|element| { element.kind() == Kind::Lifeline && element.parent == Some(owner) && element.layer() == layer }) } /// Only actual steps of this Scenario and its own Fragment/Branch sequences. /// ScenarioUse/SequenceReference are deliberately never expanded. Invalid graph /// cycles terminate, and malformed parent/step links do not manufacture membership. fn direct_messages(p: &Project, owner: Id, layer: Layer) -> BTreeSet { let mut messages = BTreeSet::new(); let mut visited = BTreeSet::new(); let mut pending = vec![owner]; while let Some(scope) = pending.pop() { if !visited.insert(scope) { continue; } let Some(element) = p.elements.get(&scope) else { continue; }; for id in element.steps().into_iter().flatten() { let Some(item) = p.elements.get(id) else { continue; }; if item.parent != Some(scope) || item.layer() != layer { continue; } match item.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { .. }, .. }) => { messages.insert(*id); } Some(SharedBody::Occurrence { body: OccurrenceBody::Fragment { branches, .. }, .. }) => { for branch in branches { if p.elements.get(branch).is_some_and(|operand| { operand.parent == Some(*id) && operand.layer() == layer && matches!( operand.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Branch { .. }, .. }) ) }) { pending.push(*branch); } } } _ => {} } } } messages } pub(crate) fn validate_diagram(p: &Project, diagram: &Diagram, out: &mut Vec) { if diagram.projection.scenario().is_none() { if diagram.layout.is_some() { validation::error( out, diagram.id, "scenario_layout_projection", "layout", "Only a Scenario projection accepts a Scenario layout, including an empty payload.", ); } return; } let scenario = match owner(p, diagram) { Ok(owner) => owner, Err(error) => { validation::error( out, diagram.id, "scenario_diagram_context", "projection", error.0, ); return; } }; if !diagram.nodes.is_empty() || !diagram.routes.is_empty() { validation::error( out, diagram.id, "scenario_diagram_content", "layout", "Scenario views do not accept architecture node placements or edge routes.", ); } let Some(DiagramLayout::Scenario(layout)) = &diagram.layout else { return; }; let mut lanes = BTreeSet::new(); for lane in &layout.lanes { if !lanes.insert(lane.lifeline) { validation::error( out, diagram.id, "duplicate_scenario_lane", "layout", "A Scenario layout has at most one position per lifeline.", ); } if !lane_member(p, scenario, diagram.layer, lane.lifeline) { validation::error( out, diagram.id, "scenario_layout_lifeline", "layout", "Lane positions reference a Lifeline directly owned by the projected Scenario.", ); } if !coordinate(lane.x) { validation::error( out, diagram.id, "scenario_layout_coordinate", "layout", "Scenario positions must be finite and within the shared coordinate bounds.", ); } } let members = direct_messages(p, scenario, diagram.layer); let mut addresses = BTreeSet::new(); for message in &layout.messages { // Reject expanded paths without cloning or indexing their potentially large contents. if !message.address.references.is_empty() { validation::error( out, diagram.id, "scenario_layout_address", "layout", "Expanded-reference message positions are not supported; references must be empty.", ); } else if !addresses.insert(message.address.occurrence) { validation::error( out, diagram.id, "duplicate_scenario_message", "layout", "A Scenario layout has at most one position per message address.", ); } if !members.contains(&message.address.occurrence) { validation::error( out, diagram.id, "scenario_layout_message", "layout", "Message positions reference Exchange occurrences in the Scenario's own ordered sequence tree.", ); } if !coordinate(message.send_y) { validation::error( out, diagram.id, "scenario_layout_coordinate", "layout", "Scenario positions must be finite and within the shared coordinate bounds.", ); } } } fn editable(p: &Project, id: Id) -> Result<(&Diagram, Id), ModelError> { let diagram = p .diagrams .get(&id) .ok_or_else(|| ModelError("Scenario diagram does not exist.".into()))?; let scenario = owner(p, diagram)?; if !diagram.nodes.is_empty() || !diagram.routes.is_empty() { return Err(ModelError( "Scenario views do not accept architecture node placements or edge routes.".into(), )); } Ok((diagram, scenario)) } fn check_message( p: &Project, diagram: &Diagram, owner: Id, address: &OccurrenceEndpoint, ) -> Result<(), ModelError> { if !address.references.is_empty() { return Err(ModelError( "Expanded-reference message positions are not supported; references must be empty." .into(), )); } if !direct_messages(p, owner, diagram.layer).contains(&address.occurrence) { return Err(ModelError("Message position requires an Exchange occurrence in the Scenario's own ordered sequence tree.".into())); } Ok(()) } fn payload(diagram: &mut Diagram) -> &mut ScenarioLayout { let DiagramLayout::Scenario(layout) = diagram .layout .get_or_insert_with(|| DiagramLayout::Scenario(ScenarioLayout::default())); layout } /// Target-scoped preparation, not whole-project or whole-layout validation. /// Unrelated intermediate errors remain repairable by later commands in a batch. pub fn set_lane( p: &Project, diagram: Id, position: ScenarioLanePosition, ) -> Result { let (current, owner) = editable(p, diagram)?; if !lane_member(p, owner, current.layer, position.lifeline) || !coordinate(position.x) { return Err(ModelError( "Lane position requires an owned Lifeline and a finite bounded x coordinate.".into(), )); } let mut result = current.clone(); let layout = payload(&mut result); if layout .lanes .iter() .filter(|row| row.lifeline == position.lifeline) .count() > 1 { return Err(ModelError( "Cannot set an ambiguous duplicate lane position.".into(), )); } if let Some(existing) = layout .lanes .iter_mut() .find(|row| row.lifeline == position.lifeline) { if existing.x != position.x { *existing = position; } } else { layout.lanes.push(position); } Ok(result) } pub fn remove_lane(p: &Project, diagram: Id, lifeline: Id) -> Result { let (current, owner) = editable(p, diagram)?; if !lane_member(p, owner, current.layer, lifeline) { return Err(ModelError( "Removing a lane position requires an owned Lifeline.".into(), )); } let mut result = current.clone(); if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout { layout.lanes.retain(|row| row.lifeline != lifeline); } Ok(result) } pub fn set_message( p: &Project, diagram: Id, position: ScenarioMessagePosition, ) -> Result { let (current, owner) = editable(p, diagram)?; check_message(p, current, owner, &position.address)?; if !coordinate(position.send_y) { return Err(ModelError( "Message position requires a finite bounded send_y coordinate.".into(), )); } let mut result = current.clone(); let layout = payload(&mut result); if layout .messages .iter() .filter(|row| row.address == position.address) .count() > 1 { return Err(ModelError( "Cannot set an ambiguous duplicate message position.".into(), )); } if let Some(existing) = layout .messages .iter_mut() .find(|row| row.address == position.address) { if existing.send_y != position.send_y { *existing = position; } } else { layout.messages.push(position); } Ok(result) } pub fn remove_message( p: &Project, diagram: Id, address: &OccurrenceEndpoint, ) -> Result { let (current, owner) = editable(p, diagram)?; check_message(p, current, owner, address)?; let mut result = current.clone(); if let Some(DiagramLayout::Scenario(layout)) = &mut result.layout { layout.messages.retain(|row| &row.address != address); } Ok(result) } /// Prune only identities in the caller's actual deletion closure, never visibility /// or missing endpoint assignments. Owner-view cascade remains the caller's job. pub fn prune_deleted(diagram: &mut Diagram, deleted: &BTreeSet) { if let Some(DiagramLayout::Scenario(layout)) = &mut diagram.layout { layout .lanes .retain(|lane| !deleted.contains(&lane.lifeline)); layout.messages.retain(|message| { !deleted.contains(&message.address.occurrence) && !message .address .references .iter() .any(|id| deleted.contains(id)) }); } } #[cfg(test)] mod tests;