Complete source 6 engineering workflows and preserve release validation evidence

This commit is contained in:
Codex
2026-09-06 21:49:04 -07:00
parent 2b2d2bc18b
commit 4b6b5fc197
290 changed files with 48271 additions and 1791 deletions
+1
View File
@@ -78,6 +78,7 @@ fn diagram(
description: "A focused projection of the shared engineering model.".into(),
layer,
projection,
view_options: Default::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+25 -5
View File
@@ -350,6 +350,9 @@ fn reference_field(key: &str) -> bool {
| "lifeline"
| "state"
| "constrained"
| "exchanges"
| "hidden_edges"
| "Any"
| "source"
| "target"
| "source_port"
@@ -1383,9 +1386,9 @@ impl Parser<'_> {
fn unit(&mut self, entry: bool, includes_allowed: bool) -> Result<SourceUnit, Error> {
let mut header = if entry {
self.take("arcadia")?;
if !matches!(self.text(), "3" | "4" | "5") {
if !matches!(self.text(), "3" | "4" | "5" | "6") {
return Err(self
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;' or 'arcadia 5;'."));
.error("Unsupported schema version: expected 'arcadia 3;', 'arcadia 4;', 'arcadia 5;' or 'arcadia 6;'."));
}
let schema_version = self.text().parse().expect("checked source version");
self.cursor += 1;
@@ -1603,6 +1606,9 @@ pub(crate) fn resolve_declarations(
};
match d.kind {
DeclKind::Element(kind, layer) => {
if project.schema_version < 6 && !crate::persistence::pre6_element_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
if legacy && !crate::persistence::v3::element_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Element constructor was not part of schema3.".into()));
}
@@ -1677,6 +1683,9 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Relation(kind) => {
if project.schema_version < 6 && !crate::persistence::pre6_relation_kind(kind) {
return Err(conversion_error(std::format!("{kind:?} requires schema/source 6.")));
}
if legacy && !crate::persistence::v3::relation_allowed(serde_json::to_value(kind).unwrap().as_str().unwrap()) {
return Err(conversion_error("Relation constructor was not part of schema3.".into()));
}
@@ -1713,8 +1722,14 @@ pub(crate) fn resolve_declarations(
);
}
DeclKind::Diagram => {
if project.schema_version < 6 && fields.contains_key("view_options") {
return Err(conversion_error("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if project.schema_version < 6 && let Some(key)=fields.keys().find(|key| !matches!(key.as_str(),"description"|"layer"|"projection"|"routes")) {
return Err(conversion_error(std::format!("Unknown diagram field '{key}' in the frozen pre6 contract.")));
}
let defaults =
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[]});
serde_json::json!({"description":"","layer":"Oa","projection":"Overview","routes":[],"view_options":crate::view_options::ViewOptions::default()});
let mut record = merge_fields(defaults.as_object().unwrap().clone(), fields)
.map_err(&conversion_error)?;
header(&mut record, d, true);
@@ -1837,8 +1852,13 @@ pub(crate) fn resolve_declarations(
}
return Ok(migrated);
}
if project.schema_version == 4 {
return crate::persistence::v4::finish(project, limits).map_err(|error| {
if project.schema_version == 4 || project.schema_version == 5 {
return (if project.schema_version == 4 {
crate::persistence::v4::finish(project, limits)
} else {
crate::persistence::migrate_v5(project, limits)
})
.map_err(|error| {
(
usize::MAX,
Error {
@@ -111,6 +111,10 @@ pub(super) fn resolve_baseline(
let json = raw(value)?;
let baseline: SemanticBaseline = serde_json::from_value(json.clone())
.map_err(|cause| error(value, format!("Invalid semantic baseline: {cause}")))?;
if version < 6 {
crate::persistence::check_pre6_baseline(&baseline)
.map_err(|cause| error(value, cause.to_string()))?;
}
let slots = historical_source_slots(&baseline).into_iter().collect();
validate_tokens(value, &mut vec![], &slots)?;
resolved.insert(key.clone(), json);
+1
View File
@@ -13,6 +13,7 @@ pub mod source_files;
pub mod source_project;
pub mod transitions;
pub mod validation;
pub mod view_options;
pub use model::*;
pub use ports::*;
pub use semantic::*;
+4
View File
@@ -67,6 +67,8 @@ impl Project4 {
if self.schema_version != 4 {
return Err(FileError::Invalid("Expected schema_version 4.".into()));
}
crate::persistence::check_pre6_relations(self.relations.values())?;
crate::persistence::check_pre6_elements(self.elements.values())?;
Ok(Project {
schema_version: SCHEMA_VERSION,
id: self.id,
@@ -102,6 +104,8 @@ pub(crate) fn review_defaults(project: &Project, report: &mut MigrationReport) {
}
pub(crate) fn finish(mut project: Project, limits: ParseLimits) -> Result<DecodeResult, FileError> {
crate::persistence::check_pre6_relations(project.relations.values())?;
crate::persistence::check_pre6_elements(project.elements.values())?;
project.schema_version = SCHEMA_VERSION;
limits.check_model(&project).map_err(FileError::Limit)?;
let mut report = MigrationReport::data_upgrade(4);
+44 -5
View File
@@ -6,7 +6,7 @@ use std::{
use uuid::Uuid;
pub type Id = Uuid;
pub const SCHEMA_VERSION: u32 = 5;
pub const SCHEMA_VERSION: u32 = 6;
pub const MAX_COORDINATE: f32 = 1_000_000.;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
@@ -39,6 +39,7 @@ pub enum Kind {
Mission,
FunctionalChain,
ExchangeItem,
ExchangeCategory,
DataType,
DataField,
Interface,
@@ -65,7 +66,7 @@ pub enum Kind {
Constraint,
}
impl Kind {
pub const ALL: [Self; 38] = [
pub const ALL: [Self; 39] = [
Self::Entity,
Self::Actor,
Self::Role,
@@ -80,6 +81,7 @@ impl Kind {
Self::Mission,
Self::FunctionalChain,
Self::ExchangeItem,
Self::ExchangeCategory,
Self::DataType,
Self::DataField,
Self::Interface,
@@ -121,6 +123,7 @@ impl Kind {
Self::Mission => "Mission",
Self::FunctionalChain => "Functional chain",
Self::ExchangeItem => "Exchange item",
Self::ExchangeCategory => "Exchange category",
Self::DataType => "Data type",
Self::DataField => "Data field",
Self::Interface => "Interface",
@@ -564,6 +567,10 @@ pub enum OccurrenceBody {
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum SharedBody {
ExchangeCategory {
family: crate::view_options::ExchangeFamily,
exchanges: Vec<Id>,
},
ExchangeItem {
kind: ExchangeItemKind,
mechanism: ExchangeMechanism,
@@ -754,6 +761,10 @@ impl ElementBody {
default: None,
unit: String::new(),
},
Kind::ExchangeCategory => SharedBody::ExchangeCategory {
family: crate::view_options::ExchangeFamily::Work,
exchanges: vec![],
},
Kind::DataField => SharedBody::DataField {
type_ref: TypeRef::String,
minimum: 1,
@@ -815,6 +826,7 @@ impl SharedBody {
pub fn kind(&self) -> Kind {
match self {
Self::ExchangeItem { .. } => Kind::ExchangeItem,
Self::ExchangeCategory { .. } => Kind::ExchangeCategory,
Self::DataType { .. } => Kind::DataType,
Self::DataField { .. } => Kind::DataField,
Self::Interface { .. } => Kind::Interface,
@@ -844,6 +856,7 @@ pub enum RelationKind {
RoleAssignment,
FunctionalExchange,
BoundaryExchange,
CommunicationMean,
MissionCapability,
CapabilityInclude,
CapabilityExtend,
@@ -858,7 +871,7 @@ pub enum RelationKind {
StateAction,
}
impl RelationKind {
pub const ALL: [Self; 23] = [
pub const ALL: [Self; 24] = [
Self::InterfaceItemAllocation,
Self::DataAssociation,
Self::Allocation,
@@ -870,6 +883,7 @@ impl RelationKind {
Self::RoleAssignment,
Self::FunctionalExchange,
Self::BoundaryExchange,
Self::CommunicationMean,
Self::MissionCapability,
Self::CapabilityInclude,
Self::CapabilityExtend,
@@ -902,6 +916,10 @@ impl RelationKind {
Self::RoleAssignment => from == Kind::Role && to == Kind::Activity,
Self::FunctionalExchange => from == Kind::Function && to == Kind::Function,
Self::BoundaryExchange => from == Kind::Port && to == Kind::Port,
Self::CommunicationMean => {
matches!(from, Kind::Entity | Kind::Actor)
&& matches!(to, Kind::Entity | Kind::Actor)
}
Self::MissionCapability => matches!(
(from, to),
(Kind::OperationalMission, Kind::Capability)
@@ -953,6 +971,7 @@ impl RelationKind {
Self::RoleAssignment => "OA role → activity",
Self::FunctionalExchange => "SA function → SA function",
Self::BoundaryExchange => "Output port → input port",
Self::CommunicationMean => "OA entity/actor → OA entity/actor",
Self::MissionCapability => "Mission → capability in the same perspective",
Self::CapabilityInclude => "Including capability → included capability",
Self::CapabilityExtend => {
@@ -963,7 +982,7 @@ impl RelationKind {
Self::SequenceLink => "Preceding → following contribution in the same process or chain",
Self::StateTransition => "State → state in one machine",
Self::ExchangeAllocation => {
"Boundary exchange → operational interaction / functional exchange"
"Communication means / boundary exchange → operational interaction / functional exchange"
}
Self::InterfaceProvision => "Participant/port → provided interface",
Self::InterfaceRequirement => "Participant/port → required interface",
@@ -986,6 +1005,7 @@ impl fmt::Display for RelationKind {
Self::RoleAssignment => "Role assignment",
Self::FunctionalExchange => "Functional exchange",
Self::BoundaryExchange => "Boundary exchange",
Self::CommunicationMean => "Communication means",
Self::MissionCapability => "Mission capability",
Self::CapabilityInclude => "Capability include",
Self::CapabilityExtend => "Capability extend",
@@ -1074,6 +1094,12 @@ pub enum RelationBody {
oriented: bool,
items: Vec<Id>,
},
CommunicationMean {
source: Id,
target: Id,
oriented: bool,
items: Vec<Id>,
},
MissionCapability {
source: Id,
target: Id,
@@ -1160,6 +1186,7 @@ impl RelationBody {
Self::RoleAssignment { .. } => RelationKind::RoleAssignment,
Self::FunctionalExchange { .. } => RelationKind::FunctionalExchange,
Self::BoundaryExchange { .. } => RelationKind::BoundaryExchange,
Self::CommunicationMean { .. } => RelationKind::CommunicationMean,
Self::MissionCapability { .. } => RelationKind::MissionCapability,
Self::CapabilityInclude { .. } => RelationKind::CapabilityInclude,
Self::CapabilityExtend { .. } => RelationKind::CapabilityExtend,
@@ -1187,6 +1214,7 @@ impl RelationBody {
| Self::RoleAssignment { source, target }
| Self::FunctionalExchange { source, target, .. }
| Self::BoundaryExchange { source, target, .. }
| Self::CommunicationMean { source, target, .. }
| Self::MissionCapability { source, target }
| Self::CapabilityInclude { source, target }
| Self::CapabilityExtend { source, target, .. }
@@ -1214,6 +1242,7 @@ impl RelationBody {
| Self::RoleAssignment { source, target }
| Self::FunctionalExchange { source, target, .. }
| Self::BoundaryExchange { source, target, .. }
| Self::CommunicationMean { source, target, .. }
| Self::MissionCapability { source, target }
| Self::CapabilityInclude { source, target }
| Self::CapabilityExtend { source, target, .. }
@@ -1235,7 +1264,8 @@ impl RelationBody {
match self {
Self::Interaction { items, .. }
| Self::FunctionalExchange { items, .. }
| Self::BoundaryExchange { items, .. } => items,
| Self::BoundaryExchange { items, .. }
| Self::CommunicationMean { items, .. } => items,
_ => &[],
}
}
@@ -1285,6 +1315,12 @@ impl RelationBody {
oriented: true,
items: vec![],
},
RelationKind::CommunicationMean => Self::CommunicationMean {
source,
target,
oriented: false,
items: vec![],
},
RelationKind::MissionCapability => Self::MissionCapability { source, target },
RelationKind::CapabilityInclude => Self::CapabilityInclude { source, target },
RelationKind::CapabilityExtend => Self::CapabilityExtend {
@@ -1387,6 +1423,8 @@ pub struct Diagram {
pub description: String,
pub layer: Layer,
pub projection: ProjectionKind,
#[serde(default)]
pub view_options: crate::view_options::ViewOptions,
#[serde(deserialize_with = "unique_id_map")]
pub nodes: BTreeMap<Id, NodePlacement>,
#[serde(default)]
@@ -1814,6 +1852,7 @@ impl Project {
description: String::new(),
layer,
projection: ProjectionKind::Overview,
view_options: crate::view_options::ViewOptions::default(),
nodes: BTreeMap::new(),
routes: vec![],
},
+173 -2
View File
@@ -139,6 +139,36 @@ pub fn decode_with_report_limited(
}
let decoded = if bytes.iter().find(|b| !b.is_ascii_whitespace()) == Some(&b'{') {
let header: serde_json::Value = serde_json::from_slice(bytes)?;
if header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
.is_some_and(|v| v <= 5)
&& let Some(diagrams) = header
.get("diagrams")
.and_then(serde_json::Value::as_object)
{
for diagram in diagrams.values().filter_map(serde_json::Value::as_object) {
for key in diagram.keys() {
if key == "view_options" {
return Err(FileError::Invalid("Diagram.view_options requires schema/source 6, including an explicitly supplied default value.".into()));
}
if !matches!(
key.as_str(),
"id" | "alias"
| "name"
| "description"
| "layer"
| "projection"
| "nodes"
| "routes"
) {
return Err(FileError::Invalid(format!(
"Unknown diagram field '{key}' in the frozen pre6 contract."
)));
}
}
}
}
match header
.get("schema_version")
.and_then(serde_json::Value::as_u64)
@@ -151,13 +181,14 @@ pub fn decode_with_report_limited(
Some(2) => migrate_v2_limited(serde_json::from_slice(bytes)?, limits)?,
Some(3) => v3::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(4) => v4::migrate(serde_json::from_slice(bytes)?, limits)?,
Some(5) => DecodeResult {
Some(5) => migrate_v5(serde_json::from_slice(bytes)?, limits)?,
Some(6) => DecodeResult {
project: serde_json::from_slice(bytes)?,
migration: None,
},
_ => {
return Err(FileError::Invalid(
"Unsupported or missing schema_version; expected 1, 2, 3, 4 or 5.".into(),
"Unsupported or missing schema_version; expected 1, 2, 3, 4, 5 or 6.".into(),
));
}
}
@@ -186,6 +217,146 @@ pub fn decode_with_report_limited(
Ok(decoded)
}
/// Versions 4 and 5 share unchanged typed records, but their constructor set
/// remains frozen. Apply this before normalizing the advertised version.
pub(crate) fn pre6_relation_kind(kind: RelationKind) -> bool {
matches!(
kind,
RelationKind::InterfaceItemAllocation
| RelationKind::DataAssociation
| RelationKind::Allocation
| RelationKind::PortAllocation
| RelationKind::Interaction
| RelationKind::CapabilityInvolvement
| RelationKind::ChainInvolvement
| RelationKind::RoleRealization
| RelationKind::RoleAssignment
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::MissionCapability
| RelationKind::CapabilityInclude
| RelationKind::CapabilityExtend
| RelationKind::CapabilityGeneralization
| RelationKind::MissionInvolvement
| RelationKind::SequenceLink
| RelationKind::StateTransition
| RelationKind::Availability
| RelationKind::ExchangeAllocation
| RelationKind::InterfaceProvision
| RelationKind::InterfaceRequirement
| RelationKind::StateAction
)
}
pub(crate) fn pre6_element_kind(kind: Kind) -> bool {
matches!(
kind,
Kind::Entity
| Kind::Actor
| Kind::Role
| Kind::Activity
| Kind::Capability
| Kind::OperationalMission
| Kind::Process
| Kind::System
| Kind::SystemActor
| Kind::Function
| Kind::SystemCapability
| Kind::Mission
| Kind::FunctionalChain
| Kind::ExchangeItem
| Kind::DataType
| Kind::DataField
| Kind::Interface
| Kind::Port
| Kind::Scenario
| Kind::Lifeline
| Kind::Occurrence
| Kind::StateMachine
| Kind::Region
| Kind::State
| Kind::Mode
| Kind::Initial
| Kind::Final
| Kind::Choice
| Kind::Junction
| Kind::Fork
| Kind::Join
| Kind::ShallowHistory
| Kind::DeepHistory
| Kind::EntryPoint
| Kind::ExitPoint
| Kind::Terminate
| Kind::Event
| Kind::Constraint
)
}
pub(crate) fn check_pre6_relations<'a>(
relations: impl Iterator<Item = &'a Relation>,
) -> Result<(), FileError> {
for relation in relations {
if !pre6_relation_kind(relation.kind()) {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 6.",
relation.kind()
)));
}
}
Ok(())
}
pub(crate) fn check_pre6_elements<'a>(
elements: impl Iterator<Item = &'a Element>,
) -> Result<(), FileError> {
for element in elements {
if !pre6_element_kind(element.kind()) {
return Err(FileError::Invalid(format!(
"{:?} requires schema/source 6.",
element.kind()
)));
}
}
Ok(())
}
pub(crate) fn check_pre6_baseline(baseline: &SemanticBaseline) -> Result<(), FileError> {
if baseline.facets.iter().any(|f| {
matches!(
f,
FacetReview::Category(_)
| FacetReview::Member {
collection: ReferenceCollection::CategoryExchanges,
..
}
)
}) {
return Err(FileError::Invalid(
"Exchange category semantic facts require schema/source 6.".into(),
));
}
Ok(())
}
pub(crate) fn migrate_v5(
mut project: Project,
limits: crate::language::ParseLimits,
) -> Result<DecodeResult, FileError> {
if project.schema_version != 5 {
return Err(FileError::Invalid("Expected schema_version 5.".into()));
}
check_pre6_relations(project.relations.values())?;
check_pre6_elements(project.elements.values())?;
for baseline in project
.traces
.values()
.filter_map(|t| t.baseline.as_ref().and_then(|b| b.semantic.as_ref()))
{
check_pre6_baseline(baseline)?;
}
limits.check_model(&project).map_err(FileError::Limit)?;
project.schema_version = SCHEMA_VERSION;
Ok(DecodeResult {
project,
migration: Some(MigrationReport::data_upgrade(5)),
})
}
pub(crate) fn migrate_v1_with_report_limited(
old: v1::Project,
limits: crate::language::ParseLimits,
+47 -1
View File
@@ -1182,6 +1182,48 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
else {
return;
};
if let RelationBody::CommunicationMean {
source,
target,
oriented,
..
} = &boundary.body
{
let actual = (
actual_performer(p, work.source()),
actual_performer(p, work.target()),
);
let compatible = |a, b| {
actual.0.is_none_or(|owner| owner == a) && actual.1.is_none_or(|owner| owner == b)
};
if !compatible(*source, *target) && (*oriented || !compatible(*target, *source)) {
error(
out,
r.id,
"exchange_allocation_performer",
"endpoints",
"Actual activity allocations must agree with one permitted communication direction; review the means or the activity allocations.",
);
} else if actual.0.is_none() || actual.1.is_none() {
warning(
out,
r.id,
"incomplete_exchange_allocation",
"endpoints",
"Add or review actual activity-to-participant Allocations; role responsibility alone does not resolve communication allocation.",
);
}
if !items_satisfy(p, work.body.items(), boundary.body.items()) {
error(
out,
r.id,
"exchange_allocation_items",
"items",
"Communication means must declare every allocated interaction item; review the carried item contract explicitly.",
);
}
return;
}
let RelationBody::BoundaryExchange {
source: a,
target: b,
@@ -1219,7 +1261,11 @@ pub(crate) fn validate_exchange_allocation(p: &Project, r: &Relation, out: &mut
r.id,
"legacy_exchange_allocation",
"endpoints",
"Resolve legacy ports and explicitly allocate the corresponding typed ports.",
if p.layer_of(boundary.id) == Some(Layer::Oa) {
"This preserved OA boundary uses legacy ports; review its communication representation without inventing SA port contracts."
} else {
"Resolve legacy ports and explicitly allocate the corresponding typed ports."
},
);
for (function, port) in [(work.source(), *a), (work.target(), *b)] {
match (actual_performer(p, function), owner(port)) {
+104 -9
View File
@@ -226,8 +226,58 @@ pub fn project_view(p: &Project, diagram: Id) -> ProjectedView {
};
project_diagram(p, d)
}
/// Reuse an index built for this accepted semantic revision during presentation
/// previews. Callers must invalidate it when semantic records change.
pub fn project_view_with_index(
p: &Project,
diagram: Id,
index: &crate::view_options::ViewIndex,
) -> ProjectedView {
p.diagrams
.get(&diagram)
.map_or_else(ProjectedView::default, |d| {
project_diagram_with_index(p, d, index)
})
}
/// Project alternate placement geometry without copying or mutating semantics.
pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
let index = crate::view_options::ViewIndex::new(p);
project_diagram_with_index(p, d, &index)
}
pub fn project_diagram_with_index(
p: &Project,
d: &Diagram,
index: &crate::view_options::ViewIndex,
) -> ProjectedView {
project_diagram_impl(p, d, None, Some(index))
}
/// Unfiltered eligibility from the same projection/anchor/fold decisions used
/// for drawing. Compute once for a view-options editor, then combine with the
/// prepared filter's reasons; hidden lines remain geometrically eligible here.
pub fn edge_availability(
p: &Project,
d: &Diagram,
) -> BTreeMap<Id, crate::view_options::EdgeAvailability> {
let mut availability = p
.relations
.keys()
.chain(p.traces.keys())
.map(|id| {
(
*id,
crate::view_options::EdgeAvailability::OutsideProjection,
)
})
.collect();
project_diagram_impl(p, d, Some(&mut availability), None);
availability
}
fn project_diagram_impl(
p: &Project,
d: &Diagram,
mut availability: Option<&mut BTreeMap<Id, crate::view_options::EdgeAvailability>>,
index: Option<&crate::view_options::ViewIndex>,
) -> ProjectedView {
let parents = parents(p, d);
let mut nodes = Vec::new();
for n in d.nodes.values() {
@@ -354,6 +404,7 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
),
ProjectionKind::FunctionalDataflow => matches!(
r.kind(),
@@ -367,20 +418,36 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
| RelationKind::MissionCapability
| RelationKind::MissionInvolvement
| RelationKind::CapabilityInvolvement
| RelationKind::CommunicationMean
),
_ => true,
};
if !included {
continue;
}
if let (Some(source), Some(target)) = (anchor(r.source()), anchor(r.target()))
&& (source != target
|| ((d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| n.id == source && n.element == r.source())))
let ends = (anchor(r.source()), anchor(r.target()));
let self_loop = (d.projection.state_machine().is_some()
|| r.kind() == RelationKind::DataAssociation)
&& r.source() == r.target()
&& nodes
.iter()
.any(|n| Some(n.id) == ends.0 && n.element == r.source());
if let Some(availability) = availability.as_deref_mut() {
use crate::view_options::EdgeAvailability as A;
availability.insert(
r.id,
match ends {
(Some(a), Some(b)) if a != b || self_loop => A::Visible,
(Some(_), Some(_)) => A::FoldedInternal,
(a, b) => A::EndpointNotShown {
source: a.is_none(),
target: b.is_none(),
},
},
);
}
if let (Some(source), Some(target)) = ends
&& (source != target || self_loop)
{
let (source_port, target_port) = match &r.body {
RelationBody::FunctionalExchange {
@@ -402,6 +469,9 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
RelationBody::BoundaryExchange {
oriented: false,
..
} | RelationBody::CommunicationMean {
oriented: false,
..
} | RelationBody::DataAssociation { .. }
),
source_port: source_port.filter(|id| visible_ports.contains(id)),
@@ -411,7 +481,22 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
}
if d.projection == ProjectionKind::Traceability {
for t in p.traces.values() {
if let (Some(source), Some(target)) = (anchor(t.oa), anchor(t.sa))
let ends = (anchor(t.oa), anchor(t.sa));
if let Some(availability) = availability.as_deref_mut() {
use crate::view_options::EdgeAvailability as A;
availability.insert(
t.id,
match ends {
(Some(a), Some(b)) if a != b => A::Visible,
(Some(_), Some(_)) => A::FoldedInternal,
(a, b) => A::EndpointNotShown {
source: a.is_none(),
target: b.is_none(),
},
},
);
}
if let (Some(source), Some(target)) = ends
&& source != target
{
edges.push(ProjectedEdge {
@@ -433,6 +518,16 @@ pub fn project_diagram(p: &Project, d: &Diagram) -> ProjectedView {
} else {
port_layout::layout(p, d, &mut nodes, &edges)
};
// Port geometry uses every eligible edge. Presentation filters must never
// move visible ports when another line is hidden or a category is selected.
if let Some(index) = index {
let filter = if d.projection == ProjectionKind::Traceability {
index.prepare_cross_layer(d.layer, &d.view_options)
} else {
index.prepare(d.layer, &d.view_options)
};
edges.retain(|edge| filter.includes(edge.id));
}
ProjectedView {
nodes,
edges,
+21 -3
View File
@@ -29,6 +29,11 @@ pub fn visit_element_refs(e: &mut Element, mut visit: impl FnMut(&mut Id)) {
}
if let Some(shared) = e.shared_mut() {
match shared {
SharedBody::ExchangeCategory { exchanges, .. } => {
for id in exchanges {
visit(id);
}
}
SharedBody::DataField {
type_ref: TypeRef::Named(id),
..
@@ -152,7 +157,9 @@ pub fn visit_relation_refs(r: &mut Relation, mut visit: impl FnMut(&mut Id)) {
visit(id);
}
}
RelationBody::Interaction { items, .. } | RelationBody::BoundaryExchange { items, .. } => {
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. } => {
for id in items {
visit(id);
}
@@ -189,6 +196,11 @@ pub fn remap_references(p: &mut Project, old: Id, new: Id) {
});
}
for d in p.diagrams.values_mut() {
d.view_options.visit_references(|id| {
if *id == old {
*id = new;
}
});
if let ProjectionKind::StateMachine { machine } = &mut d.projection
&& *machine == old
{
@@ -374,6 +386,7 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
p.traces.retain(|id, _| !removed.contains(id));
p.artifact_links.retain(|id, _| !removed.contains(id));
for d in p.diagrams.values_mut() {
d.view_options.retain_references(|id| !removed.contains(id));
d.nodes.retain(|id, _| !removed.contains(id));
d.routes.retain(|route| !removed.contains(&route.edge));
for node in d.nodes.values_mut() {
@@ -417,7 +430,11 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
SharedBody::Scenario { context, .. } => clear(context),
SharedBody::Lifeline { represented } => clear(represented),
SharedBody::Constraint { constrained, .. } => {
SharedBody::ExchangeCategory {
exchanges: constrained,
..
}
| SharedBody::Constraint { constrained, .. } => {
constrained.retain(|id| !removed.contains(id))
}
SharedBody::Occurrence { body, .. } => match body {
@@ -487,7 +504,8 @@ fn remove_objects(p: &mut Project, removed: BTreeSet<Id>) {
}
}
RelationBody::Interaction { items, .. }
| RelationBody::BoundaryExchange { items, .. } => {
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. } => {
items.retain(|id| !removed.contains(id))
}
RelationBody::FunctionalExchange { items, .. } => {
+20 -1
View File
@@ -607,6 +607,7 @@ fn route_diagram_impl(
.flatten();
routed.label = make_label(
project,
diagram.view_options.exchange_labels,
edge.id,
&routed.points,
authored_label,
@@ -1232,6 +1233,7 @@ fn label_text(project: &Project, edge: Id) -> String {
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
| RelationKind::DataAssociation => relation.name.as_str(),
RelationKind::CapabilityInclude => "«include»",
RelationKind::CapabilityExtend => "«extend»",
@@ -1262,8 +1264,11 @@ fn empty_label() -> EdgeLabelGeometry {
}
}
// Keep semantic label inputs and the independent collision sets explicit.
#[allow(clippy::too_many_arguments)]
fn make_label(
project: &Project,
labels: crate::view_options::ExchangeLabels,
edge: Id,
points: &[Position],
authored: Option<&crate::EdgeLabel>,
@@ -1271,7 +1276,21 @@ fn make_label(
previous_labels: &[RoutingBounds],
strokes: &[Segment],
) -> EdgeLabelGeometry {
let text = label_text(project, edge);
let text = if labels != crate::view_options::ExchangeLabels::Name
&& project
.relations
.get(&edge)
.is_some_and(|r| crate::view_options::exchange_family(r.kind()).is_some())
{
let label = labels.label(project, edge);
if label.chars().count() > 48 {
label.chars().take(47).chain(std::iter::once('…')).collect()
} else {
label
}
} else {
label_text(project, edge)
};
let size = Size {
width: if text.is_empty() {
0.0
+19 -2
View File
@@ -191,6 +191,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::State(StateFacet::Exit) => ("State", &["exit"]),
F::State(StateFacet::DoActivity) => ("State", &["do_activity"]),
F::Constraint(ConstraintFacet::Expression) => ("Constraint", &["expression"]),
F::Category(CategoryFacet::Family) => ("ExchangeCategory", &["family"]),
F::Relation(RelationFacet::Source) => ("*", &["source"]),
F::Relation(RelationFacet::Target) => ("*", &["target"]),
F::Relation(RelationFacet::AllocationOrder) => ("InterfaceItemAllocation", &["order"]),
@@ -206,7 +207,7 @@ fn path(own: &Own, facet: &FacetSelector) -> Option<Vec<String>> {
F::Relation(RelationFacet::TargetNavigable) => ("DataAssociation", &["target_navigable"]),
F::Relation(RelationFacet::SourcePort) => ("FunctionalExchange", &["source_port"]),
F::Relation(RelationFacet::TargetPort) => ("FunctionalExchange", &["target_port"]),
F::Relation(RelationFacet::Oriented) => ("BoundaryExchange", &["oriented"]),
F::Relation(RelationFacet::Oriented) => ("*", &["oriented"]),
F::Relation(RelationFacet::ExtendCondition) => ("CapabilityExtend", &["condition"]),
F::Relation(RelationFacet::SequenceGuard) => ("SequenceLink", &["guard"]),
F::Relation(RelationFacet::TransitionKind) => ("StateTransition", &["transition_kind"]),
@@ -311,7 +312,8 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. },
) => Some(items.clone()),
_ => None,
},
@@ -327,6 +329,10 @@ pub(crate) fn collection(project: &Project, id: Id, which: ReferenceCollection)
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained.clone()),
_ => None,
},
C::CategoryExchanges => match shared {
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges.clone()),
_ => None,
},
}
}
pub(crate) fn order_collection(which: OrderedCollection) -> ReferenceCollection {
@@ -575,6 +581,7 @@ fn catalog_facets(
ReferenceCollection::FunctionOutputItems,
ReferenceCollection::SignalItems,
ReferenceCollection::ExchangeItems,
ReferenceCollection::CategoryExchanges,
ReferenceCollection::TransitionTriggers,
ReferenceCollection::TransitionEffects,
ReferenceCollection::Constrained,
@@ -1036,6 +1043,11 @@ mod coverage {
p
};
for (kind, parts, which) in [
(
"ExchangeCategory",
vec!["exchanges"],
ReferenceCollection::CategoryExchanges,
),
("Process", vec!["steps"], ReferenceCollection::Steps),
("FunctionalChain", vec!["steps"], ReferenceCollection::Steps),
("Scenario", vec!["steps"], ReferenceCollection::Steps),
@@ -1079,6 +1091,11 @@ mod coverage {
vec!["items"],
ReferenceCollection::ExchangeItems,
),
(
"CommunicationMean",
vec!["items"],
ReferenceCollection::ExchangeItems,
),
(
"StateTransition",
vec!["triggers"],
@@ -73,6 +73,7 @@ macro_rules! enums {
} )* };
}
enums!(
crate::view_options::ExchangeFamily,
ExchangeItemKind,
ExchangeMechanism,
ParameterDirection,
+6 -1
View File
@@ -63,7 +63,8 @@ pub(crate) fn set_collection(
Some(
RelationBody::Interaction { items, .. }
| RelationBody::FunctionalExchange { items, .. }
| RelationBody::BoundaryExchange { items, .. },
| RelationBody::BoundaryExchange { items, .. }
| RelationBody::CommunicationMean { items, .. },
) => Some(items),
_ => None,
},
@@ -79,6 +80,10 @@ pub(crate) fn set_collection(
Some(SharedBody::Constraint { constrained, .. }) => Some(constrained),
_ => None,
},
C::CategoryExchanges => match p.elements.get_mut(&id).and_then(Element::shared_mut) {
Some(SharedBody::ExchangeCategory { exchanges, .. }) => Some(exchanges),
_ => None,
},
}
.ok_or("Collection is inapplicable on target")?;
*slot = list;
+12 -1
View File
@@ -795,7 +795,18 @@ fn build_review(
current.clone()
};
if resolution.decision == SemanticDecision::TakeSource {
fact = remap_fact(&fact, &map, source_layer)?;
fact = if let Some(adapted) = transitions::review_endpoint_fact(
initial,
&plan.request.transition,
&map,
request.source,
target,
&fact,
)? {
adapted
} else {
remap_fact(&fact, &map, source_layer)?
};
}
// A Keep reads the frozen target, never a value written by an earlier source.
if resolution.decision != SemanticDecision::KeepTarget {
+2
View File
@@ -86,6 +86,7 @@ macro_rules! scalar_families {
ExchangeItem, ExchangeItemFacet, ExchangeItemReview {
Nature: ExchangeItemKind, Mechanism: ExchangeMechanism, Supertype: Option<Id>
}
Category, CategoryFacet, CategoryReview { Family: crate::view_options::ExchangeFamily }
Interface, InterfaceFacet, InterfaceReview { Supertype: Option<Id> }
Data, DataFacet, DataReview {
Constructor: DataConstructor,
@@ -197,6 +198,7 @@ pub enum ReferenceCollection {
FunctionOutputItems,
SignalItems,
ExchangeItems,
CategoryExchanges,
TransitionTriggers,
TransitionEffects,
Constrained,
+706 -5
View File
@@ -9,6 +9,13 @@
//! may remain after interruption during staging/completed cleanup. They contain
//! no pending transaction unless named `.arcadia-save-ENTRY.journal`. Windows
//! has no portable directory fsync; this is not a power-loss durability promise.
//! Journal v2 records whether writes may have begun with a flushed marker.
//! Recovery of a v2 journal without that marker discards only journal metadata;
//! v1 journals retain their prior rollback rules.
//! Journal publication refuses replacement on Windows and Linux, including a
//! competing empty directory. Linux requires kernel/filesystem support for
//! `renameat2(RENAME_NOREPLACE)` and fails without a fallback when unavailable.
//! Other operating systems retain standard rename semantics.
use crate::{language, source_project::*};
use serde::{Deserialize, Serialize};
use std::{
@@ -22,6 +29,8 @@ use thiserror::Error;
const MAX_PATH_BYTES: usize = 4096;
const MAX_JOURNAL_BYTES: usize = 32 * 1024 * 1024;
const MAX_JOURNAL_FILES: usize = 4096;
const WRITE_STARTED: &str = "write-started";
const WRITE_STARTED_BYTES: &[u8] = b"Arcadia journal v2: model writes may have started\n";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum DiskExpectation {
@@ -588,6 +597,127 @@ fn staged_path(journal: &Path, index: usize, suffix: &str) -> PathBuf {
journal.join(format!("{index}.{suffix}"))
}
fn publish_journal(stage: &Path, journal: &Path) -> Result<(), SourceFileError> {
#[cfg(windows)]
{
publish_journal_with_retry(
stage,
journal,
rename_journal_without_replacement,
std::thread::sleep,
)
}
#[cfg(target_os = "linux")]
{
rename_journal_without_replacement(stage, journal).map_err(|error| io_at(journal, error))
}
// Other platforms retain their existing rename behavior; the native
// no-replacement publication guarantee is currently Windows/Linux only.
#[cfg(not(any(windows, target_os = "linux")))]
{
fs::rename(stage, journal).map_err(|error| io_at(journal, error))
}
}
#[cfg(target_os = "linux")]
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
use std::{ffi::CString, os::unix::ffi::OsStrExt};
let path_bytes = |path: &Path| {
CString::new(path.as_os_str().as_bytes()).map_err(|_| {
io::Error::new(
io::ErrorKind::InvalidInput,
"Journal path contains an interior NUL.",
)
})
};
let (stage, journal) = (path_bytes(stage)?, path_bytes(journal)?);
// Use libc's architecture-specific syscall number, avoiding a dependency on
// the newer glibc renameat2 wrapper. Both NUL-terminated byte buffers remain
// alive for the call. RENAME_NOREPLACE makes even an empty competing directory
// an error. Unsupported kernels/filesystems fail closed: never fall back to
// ordinary rename, whose replacement semantics would lose journal ownership.
let result = unsafe {
libc::syscall(
libc::SYS_renameat2,
libc::AT_FDCWD as libc::c_long,
stage.as_ptr(),
libc::AT_FDCWD as libc::c_long,
journal.as_ptr(),
libc::RENAME_NOREPLACE as libc::c_ulong,
)
};
if result == -1 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
#[cfg(windows)]
fn rename_journal_without_replacement(stage: &Path, journal: &Path) -> io::Result<()> {
use std::os::windows::ffi::OsStrExt;
#[link(name = "kernel32")]
unsafe extern "system" {
fn MoveFileExW(existing: *const u16, destination: *const u16, flags: u32) -> i32;
}
let wide = |path: &Path| -> io::Result<Vec<u16>> {
let mut value: Vec<_> = path.as_os_str().encode_wide().collect();
if value.contains(&0) {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"Journal path contains an interior NUL.",
));
}
value.push(0);
Ok(value)
};
let (stage, journal) = (wide(stage)?, wide(journal)?);
// Both terminated UTF-16 buffers live through the call. Flags=0 requests
// neither replacement, cross-volume copying nor deferred execution. Unlike
// fs::rename on modern Windows, an existing empty directory is not replaced.
if unsafe { MoveFileExW(stage.as_ptr(), journal.as_ptr(), 0) } == 0 {
Err(io::Error::last_os_error())
} else {
Ok(())
}
}
// Windows scanners may briefly open newly written stage files without delete
// sharing. Retry only publication, before any model file has been replaced.
// Ownership still transfers only after one successful rename. Never overwrite
// a journal that appeared while waiting, and retain the final OS error/path.
#[cfg(any(windows, test))]
fn publish_journal_with_retry(
stage: &Path,
journal: &Path,
mut rename: impl FnMut(&Path, &Path) -> io::Result<()>,
mut pause: impl FnMut(std::time::Duration),
) -> Result<(), SourceFileError> {
let mut delays = [5, 10, 25, 50, 100, 200].into_iter();
loop {
match fs::symlink_metadata(journal) {
Ok(_) => return Err(SourceFileError::RecoveryRequired(journal.into())),
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(io_at(journal, error)),
}
match rename(stage, journal) {
Ok(()) => return Ok(()),
Err(error) => {
// ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION, ERROR_LOCK_VIOLATION.
// A permanent ACL failure is still returned after at most 390 ms.
if matches!(error.raw_os_error(), Some(5 | 32 | 33))
&& let Some(delay) = delays.next()
{
pause(std::time::Duration::from_millis(delay));
continue;
}
return Err(io_at(journal, error));
}
}
}
}
fn write_synced(path: &Path, bytes: &[u8]) -> Result<(), SourceFileError> {
let mut file = File::options()
.write(true)
@@ -699,6 +829,26 @@ fn save_with_hook(
compiled: &CompiledSourceProject,
limits: ProjectLimits,
mut after_replace: impl FnMut(usize) -> io::Result<()>,
) -> Result<DiskProject, SourceFileError> {
save_with_phases(snapshot, compiled, limits, |phase| match phase {
SavePhase::AfterReplace(count) => after_replace(count),
_ => Ok(()),
})
}
enum SavePhase {
BeforePublish,
AfterPublish,
AfterWriteMarker,
AfterReplace(usize),
BeforeRetire,
}
fn save_with_phases(
snapshot: &DiskProject,
compiled: &CompiledSourceProject,
limits: ProjectLimits,
mut phase: impl FnMut(SavePhase) -> io::Result<()>,
) -> Result<DiskProject, SourceFileError> {
check_snapshot(snapshot, limits)?;
let checked = compile_bundle(&compiled.bundle, limits).map_err(|diagnostics| {
@@ -739,7 +889,7 @@ fn save_with_hook(
// Stable dependency order, with the project entry committed last.
entries.sort_by_key(|file| (file.path == snapshot.bundle.entry, file.path.clone()));
let journal = Journal {
version: 1,
version: 2,
entry: snapshot.bundle.entry.clone(),
files: entries,
};
@@ -775,20 +925,46 @@ fn save_with_hook(
}
write_synced(&stage.path().join("journal.json"), &metadata)?;
sync_directory(stage.path())?;
phase(SavePhase::BeforePublish).map_err(|error| io_at(&journal_dir, error))?;
// Publishing a complete directory makes interrupted staging harmless.
fs::rename(stage.path(), &journal_dir).map_err(|error| io_at(&journal_dir, error))?;
publish_journal(stage.path(), &journal_dir)?;
let _ = stage.keep(); // The renamed directory now belongs to recovery.
sync_directory(&snapshot.root)?;
phase(SavePhase::AfterPublish).map_err(|error| io_at(&journal_dir, error))?;
// Publication may have waited while another writer completed the same
// candidate. Nothing has been written by this save: never interpret their
// matching after-bytes as ours and roll them back. The absent v2 marker
// also preserves this distinction after interruption or retirement failure.
if let Err(error) = check_conflicts(&snapshot.root, &snapshot.expectations) {
let retirement = phase(SavePhase::BeforeRetire)
.map_err(|error| io_at(&journal_dir, error))
.and_then(|()| retire_journal(&snapshot.root, &journal_dir, &journal));
return match retirement {
Ok(()) => Err(error),
Err(retirement) => Err(SourceFileError::RecoveryFailed {
save: error.to_string(),
recovery: retirement.to_string(),
journal: journal_dir,
}),
};
}
write_synced(&journal_dir.join(WRITE_STARTED), WRITE_STARTED_BYTES)?;
sync_directory(&journal_dir)?;
phase(SavePhase::AfterWriteMarker).map_err(|error| io_at(&journal_dir, error))?;
let result = (|| {
let mut current = snapshot.expectations.clone();
let mut replacements = 0;
for file in journal.files.iter().filter(|file| file.write) {
check_conflicts(&snapshot.root, &current)?;
// The first check is above, before the durable write-attempt marker.
// Further checks follow real write attempts and may need rollback.
if replacements != 0 {
check_conflicts(&snapshot.root, &current)?;
}
let bytes = compiled.bundle.files[&file.path].as_bytes();
atomic_write(&snapshot.root, &file.path, bytes)?;
current.insert(file.path.clone(), DiskExpectation::Bytes(bytes.to_vec()));
replacements += 1;
after_replace(replacements)
phase(SavePhase::AfterReplace(replacements))
.map_err(|error| io_at(&snapshot.root.join(&file.path), error))?;
}
check_conflicts(&snapshot.root, &current)?;
@@ -804,6 +980,7 @@ fn save_with_hook(
}),
};
}
phase(SavePhase::BeforeRetire).map_err(|error| io_at(&journal_dir, error))?;
retire_journal(&snapshot.root, &journal_dir, &journal)?;
Ok(saved_snapshot(snapshot, compiled))
}
@@ -833,6 +1010,14 @@ fn remove_journal(directory: &Path, journal: &Journal) -> Result<(), SourceFileE
}
}
}
if journal.version == 2 {
let marker = directory.join(WRITE_STARTED);
match fs::remove_file(&marker) {
Ok(()) => {}
Err(error) if error.kind() == io::ErrorKind::NotFound => {}
Err(error) => return Err(io_at(&marker, error)),
}
}
let metadata = directory.join("journal.json");
fs::remove_file(&metadata).map_err(|error| io_at(&metadata, error))?;
fs::remove_dir(directory).map_err(|error| io_at(directory, error))
@@ -898,7 +1083,10 @@ fn recover_with_hook(
let bytes = read_journal_file(&metadata_path, MAX_JOURNAL_BYTES)?;
let journal: Journal = serde_json::from_slice(&bytes)
.map_err(|error| SourceFileError::Invalid(format!("Invalid recovery journal: {error}")))?;
if journal.version != 1 || journal.entry != entry || journal.files.len() > limits.max_files {
if !matches!(journal.version, 1 | 2)
|| journal.entry != entry
|| journal.files.len() > limits.max_files
{
return Err(SourceFileError::Invalid(
"Recovery journal version, entry or file budget is invalid.".into(),
));
@@ -908,6 +1096,25 @@ fn recover_with_hook(
let mut folded = BTreeSet::new();
let (mut before_bytes, mut after_bytes) = (0usize, 0usize);
let mut allowed = BTreeSet::from(["journal.json".to_string()]);
let write_started = if journal.version == 2 {
let marker = directory.join(WRITE_STARTED);
match fs::symlink_metadata(&marker) {
Ok(_) => {
if read_journal_file(&marker, WRITE_STARTED_BYTES.len())? != WRITE_STARTED_BYTES {
return Err(SourceFileError::Invalid(
"Invalid journal write-started marker.".into(),
));
}
allowed.insert(WRITE_STARTED.into());
true
}
Err(error) if error.kind() == io::ErrorKind::NotFound => false,
Err(error) => return Err(io_at(&marker, error)),
}
} else {
// Frozen v1 journals predate the marker and retain their rollback rules.
true
};
for (index, file) in journal.files.iter().enumerate() {
disk_path(&root, &file.path)?;
if !folded.insert(file.path.to_lowercase()) {
@@ -952,6 +1159,10 @@ fn recover_with_hook(
));
}
}
if !write_started {
retire_journal(&root, &directory, &journal)?;
return Ok(true);
}
let mut current = BTreeMap::new();
let mut conflicts = Vec::new();
for (path, before) in &original {
@@ -995,6 +1206,342 @@ fn recover_with_hook(
mod transaction_tests {
use super::*;
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_preserves_competing_destinations_and_our_stage() {
use std::os::unix::{fs::MetadataExt, fs::symlink};
for kind in ["empty directory", "nonempty directory", "file", "symlink"] {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our complete metadata").unwrap();
// Simulate a competitor arriving after the caller's absence check.
assert!(!journal.exists());
match kind {
"empty directory" => fs::create_dir(&journal).unwrap(),
"nonempty directory" => {
fs::create_dir(&journal).unwrap();
fs::write(journal.join("journal.json"), b"competitor metadata").unwrap();
}
"file" => fs::write(&journal, b"competitor file").unwrap(),
"symlink" => symlink("absent competitor target", &journal).unwrap(),
_ => unreachable!(),
}
let before = fs::symlink_metadata(&journal).unwrap();
let error = publish_journal(&stage, &journal).unwrap_err();
assert!(
matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(libc::EEXIST)),
"{kind} must refuse publication"
);
let after = fs::symlink_metadata(&journal).unwrap();
assert_eq!((before.dev(), before.ino()), (after.dev(), after.ino()));
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our complete metadata"
);
match kind {
"empty directory" => assert_eq!(fs::read_dir(&journal).unwrap().count(), 0),
"nonempty directory" => assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"competitor metadata"
),
"file" => assert_eq!(fs::read(&journal).unwrap(), b"competitor file"),
"symlink" => assert_eq!(
fs::read_link(&journal).unwrap(),
Path::new("absent competitor target")
),
_ => unreachable!(),
}
}
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_preserves_raw_path_bytes_and_rejects_nul() {
use std::{ffi::OsStr, os::unix::ffi::OsStrExt};
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join(OsStr::from_bytes(b"stage-\xff"));
let journal = directory.path().join(OsStr::from_bytes(b"journal-\xfe"));
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"complete metadata").unwrap();
for (a, b) in [
(Path::new("bad\0path"), journal.as_path()),
(stage.as_path(), Path::new("bad\0path")),
] {
let error = rename_journal_without_replacement(a, b).unwrap_err();
assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
assert!(stage.join("journal.json").exists());
assert!(!journal.exists());
}
publish_journal(&stage, &journal).unwrap();
assert!(!stage.exists());
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"complete metadata"
);
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_has_one_winner_for_concurrent_stages() {
use std::sync::Barrier;
let directory = tempfile::tempdir().unwrap();
let journal = directory.path().join("journal");
let stages = [
directory.path().join("stage-a"),
directory.path().join("stage-b"),
];
for (i, stage) in stages.iter().enumerate() {
fs::create_dir(stage).unwrap();
fs::write(stage.join("journal.json"), format!("complete writer {i}")).unwrap();
}
let barrier = Barrier::new(2);
let results = std::thread::scope(|scope| {
let journal = &journal;
let barrier = &barrier;
let handles: Vec<_> = stages
.iter()
.map(|stage| {
scope.spawn(move || {
assert!(!journal.exists());
barrier.wait();
publish_journal(stage, journal)
})
})
.collect();
handles
.into_iter()
.map(|handle| handle.join().unwrap())
.collect::<Vec<_>>()
});
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
for (i, result) in results.into_iter().enumerate() {
match result {
Ok(()) => {
assert!(!stages[i].exists());
assert_eq!(
fs::read_to_string(journal.join("journal.json")).unwrap(),
format!("complete writer {i}")
);
}
Err(SourceFileError::Io { path, source }) => {
assert_eq!(path, journal);
assert_eq!(source.raw_os_error(), Some(libc::EEXIST));
assert_eq!(
fs::read_to_string(stages[i].join("journal.json")).unwrap(),
format!("complete writer {i}")
);
}
Err(error) => panic!("unexpected publication error: {error}"),
}
}
}
#[cfg(target_os = "linux")]
#[test]
#[ignore = "requires strace renameat2 error injection and OA_TEST_RENAMEAT2_ERRNO"]
fn linux_journal_publication_injected_unsupported_error_is_fail_closed() {
let errno: i32 = std::env::var("OA_TEST_RENAMEAT2_ERRNO")
.expect("set OA_TEST_RENAMEAT2_ERRNO to the injected Linux errno")
.parse()
.unwrap();
assert!(matches!(
errno,
libc::ENOSYS | libc::EINVAL | libc::EOPNOTSUPP
));
let (directory, snapshot, compiled) = fixture();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
let original: Vec<_> = snapshot
.bundle
.files
.keys()
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
.collect();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(
phase,
SavePhase::AfterPublish | SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_)
) {
panic!("unsupported no-replace publication must stop before ownership transfer");
}
Ok(())
})
.unwrap_err();
assert!(matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(errno)));
assert!(!journal.exists());
for (path, bytes) in original {
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
}
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
#[cfg(target_os = "linux")]
#[test]
fn linux_journal_publication_refusal_never_starts_model_writes() {
let (directory, snapshot, compiled) = fixture();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
let original: Vec<_> = snapshot
.bundle
.files
.keys()
.map(|path| (path.clone(), fs::read(directory.path().join(path)).unwrap()))
.collect();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
match phase {
SavePhase::BeforePublish => fs::create_dir(&journal).unwrap(),
SavePhase::AfterPublish
| SavePhase::AfterWriteMarker
| SavePhase::AfterReplace(_) => {
panic!("a rejected publisher must not obtain ownership or write source files")
}
_ => {}
}
Ok(())
})
.unwrap_err();
assert!(matches!(error, SourceFileError::Io { path, source }
if path == journal && source.raw_os_error() == Some(libc::EEXIST)));
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
assert!(!journal.join(WRITE_STARTED).exists());
for (path, bytes) in original {
assert_eq!(fs::read(directory.path().join(path)).unwrap(), bytes);
}
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
#[cfg(windows)]
#[test]
fn journal_publication_does_not_replace_empty_target_created_after_precheck() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our journal").unwrap();
let mut called = false;
let result = publish_journal_with_retry(
&stage,
&journal,
|a, b| {
assert!(!called);
called = true;
fs::create_dir(b).unwrap();
rename_journal_without_replacement(a, b)
},
|_| {},
);
assert!(result.is_err());
assert!(called);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our journal"
);
assert_eq!(fs::read_dir(&journal).unwrap().count(), 0);
assert!(
matches!(rename_journal_without_replacement(Path::new("bad\0path"), &journal), Err(error) if error.kind() == io::ErrorKind::InvalidInput)
);
}
#[test]
fn journal_publication_retries_only_transient_windows_errors_before_transferring_ownership() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"complete staged metadata").unwrap();
let mut errors = [5, 32, 33].into_iter();
let mut waits = vec![];
publish_journal_with_retry(
&stage,
&journal,
|a, b| {
assert!(a.join("journal.json").exists());
assert!(!b.exists());
match errors.next() {
Some(code) => Err(io::Error::from_raw_os_error(code)),
None => fs::rename(a, b),
}
},
|delay| waits.push(delay.as_millis()),
)
.unwrap();
assert_eq!(waits, [5, 10, 25]);
assert!(!stage.exists());
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"complete staged metadata"
);
}
#[test]
fn journal_publication_exhaustion_and_permanent_errors_keep_stage_and_report_original_path() {
for (code, expected_calls, expected_wait) in [(5, 7, 390), (123, 1, 0)] {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"before publication").unwrap();
let (mut calls, mut waited) = (0, 0);
let error = publish_journal_with_retry(
&stage,
&journal,
|_, _| {
calls += 1;
Err(io::Error::from_raw_os_error(code))
},
|delay| waited += delay.as_millis(),
)
.unwrap_err();
assert_eq!((calls, waited), (expected_calls, expected_wait));
assert!(
matches!(error, SourceFileError::Io { path, source } if path == journal && source.raw_os_error() == Some(code))
);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"before publication"
);
assert!(!journal.exists());
}
}
#[test]
fn journal_publication_never_replaces_another_journal_that_appears_during_retry() {
let directory = tempfile::tempdir().unwrap();
let stage = directory.path().join("stage");
let journal = directory.path().join("journal");
fs::create_dir(&stage).unwrap();
fs::write(stage.join("journal.json"), b"our staged journal").unwrap();
let mut calls = 0;
let error = publish_journal_with_retry(
&stage,
&journal,
|_, _| {
calls += 1;
Err(io::Error::from_raw_os_error(32))
},
|_| {
fs::create_dir(&journal).unwrap();
fs::write(journal.join("journal.json"), b"other save owns this").unwrap();
},
)
.unwrap_err();
assert_eq!(calls, 1);
assert!(matches!(error, SourceFileError::RecoveryRequired(path) if path == journal));
assert_eq!(
fs::read(journal.join("journal.json")).unwrap(),
b"other save owns this"
);
assert_eq!(
fs::read(stage.join("journal.json")).unwrap(),
b"our staged journal"
);
}
fn fixture() -> (tempfile::TempDir, DiskProject, CompiledSourceProject) {
let directory = tempfile::tempdir().unwrap();
fs::write(
@@ -1014,6 +1561,160 @@ mod transaction_tests {
(directory, snapshot, compiled)
}
#[test]
fn unwritten_journal_never_rolls_back_a_completed_writer_with_identical_after_bytes() {
for interrupt_retirement in [false, true] {
let (directory, snapshot, compiled) = fixture();
let error = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
match phase {
SavePhase::BeforePublish => {
// A competing save finishes during our publication wait.
save_project(&snapshot, &compiled, ProjectLimits::default()).unwrap();
}
SavePhase::BeforeRetire if interrupt_retirement => {
return Err(io::Error::other("injected no-write retirement failure"));
}
SavePhase::AfterWriteMarker | SavePhase::AfterReplace(_) => {
panic!("losing writer must not start writes")
}
_ => {}
}
Ok(())
})
.unwrap_err();
assert!(matches!(
error,
SourceFileError::Conflict(_) | SourceFileError::RecoveryFailed { .. }
));
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert_eq!(journal.exists(), interrupt_retirement);
if interrupt_retirement {
assert!(!journal.join(WRITE_STARTED).exists());
// Explicit recovery after a failed retirement must also leave
// the completed other writer untouched, even matching after bytes.
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
}
assert!(!journal.exists());
}
}
#[test]
fn recovery_before_any_write_accepts_v2_marker_and_keeps_v1_rollback_compatibility() {
for version in [1, 2] {
let (directory, snapshot, compiled) = fixture();
let interrupted = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterWriteMarker) {
panic!("interrupted before atomic write");
}
Ok(())
});
});
assert!(interrupted.is_err());
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert_eq!(
fs::read(journal.join(WRITE_STARTED)).unwrap(),
WRITE_STARTED_BYTES
);
if version == 1 {
// A frozen v1 journal has no marker and means writes may have begun.
let path = journal.join("journal.json");
let mut metadata: Journal =
serde_json::from_slice(&fs::read(&path).unwrap()).unwrap();
metadata.version = 1;
fs::write(path, serde_json::to_vec(&metadata).unwrap()).unwrap();
fs::remove_file(journal.join(WRITE_STARTED)).unwrap();
for (path, bytes) in &compiled.bundle.files {
fs::write(directory.path().join(path), bytes).unwrap();
}
}
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
}
}
#[test]
fn interrupted_publication_without_write_marker_never_rolls_back_external_matching_bytes() {
let (directory, snapshot, compiled) = fixture();
let interrupted = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterPublish) {
panic!("interrupted before write marker");
}
Ok(())
});
});
assert!(interrupted.is_err());
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
assert!(!journal.join(WRITE_STARTED).exists());
for (path, bytes) in &compiled.bundle.files {
fs::write(directory.path().join(path), bytes).unwrap();
}
assert!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
)
.unwrap()
);
for (path, bytes) in &compiled.bundle.files {
assert_eq!(
fs::read_to_string(directory.path().join(path)).unwrap(),
*bytes
);
}
assert!(!journal.exists());
}
#[test]
fn malformed_v2_write_marker_refuses_recovery_without_changing_source_bytes() {
let (directory, snapshot, compiled) = fixture();
let _ = std::panic::catch_unwind(|| {
let _ = save_with_phases(&snapshot, &compiled, ProjectLimits::default(), |phase| {
if matches!(phase, SavePhase::AfterWriteMarker) {
panic!("interrupted");
}
Ok(())
});
});
let journal = journal_path(&snapshot.root, &snapshot.bundle.entry);
fs::write(journal.join(WRITE_STARTED), b"partial").unwrap();
assert!(matches!(
recover_project(
&directory.path().join("project.arcadia"),
ProjectLimits::default()
),
Err(SourceFileError::Invalid(_))
));
check_conflicts(&snapshot.root, &snapshot.expectations).unwrap();
assert!(journal.exists());
}
#[test]
fn partial_save_failures_restore_every_original_and_write_entry_last() {
for stop in 1..=3 {
+118 -7
View File
@@ -1,6 +1,8 @@
use crate::*;
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
mod communication;
pub(crate) use communication::review_endpoint_fact;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum TransitionDirection {
@@ -53,6 +55,30 @@ pub struct FieldResolution {
pub field: TransitionField,
pub decision: FieldDecision,
}
/// Technical endpoint choices are scoped to one OA exchange. Participant
/// correspondence remains in `mappings`, never in these port identities.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CommunicationPortChoice {
Reuse {
port: Id,
},
Create {
id: Id,
alias: String,
name: String,
description: String,
kind: ComponentPortKind,
orientation: ComponentPortOrientation,
},
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct CommunicationEndpointDecision {
pub source: Id,
pub source_port: CommunicationPortChoice,
pub target_port: CommunicationPortChoice,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct TransitionRequest {
@@ -63,6 +89,8 @@ pub struct TransitionRequest {
pub fields: Vec<TransitionField>,
pub resolutions: Vec<FieldResolution>,
pub destination_diagram: Option<Id>,
#[serde(default)]
pub communication_endpoints: Vec<CommunicationEndpointDecision>,
}
impl TransitionRequest {
pub fn new(direction: TransitionDirection, sources: Vec<Id>) -> Self {
@@ -74,6 +102,7 @@ impl TransitionRequest {
fields: vec![TransitionField::Name, TransitionField::Description],
resolutions: vec![],
destination_diagram: None,
communication_endpoints: vec![],
}
}
}
@@ -177,6 +206,7 @@ fn inferred_kind(kind: Kind, direction: TransitionDirection) -> Option<Kind> {
|| matches!(
k,
Kind::ExchangeItem
| Kind::ExchangeCategory
| Kind::DataType
| Kind::DataField
| Kind::Interface
@@ -255,6 +285,28 @@ fn mapped_relation(r: &Relation, direction: TransitionDirection) -> Option<Relat
items: items.clone(),
}
}
(
RelationBody::CommunicationMean {
oriented, items, ..
},
TransitionDirection::OaToSa,
) => RelationBody::BoundaryExchange {
source,
target,
oriented: *oriented,
items: items.clone(),
},
(
RelationBody::BoundaryExchange {
oriented, items, ..
},
TransitionDirection::SaToOa,
) => RelationBody::CommunicationMean {
source,
target,
oriented: *oriented,
items: items.clone(),
},
(
RelationBody::Allocation { .. }
| RelationBody::InterfaceItemAllocation { .. }
@@ -318,7 +370,7 @@ fn trace_kind(p: &Project, oa: Id, sa: Id) -> TraceKind {
_ => TraceKind::Manual,
},
_ => {
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
if matches!((p.relations.get(&oa),p.relations.get(&sa)),(Some(a),Some(b))if exchange_counterparts(a.kind(), b.kind()))
{
TraceKind::Exchange
} else {
@@ -349,11 +401,12 @@ fn sa_port_omissions(p: &Project) -> BTreeSet<Id> {
.collect();
loop {
let before = omitted.len();
for r in p
.relations
.values()
.filter(|r| r.kind() != RelationKind::FunctionalExchange)
{
for r in p.relations.values().filter(|r| {
!matches!(
r.kind(),
RelationKind::FunctionalExchange | RelationKind::BoundaryExchange
)
}) {
if crate::references::relation_references(r)
.iter()
.any(|id| omitted.contains(id))
@@ -499,6 +552,15 @@ pub fn plan_transition(
}
} else if let Some(r) = project.relations.get(&id) {
if request.direction == TransitionDirection::SaToOa
&& r.kind() == RelationKind::BoundaryExchange
{
selected.extend(
[r.source(), r.target()].into_iter().filter_map(|port| {
project.elements.get(&port).and_then(|e| e.parent)
}),
);
selected.extend(r.body.items());
} else if request.direction == TransitionDirection::SaToOa
&& let RelationBody::FunctionalExchange {
source,
target,
@@ -695,6 +757,8 @@ pub fn plan_transition(
}
}
let mut working = project.clone();
let communication_pairs =
communication::prepare_ports(project, &mut working, &targets, &mut plan);
let ordered_creations: Vec<_> = selected
.iter()
.filter(|id| creating.contains(id) && project.elements.contains_key(id))
@@ -764,6 +828,25 @@ pub fn plan_transition(
let mut created = r.clone();
created.id = target;
created.body = body;
// SA technical ports project through their reviewed owners; never
// place a port→participant pair in the global correspondence map.
if request.direction == TransitionDirection::SaToOa
&& r.kind() == RelationKind::BoundaryExchange
{
let owners = [r.source(), r.target()]
.map(|port| project.elements.get(&port).and_then(|e| e.parent));
if let [Some(a), Some(b)] = owners {
created.body.set_endpoints(a, b);
} else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Boundary endpoints need participant owners before communication transition.",
);
}
}
created.alias = working.fresh_alias(
&format!(
"{}_{}",
@@ -778,6 +861,21 @@ pub fn plan_transition(
*id = *mapped;
}
});
if request.direction == TransitionDirection::OaToSa
&& r.kind() == RelationKind::CommunicationMean
{
if let Some((a, b)) = communication_pairs.get(source) {
created.body.set_endpoints(*a, *b);
} else {
conflict(
&mut plan,
Some(*source),
Some(target),
None,
"Choose an explicit SA component-port pair for this communication means; participant mappings cannot stand in for ports.",
);
}
}
for dependency in crate::references::relation_references(&created) {
if dependency.is_nil()
&& request.direction == TransitionDirection::OaToSa
@@ -1139,9 +1237,22 @@ fn compatible_existing(
(Some(a), Some(b)) => compatible_kinds(a.kind(), b.kind(), direction),
_ => match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b)) => {
mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
(a.kind() == RelationKind::BoundaryExchange
&& b.kind() == RelationKind::BoundaryExchange)
|| mapped_relation(a, direction).is_some_and(|body| body.kind() == b.kind())
}
_ => false,
},
}
}
pub(crate) fn exchange_counterparts(oa: RelationKind, sa: RelationKind) -> bool {
matches!(
(oa, sa),
(RelationKind::Interaction, RelationKind::FunctionalExchange)
| (
RelationKind::CommunicationMean,
RelationKind::BoundaryExchange
)
)
}
@@ -0,0 +1,210 @@
use super::*;
fn port_id(choice: &CommunicationPortChoice) -> Id {
match choice {
CommunicationPortChoice::Reuse { port } => *port,
CommunicationPortChoice::Create { id, .. } => *id,
}
}
pub(super) fn prepare_ports(
original: &Project,
working: &mut Project,
targets: &BTreeMap<Id, Id>,
plan: &mut TransitionPlan,
) -> BTreeMap<Id, (Id, Id)> {
let mut pairs = BTreeMap::new();
for decision in plan.request.communication_endpoints.clone() {
let result = (|| -> Result<(Id, Id), String> {
if plan.request.direction != TransitionDirection::OaToSa {
return Err(
"SA endpoint choices apply only to OA→SA communication transition.".into(),
);
}
let means = original
.relations
.get(&decision.source)
.filter(|r| r.kind() == RelationKind::CommunicationMean)
.ok_or("Endpoint decision must select an OA communication means.")?;
if !plan.request.sources.contains(&means.id) && !targets.contains_key(&means.id) {
return Err("Endpoint decision is outside this transition selection.".into());
}
if pairs.contains_key(&decision.source) {
return Err("Choose one component-port pair per communication means.".into());
}
let mut pending = vec![];
for (participant, choice) in [
(means.source(), &decision.source_port),
(means.target(), &decision.target_port),
] {
let owner = targets.get(&participant).copied().ok_or("Review each OA participant's SA counterpart before selecting its component port.")?;
if working.elements.get(&owner).is_some_and(|e| {
e.layer() != Layer::Sa || !matches!(e.kind(), Kind::System | Kind::SystemActor)
}) {
return Err(
"Component-port owner must be the reviewed SA participant counterpart."
.into(),
);
}
match choice {
CommunicationPortChoice::Reuse { port } => {
let e = working
.elements
.get(port)
.ok_or("Selected component port does not exist.")?;
if e.parent != Some(owner)
|| e.layer() != Layer::Sa
|| !matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
{
return Err("Selected component port must belong to the reviewed SA participant and have an explicit component contract.".into());
}
}
CommunicationPortChoice::Create {
id,
alias,
name,
description,
kind,
orientation,
} => {
if id.is_nil()
|| working.contains(*id)
|| pending.iter().any(|e: &Element| e.id == *id)
{
return Err("New component-port identity must be unused; choose Reuse for an existing port.".into());
}
pending.push(Element {
id: *id,
alias: alias.clone(),
name: name.clone(),
description: description.clone(),
parent: Some(owner),
body: ElementBody::shared_in(
Layer::Sa,
SharedBody::Port {
contract: PortContract::Component {
kind: *kind,
orientation: *orientation,
},
legacy: None,
},
),
});
}
}
}
let pair = (
port_id(&decision.source_port),
port_id(&decision.target_port),
);
if pair.0 == pair.1 || targets.get(&means.source()) == targets.get(&means.target()) {
return Err("Communication counterparts must have different participant owners; review the participant boundary mapping.".into());
}
for e in pending {
working.elements.insert(e.id, e.clone());
plan.commands.push(Command::CreateElement(e));
}
Ok(pair)
})();
match result {
Ok(pair) => {
pairs.insert(decision.source, pair);
}
Err(message) => conflict(
plan,
Some(decision.source),
targets.get(&decision.source).copied(),
None,
message,
),
}
}
pairs
}
/// Adapt only the transferred endpoint value. Reviewed baseline facts continue
/// to retain the actual participant/port identities on their respective sides.
pub(crate) fn review_endpoint_fact(
project: &Project,
request: &TransitionRequest,
map: &BTreeMap<Id, Id>,
source: Id,
target: Id,
fact: &FacetReview,
) -> Result<Option<FacetReview>, String> {
let (Some(a), Some(b)) = (
project.relations.get(&source),
project.relations.get(&target),
) else {
return Ok(None);
};
let forward =
a.kind() == RelationKind::CommunicationMean && b.kind() == RelationKind::BoundaryExchange;
let reverse =
a.kind() == RelationKind::BoundaryExchange && b.kind() == RelationKind::CommunicationMean;
if !forward && !reverse {
return Ok(None);
}
let (endpoint, is_source) = match fact {
FacetReview::Relation(RelationReview::Source(_)) => (a.source(), true),
FacetReview::Relation(RelationReview::Target(_)) => (a.target(), false),
_ => return Ok(None),
};
let mapped = if forward {
let decision = request.communication_endpoints.iter().find(|d| d.source == source)
.ok_or("Taking an OA endpoint requires this exchange's explicit SA component-port decision.")?;
let port = port_id(if is_source {
&decision.source_port
} else {
&decision.target_port
});
let expected_owner = map.get(&endpoint).ok_or(
"Review the participant counterpart before taking its communication endpoint.",
)?;
let e = project
.elements
.get(&port)
.ok_or("Selected communication endpoint port does not exist.")?;
if e.parent != Some(*expected_owner)
|| e.layer() != Layer::Sa
|| !matches!(
crate::ports::port_contract(e),
Some(PortContract::Component { .. })
)
{
return Err(
"Selected component port disagrees with the reviewed participant counterpart."
.into(),
);
}
port
} else {
let owner = project
.elements
.get(&endpoint)
.and_then(|e| e.parent)
.ok_or("SA boundary port needs a participant owner.")?;
let participant = *map.get(&owner).ok_or(
"Review the SA port owner's OA participant counterpart before taking this endpoint.",
)?;
if !project.elements.get(&participant).is_some_and(|e| {
e.layer() == Layer::Oa && matches!(e.kind(), Kind::Entity | Kind::Actor)
}) {
return Err("Boundary endpoint owner must map to an OA entity or actor.".into());
}
participant
};
let mut result = fact.clone();
if let FacetReview::Relation(RelationReview::Source(values) | RelationReview::Target(values)) =
&mut result
{
match request.direction.source_layer() {
Layer::Oa => values.oa = Observed::Value(mapped),
Layer::Sa => values.sa = Observed::Value(mapped),
}
}
Ok(Some(result))
}
+172 -16
View File
@@ -387,6 +387,23 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
if let Some(shared) = e.shared() {
match shared {
SharedBody::ExchangeCategory { family, exchanges } => {
for issue in crate::view_options::validate_category(
p,
e.layer(),
e.parent,
*family,
exchanges,
) {
error(
&mut out,
e.id,
"exchange_category",
issue.field,
issue.message,
);
}
}
SharedBody::DataType { .. }
| SharedBody::DataField { .. }
| SharedBody::Interface { .. }
@@ -601,6 +618,14 @@ pub fn validate(p: &Project) -> Vec<Issue> {
}
text(&mut out, d.id, "name", &d.name);
text(&mut out, d.id, "description", &d.description);
let option_issues = if d.projection == ProjectionKind::Traceability {
crate::view_options::validate_options_cross_layer(p, &d.view_options)
} else {
crate::view_options::validate_options(p, d.layer, &d.view_options)
};
for issue in option_issues {
error(&mut out, d.id, "view_options", issue.field, issue.message);
}
if let Some(machine) = d.projection.state_machine()
&& !p
.elements
@@ -1256,40 +1281,149 @@ pub fn semantic_representation_compatible(
pub fn lifeline_matches_endpoint(p: &Project, line: Id, endpoint: Id) -> Option<bool> {
semantic_representation_compatible(p, represented(p, line)?, endpoint)
}
/// Effective semantic direction, including a Reply that reuses its Call exchange.
/// An incomplete reply correlation and a local timer have no inferred endpoints.
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
/// A single shared answer for scenario admission, drawing, and endpoint pickers.
/// Incomplete or ambiguous bindings never choose an arbitrary direction.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum MessageEndpointResolution {
NotApplicable,
Incomplete,
Resolved { source: Id, target: Id },
Ambiguous,
Conflict,
}
pub fn resolve_message_endpoints(p: &Project, body: &OccurrenceBody) -> MessageEndpointResolution {
use MessageEndpointResolution as R;
let OccurrenceBody::Exchange {
exchange: Some(exchange),
message_kind,
reply_to,
sender,
receiver,
..
} = body
else {
return None;
return R::NotApplicable;
};
if matches!(message_kind, MessageKind::Timer { .. }) {
return None;
return R::NotApplicable;
}
let Some(relation) = p.relations.get(exchange) else {
return R::Incomplete;
};
let mut candidates = vec![relation.body.endpoints()];
// A mapped unoriented SA boundary retains the OA communication's possible
// directions. Fixed oriented/legacy exchange behavior remains unchanged.
let communication = matches!(
relation.body,
RelationBody::CommunicationMean { .. }
| RelationBody::BoundaryExchange {
oriented: false,
..
}
);
if matches!(
relation.body,
RelationBody::CommunicationMean {
oriented: false,
..
} | RelationBody::BoundaryExchange {
oriented: false,
..
}
) {
candidates.push((relation.target(), relation.source()));
}
if matches!(
relation.body,
RelationBody::BoundaryExchange {
oriented: false,
..
}
) {
candidates.retain(|(source, target)| {
match (
p.elements.get(source).and_then(crate::ports::port_contract),
p.elements.get(target).and_then(crate::ports::port_contract),
) {
(
Some(a @ PortContract::Component { .. }),
Some(b @ PortContract::Component { .. }),
) => {
crate::ports::component_allows(a, true)
&& crate::ports::component_allows(b, false)
}
// Legacy direction contracts remain fixed even for an
// unoriented declaration; do not reinterpret old ports.
_ => (*source, *target) == relation.body.endpoints(),
}
});
}
let relation = p.relations.get(exchange)?;
if message_kind == &MessageKind::Reply {
let Some(reply_to) = reply_to else {
return R::Incomplete;
};
let Some(SharedBody::Occurrence {
body:
OccurrenceBody::Exchange {
call_body @ OccurrenceBody::Exchange {
message_kind: MessageKind::Call,
exchange: Some(call_exchange),
..
},
..
}) = p.elements.get(&(*reply_to)?).and_then(Element::shared)
}) = p.elements.get(reply_to).and_then(Element::shared)
else {
return None;
return R::Incomplete;
};
if call_exchange == exchange {
return Some((relation.target(), relation.source()));
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
return R::Incomplete;
};
candidates = vec![(target, source)];
} else if communication {
let R::Resolved { source, target } = resolve_message_endpoints(p, call_body) else {
return R::Incomplete;
};
let owner = |id| {
p.elements
.get(&id)
.filter(|e| e.kind() == Kind::Port)
.and_then(|e| e.parent)
.unwrap_or(id)
};
candidates.retain(|(a, b)| {
semantic_representation_compatible(p, owner(*a), owner(target)) == Some(true)
&& semantic_representation_compatible(p, owner(*b), owner(source)) == Some(true)
});
}
}
Some(relation.body.endpoints())
if communication {
candidates.retain(|(a, b)| {
sender.is_none_or(|line| lifeline_matches_endpoint(p, line, *a) != Some(false))
&& receiver.is_none_or(|line| lifeline_matches_endpoint(p, line, *b) != Some(false))
});
if candidates.is_empty() {
return R::Conflict;
}
if sender.and_then(|line| represented(p, line)).is_none()
|| receiver.and_then(|line| represented(p, line)).is_none()
{
return R::Incomplete;
}
if candidates.len() > 1 {
return R::Ambiguous;
}
}
let Some((source, target)) = candidates.first().copied() else {
return R::Conflict;
};
R::Resolved { source, target }
}
/// Compatibility adapter: only a resolved direction has inferred endpoints.
pub fn message_endpoints(p: &Project, body: &OccurrenceBody) -> Option<(Id, Id)> {
match resolve_message_endpoints(p, body) {
MessageEndpointResolution::Resolved { source, target } => Some((source, target)),
_ => None,
}
}
/// Only report order guaranteed by a common sequence or an enclosing branch path.
/// A call in one alternative does not become an unconditional call after the fragment.
@@ -1442,7 +1576,9 @@ fn validate_message(p: &Project, e: &Element, body: &OccurrenceBody, out: &mut V
ScenarioViewpoint::Functional => relation.kind() == RelationKind::FunctionalExchange,
ScenarioViewpoint::ParticipantInteraction => matches!(
relation.kind(),
RelationKind::Interaction | RelationKind::BoundaryExchange
RelationKind::Interaction
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
),
ScenarioViewpoint::BoundaryInterface => matches!(
relation.kind(),
@@ -1922,6 +2058,7 @@ fn validate_occurrence(p: &Project, e: &Element, body: &OccurrenceBody, out: &mu
RelationKind::Interaction
| RelationKind::FunctionalExchange
| RelationKind::BoundaryExchange
| RelationKind::CommunicationMean
) => {}
_ => error(
out,
@@ -2136,18 +2273,20 @@ fn validate_relation(p: &Project, r: &Relation, out: &mut Vec<Issue>) {
if let RelationBody::ExchangeAllocation { .. } = r.body {
match (p.relations.get(&source), p.relations.get(&target)) {
(Some(a), Some(b))
if a.kind() == RelationKind::BoundaryExchange
if ((a.kind() == RelationKind::BoundaryExchange
&& matches!(
b.kind(),
RelationKind::FunctionalExchange | RelationKind::Interaction
)
))
|| (a.kind() == RelationKind::CommunicationMean
&& b.kind() == RelationKind::Interaction))
&& p.layer_of(source) == p.layer_of(target) => {}
_ => error(
out,
r.id,
"exchange_allocation",
"endpoints",
"Exchange allocation relates a boundary exchange to a work exchange in the same layer.",
"Exchange allocation relates communication means to an OA interaction, or a boundary exchange to a work exchange, in the same layer.",
),
};
validate_exchange_allocation(p, r, out);
@@ -2398,7 +2537,7 @@ pub fn trace_permits(p: &Project, t: &Trace) -> bool {
_ => false,
},
_ => {
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&a.kind()==RelationKind::Interaction&&b.kind()==RelationKind::FunctionalExchange)
matches!((p.relations.get(&t.oa),p.relations.get(&t.sa)),(Some(a),Some(b))if t.kind==TraceKind::Exchange&&crate::transitions::exchange_counterparts(a.kind(), b.kind()))
}
}
}
@@ -2739,6 +2878,23 @@ fn validate_occurrence_context(
OccurrenceBody::Exchange {
sender, receiver, ..
} => {
match resolve_message_endpoints(p, body) {
MessageEndpointResolution::Conflict => error(
out,
e.id,
"scenario_endpoint",
"sender",
"Sender and receiver must agree with one permitted communication direction and the correlated Call.",
),
MessageEndpointResolution::Ambiguous => warning(
out,
e.id,
"ambiguous_message_direction",
"sender",
"Bind explicit participant lifelines to resolve the communication direction; shared roles allow both directions.",
),
_ => {}
}
if let Some((source, target)) = message_endpoints(p, body) {
check(out, *sender, Some(source), "sender");
check(out, *receiver, Some(target), "receiver");
+2 -111
View File
@@ -138,119 +138,10 @@ fn work_exchange(kind: RelationKind) -> bool {
/// Only ordered, owned occurrences contribute. Each identity is visited once,
/// including reused containers; this remains bounded even on malformed drafts.
fn included_evidence(p: &Project) -> (BTreeSet<Id>, BTreeSet<Id>) {
let mut pending: Vec<_> = p
.elements
.values()
.filter(|e| {
matches!(
e.kind(),
Kind::Process | Kind::FunctionalChain | Kind::Scenario
)
})
.map(|e| e.id)
.collect();
let mut seen = BTreeSet::new();
let mut work = BTreeSet::new();
let mut exchanges = BTreeSet::new();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
let Some(element) = p.elements.get(&id) else {
continue;
};
if let Some(steps) = element.steps() {
pending.extend(
steps
.iter()
.filter(|step| {
p.elements.get(step).is_some_and(|child| {
child.kind() == Kind::Occurrence
&& child.parent == Some(id)
&& child.layer() == element.layer()
})
})
.copied(),
);
}
let Some(SharedBody::Occurrence { body, .. }) = element.shared() else {
continue;
};
match body {
OccurrenceBody::Action {
action: Some(action),
..
} => {
if p.elements
.get(action)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
{
work.insert(*action);
}
}
OccurrenceBody::Exchange {
exchange: Some(exchange),
..
} => {
if let Some(relation) = p.relations.get(exchange)
&& work_exchange(relation.kind())
&& [relation.source(), relation.target()].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
})
{
exchanges.insert(*exchange);
work.extend([relation.source(), relation.target()]);
}
}
OccurrenceBody::Fragment { branches, .. } => {
pending.extend(
branches
.iter()
.filter(|branch| {
p.elements.get(branch).is_some_and(|child| {
child.parent == Some(id)
&& child.layer() == element.layer()
&& matches!(
child.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
})
})
.copied(),
);
}
OccurrenceBody::SequenceReference {
sequence: Some(sequence),
} => {
if let Some(owner) = element.parent.and_then(|id| p.elements.get(&id))
&& matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)
&& p.elements.get(sequence).is_some_and(|target| {
target.kind() == owner.kind() && target.layer() == element.layer()
})
{
pending.push(*sequence);
}
}
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
..
} if p.elements.get(scenario).is_some_and(|target| {
target.kind() == Kind::Scenario && target.layer() == element.layer()
}) =>
{
pending.push(*scenario);
}
_ => {}
}
}
let crate::view_options::BehaviorEvidence { work, exchanges } =
crate::view_options::all_behavior_evidence(p);
(work, exchanges)
}
#[cfg(test)]
mod tests {
use super::*;
+711
View File
@@ -0,0 +1,711 @@
//! Persisted view intent and pure visibility decisions over actual model identities.
//! Build `ViewIndex` once per accepted model revision. Preparing a view performs
//! set unions/reachability once; testing an edge never walks the model.
use crate::{Id, Kind, Layer, OccurrenceBody, Project, RelationKind, SharedBody};
use serde::{Deserialize, Serialize};
use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExchangeFamily {
Work,
Participant,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum EdgeKind {
Relation(RelationKind),
Trace,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum RelationKinds {
#[default]
All,
Only(Vec<EdgeKind>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum CategorySelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub enum ContextSelection {
#[default]
All,
Any(Vec<Id>),
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum Coverage {
#[default]
All,
InBehavior,
OutsideBehavior,
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
pub enum ExchangeLabels {
#[default]
Name,
Items,
NameAndItems,
}
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(default, deny_unknown_fields)]
pub struct ViewOptions {
pub hidden_edges: Vec<Id>,
pub relation_kinds: RelationKinds,
pub work_categories: CategorySelection,
pub participant_categories: CategorySelection,
pub coverage: Coverage,
pub work_contexts: ContextSelection,
pub exchange_labels: ExchangeLabels,
}
impl ViewOptions {
pub fn visit_references(&mut self, mut visit: impl FnMut(&mut Id)) {
for id in &mut self.hidden_edges {
visit(id);
}
for selection in [&mut self.work_categories, &mut self.participant_categories] {
if let CategorySelection::Any(ids) = selection {
for id in ids {
visit(id);
}
}
}
if let ContextSelection::Any(ids) = &mut self.work_contexts {
for id in ids {
visit(id);
}
}
}
/// Removing the last selected identity retains the explicit empty selector.
pub fn retain_references(&mut self, mut keep: impl FnMut(&Id) -> bool) {
self.hidden_edges.retain(&mut keep);
for selection in [&mut self.work_categories, &mut self.participant_categories] {
if let CategorySelection::Any(ids) = selection {
ids.retain(&mut keep);
}
}
if let ContextSelection::Any(ids) = &mut self.work_contexts {
ids.retain(keep);
}
}
}
/// Stable field names allow core/source adapters to locate the offending option.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct OptionError {
pub field: &'static str,
pub reference: Option<Id>,
pub message: String,
}
pub fn exchange_family(kind: RelationKind) -> Option<ExchangeFamily> {
match kind {
RelationKind::Interaction | RelationKind::FunctionalExchange => Some(ExchangeFamily::Work),
RelationKind::BoundaryExchange | RelationKind::CommunicationMean => {
Some(ExchangeFamily::Participant)
}
_ => None,
}
}
fn category_member(p: &Project, layer: Layer, family: ExchangeFamily, id: Id) -> bool {
p.relations.get(&id).is_some_and(|r| {
p.layer_of(id) == Some(layer)
&& matches!(
(family, layer, r.kind()),
(ExchangeFamily::Work, Layer::Oa, RelationKind::Interaction)
| (
ExchangeFamily::Work,
Layer::Sa,
RelationKind::FunctionalExchange
)
| (
ExchangeFamily::Participant,
Layer::Oa,
RelationKind::CommunicationMean
)
| (
ExchangeFamily::Participant,
_,
RelationKind::BoundaryExchange
)
)
})
}
fn context_kind(kind: Kind) -> bool {
matches!(kind, Kind::Process | Kind::FunctionalChain | Kind::Scenario)
}
fn edge_layers(p: &Project, id: Id) -> Vec<Layer> {
if p.relations.contains_key(&id) {
p.layer_of(id).into_iter().collect()
} else if p.traces.get(&id).is_some_and(|trace| {
p.layer_of(trace.oa) == Some(Layer::Oa) && p.layer_of(trace.sa) == Some(Layer::Sa)
}) {
vec![Layer::Oa, Layer::Sa]
} else {
vec![]
}
}
fn error(field: &'static str, reference: Option<Id>, message: &str) -> OptionError {
OptionError {
field,
reference,
message: message.into(),
}
}
fn distinct_ids(field: &'static str, ids: &[Id], out: &mut Vec<OptionError>) {
let mut seen = BTreeSet::new();
for id in ids {
if !seen.insert(*id) {
out.push(error(
field,
Some(*id),
"Each identity must occur only once.",
));
}
}
}
pub fn validate_category(
p: &Project,
layer: Layer,
parent: Option<Id>,
family: ExchangeFamily,
exchanges: &[Id],
) -> Vec<OptionError> {
let mut out = vec![];
if parent.is_some() {
out.push(error(
"parent",
parent,
"Exchange categories belong at the layer root.",
));
}
distinct_ids("exchanges", exchanges, &mut out);
for id in exchanges {
if !category_member(p, layer, family, *id) {
out.push(error(
"exchanges",
Some(*id),
"Choose an existing exchange of this category's family and layer.",
));
}
}
out
}
pub fn validate_options(p: &Project, layer: Layer, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, Some(layer), options)
}
/// Traceability views admit references from either architecture layer.
pub fn validate_options_cross_layer(p: &Project, options: &ViewOptions) -> Vec<OptionError> {
validate_options_in(p, None, options)
}
fn validate_options_in(
p: &Project,
layer: Option<Layer>,
options: &ViewOptions,
) -> Vec<OptionError> {
let mut out = vec![];
distinct_ids("hidden_edges", &options.hidden_edges, &mut out);
for id in &options.hidden_edges {
let layers = edge_layers(p, *id);
if layers.is_empty() || layer.is_some_and(|layer| !layers.contains(&layer)) {
out.push(error(
"hidden_edges",
Some(*id),
"Choose an existing relationship or applicable trace in this view's layer.",
));
}
}
if let RelationKinds::Only(kinds) = &options.relation_kinds {
for (index, kind) in kinds.iter().enumerate() {
if kinds[..index].contains(kind) {
out.push(error(
"relation_kinds",
None,
"Each relationship-line kind must occur only once.",
));
}
}
}
for (field, selection, family) in [
(
"work_categories",
&options.work_categories,
ExchangeFamily::Work,
),
(
"participant_categories",
&options.participant_categories,
ExchangeFamily::Participant,
),
] {
if let CategorySelection::Any(ids) = selection {
distinct_ids(field, ids, &mut out);
for id in ids {
if !p.elements.get(id).is_some_and(|e| {
layer.is_none_or(|layer| e.layer() == layer)
&& matches!(e.shared(), Some(SharedBody::ExchangeCategory { family: f, .. }) if *f == family)
}) {
out.push(error(field, Some(*id), "Choose a category of this selector's family and layer."));
}
}
}
}
if let ContextSelection::Any(ids) = &options.work_contexts {
distinct_ids("work_contexts", ids, &mut out);
for id in ids {
if !p.elements.get(id).is_some_and(|e| {
layer.is_none_or(|layer| e.layer() == layer) && context_kind(e.kind())
}) {
out.push(error(
"work_contexts",
Some(*id),
"Choose a Process, Functional chain or Scenario in this view's layer.",
));
}
}
}
out
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct BehaviorEvidence {
pub work: BTreeSet<Id>,
pub exchanges: BTreeSet<Id>,
}
/// Explicit inclusion/reuse graph, independent of semantic-parent descendants.
#[derive(Debug, Clone, Default)]
struct BehaviorIndex {
roots: BTreeMap<Id, Layer>,
next: BTreeMap<Id, Vec<Id>>,
facts: BTreeMap<Id, BehaviorEvidence>,
}
impl BehaviorIndex {
fn new(p: &Project) -> Self {
let mut index = Self::default();
for element in p.elements.values() {
let id = element.id;
if context_kind(element.kind()) {
index.roots.insert(id, element.layer());
}
let next = index.next.entry(id).or_default();
if let Some(steps) = element.steps() {
next.extend(
steps
.iter()
.filter(|step| {
p.elements.get(step).is_some_and(|child| {
child.kind() == Kind::Occurrence
&& child.parent == Some(id)
&& child.layer() == element.layer()
})
})
.copied(),
);
}
let Some(SharedBody::Occurrence { body, .. }) = element.shared() else {
continue;
};
match body {
OccurrenceBody::Action {
action: Some(work), ..
} => {
if p.elements
.get(work)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
{
index.facts.entry(id).or_default().work.insert(*work);
}
}
OccurrenceBody::Exchange {
exchange: Some(exchange),
..
} => {
if let Some(r) = p.relations.get(exchange)
&& exchange_family(r.kind()) == Some(ExchangeFamily::Work)
&& [r.source(), r.target()].iter().all(|id| {
p.elements
.get(id)
.is_some_and(|e| e.kind().is_work() && e.layer() == element.layer())
})
{
let facts = index.facts.entry(id).or_default();
facts.exchanges.insert(*exchange);
facts.work.extend([r.source(), r.target()]);
}
}
OccurrenceBody::Fragment { branches, .. } => {
next.extend(
branches
.iter()
.filter(|branch| {
p.elements.get(branch).is_some_and(|child| {
child.parent == Some(id)
&& child.layer() == element.layer()
&& matches!(
child.shared(),
Some(SharedBody::Occurrence {
body: OccurrenceBody::Branch { .. },
..
})
)
})
})
.copied(),
);
}
OccurrenceBody::SequenceReference {
sequence: Some(sequence),
} => {
if let Some(owner) = element.parent.and_then(|id| p.elements.get(&id))
&& matches!(owner.kind(), Kind::Process | Kind::FunctionalChain)
&& p.elements.get(sequence).is_some_and(|target| {
target.kind() == owner.kind() && target.layer() == element.layer()
})
{
next.push(*sequence);
}
}
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
..
} if p.elements.get(scenario).is_some_and(|target| {
target.kind() == Kind::Scenario && target.layer() == element.layer()
}) =>
{
next.push(*scenario);
}
_ => {}
}
}
index
}
fn evidence(&self, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
let mut pending: Vec<_> = roots
.into_iter()
.filter(|id| self.roots.contains_key(id))
.collect();
let mut seen = BTreeSet::new();
let mut result = BehaviorEvidence::default();
while let Some(id) = pending.pop() {
if !seen.insert(id) {
continue;
}
if let Some(next) = self.next.get(&id) {
pending.extend(next);
}
if let Some(facts) = self.facts.get(&id) {
result.work.extend(&facts.work);
result.exchanges.extend(&facts.exchanges);
}
}
result
}
}
pub fn behavior_evidence(p: &Project, roots: impl IntoIterator<Item = Id>) -> BehaviorEvidence {
BehaviorIndex::new(p).evidence(roots)
}
pub fn all_behavior_evidence(p: &Project) -> BehaviorEvidence {
let index = BehaviorIndex::new(p);
index.evidence(index.roots.keys().copied())
}
#[derive(Debug, Clone)]
struct EdgeFact {
kind: EdgeKind,
family: Option<ExchangeFamily>,
layers: Vec<Layer>,
}
#[derive(Debug, Clone)]
struct CategoryFact {
layer: Layer,
family: ExchangeFamily,
exchanges: BTreeSet<Id>,
}
#[derive(Debug, Clone)]
pub struct ViewIndex {
edges: BTreeMap<Id, EdgeFact>,
categories: BTreeMap<Id, CategoryFact>,
behavior: BehaviorIndex,
global: BehaviorEvidence,
}
impl ViewIndex {
pub fn new(p: &Project) -> Self {
let behavior = BehaviorIndex::new(p);
let global = behavior.evidence(behavior.roots.keys().copied());
let mut edges: BTreeMap<_, _> = p
.relations
.values()
.map(|r| {
(
r.id,
EdgeFact {
kind: EdgeKind::Relation(r.kind()),
family: exchange_family(r.kind()),
layers: edge_layers(p, r.id),
},
)
})
.collect();
edges.extend(p.traces.keys().map(|id| {
(
*id,
EdgeFact {
kind: EdgeKind::Trace,
family: None,
layers: edge_layers(p, *id),
},
)
}));
let categories = p
.elements
.values()
.filter_map(|e| {
let SharedBody::ExchangeCategory { family, exchanges } = e.shared()? else {
return None;
};
Some((
e.id,
CategoryFact {
layer: e.layer(),
family: *family,
exchanges: exchanges
.iter()
.filter(|id| category_member(p, e.layer(), *family, **id))
.copied()
.collect(),
},
))
})
.collect();
Self {
edges,
categories,
behavior,
global,
}
}
pub fn all_evidence(&self) -> &BehaviorEvidence {
&self.global
}
/// Options must pass `validate_options` before model admission. Malformed
/// selectors are nevertheless fail-closed here, useful for draft previews.
pub fn prepare(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
self.prepare_in(layer, options, false)
}
fn prepare_in(
&self,
layer: Layer,
options: &ViewOptions,
cross_layer: bool,
) -> PreparedFilter<'_> {
let category_union = |selection: &CategorySelection, family| match selection {
CategorySelection::All => None,
CategorySelection::Any(ids) => Some(
ids.iter()
.filter_map(|id| self.categories.get(id))
.filter(|c| (cross_layer || c.layer == layer) && c.family == family)
.flat_map(|c| c.exchanges.iter().copied())
.collect(),
),
};
let contexts = match &options.work_contexts {
ContextSelection::All => None,
ContextSelection::Any(ids) => Some(
self.behavior
.evidence(
ids.iter()
.filter(|id| {
self.behavior
.roots
.get(id)
.is_some_and(|candidate| cross_layer || *candidate == layer)
})
.copied(),
)
.exchanges,
),
};
PreparedFilter {
index: self,
layer,
cross_layer,
hidden: options.hidden_edges.iter().copied().collect(),
kinds: options.relation_kinds.clone(),
coverage: options.coverage,
work_categories: category_union(&options.work_categories, ExchangeFamily::Work),
participant_categories: category_union(
&options.participant_categories,
ExchangeFamily::Participant,
),
contexts,
}
}
/// Traceability diagrams admit edges, categories and contexts in both layers.
pub fn prepare_cross_layer(&self, layer: Layer, options: &ViewOptions) -> PreparedFilter<'_> {
self.prepare_in(layer, options, true)
}
}
/// Projection reports why an actual edge cannot have a drawable endpoint pair.
/// Filtering does not perform anchoring, folding, layout or placement insertion.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum EdgeAvailability {
Visible,
OutsideProjection,
EndpointNotShown { source: bool, target: bool },
FoldedInternal,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum OmissionReason {
ExplicitHide,
RelationKind,
Category(ExchangeFamily),
Coverage,
WorkContext,
OutsideProjection,
EndpointNotShown { source: bool, target: bool },
FoldedInternal,
WrongLayer,
UnknownEdge,
}
pub struct PreparedFilter<'a> {
index: &'a ViewIndex,
layer: Layer,
cross_layer: bool,
hidden: BTreeSet<Id>,
kinds: RelationKinds,
coverage: Coverage,
work_categories: Option<BTreeSet<Id>>,
participant_categories: Option<BTreeSet<Id>>,
contexts: Option<BTreeSet<Id>>,
}
impl PreparedFilter<'_> {
pub fn includes(&self, edge: Id) -> bool {
self.reasons(edge, EdgeAvailability::Visible).is_empty()
}
pub fn reasons(&self, edge: Id, availability: EdgeAvailability) -> Vec<OmissionReason> {
let mut out = vec![];
if self.hidden.contains(&edge) {
out.push(OmissionReason::ExplicitHide);
}
match availability {
EdgeAvailability::Visible => {}
EdgeAvailability::OutsideProjection => out.push(OmissionReason::OutsideProjection),
EdgeAvailability::EndpointNotShown { source, target } => {
out.push(OmissionReason::EndpointNotShown { source, target })
}
EdgeAvailability::FoldedInternal => out.push(OmissionReason::FoldedInternal),
}
let Some(fact) = self.index.edges.get(&edge) else {
out.push(OmissionReason::UnknownEdge);
return out;
};
if !fact.layers.contains(&self.layer) && !(self.cross_layer && !fact.layers.is_empty()) {
out.push(OmissionReason::WrongLayer);
}
if let RelationKinds::Only(kinds) = &self.kinds
&& !kinds.contains(&fact.kind)
{
out.push(OmissionReason::RelationKind);
}
if let Some(family) = fact.family {
let categories = match family {
ExchangeFamily::Work => &self.work_categories,
ExchangeFamily::Participant => &self.participant_categories,
};
if categories.as_ref().is_some_and(|ids| !ids.contains(&edge)) {
out.push(OmissionReason::Category(family));
}
if family == ExchangeFamily::Work {
let covered = self.index.global.exchanges.contains(&edge);
if matches!(self.coverage, Coverage::InBehavior) && !covered
|| matches!(self.coverage, Coverage::OutsideBehavior) && covered
{
out.push(OmissionReason::Coverage);
}
if self
.contexts
.as_ref()
.is_some_and(|ids| !ids.contains(&edge))
{
out.push(OmissionReason::WorkContext);
}
}
}
out
}
}
impl ExchangeLabels {
pub fn label(self, p: &Project, edge: Id) -> String {
let name = p.named(edge);
let Some(relation) = p
.relations
.get(&edge)
.filter(|r| exchange_family(r.kind()).is_some())
else {
return name.into();
};
if self == Self::Name {
return name.into();
}
let items = if relation.body.items().is_empty() {
"No items".into()
} else {
relation
.body
.items()
.iter()
.map(|id| p.named(*id))
.collect::<Vec<_>>()
.join(", ")
};
if self == Self::Items {
items
} else {
format!("{name} · {items}")
}
}
}
#[cfg(test)]
#[path = "view_options_tests.rs"]
mod tests;
+620
View File
@@ -0,0 +1,620 @@
use super::*;
use crate::{FragmentOperator, MessageKind, RelationBody, Trace, TraceKind};
fn add(p: &mut Project, kind: Kind, layer: Layer, name: &str) -> Id {
p.add_element(kind, layer, name, None).unwrap()
}
fn occurrence(p: &mut Project, owner: Id, body: OccurrenceBody) -> Id {
let id = p
.add_element(
Kind::Occurrence,
p.elements[&owner].layer(),
"Contribution",
Some(owner),
)
.unwrap();
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::Occurrence {
body,
precondition: String::new(),
postcondition: String::new(),
};
if let Some(steps) = p.elements.get_mut(&owner).unwrap().steps_mut() {
steps.push(id);
}
id
}
fn exchange(id: Id) -> OccurrenceBody {
OccurrenceBody::Exchange {
exchange: Some(id),
sender: None,
receiver: None,
message_kind: MessageKind::Asynchronous,
reply_to: None,
values: vec![],
}
}
fn category(p: &mut Project, layer: Layer, family: ExchangeFamily, exchanges: Vec<Id>) -> Id {
let id = add(p, Kind::ExchangeCategory, layer, "Concern");
*p.elements.get_mut(&id).unwrap().shared_mut().unwrap() =
SharedBody::ExchangeCategory { family, exchanges };
id
}
struct Model {
p: Project,
work: [Id; 4],
means: [Id; 2],
categories: [Id; 3],
contexts: [Id; 2],
allocation: Id,
trace: Id,
}
fn model() -> Model {
let mut p = Project::empty();
let a = add(&mut p, Kind::Activity, Layer::Oa, "Request");
let b = add(&mut p, Kind::Activity, Layer::Oa, "Provide");
let c = add(&mut p, Kind::Activity, Layer::Oa, "Monitor");
let x = add(&mut p, Kind::Entity, Layer::Oa, "Requester");
let y = add(&mut p, Kind::Entity, Layer::Oa, "Provider");
let z = add(&mut p, Kind::Entity, Layer::Oa, "Observer");
let allocation = p.relate(RelationKind::Allocation, "Responsible", x, a);
p.relate(RelationKind::Allocation, "Responsible", y, b);
p.relate(RelationKind::Allocation, "Responsible", z, c);
let work = [
p.relate(RelationKind::Interaction, "Request data", a, b),
p.relate(RelationKind::Interaction, "Report", b, c),
p.relate(RelationKind::Interaction, "Feedback", c, a),
p.relate(RelationKind::Interaction, "Direct monitor", a, c),
];
let means = [
p.relate(RelationKind::CommunicationMean, "Service channel", x, y),
p.relate(RelationKind::CommunicationMean, "Monitoring channel", y, z),
];
let contexts = [
add(&mut p, Kind::Process, Layer::Oa, "Service path"),
add(&mut p, Kind::Process, Layer::Oa, "Feedback path"),
];
for edge in &work[..2] {
occurrence(&mut p, contexts[0], exchange(*edge));
}
for edge in &work[1..3] {
occurrence(&mut p, contexts[1], exchange(*edge));
}
let categories = [
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[..2].to_vec()),
category(&mut p, Layer::Oa, ExchangeFamily::Work, work[1..3].to_vec()),
category(
&mut p,
Layer::Oa,
ExchangeFamily::Participant,
vec![means[0]],
),
];
let function = add(&mut p, Kind::Function, Layer::Sa, "System request");
let trace = Id::new_v4();
p.traces.insert(
trace,
Trace {
id: trace,
alias: "request_realization".into(),
name: "Request realization".into(),
description: String::new(),
oa: a,
sa: function,
kind: TraceKind::Refinement,
baseline: None,
},
);
Model {
p,
work,
means,
categories,
contexts,
allocation,
trace,
}
}
fn accepted(filter: &PreparedFilter<'_>, ids: &[Id]) -> Vec<Id> {
ids.iter()
.filter(|id| filter.includes(**id))
.copied()
.collect()
}
#[test]
fn traceability_defaults_preserve_both_layers_and_explicit_selectors_still_apply() {
let mut m = model();
let a = add(&mut m.p, Kind::Function, Layer::Sa, "Request in system");
let b = add(&mut m.p, Kind::Function, Layer::Sa, "Provide in system");
let sa_exchange =
m.p.relate(RelationKind::FunctionalExchange, "System data", a, b);
let sa_category = category(&mut m.p, Layer::Sa, ExchangeFamily::Work, vec![sa_exchange]);
let sa_context = add(
&mut m.p,
Kind::FunctionalChain,
Layer::Sa,
"System service path",
);
occurrence(&mut m.p, sa_context, exchange(sa_exchange));
let index = ViewIndex::new(&m.p);
let defaults = ViewOptions::default();
assert!(!index.prepare(Layer::Oa, &defaults).includes(sa_exchange));
let cross_layer = index.prepare_cross_layer(Layer::Oa, &defaults);
assert!(cross_layer.includes(sa_exchange));
assert!(cross_layer.includes(m.work[0]));
assert!(cross_layer.includes(m.trace));
let selected = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0]]),
..defaults
};
let cross_layer = index.prepare_cross_layer(Layer::Oa, &selected);
assert!(cross_layer.includes(m.work[0]));
assert_eq!(
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
vec![OmissionReason::Category(ExchangeFamily::Work)]
);
let none = ViewOptions {
relation_kinds: RelationKinds::Only(vec![]),
..ViewOptions::default()
};
assert!(
!index
.prepare_cross_layer(Layer::Oa, &none)
.includes(sa_exchange)
);
let mixed = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0], sa_category]),
work_contexts: ContextSelection::Any(vec![m.contexts[0], sa_context]),
hidden_edges: vec![sa_exchange],
..ViewOptions::default()
};
assert!(validate_options_cross_layer(&m.p, &mixed).is_empty());
assert!(!validate_options(&m.p, Layer::Oa, &mixed).is_empty());
let cross_layer = index.prepare_cross_layer(Layer::Oa, &mixed);
assert!(cross_layer.includes(m.work[0]));
assert_eq!(
cross_layer.reasons(sa_exchange, EdgeAvailability::Visible),
vec![OmissionReason::ExplicitHide]
);
}
#[test]
fn separate_category_families_obey_all_any_empty_and_union_truth_table() {
let m = model();
let index = ViewIndex::new(&m.p);
let work_cases = [
(CategorySelection::All, m.work.to_vec()),
(CategorySelection::Any(vec![]), vec![]),
(
CategorySelection::Any(vec![m.categories[0]]),
m.work[..2].to_vec(),
),
(
CategorySelection::Any(m.categories[..2].to_vec()),
m.work[..3].to_vec(),
),
];
let participant_cases = [
(CategorySelection::All, m.means.to_vec()),
(CategorySelection::Any(vec![]), vec![]),
(
CategorySelection::Any(vec![m.categories[2]]),
vec![m.means[0]],
),
];
for (work_categories, expected_work) in &work_cases {
for (participant_categories, expected_means) in &participant_cases {
let options = ViewOptions {
work_categories: work_categories.clone(),
participant_categories: participant_categories.clone(),
..ViewOptions::default()
};
assert!(validate_options(&m.p, Layer::Oa, &options).is_empty());
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(&accepted(&filter, &m.work), expected_work);
assert_eq!(&accepted(&filter, &m.means), expected_means);
assert!(filter.includes(m.allocation));
assert!(filter.includes(m.trace));
}
}
}
#[test]
fn global_behavior_coverage_and_chosen_context_are_independent_intersecting_predicates() {
let m = model();
let index = ViewIndex::new(&m.p);
let coverage_cases = [
(Coverage::All, m.work.to_vec()),
(Coverage::InBehavior, m.work[..3].to_vec()),
(Coverage::OutsideBehavior, vec![m.work[3]]),
];
let context_cases = [
(ContextSelection::All, m.work.to_vec()),
(ContextSelection::Any(vec![]), vec![]),
(
ContextSelection::Any(vec![m.contexts[0]]),
m.work[..2].to_vec(),
),
(
ContextSelection::Any(vec![m.contexts[1]]),
m.work[1..3].to_vec(),
),
(
ContextSelection::Any(m.contexts.to_vec()),
m.work[..3].to_vec(),
),
];
for (coverage, global) in &coverage_cases {
for (work_contexts, chosen) in &context_cases {
let expected: Vec<_> = global
.iter()
.filter(|id| chosen.contains(id))
.copied()
.collect();
let options = ViewOptions {
coverage: *coverage,
work_contexts: work_contexts.clone(),
..ViewOptions::default()
};
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(accepted(&filter, &m.work), expected);
assert_eq!(accepted(&filter, &m.means), m.means);
assert!(filter.includes(m.allocation));
}
}
}
#[test]
fn no_lines_and_explicit_trace_selection_include_actual_trace_identities() {
let m = model();
let index = ViewIndex::new(&m.p);
let all: Vec<_> = m
.work
.into_iter()
.chain(m.means)
.chain([m.allocation, m.trace])
.collect();
for (relation_kinds, expected) in [
(RelationKinds::All, all.clone()),
(RelationKinds::Only(vec![]), vec![]),
(RelationKinds::Only(vec![EdgeKind::Trace]), vec![m.trace]),
(
RelationKinds::Only(vec![
EdgeKind::Relation(RelationKind::Interaction),
EdgeKind::Trace,
]),
m.work.into_iter().chain([m.trace]).collect(),
),
] {
let options = ViewOptions {
relation_kinds,
..ViewOptions::default()
};
assert_eq!(
accepted(&index.prepare(Layer::Oa, &options), &all),
expected
);
}
}
#[test]
fn hidden_identity_survives_label_and_category_changes_without_mutating_the_model() {
let mut m = model();
let item = add(&mut m.p, Kind::ExchangeItem, Layer::Oa, "Request payload");
if let RelationBody::Interaction { items, .. } =
&mut m.p.relations.get_mut(&m.work[0]).unwrap().body
{
items.push(item);
}
let before = m.p.clone();
let index = ViewIndex::new(&m.p);
let mut options = ViewOptions {
hidden_edges: vec![m.work[0]],
..ViewOptions::default()
};
for labels in [
ExchangeLabels::Name,
ExchangeLabels::Items,
ExchangeLabels::NameAndItems,
] {
options.exchange_labels = labels;
for categories in [
CategorySelection::All,
CategorySelection::Any(vec![m.categories[0]]),
CategorySelection::Any(vec![]),
] {
options.work_categories = categories;
let filter = index.prepare(Layer::Oa, &options);
assert!(!filter.includes(m.work[0]));
assert!(
filter
.reasons(m.work[0], EdgeAvailability::Visible)
.contains(&OmissionReason::ExplicitHide)
);
}
}
assert_eq!(
ExchangeLabels::Items.label(&m.p, m.work[0]),
"Request payload"
);
assert_eq!(
ExchangeLabels::NameAndItems.label(&m.p, m.work[0]),
"Request data · Request payload"
);
assert_eq!(ExchangeLabels::Items.label(&m.p, m.work[1]), "No items");
assert_eq!(
ExchangeLabels::Items.label(&m.p, m.trace),
"Request realization"
);
assert_eq!(m.p, before);
assert_eq!(options.hidden_edges, vec![m.work[0]]);
}
#[test]
fn omission_reasons_distinguish_missing_placements_folded_edges_and_explicit_hides() {
let m = model();
let index = ViewIndex::new(&m.p);
let options = ViewOptions::default();
let filter = index.prepare(Layer::Oa, &options);
assert_eq!(
filter.reasons(
m.work[0],
EdgeAvailability::EndpointNotShown {
source: false,
target: true
}
),
vec![OmissionReason::EndpointNotShown {
source: false,
target: true
}]
);
assert_eq!(
filter.reasons(m.work[0], EdgeAvailability::FoldedInternal),
vec![OmissionReason::FoldedInternal]
);
assert_eq!(
filter.reasons(m.work[0], EdgeAvailability::OutsideProjection),
vec![OmissionReason::OutsideProjection]
);
let options = ViewOptions {
hidden_edges: vec![m.work[0]],
work_categories: CategorySelection::Any(vec![]),
..options
};
let reasons = index.prepare(Layer::Oa, &options).reasons(
m.work[0],
EdgeAvailability::EndpointNotShown {
source: true,
target: true,
},
);
assert!(reasons.contains(&OmissionReason::ExplicitHide));
assert!(reasons.contains(&OmissionReason::Category(ExchangeFamily::Work)));
assert!(reasons.contains(&OmissionReason::EndpointNotShown {
source: true,
target: true
}));
}
#[test]
fn wrong_family_layer_and_unknown_references_reject_and_draft_filters_fail_closed() {
let m = model();
let index = ViewIndex::new(&m.p);
for wrong in [m.categories[2], m.work[0], Id::new_v4()] {
let options = ViewOptions {
work_categories: CategorySelection::Any(vec![wrong]),
..ViewOptions::default()
};
assert!(
validate_options(&m.p, Layer::Oa, &options)
.iter()
.any(|e| e.field == "work_categories")
);
assert!(accepted(&index.prepare(Layer::Oa, &options), &m.work).is_empty());
}
let options = ViewOptions {
work_categories: CategorySelection::Any(vec![m.categories[0]]),
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
hidden_edges: vec![m.work[0]],
..ViewOptions::default()
};
let errors = validate_options(&m.p, Layer::Sa, &options);
for field in ["work_categories", "work_contexts", "hidden_edges"] {
assert!(errors.iter().any(|e| e.field == field));
}
assert!(validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Work, &m.work).is_empty());
assert!(
validate_category(&m.p, Layer::Oa, None, ExchangeFamily::Participant, &m.means).is_empty()
);
for (family, members) in [
(ExchangeFamily::Work, m.means.to_vec()),
(ExchangeFamily::Participant, m.work.to_vec()),
(ExchangeFamily::Work, vec![m.work[0], m.work[0]]),
] {
assert!(!validate_category(&m.p, Layer::Oa, None, family, &members).is_empty());
}
assert!(
!validate_category(
&m.p,
Layer::Oa,
Some(m.contexts[0]),
ExchangeFamily::Work,
&[]
)
.is_empty()
);
}
#[test]
fn persisted_empty_selectors_and_lifecycle_never_widen_to_all() {
let m = model();
let mut options = ViewOptions {
hidden_edges: vec![m.work[0]],
relation_kinds: RelationKinds::Only(vec![]),
work_categories: CategorySelection::Any(vec![m.categories[0]]),
participant_categories: CategorySelection::Any(vec![m.categories[2]]),
work_contexts: ContextSelection::Any(vec![m.contexts[0]]),
coverage: Coverage::OutsideBehavior,
exchange_labels: ExchangeLabels::Items,
};
let replacements: BTreeMap<_, _> = [m.work[0], m.categories[0], m.categories[2], m.contexts[0]]
.into_iter()
.map(|id| (id, Id::new_v4()))
.collect();
options.visit_references(|id| *id = replacements[id]);
assert_eq!(options.hidden_edges, vec![replacements[&m.work[0]]]);
assert_eq!(
options.work_categories,
CategorySelection::Any(vec![replacements[&m.categories[0]]])
);
assert_eq!(
options.participant_categories,
CategorySelection::Any(vec![replacements[&m.categories[2]]])
);
assert_eq!(
options.work_contexts,
ContextSelection::Any(vec![replacements[&m.contexts[0]]])
);
options.retain_references(|_| false);
assert_eq!(options.work_categories, CategorySelection::Any(vec![]));
assert_eq!(
options.participant_categories,
CategorySelection::Any(vec![])
);
assert_eq!(options.work_contexts, ContextSelection::Any(vec![]));
assert_eq!(options.relation_kinds, RelationKinds::Only(vec![]));
assert_eq!(options.coverage, Coverage::OutsideBehavior);
assert_eq!(options.exchange_labels, ExchangeLabels::Items);
assert_eq!(
serde_json::from_value::<ViewOptions>(serde_json::to_value(&options).unwrap()).unwrap(),
options
);
assert_ne!(options, ViewOptions::default());
assert_eq!(
serde_json::from_str::<ViewOptions>("{}").unwrap(),
ViewOptions::default()
);
assert!(serde_json::from_str::<ViewOptions>(r#"{"future_filter":true}"#).is_err());
assert!(serde_json::from_str::<ViewOptions>(r#"{"coverage":"Maybe"}"#).is_err());
let duplicate = ViewOptions {
relation_kinds: RelationKinds::Only(vec![EdgeKind::Trace, EdgeKind::Trace]),
hidden_edges: vec![m.trace, m.trace],
..ViewOptions::default()
};
let errors = validate_options(&m.p, Layer::Oa, &duplicate);
assert!(errors.iter().any(|e| e.field == "relation_kinds"));
assert!(errors.iter().any(|e| e.field == "hidden_edges"));
}
#[test]
fn reusable_sequences_and_nested_scenarios_use_listed_contributions_in_both_layers() {
for (layer, work_kind, sequence_kind, exchange_kind) in [
(
Layer::Oa,
Kind::Activity,
Kind::Process,
RelationKind::Interaction,
),
(
Layer::Sa,
Kind::Function,
Kind::FunctionalChain,
RelationKind::FunctionalExchange,
),
] {
let mut p = Project::empty();
let a = add(&mut p, work_kind, layer, "Request");
let b = add(&mut p, work_kind, layer, "Provide");
let used = p.relate(exchange_kind, "Included", a, b);
let unlisted = p.relate(exchange_kind, "Unlisted", b, a);
let inner = add(&mut p, sequence_kind, layer, "Reusable sequence");
let outer = add(&mut p, sequence_kind, layer, "Chosen sequence");
occurrence(&mut p, inner, exchange(used));
occurrence(
&mut p,
outer,
OccurrenceBody::SequenceReference {
sequence: Some(inner),
},
);
let unused = occurrence(&mut p, inner, exchange(unlisted));
p.elements
.get_mut(&inner)
.unwrap()
.steps_mut()
.unwrap()
.retain(|id| *id != unused);
assert_eq!(
behavior_evidence(&p, [outer]).exchanges,
BTreeSet::from([used])
);
assert!(behavior_evidence(&p, [unused]).exchanges.is_empty());
let scenario = add(&mut p, Kind::Scenario, layer, "Conversation");
let fragment = occurrence(
&mut p,
scenario,
OccurrenceBody::Fragment {
operator: FragmentOperator::Optional,
branches: vec![],
},
);
let branch = occurrence(
&mut p,
fragment,
OccurrenceBody::Branch {
guard: "requested".into(),
occurrences: vec![],
},
);
if let Some(SharedBody::Occurrence {
body: OccurrenceBody::Fragment { branches, .. },
..
}) = p.elements.get_mut(&fragment).unwrap().shared_mut()
{
branches.push(branch);
}
occurrence(&mut p, branch, exchange(unlisted));
let reuse = add(&mut p, Kind::Scenario, layer, "Chosen conversation");
occurrence(
&mut p,
reuse,
OccurrenceBody::ScenarioUse {
scenario: Some(scenario),
bindings: vec![],
},
);
assert_eq!(
behavior_evidence(&p, [reuse]).exchanges,
BTreeSet::from([unlisted])
);
assert_eq!(
all_behavior_evidence(&p).exchanges,
BTreeSet::from([used, unlisted])
);
assert_eq!(behavior_evidence(&p, [outer]).work, BTreeSet::from([a, b]));
p.elements
.get_mut(&scenario)
.unwrap()
.steps_mut()
.unwrap()
.clear();
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
// A malformed reuse cycle must terminate without inventing an occurrence.
occurrence(
&mut p,
scenario,
OccurrenceBody::ScenarioUse {
scenario: Some(reuse),
bindings: vec![],
},
);
assert!(behavior_evidence(&p, [reuse]).exchanges.is_empty());
}
}