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