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        self.prompt_checked(prompt, |_| Ok(()))
427    }
428
429    /// [`prompt`](Self::prompt), with a check of the action's own after the
430    /// runtime's: a response it refuses is asked for again with the reason
431    /// attached, as a validator's refusal is. For a check only the action
432    /// can make, such as whether typed rows are a share.
433    ///
434    /// # Errors
435    ///
436    /// As [`prompt`](Self::prompt).
437    pub fn prompt_checked(
438        &mut self,
439        prompt: &Prompt,
440        mut check: impl FnMut(&Response) -> Result<(), String>,
441    ) -> Result<Response, ReporterError> {
442        let step = self.current_step.clone();
443        let prompt_id = self.allocate_prompt_id();
444        let mut previous_rejection: Option<String> = None;
445
446        loop {
447            self.event_tx
448                .send(ExecEvent::AwaitPrompt {
449                    step: step.clone(),
450                    prompt_id,
451                    prompt: prompt.clone(),
452                    previous_attempt_rejected_because: previous_rejection.take(),
453                })
454                .map_err(|_| ReporterError::Disconnected)?;
455
456            // Inner loop: drain commands until a matching, validated response arrives.
457            loop {
458                let cmd = self
459                    .cmd_rx
460                    .recv()
461                    .map_err(|_| ReporterError::Disconnected)?;
462                match cmd {
463                    UiCommand::Abort => return Err(ReporterError::Aborted),
464                    UiCommand::LogDeviation { text } => {
465                        self.emit_deviation(step.clone(), text)?;
466                    }
467                    UiCommand::PromptResponse {
468                        prompt_id: incoming_id,
469                        response,
470                    } => {
471                        if incoming_id != prompt_id {
472                            // Stale response from a previous prompt, drop.
473                            continue;
474                        }
475                        match validate(prompt, &response).and_then(|()| check(&response)) {
476                            Ok(()) => {
477                                let record = response_to_record(&response);
478                                self.fact(StepFact::PromptAnswered {
479                                    step: step.clone(),
480                                    prompt: prompt.for_record(),
481                                    response: record,
482                                })?;
483                                return Ok(response);
484                            }
485                            Err(reason) => {
486                                previous_rejection = Some(reason);
487                                break; // re-emit AwaitPrompt with rejection
488                            }
489                        }
490                    }
491                }
492            }
493        }
494    }
495
496    fn allocate_prompt_id(&mut self) -> PromptId {
497        let id = PromptId::new(self.next_prompt_id);
498        self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
499        id
500    }
501
502    /// Handle a command that arrives outside of a prompt-wait context.
503    fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
504        match cmd {
505            UiCommand::Abort => Err(ReporterError::Aborted),
506            UiCommand::LogDeviation { text } => {
507                let step = self.current_step.clone();
508                self.emit_deviation(step, text)
509            }
510            // A stray prompt response outside any prompt context is dropped.
511            UiCommand::PromptResponse { .. } => Ok(()),
512        }
513    }
514
515    fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
516        self.fact(StepFact::DeviationRecorded { step, text })
517    }
518}
519
520/// Convert an in-flight [`Response`] into its redacted, serializable form.
521///
522/// The boundary where a plaintext secret is dropped. Lives here (next to
523/// `Response`) rather than on `ResponseRecord` so that `secrecy` stays a
524/// runtime-only dependency.
525fn response_to_record(response: &Response) -> ResponseRecord {
526    match response {
527        Response::Bool(b) => ResponseRecord::Bool { value: *b },
528        Response::Text(t) => ResponseRecord::Text { value: t.clone() },
529        Response::Secret(_) => ResponseRecord::SecretRedacted {},
530        Response::Acknowledge => ResponseRecord::Acknowledged,
531    }
532}
533
534/// Validate a response against the prompt's constraints.
535///
536/// Returns `Ok(())` if the response is acceptable, or `Err(reason)` if it
537/// must be rejected. The reason is surfaced to the frontend through
538/// [`ExecEvent::AwaitPrompt::previous_attempt_rejected_because`].
539///
540/// Two checks are performed:
541/// 1. **Shape**: the response variant matches the prompt variant (e.g.
542///    a [`Prompt::Confirm`] requires a [`Response::Bool`]). A mismatch
543///    indicates a frontend bug.
544/// 2. **Content**: the [`Prompt::Text`] and [`Prompt::Secret`] validators
545///    are applied to what was typed, and [`Prompt::Literal`] requires
546///    byte-for-byte equality with the expected string. A secret that fails
547///    its rule is refused with the rule, never with the value.
548fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
549    match (prompt, response) {
550        (Prompt::Confirm { .. }, Response::Bool(_))
551        | (Prompt::Continue { .. } | Prompt::Reveal { .. }, Response::Acknowledge)
552        | (
553            Prompt::EnterRows {
554                validator: None, ..
555            },
556            Response::Secret(_),
557        ) => Ok(()),
558
559        (Prompt::Text { validator, .. }, Response::Text(value)) => validator.check(value),
560        (
561            Prompt::Secret { validator, .. }
562            | Prompt::EnterRows {
563                validator: Some(validator),
564                ..
565            },
566            Response::Secret(value),
567        ) => validator.check(value.expose_secret()),
568
569        (Prompt::Literal { expected, .. }, Response::Text(value)) => {
570            if value == expected {
571                Ok(())
572            } else {
573                Err(format!("expected exactly: {expected}"))
574            }
575        }
576
577        _ => Err("response shape does not match prompt shape".to_string()),
578    }
579}
580
581#[cfg(test)]
582mod tests {
583    use crossbeam_channel::unbounded;
584    use secrecy::SecretString;
585
586    use super::*;
587    use crate::clock::SystemClock;
588    use rite_model::ValidatorSpec;
589
590    fn ids(step: &str) -> StepId {
591        StepId::new(step)
592    }
593
594    fn test_clock() -> Arc<dyn Clock> {
595        Arc::new(SystemClock)
596    }
597
598    #[test]
599    fn fact_stamps_event_from_the_injected_clock() {
600        use crate::test_support::{FixedClock, fixed_test_time};
601
602        let (event_tx, event_rx) = unbounded();
603        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
604        let mut sink = crate::test_support::begun_sink();
605        let fixed = fixed_test_time();
606        let mut reporter =
607            Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
608
609        reporter
610            .fact(crate::test_support::ceremony_started("T"))
611            .expect("fact");
612
613        match event_rx.recv().expect("event") {
614            ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
615            other => panic!("expected Fact, got {other:?}"),
616        }
617    }
618
619    #[test]
620    fn fact_writes_to_sink_and_forwards_to_ui() {
621        let (event_tx, event_rx) = unbounded();
622        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
623        let mut sink = crate::test_support::begun_sink();
624        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
625        reporter.set_current_step(Some(ids("s1")));
626
627        reporter
628            .fact(StepFact::DeviationRecorded {
629                step: Some(ids("s1")),
630                text: "minor".to_string(),
631            })
632            .expect("emit fact");
633
634        assert_eq!(sink.len(), 1);
635        match event_rx.try_recv().expect("ui event") {
636            ExecEvent::Fact {
637                fact: StepFact::DeviationRecorded { text, .. },
638                ..
639            } => {
640                assert_eq!(text, "minor");
641            }
642            other => panic!("unexpected event: {other:?}"),
643        }
644    }
645
646    #[test]
647    fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
648        // A draw beyond MAX_DRAW_LEN would panic inside `expand`; the boundary
649        // rejects it as a recoverable error first. The length check precedes
650        // the step/seed checks, so no seeding is needed to exercise it.
651        let (event_tx, _event_rx) = unbounded();
652        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
653        let mut sink = crate::test_support::begun_sink();
654        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
655        reporter.set_current_step(Some(ids("s1")));
656
657        let err = reporter
658            .draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
659            .expect_err("oversized draw");
660        assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
661            if len == crate::entropy::MAX_DRAW_LEN + 1));
662    }
663
664    #[test]
665    fn log_does_not_touch_transcript() {
666        let (event_tx, event_rx) = unbounded();
667        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
668        let mut sink = crate::test_support::begun_sink();
669        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
670        reporter.set_current_step(Some(ids("s1")));
671
672        reporter.log(Icon::Info, "hello").expect("log");
673
674        assert_eq!(sink.len(), 0);
675        match event_rx.try_recv().expect("ui event") {
676            ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
677            other => panic!("unexpected event: {other:?}"),
678        }
679    }
680
681    #[test]
682    fn check_abort_returns_aborted_when_abort_in_queue() {
683        let (event_tx, _event_rx) = unbounded::<ExecEvent>();
684        let (cmd_tx, cmd_rx) = unbounded();
685        let mut sink = crate::test_support::begun_sink();
686        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
687        reporter.set_current_step(Some(ids("s1")));
688
689        cmd_tx.send(UiCommand::Abort).expect("send");
690        let err = reporter.check_abort().expect_err("abort");
691        assert!(matches!(err, ReporterError::Aborted));
692    }
693
694    #[test]
695    fn check_abort_processes_deviation_then_returns_ok() {
696        let (event_tx, event_rx) = unbounded();
697        let (cmd_tx, cmd_rx) = unbounded();
698        let mut sink = crate::test_support::begun_sink();
699        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
700        reporter.set_current_step(Some(ids("s1")));
701
702        cmd_tx
703            .send(UiCommand::LogDeviation {
704                text: "phone rang".to_string(),
705            })
706            .expect("send");
707        reporter.check_abort().expect("ok");
708
709        assert_eq!(sink.len(), 1);
710        match event_rx.try_recv().expect("ui event") {
711            ExecEvent::Fact {
712                fact: StepFact::DeviationRecorded { text, .. },
713                ..
714            } => {
715                assert_eq!(text, "phone rang");
716            }
717            other => panic!("unexpected event: {other:?}"),
718        }
719    }
720
721    struct AwaitedPrompt {
722        prompt_id: PromptId,
723        rejection: Option<String>,
724    }
725
726    /// Drain `ExecEvent`s until the next `AwaitPrompt`, panicking if the
727    /// channel closes first. Non-prompt events are discarded.
728    fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
729        loop {
730            if let ExecEvent::AwaitPrompt {
731                prompt_id,
732                previous_attempt_rejected_because,
733                ..
734            } = event_rx
735                .recv()
736                .expect("event channel closed before AwaitPrompt")
737            {
738                return AwaitedPrompt {
739                    prompt_id,
740                    rejection: previous_attempt_rejected_because,
741                };
742            }
743        }
744    }
745
746    /// Spawn a "frontend" thread that drains all `ExecEvent`s and feeds each
747    /// `AwaitPrompt` to the supplied `respond` callback. Keeps `event_rx`
748    /// alive until the channel closes, so the reporter's later `Fact` sends
749    /// don't trip a spurious `Disconnected` error.
750    fn spawn_frontend<F>(
751        event_rx: Receiver<ExecEvent>,
752        cmd_tx: Sender<UiCommand>,
753        mut respond: F,
754    ) -> std::thread::JoinHandle<()>
755    where
756        F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
757    {
758        std::thread::spawn(move || {
759            while let Ok(ev) = event_rx.recv() {
760                if let ExecEvent::AwaitPrompt {
761                    prompt_id, prompt, ..
762                } = ev
763                {
764                    respond(prompt_id, &prompt, &cmd_tx);
765                }
766            }
767        })
768    }
769
770    #[test]
771    fn prompt_round_trip_emits_await_and_records_fact() {
772        let (event_tx, event_rx) = unbounded();
773        let (cmd_tx, cmd_rx) = unbounded();
774        let mut sink = crate::test_support::begun_sink();
775        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
776        reporter.set_current_step(Some(ids("s1")));
777
778        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
779            tx.send(UiCommand::PromptResponse {
780                prompt_id,
781                response: Response::Bool(true),
782            })
783            .expect("send response");
784        });
785
786        let response = reporter
787            .prompt(&Prompt::Confirm {
788                question: "proceed?".to_string(),
789                default: Some(true),
790            })
791            .expect("prompt");
792        assert!(matches!(response, Response::Bool(true)));
793        assert_eq!(sink.len(), 1);
794        assert!(matches!(
795            sink.facts().first(),
796            Some(StepFact::PromptAnswered { .. })
797        ));
798
799        drop(event_tx);
800        frontend.join().expect("frontend join");
801    }
802
803    #[test]
804    fn prompt_redacts_secret_in_record() {
805        let (event_tx, event_rx) = unbounded();
806        let (cmd_tx, cmd_rx) = unbounded();
807        let mut sink = crate::test_support::begun_sink();
808        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
809        reporter.set_current_step(Some(ids("s1")));
810
811        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
812            tx.send(UiCommand::PromptResponse {
813                prompt_id,
814                response: Response::Secret(SecretString::from("hunter2".to_string())),
815            })
816            .expect("send response");
817        });
818
819        reporter
820            .prompt(&Prompt::Secret {
821                label: "PIN".to_string(),
822                validator: ValidatorSpec::NonEmpty,
823            })
824            .expect("prompt");
825
826        match sink.facts().first().expect("fact") {
827            StepFact::PromptAnswered { response, .. } => match response {
828                ResponseRecord::SecretRedacted {} => {}
829                other => panic!("expected redacted secret, got {other:?}"),
830            },
831            other => panic!("unexpected fact: {other:?}"),
832        }
833
834        drop(event_tx);
835        frontend.join().expect("frontend join");
836    }
837
838    #[test]
839    fn prompt_loop_handles_deviation_then_response() {
840        let (event_tx, event_rx) = unbounded();
841        let (cmd_tx, cmd_rx) = unbounded();
842        let mut sink = crate::test_support::begun_sink();
843        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
844        reporter.set_current_step(Some(ids("s1")));
845
846        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
847            tx.send(UiCommand::LogDeviation {
848                text: "noted".to_string(),
849            })
850            .expect("send deviation");
851            tx.send(UiCommand::PromptResponse {
852                prompt_id,
853                response: Response::Acknowledge,
854            })
855            .expect("send response");
856        });
857
858        reporter
859            .prompt(&Prompt::Continue { hint: None })
860            .expect("prompt");
861
862        assert_eq!(sink.len(), 2);
863        assert!(matches!(
864            sink.facts().first(),
865            Some(StepFact::DeviationRecorded { .. })
866        ));
867        assert!(matches!(
868            sink.facts().get(1),
869            Some(StepFact::PromptAnswered { .. })
870        ));
871
872        drop(event_tx);
873        frontend.join().expect("frontend join");
874    }
875
876    #[test]
877    fn validator_signature_compiles_with_predefined_variant() {
878        // Sanity check: Predefined variant of ValidatorSpec is referenceable.
879        let _v = ValidatorSpec::Predefined("serial_number".to_string());
880    }
881
882    #[test]
883    fn non_empty_validator_rejects_empty_and_accepts_text() {
884        assert!(
885            super::validate(
886                &Prompt::Text {
887                    label: "name".to_string(),
888                    validator: ValidatorSpec::NonEmpty,
889                },
890                &Response::Text(String::new()),
891            )
892            .is_err()
893        );
894
895        assert!(
896            super::validate(
897                &Prompt::Text {
898                    label: "name".to_string(),
899                    validator: ValidatorSpec::NonEmpty,
900                },
901                &Response::Text("Alice".to_string()),
902            )
903            .is_ok()
904        );
905    }
906
907    #[test]
908    fn non_empty_validator_rejects_whitespace_only() {
909        let err = super::validate(
910            &Prompt::Text {
911                label: "name".to_string(),
912                validator: ValidatorSpec::NonEmpty,
913            },
914            &Response::Text("   \t".to_string()),
915        )
916        .expect_err("should reject whitespace");
917        assert!(err.contains("must not be empty"));
918    }
919
920    #[test]
921    fn literal_validator_requires_byte_for_byte_match() {
922        let prompt = Prompt::Literal {
923            label: "type 'attest'".to_string(),
924            expected: "attest".to_string(),
925        };
926        assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
927        let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
928            .expect_err("case-sensitive");
929        assert!(err.contains("attest"));
930    }
931
932    #[test]
933    fn regex_validator_applies_to_text_and_secret_alike() {
934        let text = Prompt::Text {
935            label: "id".to_string(),
936            validator: ValidatorSpec::Regex(r"[a-z]+".to_string()),
937        };
938        assert!(super::validate(&text, &Response::Text("abc".to_string())).is_ok());
939        let err = super::validate(&text, &Response::Text("abc1".to_string()))
940            .expect_err("digit outside the pattern");
941        assert!(err.contains("[a-z]+"), "{err}");
942
943        // The rule is applied to the secret, and the refusal names the rule
944        // and not what was typed.
945        let secret = Prompt::Secret {
946            label: "PIN".to_string(),
947            validator: ValidatorSpec::Regex(r"[0-9]{6}".to_string()),
948        };
949        assert!(super::validate(&secret, &Response::Secret("123456".to_string().into())).is_ok());
950        let err = super::validate(&secret, &Response::Secret("12345".to_string().into()))
951            .expect_err("too short");
952        // No message: on failure it would print the value this checks for.
953        assert!(!err.contains("12345"));
954    }
955
956    #[test]
957    fn predefined_validator_unknown_name_rejected() {
958        let err = super::validate(
959            &Prompt::Text {
960                label: "sn".to_string(),
961                validator: ValidatorSpec::Predefined("serial_number".to_string()),
962            },
963            &Response::Text("AB1234".to_string()),
964        )
965        .expect_err("predefined unknown");
966        assert!(err.contains("serial_number"));
967    }
968
969    #[test]
970    fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
971        let (event_tx, event_rx) = unbounded();
972        let (cmd_tx, cmd_rx) = unbounded();
973        let mut sink = crate::test_support::begun_sink();
974        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
975        reporter.set_current_step(Some(ids("s1")));
976
977        // Frontend: first AwaitPrompt → reply empty (rejected by validator),
978        // second AwaitPrompt → carries the rejection reason, reply "Alice".
979        // Then keep `event_rx` alive until the reporter drops `event_tx` so
980        // its post-response `PromptAnswered` send doesn't race the channel.
981        let frontend = std::thread::spawn(move || {
982            let first = recv_await_prompt(&event_rx);
983            assert!(first.rejection.is_none(), "first prompt has no rejection");
984            cmd_tx
985                .send(UiCommand::PromptResponse {
986                    prompt_id: first.prompt_id,
987                    response: Response::Text(String::new()),
988                })
989                .expect("send empty");
990
991            let second = recv_await_prompt(&event_rx);
992            assert!(
993                second.rejection.is_some(),
994                "second prompt carries the rejection reason",
995            );
996            cmd_tx
997                .send(UiCommand::PromptResponse {
998                    prompt_id: second.prompt_id,
999                    response: Response::Text("Alice".to_string()),
1000                })
1001                .expect("send Alice");
1002
1003            while event_rx.recv().is_ok() {}
1004        });
1005
1006        let response = reporter
1007            .prompt(&Prompt::Text {
1008                label: "name".to_string(),
1009                validator: ValidatorSpec::NonEmpty,
1010            })
1011            .expect("prompt");
1012        match response {
1013            Response::Text(t) => assert_eq!(t, "Alice"),
1014            _ => panic!("expected Text"),
1015        }
1016        drop(event_tx);
1017        frontend.join().expect("frontend join");
1018    }
1019}