Skip to main content

tmprl_core/
mutation.rs

1//! Things that change a cluster, and the confirmation that stands in front of them.
2//!
3//! Everything up to now has been a reader. These are the operations that cannot be undone by
4//! pressing `R`, so the design in `docs/ARCHITECTURE.md` §9 puts one confirmation in front of
5//! every one of them, and that confirmation shows **the equivalent `temporal` CLI command**.
6//!
7//! That last part is the load-bearing bit. It teaches the CLI, it makes the action auditable
8//! at a glance, you can read exactly what is about to happen rather than trusting a verb,
9//! and it gives an escape hatch to anyone who would rather run it themselves. Which means the
10//! rendered command has to be *correct*: someone will copy it and run it. The flags here are
11//! checked against `temporal workflow --help`, and the quoting is tested.
12
13use crate::timerange::{Overlap, TimeRange, to_rfc3339};
14
15/// A change to a cluster, fully specified.
16#[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        /// JSON, as typed. `None` sends no input at all, which is not the same as `null`.
35        input: Option<String>,
36    },
37    Delete {
38        namespace: String,
39        workflow_id: String,
40        run_id: String,
41    },
42    /// Rewind to a completed workflow task and replay forward from there. The event id is
43    /// already resolved to a valid reset point. See `history::reset_point`.
44    Reset {
45        namespace: String,
46        workflow_id: String,
47        run_id: String,
48        event_id: i64,
49        reason: String,
50    },
51    /// Send an update and wait for its outcome. Unlike a signal, an update can be rejected
52    /// by the workflow and reports back.
53    Update {
54        namespace: String,
55        workflow_id: String,
56        run_id: String,
57        name: String,
58        input: Option<String>,
59    },
60    /// Pause or resume a schedule. `paused` is the state being moved *to*.
61    PauseSchedule {
62        namespace: String,
63        schedule_id: String,
64        paused: bool,
65    },
66    /// Run a scheduled workflow now, without waiting for its next time.
67    TriggerSchedule {
68        namespace: String,
69        schedule_id: String,
70    },
71    DeleteSchedule {
72        namespace: String,
73        schedule_id: String,
74    },
75    /// Create a schedule. `spec` is passed to the server as a cron string, which also
76    /// accepts `@every 1h`.
77    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    /// Replay every action the schedule would have taken over a past window.
87    BackfillSchedule {
88        namespace: String,
89        schedule_id: String,
90        range: TimeRange,
91        overlap: Overlap,
92    },
93}
94
95/// The three schedule operations act on a schedule id rather than an execution, so they do
96/// not share the workflow accessors.
97impl Mutation {
98    /// The schedule this acts on, if it is a schedule operation at all.
99    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    /// What the confirmation calls it.
113    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    /// How to say it happened. Spelled out rather than derived: "cancel" + "d" is "canceld",
131    /// and a status line that cannot spell does not inspire confidence in what it just did.
132    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    /// What a batch of this is a batch *of*, for the heading over a count.
150    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            // Schedule operations have no execution; the id is the schedule's.
191            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    /// Whether the workflow does not survive it.
216    ///
217    /// A signal is a change but not a loss; a delete removes the history itself and cannot be
218    /// walked back even by starting the workflow again.
219    pub fn is_destructive(&self) -> bool {
220        // A reset abandons everything after the reset point, so it loses work even though
221        // the workflow survives. An update, like a signal, only adds to it. Pausing and
222        // triggering a schedule are reversible by doing the opposite.
223        !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    /// Whether it destroys the record as well as the run. Only `delete` does, which is why it
235    /// is the one that asks for more than a keypress.
236    pub fn destroys_history(&self) -> bool {
237        matches!(
238            self,
239            Mutation::Delete { .. } | Mutation::DeleteSchedule { .. }
240        )
241    }
242
243    /// The equivalent `temporal` command, ready to paste into a shell.
244    ///
245    /// Flags follow `temporal workflow --help`: `-w/--workflow-id`, `-r/--run-id`,
246    /// `-n/--namespace`, `--reason`, `--name`, `--input`. The long forms are used because
247    /// this is meant to be read as much as run.
248    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            // `update execute` rather than `update start`: tmprl waits for the outcome, and
282            // the command shown should be the one that behaves the same way.
283            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    /// One line for `~/.local/state/tmprl/audit.jsonl`.
365    ///
366    /// The CLI equivalent is recorded alongside the fields, so the log answers "what was
367    /// actually done" without the reader having to reconstruct it from parts.
368    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/// Which cluster a mutation was sent to.
391///
392/// Namespace names repeat across environments, so a namespace alone cannot answer "was
393/// this SIT or production". Profile and address together can.
394#[derive(Debug, Clone, Copy, PartialEq, Eq)]
395pub struct Target<'a> {
396    pub profile: &'a str,
397    pub address: &'a str,
398}
399
400/// What a confirmation is waiting for.
401#[derive(Debug, Clone, PartialEq, Eq)]
402pub struct Confirm {
403    /// Every mutation this confirmation covers, in the order they will run. One for an
404    /// ordinary action, one per selected row for a batch.
405    pub mutations: Vec<Mutation>,
406    /// When set, the reader must type this exactly before the action is allowed. Reserved for
407    /// the operations where a single keypress is too cheap for what it does.
408    pub typed_word: Option<String>,
409    /// What they have typed so far.
410    pub entered: String,
411}
412
413impl Confirm {
414    pub fn new(mutation: Mutation) -> Self {
415        Self::batch(vec![mutation])
416    }
417
418    /// A confirmation over several rows.
419    ///
420    /// The word owed scales with what is at stake: destroying histories always costs the
421    /// word, and a destructive batch costs the count, because one keypress is too cheap to
422    /// end a dozen workflows at once.
423    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    /// The mutation that stands for the set: the first, which every other one matches in
439    /// verb and namespace because a batch is one action over many rows.
440    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    /// Whether this covers more than one row, which is what the heading has to say.
453    pub fn is_batch(&self) -> bool {
454        self.mutations.len() > 1
455    }
456
457    /// Whether pressing Enter now would go ahead.
458    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    /// Why a word is owed at all.
466    ///
467    /// The renderer must not infer this from the word: "delete" is owed because a history is
468    /// about to stop existing, a count is owed because one keypress is too cheap for the
469    /// number of rows, and telling a reader that terminating destroys a history would be a
470    /// lie in the one place that has to be exact.
471    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    /// What to tell the reader they still owe.
485    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
494/// Quote a value for a POSIX shell.
495///
496/// Single quotes, with the one escape a single-quoted string needs. This matters more than it
497/// looks: the rendered command is meant to be copied and run, and a workflow id can contain
498/// spaces, quotes or a `;`. A command that is *almost* right is worse than none.
499pub 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        // Telling a reader that terminating destroys a history would be a lie in the one
543        // place that has to be exact.
544        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        // Someone will copy this line and run it, so the times have to be in the format
575        // `temporal schedule backfill` accepts, not the millis tmprl holds internally.
576        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        // `--input ''` is a JSON parse error, not "no input".
628        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        // Someone will copy this and run it. A workflow id with a space or a semicolon must
650        // not turn into two commands.
651        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        // The one escape a single-quoted shell string needs.
660        assert!(cli.contains(r"'it'\''s stuck'"), "got {cli}");
661    }
662
663    #[test]
664    fn plain_values_are_not_quoted_needlessly() {
665        // The command is meant to be read as much as run.
666        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        // A reset abandons everything after the point, so it loses work even though the
687        // workflow survives.
688        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        // `update start` returns once accepted; `update execute` waits. The command shown
696        // should behave the way tmprl does.
697        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        // Like a signal, an update adds to a workflow rather than ending it.
711        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        // "cancel" + "d" is "canceld".
729        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        // The CLI has no `pause` subcommand: it is `toggle --pause`.
757        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    /// The cluster the test mutations are sent to.
856    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        // The command goes in too, so the log answers "what happened" on its own.
871        assert!(line.contains("temporal workflow terminate"), "{line}");
872        // And it is one line, because the file is JSONL.
873        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        // The log is what was attempted, not only what succeeded.
895        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        // Two environments sharing a namespace name produce lines that differ only here,
902        // which is the whole reason the target is recorded.
903        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}