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