430 lines
14 KiB
Rust
430 lines
14 KiB
Rust
//! 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<ScenarioLanePosition>,
|
|
#[serde(default)]
|
|
pub messages: Vec<ScenarioMessagePosition>,
|
|
}
|
|
|
|
#[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<Id, ModelError> {
|
|
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<Id> {
|
|
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<validation::Issue>) {
|
|
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<Diagram, ModelError> {
|
|
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<Diagram, ModelError> {
|
|
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<Diagram, ModelError> {
|
|
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<Diagram, ModelError> {
|
|
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<Id>) {
|
|
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;
|