Skip to main content

rite_runtime/
reporter.rs

1//! Action-facing handle for emitting events and prompting the operator.
2//!
3//! The [`Reporter`] is held by the executor and threaded into every action
4//! handler. It is the only way handlers interact with the outside world ,
5//! they cannot write to the transcript, send to the UI channel, or read
6//! commands directly. This keeps the audit surface narrow and consistent.
7//!
8//! # Responsibilities
9//!
10//! - **Facts**: [`Reporter::fact`] records a [`StepFact`] to the inline
11//!   [`TranscriptSink`] and forwards it to the UI as [`ExecEvent::Fact`].
12//! - **Signals**: [`Reporter::log`] and [`Reporter::progress`] emit
13//!   [`UiSignal`]s that never touch the transcript.
14//! - **Prompts**: [`Reporter::prompt`] emits an [`ExecEvent::AwaitPrompt`],
15//!   blocks for a matching response, validates it, and automatically
16//!   records [`StepFact::PromptAnswered`] on acceptance.
17//! - **Cooperative cancellation**: [`Reporter::check_abort`] drains any
18//!   pending [`UiCommand`]s, returning [`ReporterError::Aborted`] if the
19//!   operator requested abort.
20//!
21//! While blocking for a prompt response, the reporter also processes any
22//! [`UiCommand::LogDeviation`] and [`UiCommand::Abort`] commands that
23//! arrive, so deviations are recorded with no lag and abort interrupts
24//! the prompt cleanly.
25
26use std::sync::Arc;
27
28use crossbeam_channel::{Receiver, Sender, TryRecvError};
29use rite_model::{ErrorClass, ErrorRecord, Level, Prompt, ResponseRecord, StepFact, StepId};
30use secrecy::ExposeSecret;
31use thiserror::Error;
32
33use crate::clock::Clock;
34use crate::entropy::CeremonyRandom;
35use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal};
36use crate::transcript_sink::TranscriptSink;
37
38/// Errors that can arise while emitting events or awaiting a response.
39#[derive(Debug, Error)]
40pub enum ReporterError {
41    /// The operator requested an abort. Propagated up through the action
42    /// handler so the executor can unwind to a clean failure.
43    #[error("ceremony aborted by operator")]
44    Aborted,
45    /// The UI thread disconnected from one of the channels. The executor
46    /// treats this as a fatal failure (the operator is no longer reachable).
47    #[error("frontend channel disconnected")]
48    Disconnected,
49    /// Failed to persist a fact to the transcript sink.
50    #[error("transcript write failed: {0}")]
51    Transcript(#[from] std::io::Error),
52    /// Reporter was asked to emit a step-scoped signal but no step is set.
53    #[error("internal: no current step set for {0}")]
54    NoCurrentStep(&'static str),
55    /// An action drew the same `(step, purpose)` twice. A purpose is drawn at
56    /// most once per step; a repeat would reuse the value, so it is a bug in
57    /// the action and fails the ceremony.
58    #[error("duplicate entropy draw for purpose '{purpose}' in step '{step}'")]
59    DuplicateDraw {
60        /// Step that issued the duplicate draw.
61        step: StepId,
62        /// Purpose drawn more than once.
63        purpose: String,
64    },
65    /// A value was drawn before the entropy source was seeded. The runner
66    /// installs the machine seed at ceremony start, before any step can run,
67    /// so this is an internal ordering bug, never an operator-reachable state.
68    #[error("internal: entropy source drawn before it was seeded")]
69    Unseeded,
70    /// An action requested more bytes than a single `HKDF-Expand` can produce
71    /// ([`MAX_DRAW_LEN`](crate::entropy::MAX_DRAW_LEN) bytes). The draw side
72    /// rejects it here so the derivation fails cleanly instead of panicking
73    /// inside `expand`. Every value a ceremony draws (nonces, serials,
74    /// challenges) is far below this, so a request beyond it is an action bug.
75    #[error("entropy draw of {len} bytes exceeds the {max}-byte HKDF-Expand limit")]
76    DrawTooLong {
77        /// Requested draw length in bytes.
78        len: usize,
79        /// The `HKDF-Expand` output limit.
80        max: usize,
81    },
82}
83
84impl ReporterError {
85    /// Convert this error into an [`ErrorRecord`] suitable for the transcript.
86    #[must_use]
87    pub fn to_error_record(&self) -> ErrorRecord {
88        let (class, kind) = match self {
89            ReporterError::Aborted => (ErrorClass::Abort, "aborted"),
90            ReporterError::Disconnected => (ErrorClass::Integrity, "frontend_disconnected"),
91            ReporterError::Transcript(_) => (ErrorClass::Integrity, "transcript_io"),
92            ReporterError::NoCurrentStep(_) => (ErrorClass::Integrity, "internal_no_current_step"),
93            ReporterError::DuplicateDraw { .. } => {
94                (ErrorClass::Integrity, "duplicate_entropy_draw")
95            }
96            ReporterError::Unseeded => (ErrorClass::Integrity, "internal_entropy_unseeded"),
97            ReporterError::DrawTooLong { .. } => (ErrorClass::Integrity, "entropy_draw_too_long"),
98        };
99        ErrorRecord::new(class, kind, self.to_string())
100    }
101}
102
103/// Action-facing handle threaded into every action handler.
104pub struct Reporter<'a> {
105    event_tx: &'a Sender<ExecEvent>,
106    cmd_rx: &'a Receiver<UiCommand>,
107    transcript: &'a mut dyn TranscriptSink,
108    next_prompt_id: u64,
109    current_step: Option<StepId>,
110    /// The backend the current step runs with, named on every
111    /// [`StepFact::BackendOperation`] it records.
112    current_backend: Option<String>,
113    /// The ceremony entropy source: the single auditable origin of every
114    /// random value drawn during the run. `None` until the runner seeds it at
115    /// ceremony start; a draw before then is an internal ordering bug and fails
116    /// loudly rather than deriving from a stand-in seed.
117    random: Option<CeremonyRandom>,
118    /// The run clock. Read once per fact in [`Reporter::fact`] to stamp the
119    /// event time onto both the transcript line and the live UI event.
120    clock: Arc<dyn Clock>,
121    /// Count of side-effect facts ([`StepFact::is_side_effect`]) emitted over
122    /// the whole run. The executor snapshots this before a step attempt and
123    /// compares afterwards: if the attempt performed work on the world (a
124    /// backend operation, an artifact, an entropy draw) it is no longer
125    /// safely re-executable, so a transient error is not retried. Interaction
126    /// records like an answered prompt do not count: re-running the attempt
127    /// just prompts again.
128    side_effects_emitted: u64,
129}
130
131impl<'a> Reporter<'a> {
132    /// Build a reporter bound to the given channels, transcript sink, and clock.
133    pub fn new(
134        event_tx: &'a Sender<ExecEvent>,
135        cmd_rx: &'a Receiver<UiCommand>,
136        transcript: &'a mut dyn TranscriptSink,
137        clock: Arc<dyn Clock>,
138    ) -> Self {
139        Self {
140            event_tx,
141            cmd_rx,
142            transcript,
143            next_prompt_id: 0,
144            current_step: None,
145            current_backend: None,
146            random: None,
147            clock,
148            side_effects_emitted: 0,
149        }
150    }
151
152    /// Number of side-effect facts emitted so far in this run. Used by the
153    /// executor to detect whether a step attempt performed work on the world
154    /// before failing, which makes it unsafe to re-execute.
155    #[must_use]
156    pub fn side_effects_emitted(&self) -> u64 {
157        self.side_effects_emitted
158    }
159
160    /// Install the machine seed for the entropy source.
161    ///
162    /// Called once by the runner at ceremony start, before any step can draw.
163    /// Until this runs the source is absent and any draw fails. The caller is
164    /// responsible for recording the corresponding [`StepFact::EntropySeeded`]
165    /// so the source stays reconstructible from the transcript.
166    pub fn seed_entropy(&mut self, m: &[u8]) {
167        self.random = Some(CeremonyRandom::from_machine_seed(m));
168    }
169
170    /// Set the step that subsequent log lines and progress signals are
171    /// attributed to. Called by the executor at each step boundary.
172    pub fn set_current_step(&mut self, step: Option<StepId>) {
173        self.current_step = step;
174    }
175
176    /// Set the backend the current step runs with, by its name in the
177    /// ceremony. Called by the executor with [`Reporter::set_current_step`].
178    pub fn set_current_backend(&mut self, backend: Option<String>) {
179        self.current_backend = backend;
180    }
181
182    /// The current step, if any. Mostly useful for tests.
183    #[must_use]
184    pub fn current_step(&self) -> Option<&StepId> {
185        self.current_step.as_ref()
186    }
187
188    /// Record a transcript-worthy fact.
189    ///
190    /// Facts are the audit record: everything durable flows through here, and
191    /// it comes from an action in the plan (plus the runner's own lifecycle
192    /// facts). Writes to the transcript sink synchronously, then forwards to
193    /// the UI as [`ExecEvent::Fact`]. The transcript is flushed before the UI
194    /// observes the fact. To surface something to the operator *without*
195    /// recording it, use [`Reporter::signal`] / [`Reporter::log`] instead.
196    ///
197    /// # Errors
198    ///
199    /// Returns [`ReporterError::Transcript`] if the sink fails to persist,
200    /// or [`ReporterError::Disconnected`] if the frontend is gone.
201    pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> {
202        let level = fact.default_level();
203        self.fact_at(level, fact)
204    }
205
206    /// Record a durable fact at `level` instead of its type's default.
207    ///
208    /// For the runtime, where the level depends on what the fact describes
209    /// rather than on its type alone, such as an artifact's content.
210    ///
211    /// # Errors
212    ///
213    /// Same as [`Reporter::fact`].
214    pub fn fact_at(&mut self, level: Level, fact: StepFact) -> Result<(), ReporterError> {
215        // Stamp the event time once, from the run clock, and use it for both
216        // the durable record and the live UI event so they never disagree.
217        let at = self.clock.now();
218        self.transcript.record(at, level, &fact)?;
219        if fact.is_side_effect() {
220            self.side_effects_emitted = self.side_effects_emitted.saturating_add(1);
221        }
222        self.event_tx
223            .send(ExecEvent::Fact { at, fact })
224            .map_err(|_| ReporterError::Disconnected)?;
225        Ok(())
226    }
227
228    /// Record a [`StepFact::BackendOperation`] of the current step, naming
229    /// the backend the step runs with.
230    ///
231    /// # Errors
232    ///
233    /// [`ReporterError::NoCurrentStep`] if called outside a step, or the
234    /// errors of [`Reporter::fact`].
235    pub fn backend_operation(
236        &mut self,
237        kind: &str,
238        inputs: serde_json::Value,
239        outputs: serde_json::Value,
240        fingerprint: Option<String>,
241    ) -> Result<(), ReporterError> {
242        let step = self
243            .current_step
244            .clone()
245            .ok_or(ReporterError::NoCurrentStep("backend_operation"))?;
246        self.fact(StepFact::BackendOperation {
247            step,
248            kind: kind.to_string(),
249            backend: self.current_backend.clone(),
250            inputs,
251            outputs,
252            fingerprint,
253        })
254    }
255
256    /// Draw `len` bytes from the ceremony entropy source for `purpose`.
257    ///
258    /// The generic primitive behind every nonce, certificate serial, and
259    /// challenge: the caller names a purpose, the source derives the value
260    /// from the current epoch seed and records an [`StepFact::EntropyDrawn`]
261    /// carrying the derivation path and value, so `rite verify` can re-derive
262    /// it. Requires a current step (the path is step-scoped).
263    ///
264    /// # Errors
265    ///
266    /// [`ReporterError::NoCurrentStep`] if called outside a step, or
267    /// [`ReporterError::Transcript`] / [`ReporterError::Disconnected`] if the
268    /// fact cannot be emitted.
269    pub fn draw(&mut self, purpose: &str, len: usize) -> Result<Vec<u8>, ReporterError> {
270        if len > crate::entropy::MAX_DRAW_LEN {
271            return Err(ReporterError::DrawTooLong {
272                len,
273                max: crate::entropy::MAX_DRAW_LEN,
274            });
275        }
276        let step = self
277            .current_step
278            .clone()
279            .ok_or(ReporterError::NoCurrentStep("draw"))?;
280        let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
281        let drawn =
282            random
283                .draw(&step, purpose, len)
284                .ok_or_else(|| ReporterError::DuplicateDraw {
285                    step: step.clone(),
286                    purpose: purpose.to_string(),
287                })?;
288        self.fact(StepFact::EntropyDrawn {
289            step,
290            path: drawn.path,
291            value: base16ct::lower::encode_string(&drawn.value),
292        })?;
293        Ok(drawn.value)
294    }
295
296    /// Fold a human entropy contribution into the seed, advancing the ratchet.
297    ///
298    /// The contribution is public, witnessed entropy (the HMAC *message* keyed
299    /// by the prior seed, never a secret), so any input is safe and only adds
300    /// unpredictability. Records an [`StepFact::EntropyContributed`] carrying
301    /// the verbatim contribution and the new epoch, so the chain re-folds
302    /// identically on verification. Requires a current step.
303    ///
304    /// # Errors
305    ///
306    /// [`ReporterError::NoCurrentStep`] if called outside a step, or
307    /// [`ReporterError::Transcript`] / [`ReporterError::Disconnected`] if the
308    /// fact cannot be emitted.
309    pub fn fold_entropy(&mut self, contribution: &str) -> Result<(), ReporterError> {
310        let step = self
311            .current_step
312            .clone()
313            .ok_or(ReporterError::NoCurrentStep("fold_entropy"))?;
314        let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
315        random.fold(contribution.as_bytes());
316        let epoch = random.epoch();
317        self.fact(StepFact::EntropyContributed {
318            step,
319            epoch,
320            contribution: contribution.to_string(),
321        })
322    }
323
324    /// Emit a raw [`UiSignal`]. Never recorded to the transcript.
325    ///
326    /// Signals are ephemeral operator-assistance: a frontend may drop any of
327    /// them without affecting correctness or the transcript. They are never
328    /// promoted into the audit record; use [`Reporter::fact`] for anything
329    /// that must persist. Most callers should use the specialized helpers
330    /// ([`Reporter::log`] for narration, [`Reporter::progress`] for spinners).
331    /// This method exists for structured signals (e.g. the pre-ceremony
332    /// overview, the system snapshot) that don't fit either shape.
333    ///
334    /// # Errors
335    ///
336    /// Returns [`ReporterError::Disconnected`] if the frontend is gone.
337    pub fn signal(&mut self, signal: UiSignal) -> Result<(), ReporterError> {
338        self.event_tx
339            .send(ExecEvent::Signal(signal))
340            .map_err(|_| ReporterError::Disconnected)?;
341        Ok(())
342    }
343
344    /// Emit a UI-only narration line. Never recorded to the transcript.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`ReporterError::Disconnected`] if the frontend is gone.
349    pub fn log(&mut self, icon: Icon, text: impl Into<String>) -> Result<(), ReporterError> {
350        let signal = UiSignal::LogLine {
351            step: self.current_step.clone(),
352            icon,
353            text: text.into(),
354        };
355        self.event_tx
356            .send(ExecEvent::Signal(signal))
357            .map_err(|_| ReporterError::Disconnected)?;
358        Ok(())
359    }
360
361    /// Emit a progress signal (e.g. spinner phase, completion fraction).
362    ///
363    /// Never recorded to the transcript. Requires a current step.
364    ///
365    /// # Errors
366    ///
367    /// Returns [`ReporterError::Disconnected`] or
368    /// [`ReporterError::NoCurrentStep`].
369    pub fn progress(
370        &mut self,
371        phase: impl Into<String>,
372        fraction: Option<f32>,
373    ) -> Result<(), ReporterError> {
374        let step = self
375            .current_step
376            .clone()
377            .ok_or(ReporterError::NoCurrentStep("progress"))?;
378        let signal = UiSignal::Progress {
379            step,
380            phase: phase.into(),
381            fraction,
382        };
383        self.event_tx
384            .send(ExecEvent::Signal(signal))
385            .map_err(|_| ReporterError::Disconnected)?;
386        Ok(())
387    }
388
389    /// Drain any pending commands. Returns [`ReporterError::Aborted`] if an
390    /// abort command is in the queue.
391    ///
392    /// Designed to be called liberally during long-running backend
393    /// operations. `try_recv` on a crossbeam channel is on the order of
394    /// nanoseconds, so the cost is negligible.
395    ///
396    /// # Errors
397    ///
398    /// Returns [`ReporterError::Aborted`] if abort was requested, or
399    /// [`ReporterError::Disconnected`] if the frontend went away.
400    pub fn check_abort(&mut self) -> Result<(), ReporterError> {
401        loop {
402            match self.cmd_rx.try_recv() {
403                Ok(cmd) => self.handle_offline_command(cmd)?,
404                Err(TryRecvError::Empty) => return Ok(()),
405                Err(TryRecvError::Disconnected) => return Err(ReporterError::Disconnected),
406            }
407        }
408    }
409
410    /// Issue a prompt and block until a validated response is received.
411    ///
412    /// While blocking, also handles [`UiCommand::Abort`] and
413    /// [`UiCommand::LogDeviation`] so the user can deviate or abort mid-prompt.
414    /// On rejection by the runtime's validator, re-emits the prompt with
415    /// the rejection reason attached so the frontend can surface it.
416    ///
417    /// Automatically emits [`StepFact::PromptAnswered`] when the response
418    /// is accepted.
419    ///
420    /// # Errors
421    ///
422    /// Returns [`ReporterError::Aborted`] if abort was requested,
423    /// [`ReporterError::Disconnected`] if the frontend went away, or
424    /// [`ReporterError::Transcript`] if the fact cannot be recorded.
425    pub fn prompt(&mut self, prompt: &Prompt) -> Result<Response, ReporterError> {
426        let step = self.current_step.clone();
427        let prompt_id = self.allocate_prompt_id();
428        let mut previous_rejection: Option<String> = None;
429
430        loop {
431            self.event_tx
432                .send(ExecEvent::AwaitPrompt {
433                    step: step.clone(),
434                    prompt_id,
435                    prompt: prompt.clone(),
436                    previous_attempt_rejected_because: previous_rejection.take(),
437                })
438                .map_err(|_| ReporterError::Disconnected)?;
439
440            // Inner loop: drain commands until a matching, validated response arrives.
441            loop {
442                let cmd = self
443                    .cmd_rx
444                    .recv()
445                    .map_err(|_| ReporterError::Disconnected)?;
446                match cmd {
447                    UiCommand::Abort => return Err(ReporterError::Aborted),
448                    UiCommand::LogDeviation { text } => {
449                        self.emit_deviation(step.clone(), text)?;
450                    }
451                    UiCommand::PromptResponse {
452                        prompt_id: incoming_id,
453                        response,
454                    } => {
455                        if incoming_id != prompt_id {
456                            // Stale response from a previous prompt, drop.
457                            continue;
458                        }
459                        match validate(prompt, &response) {
460                            Ok(()) => {
461                                let record = response_to_record(&response);
462                                self.fact(StepFact::PromptAnswered {
463                                    step: step.clone(),
464                                    prompt: prompt.clone(),
465                                    response: record,
466                                })?;
467                                return Ok(response);
468                            }
469                            Err(reason) => {
470                                previous_rejection = Some(reason);
471                                break; // re-emit AwaitPrompt with rejection
472                            }
473                        }
474                    }
475                }
476            }
477        }
478    }
479
480    fn allocate_prompt_id(&mut self) -> PromptId {
481        let id = PromptId::new(self.next_prompt_id);
482        self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
483        id
484    }
485
486    /// Handle a command that arrives outside of a prompt-wait context.
487    fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
488        match cmd {
489            UiCommand::Abort => Err(ReporterError::Aborted),
490            UiCommand::LogDeviation { text } => {
491                let step = self.current_step.clone();
492                self.emit_deviation(step, text)
493            }
494            // A stray prompt response outside any prompt context is dropped.
495            UiCommand::PromptResponse { .. } => Ok(()),
496        }
497    }
498
499    fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
500        self.fact(StepFact::DeviationRecorded { step, text })
501    }
502}
503
504/// Convert an in-flight [`Response`] into its redacted, serializable form.
505///
506/// The boundary where a plaintext secret is dropped. Lives here (next to
507/// `Response`) rather than on `ResponseRecord` so that `secrecy` stays a
508/// runtime-only dependency.
509fn response_to_record(response: &Response) -> ResponseRecord {
510    match response {
511        Response::Bool(b) => ResponseRecord::Bool { value: *b },
512        Response::Text(t) => ResponseRecord::Text { value: t.clone() },
513        Response::Secret(_) => ResponseRecord::SecretRedacted {},
514        Response::Acknowledge => ResponseRecord::Acknowledged,
515    }
516}
517
518/// Validate a response against the prompt's constraints.
519///
520/// Returns `Ok(())` if the response is acceptable, or `Err(reason)` if it
521/// must be rejected. The reason is surfaced to the frontend through
522/// [`ExecEvent::AwaitPrompt::previous_attempt_rejected_because`].
523///
524/// Two checks are performed:
525/// 1. **Shape**: the response variant matches the prompt variant (e.g.
526///    a [`Prompt::Confirm`] requires a [`Response::Bool`]). A mismatch
527///    indicates a frontend bug.
528/// 2. **Content**: the [`Prompt::Text`] and [`Prompt::Secret`] validators
529///    are applied to what was typed, and [`Prompt::Literal`] requires
530///    byte-for-byte equality with the expected string. A secret that fails
531///    its rule is refused with the rule, never with the value.
532fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
533    match (prompt, response) {
534        (Prompt::Confirm { .. }, Response::Bool(_))
535        | (Prompt::Continue { .. }, Response::Acknowledge) => Ok(()),
536
537        (Prompt::Text { validator, .. }, Response::Text(value)) => validator.check(value),
538        (Prompt::Secret { validator, .. }, Response::Secret(value)) => {
539            validator.check(value.expose_secret())
540        }
541
542        (Prompt::Literal { expected, .. }, Response::Text(value)) => {
543            if value == expected {
544                Ok(())
545            } else {
546                Err(format!("expected exactly: {expected}"))
547            }
548        }
549
550        _ => Err("response shape does not match prompt shape".to_string()),
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use crossbeam_channel::unbounded;
557    use secrecy::SecretString;
558
559    use super::*;
560    use crate::clock::SystemClock;
561    use rite_model::ValidatorSpec;
562
563    fn ids(step: &str) -> StepId {
564        StepId::new(step)
565    }
566
567    fn test_clock() -> Arc<dyn Clock> {
568        Arc::new(SystemClock)
569    }
570
571    #[test]
572    fn fact_stamps_event_from_the_injected_clock() {
573        use crate::test_support::{FixedClock, fixed_test_time};
574
575        let (event_tx, event_rx) = unbounded();
576        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
577        let mut sink = crate::test_support::begun_sink();
578        let fixed = fixed_test_time();
579        let mut reporter =
580            Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
581
582        reporter
583            .fact(crate::test_support::ceremony_started("T"))
584            .expect("fact");
585
586        match event_rx.recv().expect("event") {
587            ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
588            other => panic!("expected Fact, got {other:?}"),
589        }
590    }
591
592    #[test]
593    fn fact_writes_to_sink_and_forwards_to_ui() {
594        let (event_tx, event_rx) = unbounded();
595        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
596        let mut sink = crate::test_support::begun_sink();
597        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
598        reporter.set_current_step(Some(ids("s1")));
599
600        reporter
601            .fact(StepFact::DeviationRecorded {
602                step: Some(ids("s1")),
603                text: "minor".to_string(),
604            })
605            .expect("emit fact");
606
607        assert_eq!(sink.len(), 1);
608        match event_rx.try_recv().expect("ui event") {
609            ExecEvent::Fact {
610                fact: StepFact::DeviationRecorded { text, .. },
611                ..
612            } => {
613                assert_eq!(text, "minor");
614            }
615            other => panic!("unexpected event: {other:?}"),
616        }
617    }
618
619    #[test]
620    fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
621        // A draw beyond MAX_DRAW_LEN would panic inside `expand`; the boundary
622        // rejects it as a recoverable error first. The length check precedes
623        // the step/seed checks, so no seeding is needed to exercise it.
624        let (event_tx, _event_rx) = unbounded();
625        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
626        let mut sink = crate::test_support::begun_sink();
627        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
628        reporter.set_current_step(Some(ids("s1")));
629
630        let err = reporter
631            .draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
632            .expect_err("oversized draw");
633        assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
634            if len == crate::entropy::MAX_DRAW_LEN + 1));
635    }
636
637    #[test]
638    fn log_does_not_touch_transcript() {
639        let (event_tx, event_rx) = unbounded();
640        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
641        let mut sink = crate::test_support::begun_sink();
642        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
643        reporter.set_current_step(Some(ids("s1")));
644
645        reporter.log(Icon::Info, "hello").expect("log");
646
647        assert_eq!(sink.len(), 0);
648        match event_rx.try_recv().expect("ui event") {
649            ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
650            other => panic!("unexpected event: {other:?}"),
651        }
652    }
653
654    #[test]
655    fn check_abort_returns_aborted_when_abort_in_queue() {
656        let (event_tx, _event_rx) = unbounded::<ExecEvent>();
657        let (cmd_tx, cmd_rx) = unbounded();
658        let mut sink = crate::test_support::begun_sink();
659        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
660        reporter.set_current_step(Some(ids("s1")));
661
662        cmd_tx.send(UiCommand::Abort).expect("send");
663        let err = reporter.check_abort().expect_err("abort");
664        assert!(matches!(err, ReporterError::Aborted));
665    }
666
667    #[test]
668    fn check_abort_processes_deviation_then_returns_ok() {
669        let (event_tx, event_rx) = unbounded();
670        let (cmd_tx, cmd_rx) = unbounded();
671        let mut sink = crate::test_support::begun_sink();
672        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
673        reporter.set_current_step(Some(ids("s1")));
674
675        cmd_tx
676            .send(UiCommand::LogDeviation {
677                text: "phone rang".to_string(),
678            })
679            .expect("send");
680        reporter.check_abort().expect("ok");
681
682        assert_eq!(sink.len(), 1);
683        match event_rx.try_recv().expect("ui event") {
684            ExecEvent::Fact {
685                fact: StepFact::DeviationRecorded { text, .. },
686                ..
687            } => {
688                assert_eq!(text, "phone rang");
689            }
690            other => panic!("unexpected event: {other:?}"),
691        }
692    }
693
694    struct AwaitedPrompt {
695        prompt_id: PromptId,
696        rejection: Option<String>,
697    }
698
699    /// Drain `ExecEvent`s until the next `AwaitPrompt`, panicking if the
700    /// channel closes first. Non-prompt events are discarded.
701    fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
702        loop {
703            if let ExecEvent::AwaitPrompt {
704                prompt_id,
705                previous_attempt_rejected_because,
706                ..
707            } = event_rx
708                .recv()
709                .expect("event channel closed before AwaitPrompt")
710            {
711                return AwaitedPrompt {
712                    prompt_id,
713                    rejection: previous_attempt_rejected_because,
714                };
715            }
716        }
717    }
718
719    /// Spawn a "frontend" thread that drains all `ExecEvent`s and feeds each
720    /// `AwaitPrompt` to the supplied `respond` callback. Keeps `event_rx`
721    /// alive until the channel closes, so the reporter's later `Fact` sends
722    /// don't trip a spurious `Disconnected` error.
723    fn spawn_frontend<F>(
724        event_rx: Receiver<ExecEvent>,
725        cmd_tx: Sender<UiCommand>,
726        mut respond: F,
727    ) -> std::thread::JoinHandle<()>
728    where
729        F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
730    {
731        std::thread::spawn(move || {
732            while let Ok(ev) = event_rx.recv() {
733                if let ExecEvent::AwaitPrompt {
734                    prompt_id, prompt, ..
735                } = ev
736                {
737                    respond(prompt_id, &prompt, &cmd_tx);
738                }
739            }
740        })
741    }
742
743    #[test]
744    fn prompt_round_trip_emits_await_and_records_fact() {
745        let (event_tx, event_rx) = unbounded();
746        let (cmd_tx, cmd_rx) = unbounded();
747        let mut sink = crate::test_support::begun_sink();
748        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
749        reporter.set_current_step(Some(ids("s1")));
750
751        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
752            tx.send(UiCommand::PromptResponse {
753                prompt_id,
754                response: Response::Bool(true),
755            })
756            .expect("send response");
757        });
758
759        let response = reporter
760            .prompt(&Prompt::Confirm {
761                question: "proceed?".to_string(),
762                default: Some(true),
763            })
764            .expect("prompt");
765        assert!(matches!(response, Response::Bool(true)));
766        assert_eq!(sink.len(), 1);
767        assert!(matches!(
768            sink.facts().first(),
769            Some(StepFact::PromptAnswered { .. })
770        ));
771
772        drop(event_tx);
773        frontend.join().expect("frontend join");
774    }
775
776    #[test]
777    fn prompt_redacts_secret_in_record() {
778        let (event_tx, event_rx) = unbounded();
779        let (cmd_tx, cmd_rx) = unbounded();
780        let mut sink = crate::test_support::begun_sink();
781        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
782        reporter.set_current_step(Some(ids("s1")));
783
784        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
785            tx.send(UiCommand::PromptResponse {
786                prompt_id,
787                response: Response::Secret(SecretString::from("hunter2".to_string())),
788            })
789            .expect("send response");
790        });
791
792        reporter
793            .prompt(&Prompt::Secret {
794                label: "PIN".to_string(),
795                validator: ValidatorSpec::NonEmpty,
796            })
797            .expect("prompt");
798
799        match sink.facts().first().expect("fact") {
800            StepFact::PromptAnswered { response, .. } => match response {
801                ResponseRecord::SecretRedacted {} => {}
802                other => panic!("expected redacted secret, got {other:?}"),
803            },
804            other => panic!("unexpected fact: {other:?}"),
805        }
806
807        drop(event_tx);
808        frontend.join().expect("frontend join");
809    }
810
811    #[test]
812    fn prompt_loop_handles_deviation_then_response() {
813        let (event_tx, event_rx) = unbounded();
814        let (cmd_tx, cmd_rx) = unbounded();
815        let mut sink = crate::test_support::begun_sink();
816        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
817        reporter.set_current_step(Some(ids("s1")));
818
819        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
820            tx.send(UiCommand::LogDeviation {
821                text: "noted".to_string(),
822            })
823            .expect("send deviation");
824            tx.send(UiCommand::PromptResponse {
825                prompt_id,
826                response: Response::Acknowledge,
827            })
828            .expect("send response");
829        });
830
831        reporter
832            .prompt(&Prompt::Continue { hint: None })
833            .expect("prompt");
834
835        assert_eq!(sink.len(), 2);
836        assert!(matches!(
837            sink.facts().first(),
838            Some(StepFact::DeviationRecorded { .. })
839        ));
840        assert!(matches!(
841            sink.facts().get(1),
842            Some(StepFact::PromptAnswered { .. })
843        ));
844
845        drop(event_tx);
846        frontend.join().expect("frontend join");
847    }
848
849    #[test]
850    fn validator_signature_compiles_with_predefined_variant() {
851        // Sanity check: Predefined variant of ValidatorSpec is referenceable.
852        let _v = ValidatorSpec::Predefined("serial_number".to_string());
853    }
854
855    #[test]
856    fn non_empty_validator_rejects_empty_and_accepts_text() {
857        assert!(
858            super::validate(
859                &Prompt::Text {
860                    label: "name".to_string(),
861                    validator: ValidatorSpec::NonEmpty,
862                },
863                &Response::Text(String::new()),
864            )
865            .is_err()
866        );
867
868        assert!(
869            super::validate(
870                &Prompt::Text {
871                    label: "name".to_string(),
872                    validator: ValidatorSpec::NonEmpty,
873                },
874                &Response::Text("Alice".to_string()),
875            )
876            .is_ok()
877        );
878    }
879
880    #[test]
881    fn non_empty_validator_rejects_whitespace_only() {
882        let err = super::validate(
883            &Prompt::Text {
884                label: "name".to_string(),
885                validator: ValidatorSpec::NonEmpty,
886            },
887            &Response::Text("   \t".to_string()),
888        )
889        .expect_err("should reject whitespace");
890        assert!(err.contains("must not be empty"));
891    }
892
893    #[test]
894    fn literal_validator_requires_byte_for_byte_match() {
895        let prompt = Prompt::Literal {
896            label: "type 'attest'".to_string(),
897            expected: "attest".to_string(),
898        };
899        assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
900        let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
901            .expect_err("case-sensitive");
902        assert!(err.contains("attest"));
903    }
904
905    #[test]
906    fn regex_validator_applies_to_text_and_secret_alike() {
907        let text = Prompt::Text {
908            label: "id".to_string(),
909            validator: ValidatorSpec::Regex(r"[a-z]+".to_string()),
910        };
911        assert!(super::validate(&text, &Response::Text("abc".to_string())).is_ok());
912        let err = super::validate(&text, &Response::Text("abc1".to_string()))
913            .expect_err("digit outside the pattern");
914        assert!(err.contains("[a-z]+"), "{err}");
915
916        // The rule is applied to the secret, and the refusal names the rule
917        // and not what was typed.
918        let secret = Prompt::Secret {
919            label: "PIN".to_string(),
920            validator: ValidatorSpec::Regex(r"[0-9]{6}".to_string()),
921        };
922        assert!(super::validate(&secret, &Response::Secret("123456".to_string().into())).is_ok());
923        let err = super::validate(&secret, &Response::Secret("12345".to_string().into()))
924            .expect_err("too short");
925        // No message: on failure it would print the value this checks for.
926        assert!(!err.contains("12345"));
927    }
928
929    #[test]
930    fn predefined_validator_unknown_name_rejected() {
931        let err = super::validate(
932            &Prompt::Text {
933                label: "sn".to_string(),
934                validator: ValidatorSpec::Predefined("serial_number".to_string()),
935            },
936            &Response::Text("AB1234".to_string()),
937        )
938        .expect_err("predefined unknown");
939        assert!(err.contains("serial_number"));
940    }
941
942    #[test]
943    fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
944        let (event_tx, event_rx) = unbounded();
945        let (cmd_tx, cmd_rx) = unbounded();
946        let mut sink = crate::test_support::begun_sink();
947        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
948        reporter.set_current_step(Some(ids("s1")));
949
950        // Frontend: first AwaitPrompt → reply empty (rejected by validator),
951        // second AwaitPrompt → carries the rejection reason, reply "Alice".
952        // Then keep `event_rx` alive until the reporter drops `event_tx` so
953        // its post-response `PromptAnswered` send doesn't race the channel.
954        let frontend = std::thread::spawn(move || {
955            let first = recv_await_prompt(&event_rx);
956            assert!(first.rejection.is_none(), "first prompt has no rejection");
957            cmd_tx
958                .send(UiCommand::PromptResponse {
959                    prompt_id: first.prompt_id,
960                    response: Response::Text(String::new()),
961                })
962                .expect("send empty");
963
964            let second = recv_await_prompt(&event_rx);
965            assert!(
966                second.rejection.is_some(),
967                "second prompt carries the rejection reason",
968            );
969            cmd_tx
970                .send(UiCommand::PromptResponse {
971                    prompt_id: second.prompt_id,
972                    response: Response::Text("Alice".to_string()),
973                })
974                .expect("send Alice");
975
976            while event_rx.recv().is_ok() {}
977        });
978
979        let response = reporter
980            .prompt(&Prompt::Text {
981                label: "name".to_string(),
982                validator: ValidatorSpec::NonEmpty,
983            })
984            .expect("prompt");
985        match response {
986            Response::Text(t) => assert_eq!(t, "Alice"),
987            _ => panic!("expected Text"),
988        }
989        drop(event_tx);
990        frontend.join().expect("frontend join");
991    }
992}