Complete source 6 engineering workflows and preserve release validation evidence
This commit is contained in:
@@ -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![],
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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![],
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, .. } => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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, ¤t)?;
|
||||
// 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, ¤t)?;
|
||||
}
|
||||
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, ¤t)?;
|
||||
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
@@ -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;
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user