//! One address for a carried value, shared by scenario creation and property editing. use oa_core::*; fn resolve_operation( project: &Project, message: &Element, ) -> Result { if project.elements.get(&message.id) == Some(message) { return operations::resolve(project, message.id); } let active = matches!( message.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { operation: Some(_), .. }, .. }) ) || matches!(message.shared(),Some(SharedBody::Occurrence { body:OccurrenceBody::Exchange { message_kind:MessageKind::Reply,reply_to:Some(call),..},.. }) if matches!(project.elements.get(call).and_then(|e|e.shared()),Some(SharedBody::Occurrence {body:OccurrenceBody::Exchange {operation:Some(_),..},..}))); if !active { return Ok(operations::OperationResolution::Ordinary); } let mut candidate = project.clone(); candidate.elements.insert(message.id, message.clone()); operations::resolve(&candidate, message.id) } /// The same exact signature used by core admission, evaluated with the authored /// message in place. Unresolved operation intent never falls back to channel union. pub(crate) fn message_signature( project: &Project, message: &Element, ) -> Result, String> { match resolve_operation(project, message).map_err(|e| e.to_string())? { operations::OperationResolution::Selected(operation) => { Ok(Some(operations::signature_relation(&operation))) } operations::OperationResolution::Unresolved { reason, .. } => Err(reason), operations::OperationResolution::Ordinary => Ok(match message.shared() { Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { exchange, .. }, .. }) => exchange.and_then(|id| project.relations.get(&id)).cloned(), _ => None, }), } } pub(crate) fn operation_choices(project: &Project, message: &Element) -> Vec { let mut candidate = project.clone(); candidate.elements.insert(message.id, message.clone()); let mut choices: Vec<_> = operations::eligible_allocations(&candidate, message.id) .into_iter() .map(|id| crate::app::Choice { id, label: operation_identity(project, id), }) .collect(); choices.sort_by(|a, b| a.label.cmp(&b.label).then(a.id.cmp(&b.id))); choices } pub(crate) fn operation_identity(project: &Project, allocation: Id) -> String { if let Some(r) = project.relations.get(&allocation) { format!( "{} / {} · {} (@{})", project.named(r.source()), project.named(r.target()), r.name, r.alias ) } else { format!("Unavailable operation allocation · {allocation}") } } pub(crate) fn operation_label(project: &Project, message: &Element) -> Option { let reply = matches!( message.shared(), Some(SharedBody::Occurrence { body: OccurrenceBody::Exchange { message_kind: MessageKind::Reply, .. }, .. }) ); let prefix = if reply { "Reply operation from Call" } else { "Operation" }; match resolve_operation(project, message) { Ok(operations::OperationResolution::Ordinary) => None, Ok(operations::OperationResolution::Unresolved { reason, .. }) => { Some(format!("{prefix}: unresolved · {reason}")) } Ok(operations::OperationResolution::Selected(operation)) => Some(format!( "{prefix}: {}", operation_identity(project, operation.allocation) )), Err(error) => Some(format!("{prefix}: {error}")), } } #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub struct FieldPathKey { pub path: Vec, pub field: Id, } #[derive(Debug, Clone, PartialEq, Eq)] pub struct FieldPathChoice { pub key: FieldPathKey, pub label: String, pub picker_label: String, } impl std::fmt::Display for FieldPathChoice { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { f.write_str(&self.picker_label) } } fn phase_allows(project: &Project, entry: &data::CarriedFieldPath, phase: &MessageKind) -> bool { data::item_mechanism(project, entry.item) != Some(ExchangeMechanism::Operation) || match phase { MessageKind::Call => matches!( entry.direction, ParameterDirection::Unspecified | ParameterDirection::In | ParameterDirection::InOut ), MessageKind::Reply => matches!( entry.direction, ParameterDirection::Unspecified | ParameterDirection::Out | ParameterDirection::InOut | ParameterDirection::Return ), _ => true, } } /// Selected asynchronous operations carry request parameters. Ordinary channel /// messages retain their existing phase rules; unresolved intent has no signature. pub(crate) fn operation_value_phase( kind: &MessageKind, operation: Option<&OperationUse>, ) -> MessageKind { if *kind == MessageKind::Asynchronous && operation.is_some() { MessageKind::Call } else { kind.clone() } } fn named_choice(project: &Project, entry: data::CarriedFieldPath) -> FieldPathChoice { let qualified = entry .path .iter() .chain(std::iter::once(&entry.field)) .map(|id| { project .elements .get(id) .map(|e| format!("{} (@{})", e.name, e.alias)) .unwrap_or_else(|| id.to_string()) }) .collect::>(); let direction = if data::item_mechanism(project, entry.item) == Some(ExchangeMechanism::Operation) { match entry.direction { ParameterDirection::Unspecified => " · direction unspecified — review", ParameterDirection::In => " · In", ParameterDirection::Out => " · Out", ParameterDirection::InOut => " · InOut", ParameterDirection::Return => " · Return", } } else { "" }; let terminal = qualified .last() .expect("A field path always has a terminal"); let owner_path = qualified[..qualified.len() - 1].join(" → "); let picker_label = format!( "{terminal}{direction} — {}{}", project.named(entry.item), if owner_path.is_empty() { String::new() } else { format!(" / {owner_path}") }, ); FieldPathChoice { key: FieldPathKey { path: entry.path, field: entry.field, }, label: format!( "{} / {}{direction}", project.named(entry.item), qualified.join(" → ") ), picker_label, } } // A bounded picker must expose its distinguishing field before a long path. // Keep the full address separately for the wrapping detail text. fn sorted_choices(mut choices: Vec) -> Vec { choices.sort_by_cached_key(|choice| choice.picker_label.to_lowercase()); choices } #[cfg(test)] pub fn choices( project: &Project, exchange: &Relation, phase: &MessageKind, ) -> Result, String> { Ok(sorted_choices( data::try_carried_field_paths(project, exchange)? .into_iter() .filter(|entry| phase_allows(project, entry, phase)) .map(|entry| named_choice(project, entry)) .collect(), )) } /// Direct carried fields only: an unrelated deep branch must not prevent /// browsing a root field. Preserve the core resolver's first carried owner. pub fn roots(project: &Project, exchange: &Relation, phase: &MessageKind) -> Vec { let mut seen = std::collections::BTreeSet::new(); sorted_choices( exchange .body .items() .iter() .flat_map(|item| { data::effective_fields(project, *item) .into_iter() .filter_map(|field| { let SharedBody::DataField { direction, .. } = field.shared()? else { return None; }; Some(data::CarriedFieldPath { item: *item, path: vec![], field: field.id, direction: *direction, }) }) }) .filter(|entry| seen.insert(entry.field)) .filter(|entry| phase_allows(project, entry, phase)) .map(|entry| named_choice(project, entry)) .collect(), ) } /// Browse one record at a time, including explicitly chosen recursive hops. pub fn at_scope( project: &Project, exchange: &Relation, phase: &MessageKind, scope: Option<&FieldPathKey>, ) -> Result, String> { match scope { Some(key) => children(project, exchange, phase, key), None => Ok(roots(project, exchange, phase)), } } /// Resolve authored finite paths as well as the finite representative picker list. /// In particular, optional recursive Value records may be traversed repeatedly. pub fn resolve( project: &Project, exchange: &Relation, phase: &MessageKind, key: &FieldPathKey, ) -> Option { data::carried_value_path(project, exchange, &key.path, key.field) .filter(|entry| phase_allows(project, entry, phase)) .map(|entry| named_choice(project, entry)) } pub fn parent(key: &FieldPathKey) -> Option { let (&field, path) = key.path.split_last()?; Some(FieldPathKey { path: path.to_vec(), field, }) } /// Expand exactly one selected record. This keeps recursive authoring explicit /// and finite, and never traverses Reference storage or manufactures value paths. pub fn children( project: &Project, exchange: &Relation, phase: &MessageKind, key: &FieldPathKey, ) -> Result, String> { let Some(entry) = data::carried_value_path(project, exchange, &key.path, key.field) .filter(|entry| phase_allows(project, entry, phase)) else { return Ok(vec![]); }; let Some(SharedBody::DataField { type_ref: TypeRef::Named(record), storage: FieldStorage::Value, .. }) = project.elements.get(&key.field).and_then(Element::shared) else { return Ok(vec![]); }; if !data::is_record(project, *record) { return Ok(vec![]); } if key.path.len() >= data::MAX_VALUE_PATH_DEPTH { return Err(format!( "A value path can contain at most {} enclosing fields.", data::MAX_VALUE_PATH_DEPTH )); } let mut path = key.path.clone(); path.push(key.field); Ok(sorted_choices( data::effective_fields(project, *record) .into_iter() .map(|field| { named_choice( project, data::CarriedFieldPath { item: entry.item, path: path.clone(), field: field.id, direction: entry.direction, }, ) }) .collect(), )) } #[cfg(test)] pub(crate) mod tests { use super::*; pub struct Payload { pub project: Project, pub exchange: Id, pub item: Id, pub leaf: Id, pub origin: Id, pub destination: Id, pub scenario: Id, } pub fn payload() -> Payload { let mut p = Project::empty(); let item = p .add_element(Kind::ExchangeItem, Layer::Oa, "Move", None) .unwrap(); *p.elements.get_mut(&item).unwrap().shared_mut().unwrap() = SharedBody::ExchangeItem { kind: ExchangeItemKind::Information, mechanism: ExchangeMechanism::Operation, supertype: None, }; let record = p .add_element(Kind::DataType, Layer::Oa, "Coordinate", None) .unwrap(); *p.elements.get_mut(&record).unwrap().shared_mut().unwrap() = SharedBody::DataType { definition: DataTypeDefinition::Record { supertype: None }, default: None, unit: String::new(), }; let leaf = p .add_element(Kind::DataField, Layer::Oa, "latitude", Some(record)) .unwrap(); let origin = p .add_element(Kind::DataField, Layer::Oa, "origin", Some(item)) .unwrap(); let destination = p .add_element(Kind::DataField, Layer::Oa, "destination", Some(item)) .unwrap(); for (id, type_ref, direction) in [ (leaf, TypeRef::Real, ParameterDirection::Unspecified), (origin, TypeRef::Named(record), ParameterDirection::In), (destination, TypeRef::Named(record), ParameterDirection::Out), ] { *p.elements.get_mut(&id).unwrap().shared_mut().unwrap() = SharedBody::DataField { type_ref, direction, minimum: 1, maximum: Some(1), unit: String::new(), storage: FieldStorage::Value, default: None, }; } let source = p .add_element(Kind::Activity, Layer::Oa, "Request", None) .unwrap(); let target = p .add_element(Kind::Activity, Layer::Oa, "Perform", None) .unwrap(); let exchange = p.relate(RelationKind::Interaction, "Movement", source, target); p.relations.get_mut(&exchange).unwrap().body = RelationBody::Interaction { source, target, items: vec![item], }; let scenario = p .add_element(Kind::Scenario, Layer::Oa, "Conversation", None) .unwrap(); validate_candidate(&p).unwrap(); Payload { project: p, exchange, item, leaf, origin, destination, scenario, } } #[test] fn phase_choices_keep_reused_record_addresses_distinct() { let mut f = payload(); assert_eq!( data::item_mechanism(&f.project, f.item), Some(ExchangeMechanism::Operation) ); let call = choices( &f.project, &f.project.relations[&f.exchange], &MessageKind::Call, ) .unwrap(); assert!( call.iter() .any(|c| c.key.path == [f.origin] && c.key.field == f.leaf) ); assert!(!call.iter().any(|c| c.key.path == [f.destination])); let reply = choices( &f.project, &f.project.relations[&f.exchange], &MessageKind::Reply, ) .unwrap(); assert!( reply .iter() .any(|c| c.key.path == [f.destination] && c.key.field == f.leaf) ); assert!(!reply.iter().any(|c| c.key.path == [f.origin])); if let Some(SharedBody::DataField { direction, .. }) = f .project .elements .get_mut(&f.destination) .unwrap() .shared_mut() { *direction = ParameterDirection::InOut; } let both = choices( &f.project, &f.project.relations[&f.exchange], &MessageKind::Call, ) .unwrap(); let leaves = both .iter() .filter(|c| c.key.field == f.leaf) .collect::>(); assert_eq!(leaves.len(), 2); assert_ne!(leaves[0].key, leaves[1].key); assert_ne!(leaves[0].label, leaves[1].label); let values = leaves .into_iter() .map(|c| (c.key.clone(), "1".to_string())) .collect::>(); assert_eq!(values.len(), 2); } #[test] fn reference_parameter_stops_descent_and_unspecified_direction_remains_visible() { let mut f = payload(); if let Some(SharedBody::DataField { storage, direction, .. }) = f.project.elements.get_mut(&f.origin).unwrap().shared_mut() { *storage = FieldStorage::Reference; *direction = ParameterDirection::Unspecified; } let call = choices( &f.project, &f.project.relations[&f.exchange], &MessageKind::Call, ) .unwrap(); let direct = call.iter().find(|c| c.key.field == f.origin).unwrap(); assert!(direct.key.path.is_empty()); assert!(direct.label.contains("unspecified")); assert!(!call.iter().any(|c| c.key.path.contains(&f.origin))); } pub fn recursive_payload() -> (Payload, Id) { let mut f = payload(); let record = f.project.elements[&f.leaf].parent.unwrap(); let next = f .project .add_element(Kind::DataField, Layer::Oa, "next", Some(record)) .unwrap(); *f.project .elements .get_mut(&next) .unwrap() .shared_mut() .unwrap() = SharedBody::DataField { type_ref: TypeRef::Named(record), minimum: 0, maximum: Some(1), unit: String::new(), direction: ParameterDirection::Unspecified, storage: FieldStorage::Value, default: None, }; validate_candidate(&f.project).unwrap(); (f, next) } pub fn wide_payload() -> Payload { let mut f = payload(); let record = f.project.elements[&f.leaf].parent.unwrap(); for (owner, template, count) in [(f.item, f.origin, 95), (record, f.leaf, 127)] { for index in 0..count { let body = f.project.elements[&template].shared().unwrap().clone(); let id = f .project .add_element( Kind::DataField, Layer::Oa, &format!("Field {template} {index}"), Some(owner), ) .unwrap(); *f.project .elements .get_mut(&id) .unwrap() .shared_mut() .unwrap() = body; } } f } #[test] fn roots_and_one_hop_children_do_not_expand_unrelated_payload_branches() { let mut f = wide_payload(); let exchange = &f.project.relations[&f.exchange]; let direct = roots(&f.project, exchange, &MessageKind::Call); assert_eq!(direct.len(), 96); assert!(direct.iter().all(|choice| choice.key.path.is_empty())); let representative = choices(&f.project, exchange, &MessageKind::Call).unwrap(); assert_eq!(representative.len(), 96 * 129); for root in &direct { assert_eq!( resolve(&f.project, exchange, &MessageKind::Call, &root.key), Some(root.clone()) ); } let nested = children(&f.project, exchange, &MessageKind::Call, &direct[0].key).unwrap(); assert_eq!(nested.len(), 128); for child in &nested { assert_eq!( resolve(&f.project, exchange, &MessageKind::Call, &child.key), Some(child.clone()) ); assert!(child.label.ends_with(" · In")); } // A temporarily over-limit model still exposes its unrelated root fields; // the editor must not expand the whole graph before showing a choice. for index in 0..32 { let body = f.project.elements[&f.origin].shared().unwrap().clone(); let id = f .project .add_element( Kind::DataField, Layer::Oa, &format!("Extra {index}"), Some(f.item), ) .unwrap(); *f.project .elements .get_mut(&id) .unwrap() .shared_mut() .unwrap() = body; } let exchange = &f.project.relations[&f.exchange]; assert!(choices(&f.project, exchange, &MessageKind::Call).is_err()); assert_eq!(roots(&f.project, exchange, &MessageKind::Call).len(), 128); assert_eq!( children(&f.project, exchange, &MessageKind::Call, &direct[0].key) .unwrap() .len(), 128 ); } #[test] fn recursive_records_can_be_drilled_into_and_valid_imported_paths_resolve() { let (f, next) = recursive_payload(); let exchange = &f.project.relations[&f.exchange]; let record = FieldPathKey { path: vec![f.origin], field: next, }; let nested = children(&f.project, exchange, &MessageKind::Call, &record).unwrap(); let leaf = nested.iter().find(|c| c.key.field == f.leaf).unwrap(); assert_eq!(leaf.key.path, vec![f.origin, next]); assert!( !choices(&f.project, exchange, &MessageKind::Call) .unwrap() .iter() .any(|c| c.key == leaf.key) ); assert_eq!( resolve(&f.project, exchange, &MessageKind::Call, &leaf.key), Some(leaf.clone()) ); assert_eq!(parent(&leaf.key), Some(record)); let deepest = FieldPathKey { path: std::iter::once(f.origin) .chain(std::iter::repeat_n(next, data::MAX_VALUE_PATH_DEPTH - 1)) .collect(), field: next, }; assert!(resolve(&f.project, exchange, &MessageKind::Call, &deepest).is_some()); assert!( children(&f.project, exchange, &MessageKind::Call, &deepest) .unwrap_err() .contains("128") ); } #[test] fn recursive_picker_starts_with_distinct_leaf_names_and_sorts_them() { let (f, next) = recursive_payload(); let exchange = &f.project.relations[&f.exchange]; let context = FieldPathKey { path: vec![f.origin, next, next], field: next, }; let visible = children(&f.project, exchange, &MessageKind::Call, &context).unwrap(); let leaf = visible .iter() .find(|choice| choice.key.field == f.leaf) .unwrap(); assert!(leaf.to_string().starts_with("latitude (@latitude)")); assert!(leaf.label.contains("origin (@origin) → next (@next)")); assert!( visible.windows(2).all( |pair| pair[0].to_string().to_lowercase() <= pair[1].to_string().to_lowercase() ) ); assert_ne!( visible[0].to_string().chars().take(24).collect::(), visible[1].to_string().chars().take(24).collect::() ); assert_eq!( resolve(&f.project, exchange, &MessageKind::Call, &leaf.key), Some(leaf.clone()) ); } }