1use serde::{Deserialize, Serialize};
22use turnframe_core::case::CaseRef;
23use turnframe_core::interaction::{AcceptedResponse, InteractionPayload, StoredInteractionAction};
24use turnframe_core::target::TargetTokenMap;
25use turnframe_core::understanding::{
26 ActAction, ActId, ActStatus, ActTarget, ArgumentValue, RecordValue, UnderstoodAct, UnitId,
27 WordRange,
28};
29
30pub const DEFERRED_ACT_KEY: &str = "turnframe.deferred_act";
32
33pub const CARD_UNIT: UnitId = UnitId(0);
35
36#[must_use]
38pub const fn card_act_id() -> ActId {
39 ActId::new(CARD_UNIT, 1)
40}
41
42#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
44#[serde(deny_unknown_fields)]
45pub struct DeferredAct {
46 pub act: UnderstoodAct,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub case_ref: Option<CaseRef>,
52}
53
54impl DeferredAct {
55 #[must_use]
57 pub const fn unbound(act: UnderstoodAct) -> Self {
58 Self {
59 act,
60 case_ref: None,
61 }
62 }
63
64 #[must_use]
66 pub const fn on(act: UnderstoodAct, case_ref: CaseRef) -> Self {
67 Self {
68 act,
69 case_ref: Some(case_ref),
70 }
71 }
72
73 #[must_use]
76 pub fn attach_to(&self, mut payload: InteractionPayload) -> InteractionPayload {
77 let Ok(value) = serde_json::to_value(self) else {
78 return payload;
79 };
80 match &mut payload.metadata {
81 serde_json::Value::Null => {
82 let mut map = serde_json::Map::new();
83 map.insert(DEFERRED_ACT_KEY.to_owned(), value);
84 payload.metadata = serde_json::Value::Object(map);
85 }
86 serde_json::Value::Object(map) => {
87 map.insert(DEFERRED_ACT_KEY.to_owned(), value);
88 }
89 _ => {
90 tracing::warn!(
91 target: "turnframe.resume",
92 "card metadata is not an object; the card carries no deferred act"
93 );
94 }
95 }
96 payload
97 }
98
99 #[must_use]
101 pub fn of(payload: &InteractionPayload) -> Option<Self> {
102 let value = payload.metadata.get(DEFERRED_ACT_KEY)?;
103 serde_json::from_value(value.clone())
104 .inspect_err(|_| {
105 tracing::warn!(
106 target: "turnframe.resume",
107 "card metadata carries an unreadable deferred act; it is ignored"
108 );
109 })
110 .ok()
111 }
112}
113
114pub const DEPENDENT_ACTS_KEY: &str = "turnframe.dependent_acts";
116
117#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
119#[serde(deny_unknown_fields)]
120pub struct MadeCase {
121 pub act: ActId,
123 pub case_ref: CaseRef,
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
130#[serde(deny_unknown_fields)]
131pub struct DependentAct {
132 pub act: UnderstoodAct,
134 #[serde(default, skip_serializing_if = "Vec::is_empty")]
136 pub made: Vec<MadeCase>,
137}
138
139impl DependentAct {
140 #[must_use]
142 pub fn attach_to(self, mut payload: InteractionPayload) -> InteractionPayload {
143 let mut dependents = Self::of(&payload);
144 dependents.push(self);
145 let Ok(value) = serde_json::to_value(dependents) else {
146 return payload;
147 };
148 match &mut payload.metadata {
149 serde_json::Value::Null => {
150 let mut map = serde_json::Map::new();
151 map.insert(DEPENDENT_ACTS_KEY.to_owned(), value);
152 payload.metadata = serde_json::Value::Object(map);
153 }
154 serde_json::Value::Object(map) => {
155 map.insert(DEPENDENT_ACTS_KEY.to_owned(), value);
156 }
157 _ => tracing::warn!(
158 target: "turnframe.resume",
159 "card metadata is not an object; the card carries no dependent act"
160 ),
161 }
162 payload
163 }
164
165 #[must_use]
167 pub fn of(payload: &InteractionPayload) -> Vec<Self> {
168 payload
169 .metadata
170 .get(DEPENDENT_ACTS_KEY)
171 .and_then(|value| serde_json::from_value(value.clone()).ok())
172 .unwrap_or_default()
173 }
174}
175
176#[must_use]
181pub fn dependents(payload: &InteractionPayload, tokens: &TargetTokenMap) -> Vec<UnderstoodAct> {
182 let waiting = DependentAct::of(payload);
183 let renumbered: std::collections::BTreeMap<ActId, ActId> = waiting
184 .iter()
185 .zip(2..)
186 .map(|(dependent, position)| (dependent.act.id, ActId::new(CARD_UNIT, position)))
187 .collect();
188 waiting
189 .into_iter()
190 .map(|dependent| {
191 let made = |earlier: &ActId| {
192 let case_ref = &dependent.made.iter().find(|m| m.act == *earlier)?.case_ref;
193 tokens.token_for(&case_ref.key()).cloned()
194 };
195 let mut act = dependent.act.clone();
196 act.id = renumbered[&dependent.act.id];
197 if let ActTarget::SameTurn { act: earlier } = &act.target {
198 act.target = match (renumbered.get(earlier), made(earlier)) {
199 (Some(renamed), _) => ActTarget::SameTurn { act: *renamed },
200 (None, Some(token)) => ActTarget::Record { token },
201 (None, None) => act.target.clone(),
202 };
203 }
204 for argument in act.arguments.values_mut() {
205 let ArgumentValue::Record(RecordValue::SameTurn { act: earlier }) = &argument.value
206 else {
207 continue;
208 };
209 argument.value = match (renumbered.get(earlier), made(earlier)) {
210 (Some(renamed), _) => {
211 ArgumentValue::Record(RecordValue::SameTurn { act: *renamed })
212 }
213 (None, Some(token)) => ArgumentValue::Record(RecordValue::Record { token }),
214 (None, None) => continue,
215 };
216 }
217 act.depends_on = act
218 .depends_on
219 .iter()
220 .filter_map(|earlier| renumbered.get(earlier).copied())
221 .collect();
222 act
223 })
224 .collect()
225}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
229#[non_exhaustive]
230pub enum Resumption {
231 Continue(Box<UnderstoodAct>),
233 Declined {
236 record: Option<Box<UnderstoodAct>>,
238 },
239 Nothing,
242}
243
244impl Resumption {
245 #[must_use]
247 pub fn act(&self) -> Option<&UnderstoodAct> {
248 match self {
249 Self::Continue(act) => Some(act),
250 _ => None,
251 }
252 }
253
254 #[must_use]
256 pub const fn is_declined(&self) -> bool {
257 matches!(self, Self::Declined { .. })
258 }
259}
260
261#[must_use]
263pub fn card_act(action: ActAction, target: ActTarget) -> UnderstoodAct {
264 UnderstoodAct {
265 id: card_act_id(),
266 action,
267 target,
268 arguments: std::collections::BTreeMap::new(),
269 words: WordRange {
270 first: 0,
271 last: 0,
272 start: 0,
273 end: 0,
274 },
275 depends_on: Vec::new(),
276 status: ActStatus::Ready,
277 }
278}
279
280#[must_use]
285pub fn resume(
286 accepted: &AcceptedResponse,
287 payload: &InteractionPayload,
288 tokens: &TargetTokenMap,
289) -> Resumption {
290 if accepted.action.declines() {
292 let record = accepted.action.records().and_then(|operation| {
293 let token = tokens.token_for(&accepted.case_ref.key())?;
294 Some(Box::new(card_act(
295 ActAction::Apply {
296 operation: operation.clone(),
297 },
298 ActTarget::Record {
299 token: token.clone(),
300 },
301 )))
302 });
303 return Resumption::Declined { record };
304 }
305 let Some(deferred) = DeferredAct::of(payload) else {
306 return Resumption::Nothing;
307 };
308 let case_ref = match &accepted.action {
309 StoredInteractionAction::SelectTarget { case_ref } => Some(case_ref.clone()),
310 StoredInteractionAction::ResolveClarification { .. }
311 | StoredInteractionAction::Custom { .. } => deferred.case_ref.clone(),
312 _ => return Resumption::Nothing,
313 };
314 let mut act = deferred.act;
315 act.id = card_act_id();
316 act.depends_on.clear();
317 act.status = ActStatus::Ready;
318 if !matches!(act.action, ActAction::Start { .. }) {
319 let Some(token) = case_ref
320 .as_ref()
321 .and_then(|case_ref| tokens.token_for(&case_ref.key()))
322 else {
323 tracing::warn!(
324 target: "turnframe.resume",
325 "the case a card named is not addressable in this turn; nothing was resumed"
326 );
327 return Resumption::Nothing;
328 };
329 act.target = ActTarget::Record {
330 token: token.clone(),
331 };
332 }
333 Resumption::Continue(Box::new(act))
334}
335
336#[cfg(test)]
337mod tests {
338 use turnframe_core::hash::Digest;
339 use turnframe_core::ids::{
340 AccountId, CaseRevision, InteractionId, OperationKey, OptionId, TurnId,
341 };
342 use turnframe_core::interaction::InteractionKind;
343 use turnframe_core::understanding::UnderstoodArgument;
344
345 use super::*;
346
347 fn case(id: &str) -> CaseRef {
348 CaseRef::new("trip", id, CaseRevision(3))
349 }
350
351 fn act() -> UnderstoodAct {
352 let mut act = card_act(
353 ActAction::Apply {
354 operation: OperationKey::from("trip.set_name"),
355 },
356 ActTarget::Ambiguous {
357 candidates: Vec::new(),
358 },
359 );
360 act.id = ActId::new(UnitId(1), 1);
361 act.arguments.insert(
362 "value".to_owned(),
363 UnderstoodArgument {
364 value: ArgumentValue::Json(serde_json::json!("Lisbon")),
365 excerpt: None,
366 },
367 );
368 act
369 }
370
371 fn tokens() -> TargetTokenMap {
372 let mut map = TargetTokenMap::new(AccountId::from("acct"), TurnId::nil());
373 map.issue(case("trip-1"), "Rossi".to_owned());
374 map.issue(case("trip-2"), "Rossi".to_owned());
375 map
376 }
377
378 fn answer(action: StoredInteractionAction) -> AcceptedResponse {
379 AcceptedResponse {
380 interaction_id: InteractionId::nil(),
381 case_ref: case("trip-1"),
382 kind: InteractionKind::SelectTarget,
383 option_id: OptionId::from("pick"),
384 action,
385 channel: turnframe_core::command::ResolutionChannel::Click,
386 freeform_input: None,
387 payload_hash: Digest::of_bytes(b"p"),
388 }
389 }
390
391 fn payload_with(deferred: &DeferredAct) -> InteractionPayload {
392 deferred.attach_to(InteractionPayload::new("Which one?"))
393 }
394
395 fn token_of(tokens: &TargetTokenMap, id: &str) -> ActTarget {
396 ActTarget::Record {
397 token: tokens.token_for(&case(id).key()).unwrap().clone(),
398 }
399 }
400
401 #[test]
402 fn a_deferred_act_survives_the_payload_round_trip() {
403 let deferred = DeferredAct::unbound(act());
404 let payload = payload_with(&deferred);
405 assert_eq!(DeferredAct::of(&payload), Some(deferred.clone()));
406 let json = serde_json::to_value(&payload).unwrap();
407 let back: InteractionPayload = serde_json::from_value(json).unwrap();
408 assert_eq!(DeferredAct::of(&back), Some(deferred));
409 assert_eq!(DeferredAct::of(&InteractionPayload::new("bare")), None);
410 }
411
412 #[test]
413 fn attaching_keeps_the_applications_own_metadata() {
414 let payload = InteractionPayload::new("Which one?")
415 .with_metadata(serde_json::json!({"preview": "abc"}));
416 let attached = DeferredAct::unbound(act()).attach_to(payload);
417 assert_eq!(attached.metadata["preview"], "abc");
418 assert!(DeferredAct::of(&attached).is_some());
419 let odd = InteractionPayload::new("Which one?").with_metadata(serde_json::json!(7));
420 let untouched = DeferredAct::unbound(act()).attach_to(odd.clone());
421 assert_eq!(untouched.metadata, odd.metadata);
422 }
423
424 #[test]
425 fn a_selection_binds_the_act_to_the_case_the_user_picked() {
426 let tokens = tokens();
427 let payload = payload_with(&DeferredAct::unbound(act()));
428 let resumed = resume(
429 &answer(StoredInteractionAction::SelectTarget {
430 case_ref: case("trip-2"),
431 }),
432 &payload,
433 &tokens,
434 );
435 let act = resumed.act().expect("the act continues");
436 assert_eq!(act.target, token_of(&tokens, "trip-2"));
437 assert_eq!(act.id, card_act_id());
438 assert_eq!(
439 act.arguments["value"].value,
440 ArgumentValue::Json(serde_json::json!("Lisbon"))
441 );
442 }
443
444 #[test]
445 fn a_decline_may_record_that_the_card_was_answered() {
446 let tokens = tokens();
447 let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
448 assert_eq!(
449 resume(
450 &answer(StoredInteractionAction::DeclineCommands),
451 &payload,
452 &tokens
453 ),
454 Resumption::Declined { record: None }
455 );
456 let recording = resume(
457 &answer(StoredInteractionAction::DeclineAndRecord {
458 operation: OperationKey::from("trip.note_declined"),
459 }),
460 &payload,
461 &tokens,
462 );
463 let Resumption::Declined { record: Some(act) } = recording else {
464 panic!("the refusal is recorded: {recording:?}");
465 };
466 assert_eq!(
467 act.action,
468 ActAction::Apply {
469 operation: OperationKey::from("trip.note_declined")
470 }
471 );
472 assert_eq!(act.target, token_of(&tokens, "trip-1"));
473 }
474
475 #[test]
476 fn a_clarification_runs_the_act_against_the_case_it_was_aimed_at() {
477 let tokens = tokens();
478 let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
479 let resumed = resume(
480 &answer(StoredInteractionAction::ResolveClarification {
481 answer_key: crate::reduce::CONDITION_HOLDS_ANSWER.to_owned(),
482 }),
483 &payload,
484 &tokens,
485 );
486 assert_eq!(
487 resumed.act().map(|act| &act.target),
488 Some(&token_of(&tokens, "trip-1"))
489 );
490 assert!(!resumed.is_declined());
491 }
492
493 #[test]
494 fn refusing_is_recorded_and_never_silently_dropped() {
495 let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
496 for action in [
497 StoredInteractionAction::Dismiss,
498 StoredInteractionAction::DeclineCommands,
499 ] {
500 let resumed = resume(&answer(action), &payload, &tokens());
501 assert_eq!(resumed, Resumption::Declined { record: None });
502 }
503 }
504
505 #[test]
506 fn there_is_nothing_to_resume_without_a_card_that_remembers() {
507 let tokens = tokens();
508 let bare = InteractionPayload::new("Which one?");
509 let select = StoredInteractionAction::SelectTarget {
510 case_ref: case("trip-1"),
511 };
512 assert_eq!(resume(&answer(select), &bare, &tokens), Resumption::Nothing);
513 let payload = payload_with(&DeferredAct::on(act(), case("trip-1")));
514 let confirm = StoredInteractionAction::ConfirmCommands {
515 command_refs: Vec::new(),
516 };
517 assert_eq!(
518 resume(&answer(confirm), &payload, &tokens),
519 Resumption::Nothing
520 );
521 let elsewhere = payload_with(&DeferredAct::on(act(), case("trip-9")));
522 let clarify = StoredInteractionAction::ResolveClarification {
523 answer_key: "yes".to_owned(),
524 };
525 assert_eq!(
526 resume(&answer(clarify), &elsewhere, &tokens),
527 Resumption::Nothing
528 );
529 }
530
531 #[test]
532 fn a_start_is_resumed_as_it_stands() {
533 let start = card_act(
534 ActAction::Start {
535 workflow: "trip".into(),
536 },
537 ActTarget::New {
538 workflow: "trip".into(),
539 },
540 );
541 let resumed = resume(
542 &answer(StoredInteractionAction::Custom {
543 key: "app.yes".to_owned(),
544 payload: serde_json::Value::Null,
545 }),
546 &payload_with(&DeferredAct::unbound(start)),
547 &tokens(),
548 );
549 assert!(matches!(
550 resumed.act().map(|act| &act.action),
551 Some(ActAction::Start { .. })
552 ));
553 }
554
555 fn dependent(id: u16, target: ActTarget, depends_on: &[ActId]) -> UnderstoodAct {
556 let mut act = card_act(
557 ActAction::Apply {
558 operation: OperationKey::from("trip.set_traveler"),
559 },
560 target,
561 );
562 act.id = ActId::new(UnitId(1), id);
563 act.depends_on = depends_on.to_vec();
564 act
565 }
566
567 #[test]
568 fn dependents_point_at_the_case_their_prerequisite_made() {
569 let tokens = tokens();
570 let opener = ActId::new(UnitId(1), 1);
571 let mut first = dependent(2, ActTarget::SameTurn { act: opener }, &[opener]);
572 first.arguments.insert(
573 "traveler".to_owned(),
574 UnderstoodArgument {
575 value: ArgumentValue::Record(RecordValue::SameTurn { act: opener }),
576 excerpt: None,
577 },
578 );
579 let second_id = ActId::new(UnitId(1), 2);
580 let second = dependent(3, ActTarget::SameTurn { act: second_id }, &[second_id]);
581 let made = vec![MadeCase {
582 act: opener,
583 case_ref: case("trip-1"),
584 }];
585 let payload = DependentAct {
586 act: second,
587 made: Vec::new(),
588 }
589 .attach_to(DependentAct { act: first, made }.attach_to(InteractionPayload::new("Sure?")));
590 assert_eq!(DependentAct::of(&payload).len(), 2);
591
592 let acts = dependents(&payload, &tokens);
593 assert_eq!(acts[0].id, ActId::new(CARD_UNIT, 2));
594 assert_eq!(acts[0].target, token_of(&tokens, "trip-1"));
595 assert!(acts[0].depends_on.is_empty());
596 let ActTarget::Record { token } = token_of(&tokens, "trip-1") else {
597 unreachable!()
598 };
599 assert_eq!(
600 acts[0].arguments["traveler"].value,
601 ArgumentValue::Record(RecordValue::Record { token })
602 );
603 let renamed = ActId::new(CARD_UNIT, 2);
604 assert_eq!(acts[1].target, ActTarget::SameTurn { act: renamed });
605 assert_eq!(acts[1].depends_on, vec![renamed]);
606 }
607
608 #[test]
609 fn a_card_without_dependents_lets_nothing_run() {
610 assert!(dependents(&InteractionPayload::new("Sure?"), &tokens()).is_empty());
611 }
612}