Files
ARCADIA-Studio/crates/oa-core/src/scenario_layout.rs
T

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;