//! Typed authored workflow inputs; no stored command is executable on recovery. use super::*; use serde::{Deserialize, Serialize}; #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] enum Saved { Semantic(serde_json::Value), Ports(serde_json::Value), Relation { base: String, id: Id, name: String, kind: RelationKind, source: Option, target: Option, condition: String, layer: Layer, items: Vec, source_port: PortChoice, target_port: PortChoice, oriented: bool, }, ManualTrace { base: String, name: String, source: Id, target: Option, }, Transition { base: String, request: TransitionRequest, #[serde(default)] communication_ports: std::collections::BTreeMap, }, } pub(crate) fn endpoint(p: &Project, port: PortChoice) -> EndpointChoice { let label = match port { PortChoice::Auto => "Automatic ยท reuse only if unique".into(), PortChoice::Create => "Create a new port".into(), PortChoice::Reuse(id) => format!("Reuse {}", choice(p, id).label), }; EndpointChoice { choice: port, label, } } fn check(p: &Project, base: &str) -> Result<(), String> { if project_fingerprint(p) != base { return Err( "Workflow recovery does not match the accepted model identity and revision.".into(), ); } Ok(()) } impl Workflow { pub(crate) fn from_recovery_after_source5_upgrade( p: &Project, legacy_base: &str, value: serde_json::Value, ) -> Result { let mut saved: Saved = serde_json::from_value(value).map_err(|e| e.to_string())?; match &mut saved { Saved::Semantic(value) => { return crate::semantic_review::State::from_recovery_after_source5_upgrade( p, legacy_base, value.take(), ) .map(|state| Self::Semantic(Box::new(state))); } Saved::Ports(value) => { return crate::port_workflow::State::from_recovery_after_source5_upgrade( p, legacy_base, value.take(), ) .map(|state| Self::Ports(Box::new(state))); } Saved::Relation { base, .. } | Saved::ManualTrace { base, .. } | Saved::Transition { base, .. } => { if base != legacy_base { return Err("Workflow recovery does not match the accepted source-5 model identity and revision.".into()); } *base = project_fingerprint(p); } } Self::from_recovery(p, serde_json::to_value(saved).map_err(|e| e.to_string())?) } pub fn recovery_snapshot(&self) -> Result { let saved = match self { Self::Semantic(state) => Saved::Semantic(state.recovery_snapshot()?), Self::Ports(state) => Saved::Ports(state.recovery_snapshot()?), Self::Relation(d) => Saved::Relation { base: d.base_fingerprint.clone(), id: d.id, name: d.name.clone(), kind: d.kind, source: d.source.as_ref().map(|c| c.id), target: d.target.as_ref().map(|c| c.id), condition: d.condition.text(), layer: d.layer, items: d.items.clone(), source_port: d.source_port.choice, target_port: d.target_port.choice, oriented: d.oriented, }, Self::ManualTrace { base_fingerprint, name, source, target, } => Saved::ManualTrace { base: base_fingerprint.clone(), name: name.clone(), source: source.id, target: target.as_ref().map(|c| c.id), }, Self::Transition(d) => Saved::Transition { base: d.plan.base_fingerprint.clone(), request: d.request.clone(), communication_ports: d.communication_ports.clone(), }, }; serde_json::to_value(saved).map_err(|e| e.to_string()) } pub fn from_recovery(p: &Project, value: serde_json::Value) -> Result { match serde_json::from_value::(value).map_err(|e| e.to_string())? { Saved::Semantic(value) => crate::semantic_review::State::from_recovery(p, value) .map(|state| Self::Semantic(Box::new(state))), Saved::Ports(value) => crate::port_workflow::State::from_recovery(p, value) .map(|state| Self::Ports(Box::new(state))), Saved::Relation { base, id, name, kind, source, target, condition, layer, items, source_port, target_port, oriented, } => { check(p, &base)?; if items.len() > crate::source_workspace::LIMITS.model.max_records { return Err("Recovered relationship exceeds item limits.".into()); } let mut d = Relationship { base_fingerprint: base, id, name, kind, source: source.map(|id| choice(p, id)), target: target.map(|id| choice(p, id)), condition: text_editor::Content::with_text(&condition), layer, items, source_port: endpoint(p, source_port), target_port: endpoint(p, target_port), oriented, sources: vec![], targets: vec![], }; d.refresh_choices(p); Ok(Self::Relation(Box::new(d))) } Saved::ManualTrace { base, name, source, target, } => { check(p, &base)?; let mut workflow = Self::manual_trace(p, source)?; if let Self::ManualTrace { name: n, target: t, .. } = &mut workflow { *n = name; *t = target.map(|id| choice(p, id)); } Ok(workflow) } Saved::Transition { base, request, mut communication_ports, } => { check(p, &base)?; if communication_ports.len() > crate::source_workspace::LIMITS.model.max_records { return Err("Recovered transition exceeds endpoint draft limits.".into()); } for (id, draft) in communication::from_request(&request) { communication_ports.entry(id).or_insert(draft); } let plan = plan_transition(p, &request).map_err(|e| e.to_string())?; let mut transition = Transition { request, plan, communication_ports, }; transition.sync_communication_ports(); transition.plan = plan_transition(p, &transition.request).map_err(|e| e.to_string())?; Ok(Self::Transition(Box::new(transition))) } } } } #[cfg(test)] mod tests { use super::*; #[test] fn incomplete_relationship_and_trace_recover_raw_fields_and_reject_changed_model() { let mut p = Project::empty(); let a = p .add_element(Kind::Activity, Layer::Oa, "Source", None) .unwrap(); let mut relation = Workflow::relationship(&p, a, Layer::Oa); if let Workflow::Relation(d) = &mut relation { d.name = " unfinished name ".into(); d.condition = text_editor::Content::with_text("raw (\n unfinished"); d.target = None; } let saved = relation.recovery_snapshot().unwrap(); let restored = Workflow::from_recovery(&p, saved.clone()).unwrap(); assert_eq!(restored.recovery_snapshot().unwrap(), saved); let trace = Workflow::manual_trace(&p, a).unwrap(); let saved_trace = trace.recovery_snapshot().unwrap(); assert_eq!( Workflow::from_recovery(&p, saved_trace.clone()) .unwrap() .recovery_snapshot() .unwrap(), saved_trace ); p.name.push('!'); assert!(Workflow::from_recovery(&p, saved).is_err()); assert!(Workflow::from_recovery(&p, saved_trace).is_err()); } #[test] fn transition_recovery_replans_typed_request_without_persisted_commands() { let mut p = Project::empty(); let a = p .add_element(Kind::Activity, Layer::Oa, "Source", None) .unwrap(); let workflow = Workflow::transition(&p, a).unwrap(); let saved = workflow.recovery_snapshot().unwrap(); assert!(!serde_json::to_string(&saved).unwrap().contains("commands")); let Workflow::Transition(restored) = Workflow::from_recovery(&p, saved).unwrap() else { panic!() }; assert_eq!(restored.plan.base_fingerprint, project_fingerprint(&p)); assert_eq!(restored.request.sources, vec![a]); } }