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