1use crate::timerange::{Overlap, TimeRange, to_rfc3339};
14
15#[derive(Debug, Clone, PartialEq, Eq)]
17pub enum Mutation {
18 Cancel {
19 namespace: String,
20 workflow_id: String,
21 run_id: String,
22 },
23 Terminate {
24 namespace: String,
25 workflow_id: String,
26 run_id: String,
27 reason: String,
28 },
29 Signal {
30 namespace: String,
31 workflow_id: String,
32 run_id: String,
33 name: String,
34 input: Option<String>,
36 },
37 Delete {
38 namespace: String,
39 workflow_id: String,
40 run_id: String,
41 },
42 Reset {
45 namespace: String,
46 workflow_id: String,
47 run_id: String,
48 event_id: i64,
49 reason: String,
50 },
51 Update {
54 namespace: String,
55 workflow_id: String,
56 run_id: String,
57 name: String,
58 input: Option<String>,
59 },
60 PauseSchedule {
62 namespace: String,
63 schedule_id: String,
64 paused: bool,
65 },
66 TriggerSchedule {
68 namespace: String,
69 schedule_id: String,
70 },
71 DeleteSchedule {
72 namespace: String,
73 schedule_id: String,
74 },
75 CreateSchedule {
78 namespace: String,
79 schedule_id: String,
80 workflow_id: String,
81 workflow_type: String,
82 task_queue: String,
83 spec: String,
84 input: Option<String>,
85 },
86 BackfillSchedule {
88 namespace: String,
89 schedule_id: String,
90 range: TimeRange,
91 overlap: Overlap,
92 },
93}
94
95impl Mutation {
98 pub fn schedule_id(&self) -> Option<&str> {
100 match self {
101 Mutation::PauseSchedule { schedule_id, .. }
102 | Mutation::TriggerSchedule { schedule_id, .. }
103 | Mutation::DeleteSchedule { schedule_id, .. }
104 | Mutation::BackfillSchedule { schedule_id, .. }
105 | Mutation::CreateSchedule { schedule_id, .. } => Some(schedule_id),
106 _ => None,
107 }
108 }
109}
110
111impl Mutation {
112 pub fn verb(&self) -> &'static str {
114 match self {
115 Mutation::Cancel { .. } => "Cancel",
116 Mutation::Terminate { .. } => "Terminate",
117 Mutation::Signal { .. } => "Signal",
118 Mutation::Delete { .. } => "Delete",
119 Mutation::Reset { .. } => "Reset",
120 Mutation::Update { .. } => "Update",
121 Mutation::PauseSchedule { paused: true, .. } => "Pause",
122 Mutation::PauseSchedule { paused: false, .. } => "Resume",
123 Mutation::TriggerSchedule { .. } => "Trigger",
124 Mutation::DeleteSchedule { .. } => "Delete schedule",
125 Mutation::BackfillSchedule { .. } => "Backfill",
126 Mutation::CreateSchedule { .. } => "Create schedule",
127 }
128 }
129
130 pub fn past_tense(&self) -> &'static str {
133 match self {
134 Mutation::Cancel { .. } => "cancelled",
135 Mutation::Terminate { .. } => "terminated",
136 Mutation::Signal { .. } => "signalled",
137 Mutation::Delete { .. } => "deleted",
138 Mutation::Reset { .. } => "reset",
139 Mutation::Update { .. } => "updated",
140 Mutation::PauseSchedule { paused: true, .. } => "paused",
141 Mutation::PauseSchedule { paused: false, .. } => "resumed",
142 Mutation::TriggerSchedule { .. } => "triggered",
143 Mutation::DeleteSchedule { .. } => "deleted",
144 Mutation::BackfillSchedule { .. } => "backfilled",
145 Mutation::CreateSchedule { .. } => "created",
146 }
147 }
148
149 pub fn subject_plural(&self) -> &'static str {
151 match self {
152 Mutation::Cancel { .. }
153 | Mutation::Terminate { .. }
154 | Mutation::Signal { .. }
155 | Mutation::Delete { .. }
156 | Mutation::Reset { .. }
157 | Mutation::Update { .. } => "workflows",
158 Mutation::PauseSchedule { .. }
159 | Mutation::TriggerSchedule { .. }
160 | Mutation::DeleteSchedule { .. }
161 | Mutation::BackfillSchedule { .. }
162 | Mutation::CreateSchedule { .. } => "schedules",
163 }
164 }
165
166 pub fn namespace(&self) -> &str {
167 match self {
168 Mutation::Cancel { namespace, .. }
169 | Mutation::Terminate { namespace, .. }
170 | Mutation::Signal { namespace, .. }
171 | Mutation::Delete { namespace, .. }
172 | Mutation::Reset { namespace, .. }
173 | Mutation::Update { namespace, .. }
174 | Mutation::PauseSchedule { namespace, .. }
175 | Mutation::TriggerSchedule { namespace, .. }
176 | Mutation::DeleteSchedule { namespace, .. }
177 | Mutation::BackfillSchedule { namespace, .. }
178 | Mutation::CreateSchedule { namespace, .. } => namespace,
179 }
180 }
181
182 pub fn workflow_id(&self) -> &str {
183 match self {
184 Mutation::Cancel { workflow_id, .. }
185 | Mutation::Terminate { workflow_id, .. }
186 | Mutation::Signal { workflow_id, .. }
187 | Mutation::Delete { workflow_id, .. }
188 | Mutation::Reset { workflow_id, .. }
189 | Mutation::Update { workflow_id, .. } => workflow_id,
190 Mutation::PauseSchedule { schedule_id, .. }
192 | Mutation::TriggerSchedule { schedule_id, .. }
193 | Mutation::DeleteSchedule { schedule_id, .. }
194 | Mutation::BackfillSchedule { schedule_id, .. }
195 | Mutation::CreateSchedule { schedule_id, .. } => schedule_id,
196 }
197 }
198
199 pub fn run_id(&self) -> &str {
200 match self {
201 Mutation::Cancel { run_id, .. }
202 | Mutation::Terminate { run_id, .. }
203 | Mutation::Signal { run_id, .. }
204 | Mutation::Delete { run_id, .. }
205 | Mutation::Reset { run_id, .. }
206 | Mutation::Update { run_id, .. } => run_id,
207 Mutation::PauseSchedule { .. }
208 | Mutation::TriggerSchedule { .. }
209 | Mutation::DeleteSchedule { .. }
210 | Mutation::BackfillSchedule { .. }
211 | Mutation::CreateSchedule { .. } => "",
212 }
213 }
214
215 pub fn is_destructive(&self) -> bool {
220 !matches!(
224 self,
225 Mutation::Signal { .. }
226 | Mutation::Update { .. }
227 | Mutation::PauseSchedule { .. }
228 | Mutation::TriggerSchedule { .. }
229 | Mutation::BackfillSchedule { .. }
230 | Mutation::CreateSchedule { .. }
231 )
232 }
233
234 pub fn destroys_history(&self) -> bool {
237 matches!(
238 self,
239 Mutation::Delete { .. } | Mutation::DeleteSchedule { .. }
240 )
241 }
242
243 pub fn cli(&self) -> String {
249 let base = |verb: &str, m: &Mutation| {
250 format!(
251 "temporal workflow {verb} --namespace {} --workflow-id {} --run-id {}",
252 shell_quote(m.namespace()),
253 shell_quote(m.workflow_id()),
254 shell_quote(m.run_id()),
255 )
256 };
257 match self {
258 Mutation::Cancel { .. } => base("cancel", self),
259 Mutation::Delete { .. } => base("delete", self),
260 Mutation::Terminate { reason, .. } => {
261 format!(
262 "{} --reason {}",
263 base("terminate", self),
264 shell_quote(reason)
265 )
266 }
267 Mutation::Signal { name, input, .. } => {
268 let mut out = format!("{} --name {}", base("signal", self), shell_quote(name));
269 if let Some(input) = input {
270 out.push_str(&format!(" --input {}", shell_quote(input)));
271 }
272 out
273 }
274 Mutation::Reset {
275 event_id, reason, ..
276 } => format!(
277 "{} --event-id {event_id} --reason {}",
278 base("reset", self),
279 shell_quote(reason)
280 ),
281 Mutation::Update { name, input, .. } => {
284 let mut out = format!(
285 "temporal workflow update execute --namespace {} --workflow-id {} \
286 --run-id {} --name {}",
287 shell_quote(self.namespace()),
288 shell_quote(self.workflow_id()),
289 shell_quote(self.run_id()),
290 shell_quote(name)
291 );
292 if let Some(input) = input {
293 out.push_str(&format!(" --input {}", shell_quote(input)));
294 }
295 out
296 }
297 Mutation::PauseSchedule {
298 namespace,
299 schedule_id,
300 paused,
301 } => format!(
302 "temporal schedule toggle --namespace {} --schedule-id {} {}",
303 shell_quote(namespace),
304 shell_quote(schedule_id),
305 if *paused { "--pause" } else { "--unpause" }
306 ),
307 Mutation::TriggerSchedule {
308 namespace,
309 schedule_id,
310 } => format!(
311 "temporal schedule trigger --namespace {} --schedule-id {}",
312 shell_quote(namespace),
313 shell_quote(schedule_id)
314 ),
315 Mutation::DeleteSchedule {
316 namespace,
317 schedule_id,
318 } => format!(
319 "temporal schedule delete --namespace {} --schedule-id {}",
320 shell_quote(namespace),
321 shell_quote(schedule_id)
322 ),
323 Mutation::CreateSchedule {
324 namespace,
325 schedule_id,
326 workflow_id,
327 workflow_type,
328 task_queue,
329 spec,
330 input,
331 } => {
332 let mut out = format!(
333 "temporal schedule create --namespace {} --schedule-id {} \
334 --workflow-id {} --type {} --task-queue {} --cron {}",
335 shell_quote(namespace),
336 shell_quote(schedule_id),
337 shell_quote(workflow_id),
338 shell_quote(workflow_type),
339 shell_quote(task_queue),
340 shell_quote(spec)
341 );
342 if let Some(input) = input {
343 out.push_str(&format!(" --input {}", shell_quote(input)));
344 }
345 out
346 }
347 Mutation::BackfillSchedule {
348 namespace,
349 schedule_id,
350 range,
351 overlap,
352 } => format!(
353 "temporal schedule backfill --namespace {} --schedule-id {} \
354 --start-time {} --end-time {} --overlap-policy {}",
355 shell_quote(namespace),
356 shell_quote(schedule_id),
357 to_rfc3339(range.start_ms),
358 to_rfc3339(range.end_ms),
359 overlap.name()
360 ),
361 }
362 }
363
364 pub fn audit_line(&self, at_epoch_millis: i64, target: Target<'_>, outcome: &str) -> String {
369 let mut out = String::from("{");
370 out.push_str(&format!(r#""at":{at_epoch_millis},"#));
371 out.push_str(&format!(r#""action":{},"#, json_string(self.verb())));
372 out.push_str(&format!(r#""profile":{},"#, json_string(target.profile)));
373 out.push_str(&format!(r#""address":{},"#, json_string(target.address)));
374 out.push_str(&format!(
375 r#""namespace":{},"#,
376 json_string(self.namespace())
377 ));
378 out.push_str(&format!(
379 r#""workflowId":{},"#,
380 json_string(self.workflow_id())
381 ));
382 out.push_str(&format!(r#""runId":{},"#, json_string(self.run_id())));
383 out.push_str(&format!(r#""outcome":{},"#, json_string(outcome)));
384 out.push_str(&format!(r#""command":{}"#, json_string(&self.cli())));
385 out.push('}');
386 out
387 }
388}
389
390#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub struct Target<'a> {
396 pub profile: &'a str,
397 pub address: &'a str,
398}
399
400#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct Confirm {
403 pub mutations: Vec<Mutation>,
406 pub typed_word: Option<String>,
409 pub entered: String,
411}
412
413impl Confirm {
414 pub fn new(mutation: Mutation) -> Self {
415 Self::batch(vec![mutation])
416 }
417
418 pub fn batch(mutations: Vec<Mutation>) -> Self {
424 let typed_word = if mutations.iter().any(Mutation::destroys_history) {
425 Some("delete".to_string())
426 } else if mutations.len() > 1 && mutations.iter().any(Mutation::is_destructive) {
427 Some(mutations.len().to_string())
428 } else {
429 None
430 };
431 Self {
432 mutations,
433 typed_word,
434 entered: String::new(),
435 }
436 }
437
438 pub fn first(&self) -> &Mutation {
441 &self.mutations[0]
442 }
443
444 pub fn len(&self) -> usize {
445 self.mutations.len()
446 }
447
448 pub fn is_empty(&self) -> bool {
449 self.mutations.is_empty()
450 }
451
452 pub fn is_batch(&self) -> bool {
454 self.mutations.len() > 1
455 }
456
457 pub fn is_satisfied(&self) -> bool {
459 match &self.typed_word {
460 None => true,
461 Some(word) => self.entered.trim() == word,
462 }
463 }
464
465 pub fn caution(&self) -> Option<String> {
472 self.typed_word.as_ref()?;
473 Some(if self.first().destroys_history() {
474 "this destroys the history itself.".to_string()
475 } else {
476 format!(
477 "this covers {} {}.",
478 self.len(),
479 self.first().subject_plural()
480 )
481 })
482 }
483
484 pub fn prompt(&self) -> String {
486 match &self.typed_word {
487 None => "⏎ to confirm Esc to cancel".into(),
488 Some(word) if self.is_satisfied() => "⏎ to confirm Esc to cancel".into(),
489 Some(word) => format!("type `{word}` to confirm Esc to cancel"),
490 }
491 }
492}
493
494pub fn shell_quote(s: &str) -> String {
500 if !s.is_empty()
501 && s.chars()
502 .all(|c| c.is_ascii_alphanumeric() || "-_./:@".contains(c))
503 {
504 return s.to_string();
505 }
506 format!("'{}'", s.replace('\'', r"'\''"))
507}
508
509fn json_string(s: &str) -> String {
510 let mut out = String::with_capacity(s.len() + 2);
511 out.push('"');
512 for c in s.chars() {
513 match c {
514 '"' => out.push_str("\\\""),
515 '\\' => out.push_str("\\\\"),
516 '\n' => out.push_str("\\n"),
517 '\r' => out.push_str("\\r"),
518 '\t' => out.push_str("\\t"),
519 c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
520 c => out.push(c),
521 }
522 }
523 out.push('"');
524 out
525}
526
527#[cfg(test)]
528mod tests {
529 use super::*;
530
531 fn terminate(reason: &str) -> Mutation {
532 Mutation::Terminate {
533 namespace: "default".into(),
534 workflow_id: "order-1".into(),
535 run_id: "run-abc".into(),
536 reason: reason.into(),
537 }
538 }
539
540 #[test]
541 fn the_caution_says_why_the_word_is_owed_rather_than_assuming_delete() {
542 let wf = |id: &str| Mutation::Terminate {
545 namespace: "default".into(),
546 workflow_id: id.into(),
547 run_id: "r".into(),
548 reason: "r".into(),
549 };
550 let batch = Confirm::batch(vec![wf("a"), wf("b"), wf("c")]);
551 assert_eq!(batch.typed_word.as_deref(), Some("3"));
552 assert_eq!(batch.caution().as_deref(), Some("this covers 3 workflows."));
553
554 let del = Confirm::new(Mutation::Delete {
555 namespace: "default".into(),
556 workflow_id: "a".into(),
557 run_id: "r".into(),
558 });
559 assert_eq!(del.typed_word.as_deref(), Some("delete"));
560 assert_eq!(
561 del.caution().as_deref(),
562 Some("this destroys the history itself.")
563 );
564
565 assert_eq!(
566 Confirm::new(wf("a")).caution(),
567 None,
568 "one row owes nothing"
569 );
570 }
571
572 #[test]
573 fn a_backfill_renders_the_cli_with_rfc3339_bounds() {
574 let m = Mutation::BackfillSchedule {
577 namespace: "default".into(),
578 schedule_id: "nightly recon".into(),
579 range: TimeRange {
580 start_ms: 1_788_566_400_000,
581 end_ms: 1_788_652_800_000,
582 },
583 overlap: Overlap::BufferAll,
584 };
585 assert_eq!(
586 m.cli(),
587 "temporal schedule backfill --namespace default --schedule-id 'nightly recon' \
588 --start-time 2026-09-05T00:00:00Z --end-time 2026-09-06T00:00:00Z \
589 --overlap-policy BufferAll"
590 );
591 assert_eq!(m.verb(), "Backfill");
592 assert_eq!(m.past_tense(), "backfilled");
593 assert_eq!(m.schedule_id(), Some("nightly recon"));
594 assert!(!m.is_destructive(), "it starts runs, it destroys nothing");
595 }
596
597 #[test]
598 fn a_terminate_renders_the_command_that_would_do_it() {
599 assert_eq!(
600 terminate("stuck").cli(),
601 "temporal workflow terminate --namespace default --workflow-id order-1 \
602 --run-id run-abc --reason stuck"
603 );
604 }
605
606 #[test]
607 fn a_cancel_and_a_delete_carry_no_reason() {
608 let cancel = Mutation::Cancel {
609 namespace: "payments".into(),
610 workflow_id: "charge-9".into(),
611 run_id: "r1".into(),
612 };
613 assert_eq!(
614 cancel.cli(),
615 "temporal workflow cancel --namespace payments --workflow-id charge-9 --run-id r1"
616 );
617 let delete = Mutation::Delete {
618 namespace: "payments".into(),
619 workflow_id: "charge-9".into(),
620 run_id: "r1".into(),
621 };
622 assert!(delete.cli().starts_with("temporal workflow delete "));
623 }
624
625 #[test]
626 fn a_signal_without_input_does_not_pass_an_empty_one() {
627 let signal = Mutation::Signal {
629 namespace: "default".into(),
630 workflow_id: "w".into(),
631 run_id: "r".into(),
632 name: "ping".into(),
633 input: None,
634 };
635 assert!(!signal.cli().contains("--input"));
636
637 let with_input = Mutation::Signal {
638 namespace: "default".into(),
639 workflow_id: "w".into(),
640 run_id: "r".into(),
641 name: "ping".into(),
642 input: Some(r#"{"a":1}"#.into()),
643 };
644 assert!(with_input.cli().ends_with(r#"--input '{"a":1}'"#));
645 }
646
647 #[test]
648 fn values_that_would_break_a_shell_are_quoted() {
649 let m = Mutation::Terminate {
652 namespace: "default".into(),
653 workflow_id: "order 1; rm -rf /".into(),
654 run_id: "r".into(),
655 reason: "it's stuck".into(),
656 };
657 let cli = m.cli();
658 assert!(cli.contains("'order 1; rm -rf /'"), "got {cli}");
659 assert!(cli.contains(r"'it'\''s stuck'"), "got {cli}");
661 }
662
663 #[test]
664 fn plain_values_are_not_quoted_needlessly() {
665 assert_eq!(shell_quote("order-1"), "order-1");
667 assert_eq!(shell_quote("ns.with.dots"), "ns.with.dots");
668 assert_eq!(shell_quote(""), "''", "an empty value still needs quotes");
669 assert_eq!(shell_quote("has space"), "'has space'");
670 }
671
672 #[test]
673 fn a_reset_names_the_event_it_goes_back_to() {
674 let m = Mutation::Reset {
675 namespace: "default".into(),
676 workflow_id: "order-1".into(),
677 run_id: "r".into(),
678 event_id: 9,
679 reason: "bad deploy".into(),
680 };
681 assert_eq!(
682 m.cli(),
683 "temporal workflow reset --namespace default --workflow-id order-1 --run-id r \
684 --event-id 9 --reason 'bad deploy'"
685 );
686 assert!(m.is_destructive());
689 assert!(!m.destroys_history(), "the history is still there");
690 assert_eq!(Confirm::new(m).typed_word, None);
691 }
692
693 #[test]
694 fn an_update_uses_execute_because_tmprl_waits_for_the_outcome() {
695 let m = Mutation::Update {
698 namespace: "default".into(),
699 workflow_id: "w".into(),
700 run_id: "r".into(),
701 name: "setLimit".into(),
702 input: Some("50".into()),
703 };
704 let cli = m.cli();
705 assert!(
706 cli.starts_with("temporal workflow update execute "),
707 "{cli}"
708 );
709 assert!(cli.ends_with("--name setLimit --input 50"), "{cli}");
710 assert!(!m.is_destructive());
712 }
713
714 #[test]
715 fn an_update_without_input_passes_none() {
716 let m = Mutation::Update {
717 namespace: "d".into(),
718 workflow_id: "w".into(),
719 run_id: "r".into(),
720 name: "ping".into(),
721 input: None,
722 };
723 assert!(!m.cli().contains("--input"));
724 }
725
726 #[test]
727 fn every_verb_has_a_past_tense_that_is_a_word() {
728 for (m, expected) in [
730 (
731 Mutation::Cancel {
732 namespace: "d".into(),
733 workflow_id: "w".into(),
734 run_id: "r".into(),
735 },
736 "cancelled",
737 ),
738 (terminate("x"), "terminated"),
739 (
740 Mutation::Reset {
741 namespace: "d".into(),
742 workflow_id: "w".into(),
743 run_id: "r".into(),
744 event_id: 1,
745 reason: "x".into(),
746 },
747 "reset",
748 ),
749 ] {
750 assert_eq!(m.past_tense(), expected);
751 }
752 }
753
754 #[test]
755 fn pausing_a_schedule_renders_the_toggle_command() {
756 let m = Mutation::PauseSchedule {
758 namespace: "payments".into(),
759 schedule_id: "nightly".into(),
760 paused: true,
761 };
762 assert_eq!(
763 m.cli(),
764 "temporal schedule toggle --namespace payments --schedule-id nightly --pause"
765 );
766 assert_eq!(m.verb(), "Pause");
767 assert_eq!(m.past_tense(), "paused");
768 assert!(!m.is_destructive(), "unpausing puts it back");
769
770 let resumed = Mutation::PauseSchedule {
771 namespace: "payments".into(),
772 schedule_id: "nightly".into(),
773 paused: false,
774 };
775 assert!(resumed.cli().ends_with("--unpause"));
776 assert_eq!(resumed.verb(), "Resume");
777 }
778
779 #[test]
780 fn triggering_is_reversible_but_deleting_a_schedule_is_not() {
781 let trigger = Mutation::TriggerSchedule {
782 namespace: "d".into(),
783 schedule_id: "s".into(),
784 };
785 assert!(!trigger.is_destructive());
786 assert_eq!(
787 trigger.cli(),
788 "temporal schedule trigger --namespace d --schedule-id s"
789 );
790
791 let delete = Mutation::DeleteSchedule {
792 namespace: "d".into(),
793 schedule_id: "s".into(),
794 };
795 assert!(delete.is_destructive());
796 assert!(delete.destroys_history(), "the schedule is gone for good");
797 assert_eq!(Confirm::new(delete).typed_word.as_deref(), Some("delete"));
798 }
799
800 #[test]
801 fn a_schedule_operation_reports_its_id_and_has_no_run() {
802 let m = Mutation::TriggerSchedule {
803 namespace: "d".into(),
804 schedule_id: "nightly".into(),
805 };
806 assert_eq!(m.schedule_id(), Some("nightly"));
807 assert_eq!(m.workflow_id(), "nightly", "the id column shows it");
808 assert_eq!(m.run_id(), "", "a schedule has no execution");
809 assert_eq!(terminate("x").schedule_id(), None);
810 }
811
812 #[test]
813 fn a_signal_is_a_change_but_not_a_loss() {
814 let signal = Mutation::Signal {
815 namespace: "d".into(),
816 workflow_id: "w".into(),
817 run_id: "r".into(),
818 name: "n".into(),
819 input: None,
820 };
821 assert!(!signal.is_destructive());
822 assert!(terminate("x").is_destructive());
823 }
824
825 #[test]
826 fn only_delete_destroys_the_history_and_only_it_asks_for_a_word() {
827 let delete = Mutation::Delete {
828 namespace: "d".into(),
829 workflow_id: "w".into(),
830 run_id: "r".into(),
831 };
832 assert!(delete.destroys_history());
833 assert!(!terminate("x").destroys_history());
834
835 let mut confirm = Confirm::new(delete);
836 assert_eq!(confirm.typed_word.as_deref(), Some("delete"));
837 assert!(!confirm.is_satisfied(), "a keypress is too cheap for this");
838 assert!(confirm.prompt().contains("type `delete`"));
839
840 confirm.entered = "delet".into();
841 assert!(!confirm.is_satisfied(), "nearly is not the same as typed");
842 confirm.entered = "delete".into();
843 assert!(confirm.is_satisfied());
844 assert!(confirm.prompt().contains("⏎"));
845 }
846
847 #[test]
848 fn everything_else_needs_one_confirmation_and_no_typing() {
849 let confirm = Confirm::new(terminate("stuck"));
850 assert_eq!(confirm.typed_word, None);
851 assert!(confirm.is_satisfied());
852 assert!(confirm.prompt().contains("⏎ to confirm"));
853 }
854
855 fn sit() -> Target<'static> {
857 Target {
858 profile: "sit",
859 address: "http://temporal-sit.internal:7233",
860 }
861 }
862
863 #[test]
864 fn an_audit_line_records_what_was_done_and_how_it_ended() {
865 let line = terminate("stuck").audit_line(1_700_000_000_000, sit(), "ok");
866 assert!(line.contains(r#""action":"Terminate""#), "{line}");
867 assert!(line.contains(r#""workflowId":"order-1""#), "{line}");
868 assert!(line.contains(r#""outcome":"ok""#), "{line}");
869 assert!(line.contains(r#""at":1700000000000"#), "{line}");
870 assert!(line.contains("temporal workflow terminate"), "{line}");
872 assert!(!line.contains('\n'));
874 }
875
876 #[test]
877 fn an_audit_line_escapes_what_it_quotes() {
878 let m = Mutation::Terminate {
879 namespace: "d".into(),
880 workflow_id: "has \"quotes\" and\nnewline".into(),
881 run_id: "r".into(),
882 reason: "x".into(),
883 };
884 let line = m.audit_line(0, sit(), "failed");
885 assert!(
886 !line.contains('\n'),
887 "a JSONL line cannot contain a newline"
888 );
889 assert!(line.contains(r#"\"quotes\""#), "{line}");
890 }
891
892 #[test]
893 fn a_failed_mutation_is_still_recorded() {
894 let line = terminate("x").audit_line(1, sit(), "failed: permission denied");
896 assert!(line.contains("permission denied"), "{line}");
897 }
898
899 #[test]
900 fn an_audit_line_records_which_cluster_was_hit() {
901 let line = terminate("stuck").audit_line(1, sit(), "ok");
904 assert!(line.contains(r#""profile":"sit""#), "{line}");
905 assert!(
906 line.contains(r#""address":"http://temporal-sit.internal:7233""#),
907 "{line}"
908 );
909
910 let prod = Target {
911 profile: "prod",
912 address: "http://temporal.internal:7233",
913 };
914 let other = terminate("stuck").audit_line(1, prod, "ok");
915 assert_ne!(
916 line, other,
917 "the same namespace on two clusters must differ"
918 );
919 }
920}