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 chrono::Utc;
27use crossbeam_channel::{Receiver, Sender, TryRecvError};
28use rite_model::{ErrorRecord, Prompt, ResponseRecord, StepFact, StepId};
29use secrecy::ExposeSecret;
30use thiserror::Error;
31
32use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal};
33use crate::transcript::sha256_hex;
34use crate::transcript_sink::TranscriptSink;
35
36/// Errors that can arise while emitting events or awaiting a response.
37#[derive(Debug, Error)]
38pub enum ReporterError {
39    /// The operator requested an abort. Propagated up through the action
40    /// handler so the executor can unwind to a clean failure.
41    #[error("ceremony aborted by operator")]
42    Aborted,
43    /// The UI thread disconnected from one of the channels. The executor
44    /// treats this as a fatal failure (the operator is no longer reachable).
45    #[error("frontend channel disconnected")]
46    Disconnected,
47    /// Failed to persist a fact to the transcript sink.
48    #[error("transcript write failed: {0}")]
49    Transcript(#[from] std::io::Error),
50    /// Reporter was asked to emit a step-scoped signal but no step is set.
51    #[error("internal: no current step set for {0}")]
52    NoCurrentStep(&'static str),
53}
54
55impl ReporterError {
56    /// Convert this error into an [`ErrorRecord`] suitable for the transcript.
57    #[must_use]
58    pub fn to_error_record(&self) -> ErrorRecord {
59        let kind = match self {
60            ReporterError::Aborted => "aborted",
61            ReporterError::Disconnected => "frontend_disconnected",
62            ReporterError::Transcript(_) => "transcript_io",
63            ReporterError::NoCurrentStep(_) => "internal_no_current_step",
64        };
65        ErrorRecord::new(kind, self.to_string())
66    }
67}
68
69/// Action-facing handle threaded into every action handler.
70pub struct Reporter<'a> {
71    event_tx: &'a Sender<ExecEvent>,
72    cmd_rx: &'a Receiver<UiCommand>,
73    transcript: &'a mut dyn TranscriptSink,
74    next_prompt_id: u64,
75    current_step: Option<StepId>,
76}
77
78impl<'a> Reporter<'a> {
79    /// Build a reporter bound to the given channels and transcript sink.
80    pub fn new(
81        event_tx: &'a Sender<ExecEvent>,
82        cmd_rx: &'a Receiver<UiCommand>,
83        transcript: &'a mut dyn TranscriptSink,
84    ) -> Self {
85        Self {
86            event_tx,
87            cmd_rx,
88            transcript,
89            next_prompt_id: 0,
90            current_step: None,
91        }
92    }
93
94    /// Set the step that subsequent log lines and progress signals are
95    /// attributed to. Called by the executor at each step boundary.
96    pub fn set_current_step(&mut self, step: Option<StepId>) {
97        self.current_step = step;
98    }
99
100    /// The current step, if any. Mostly useful for tests.
101    #[must_use]
102    pub fn current_step(&self) -> Option<&StepId> {
103        self.current_step.as_ref()
104    }
105
106    /// Record a transcript-worthy fact.
107    ///
108    /// Facts are the audit record: everything durable flows through here, and
109    /// it comes from an action in the plan (plus the runner's own lifecycle
110    /// facts). Writes to the transcript sink synchronously, then forwards to
111    /// the UI as [`ExecEvent::Fact`]. The transcript is flushed before the UI
112    /// observes the fact. To surface something to the operator *without*
113    /// recording it, use [`Reporter::signal`] / [`Reporter::log`] instead.
114    ///
115    /// # Errors
116    ///
117    /// Returns [`ReporterError::Transcript`] if the sink fails to persist,
118    /// or [`ReporterError::Disconnected`] if the frontend is gone.
119    pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> {
120        self.transcript.record(&fact)?;
121        self.event_tx
122            .send(ExecEvent::Fact(fact))
123            .map_err(|_| ReporterError::Disconnected)?;
124        Ok(())
125    }
126
127    /// Emit a raw [`UiSignal`]. Never recorded to the transcript.
128    ///
129    /// Signals are ephemeral operator-assistance: a frontend may drop any of
130    /// them without affecting correctness or the transcript. They are never
131    /// promoted into the audit record; use [`Reporter::fact`] for anything
132    /// that must persist. Most callers should use the specialized helpers
133    /// ([`Reporter::log`] for narration, [`Reporter::progress`] for spinners).
134    /// This method exists for structured signals (e.g. the pre-ceremony
135    /// overview, the system snapshot) that don't fit either shape.
136    ///
137    /// # Errors
138    ///
139    /// Returns [`ReporterError::Disconnected`] if the frontend is gone.
140    pub fn signal(&mut self, signal: UiSignal) -> Result<(), ReporterError> {
141        self.event_tx
142            .send(ExecEvent::Signal(signal))
143            .map_err(|_| ReporterError::Disconnected)?;
144        Ok(())
145    }
146
147    /// Emit a UI-only narration line. Never recorded to the transcript.
148    ///
149    /// # Errors
150    ///
151    /// Returns [`ReporterError::Disconnected`] if the frontend is gone.
152    pub fn log(&mut self, icon: Icon, text: impl Into<String>) -> Result<(), ReporterError> {
153        let signal = UiSignal::LogLine {
154            step: self.current_step.clone(),
155            icon,
156            text: text.into(),
157        };
158        self.event_tx
159            .send(ExecEvent::Signal(signal))
160            .map_err(|_| ReporterError::Disconnected)?;
161        Ok(())
162    }
163
164    /// Emit a progress signal (e.g. spinner phase, completion fraction).
165    ///
166    /// Never recorded to the transcript. Requires a current step.
167    ///
168    /// # Errors
169    ///
170    /// Returns [`ReporterError::Disconnected`] or
171    /// [`ReporterError::NoCurrentStep`].
172    pub fn progress(
173        &mut self,
174        phase: impl Into<String>,
175        fraction: Option<f32>,
176    ) -> Result<(), ReporterError> {
177        let step = self
178            .current_step
179            .clone()
180            .ok_or(ReporterError::NoCurrentStep("progress"))?;
181        let signal = UiSignal::Progress {
182            step,
183            phase: phase.into(),
184            fraction,
185        };
186        self.event_tx
187            .send(ExecEvent::Signal(signal))
188            .map_err(|_| ReporterError::Disconnected)?;
189        Ok(())
190    }
191
192    /// Drain any pending commands. Returns [`ReporterError::Aborted`] if an
193    /// abort command is in the queue.
194    ///
195    /// Designed to be called liberally during long-running backend
196    /// operations. `try_recv` on a crossbeam channel is on the order of
197    /// nanoseconds, so the cost is negligible.
198    ///
199    /// # Errors
200    ///
201    /// Returns [`ReporterError::Aborted`] if abort was requested, or
202    /// [`ReporterError::Disconnected`] if the frontend went away.
203    pub fn check_abort(&mut self) -> Result<(), ReporterError> {
204        loop {
205            match self.cmd_rx.try_recv() {
206                Ok(cmd) => self.handle_offline_command(cmd)?,
207                Err(TryRecvError::Empty) => return Ok(()),
208                Err(TryRecvError::Disconnected) => return Err(ReporterError::Disconnected),
209            }
210        }
211    }
212
213    /// Issue a prompt and block until a validated response is received.
214    ///
215    /// While blocking, also handles [`UiCommand::Abort`] and
216    /// [`UiCommand::LogDeviation`] so the user can deviate or abort mid-prompt.
217    /// On rejection by the runtime's validator, re-emits the prompt with
218    /// the rejection reason attached so the frontend can surface it.
219    ///
220    /// Automatically emits [`StepFact::PromptAnswered`] when the response
221    /// is accepted.
222    ///
223    /// # Errors
224    ///
225    /// Returns [`ReporterError::Aborted`] if abort was requested,
226    /// [`ReporterError::Disconnected`] if the frontend went away, or
227    /// [`ReporterError::Transcript`] if the fact cannot be recorded.
228    pub fn prompt(&mut self, prompt: &Prompt) -> Result<Response, ReporterError> {
229        let step = self.current_step.clone();
230        let prompt_id = self.allocate_prompt_id();
231        let mut previous_rejection: Option<String> = None;
232
233        loop {
234            self.event_tx
235                .send(ExecEvent::AwaitPrompt {
236                    step: step.clone(),
237                    prompt_id,
238                    prompt: prompt.clone(),
239                    previous_attempt_rejected_because: previous_rejection.take(),
240                })
241                .map_err(|_| ReporterError::Disconnected)?;
242
243            // Inner loop: drain commands until a matching, validated response arrives.
244            loop {
245                let cmd = self
246                    .cmd_rx
247                    .recv()
248                    .map_err(|_| ReporterError::Disconnected)?;
249                match cmd {
250                    UiCommand::Abort => return Err(ReporterError::Aborted),
251                    UiCommand::LogDeviation { text } => {
252                        self.emit_deviation(step.clone(), text)?;
253                    }
254                    UiCommand::PromptResponse {
255                        prompt_id: incoming_id,
256                        response,
257                    } => {
258                        if incoming_id != prompt_id {
259                            // Stale response from a previous prompt, drop.
260                            continue;
261                        }
262                        match validate(prompt, &response) {
263                            Ok(()) => {
264                                let record = response_to_record(&response);
265                                self.fact(StepFact::PromptAnswered {
266                                    step: step.clone(),
267                                    prompt: prompt.clone(),
268                                    response: record,
269                                    at: Utc::now(),
270                                })?;
271                                return Ok(response);
272                            }
273                            Err(reason) => {
274                                previous_rejection = Some(reason);
275                                break; // re-emit AwaitPrompt with rejection
276                            }
277                        }
278                    }
279                }
280            }
281        }
282    }
283
284    fn allocate_prompt_id(&mut self) -> PromptId {
285        let id = PromptId::new(self.next_prompt_id);
286        self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
287        id
288    }
289
290    /// Handle a command that arrives outside of a prompt-wait context.
291    fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
292        match cmd {
293            UiCommand::Abort => Err(ReporterError::Aborted),
294            UiCommand::LogDeviation { text } => {
295                let step = self.current_step.clone();
296                self.emit_deviation(step, text)
297            }
298            // A stray prompt response outside any prompt context is dropped.
299            UiCommand::PromptResponse { .. } => Ok(()),
300        }
301    }
302
303    fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
304        self.fact(StepFact::DeviationRecorded {
305            step,
306            text,
307            at: Utc::now(),
308        })
309    }
310}
311
312/// Convert an in-flight [`Response`] into its redacted, serializable form.
313///
314/// The boundary where plaintext secrets become a deterministic hash. Lives
315/// here (next to `Response`) rather than on `ResponseRecord` so that
316/// `secrecy` stays a runtime-only dependency.
317fn response_to_record(response: &Response) -> ResponseRecord {
318    match response {
319        Response::Bool(b) => ResponseRecord::Bool { value: *b },
320        Response::Text(t) => ResponseRecord::Text { value: t.clone() },
321        Response::Secret(s) => ResponseRecord::SecretRedacted {
322            sha256_of_plaintext: sha256_hex(s.expose_secret().as_bytes()),
323        },
324        Response::Acknowledge => ResponseRecord::Acknowledged,
325    }
326}
327
328/// Validate a response against the prompt's constraints.
329///
330/// Returns `Ok(())` if the response is acceptable, or `Err(reason)` if it
331/// must be rejected. The reason is surfaced to the frontend through
332/// [`ExecEvent::AwaitPrompt::previous_attempt_rejected_because`].
333///
334/// Two checks are performed:
335/// 1. **Shape**: the response variant matches the prompt variant (e.g.
336///    a [`Prompt::Confirm`] requires a [`Response::Bool`]). A mismatch
337///    indicates a frontend bug.
338/// 2. **Content**: the [`Prompt::Text`] validator is applied to the text,
339///    and [`Prompt::Literal`] requires byte-for-byte equality with the
340///    expected string.
341fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
342    match (prompt, response) {
343        (Prompt::Confirm { .. }, Response::Bool(_))
344        | (Prompt::Continue { .. }, Response::Acknowledge)
345        | (Prompt::Secret { .. }, Response::Secret(_)) => Ok(()),
346
347        (Prompt::Text { validator, .. }, Response::Text(value)) => {
348            apply_validator(validator, value)
349        }
350
351        (Prompt::Literal { expected, .. }, Response::Text(value)) => {
352            if value == expected {
353                Ok(())
354            } else {
355                Err(format!("expected exactly: {expected}"))
356            }
357        }
358
359        _ => Err("response shape does not match prompt shape".to_string()),
360    }
361}
362
363fn apply_validator(spec: &rite_model::ValidatorSpec, value: &str) -> Result<(), String> {
364    use rite_model::ValidatorSpec;
365    match spec {
366        ValidatorSpec::NonEmpty => {
367            if value.trim().is_empty() {
368                Err("value must not be empty".to_string())
369            } else {
370                Ok(())
371            }
372        }
373        // Regex validation requires an additional dependency; not yet wired in.
374        ValidatorSpec::Regex(_) => Err("regex validation is not yet implemented".to_string()),
375        // Named predicates will land alongside specific ceremony actions
376        // that need them (serial numbers, hex strings, etc.).
377        ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")),
378        _ => Err("unknown validator variant".to_string()),
379    }
380}
381
382#[cfg(test)]
383mod tests {
384    use crossbeam_channel::unbounded;
385    use secrecy::SecretString;
386
387    use super::*;
388    use crate::transcript_sink::InMemorySink;
389    use rite_model::ValidatorSpec;
390
391    fn ids(step: &str) -> StepId {
392        StepId::new(step)
393    }
394
395    #[test]
396    fn fact_writes_to_sink_and_forwards_to_ui() {
397        let (event_tx, event_rx) = unbounded();
398        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
399        let mut sink = InMemorySink::new();
400        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
401        reporter.set_current_step(Some(ids("s1")));
402
403        reporter
404            .fact(StepFact::DeviationRecorded {
405                step: Some(ids("s1")),
406                text: "minor".to_string(),
407                at: Utc::now(),
408            })
409            .expect("emit fact");
410
411        assert_eq!(sink.len(), 1);
412        match event_rx.try_recv().expect("ui event") {
413            ExecEvent::Fact(StepFact::DeviationRecorded { text, .. }) => {
414                assert_eq!(text, "minor");
415            }
416            other => panic!("unexpected event: {other:?}"),
417        }
418    }
419
420    #[test]
421    fn log_does_not_touch_transcript() {
422        let (event_tx, event_rx) = unbounded();
423        let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
424        let mut sink = InMemorySink::new();
425        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
426        reporter.set_current_step(Some(ids("s1")));
427
428        reporter.log(Icon::Info, "hello").expect("log");
429
430        assert_eq!(sink.len(), 0);
431        match event_rx.try_recv().expect("ui event") {
432            ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
433            other => panic!("unexpected event: {other:?}"),
434        }
435    }
436
437    #[test]
438    fn check_abort_returns_aborted_when_abort_in_queue() {
439        let (event_tx, _event_rx) = unbounded::<ExecEvent>();
440        let (cmd_tx, cmd_rx) = unbounded();
441        let mut sink = InMemorySink::new();
442        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
443        reporter.set_current_step(Some(ids("s1")));
444
445        cmd_tx.send(UiCommand::Abort).expect("send");
446        let err = reporter.check_abort().expect_err("abort");
447        assert!(matches!(err, ReporterError::Aborted));
448    }
449
450    #[test]
451    fn check_abort_processes_deviation_then_returns_ok() {
452        let (event_tx, event_rx) = unbounded();
453        let (cmd_tx, cmd_rx) = unbounded();
454        let mut sink = InMemorySink::new();
455        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
456        reporter.set_current_step(Some(ids("s1")));
457
458        cmd_tx
459            .send(UiCommand::LogDeviation {
460                text: "phone rang".to_string(),
461            })
462            .expect("send");
463        reporter.check_abort().expect("ok");
464
465        assert_eq!(sink.len(), 1);
466        match event_rx.try_recv().expect("ui event") {
467            ExecEvent::Fact(StepFact::DeviationRecorded { text, .. }) => {
468                assert_eq!(text, "phone rang");
469            }
470            other => panic!("unexpected event: {other:?}"),
471        }
472    }
473
474    struct AwaitedPrompt {
475        prompt_id: PromptId,
476        rejection: Option<String>,
477    }
478
479    /// Drain `ExecEvent`s until the next `AwaitPrompt`, panicking if the
480    /// channel closes first. Non-prompt events are discarded.
481    fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
482        loop {
483            if let ExecEvent::AwaitPrompt {
484                prompt_id,
485                previous_attempt_rejected_because,
486                ..
487            } = event_rx
488                .recv()
489                .expect("event channel closed before AwaitPrompt")
490            {
491                return AwaitedPrompt {
492                    prompt_id,
493                    rejection: previous_attempt_rejected_because,
494                };
495            }
496        }
497    }
498
499    /// Spawn a "frontend" thread that drains all `ExecEvent`s and feeds each
500    /// `AwaitPrompt` to the supplied `respond` callback. Keeps `event_rx`
501    /// alive until the channel closes, so the reporter's later `Fact` sends
502    /// don't trip a spurious `Disconnected` error.
503    fn spawn_frontend<F>(
504        event_rx: Receiver<ExecEvent>,
505        cmd_tx: Sender<UiCommand>,
506        mut respond: F,
507    ) -> std::thread::JoinHandle<()>
508    where
509        F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
510    {
511        std::thread::spawn(move || {
512            while let Ok(ev) = event_rx.recv() {
513                if let ExecEvent::AwaitPrompt {
514                    prompt_id, prompt, ..
515                } = ev
516                {
517                    respond(prompt_id, &prompt, &cmd_tx);
518                }
519            }
520        })
521    }
522
523    #[test]
524    fn prompt_round_trip_emits_await_and_records_fact() {
525        let (event_tx, event_rx) = unbounded();
526        let (cmd_tx, cmd_rx) = unbounded();
527        let mut sink = InMemorySink::new();
528        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
529        reporter.set_current_step(Some(ids("s1")));
530
531        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
532            tx.send(UiCommand::PromptResponse {
533                prompt_id,
534                response: Response::Bool(true),
535            })
536            .expect("send response");
537        });
538
539        let response = reporter
540            .prompt(&Prompt::Confirm {
541                question: "proceed?".to_string(),
542                default: Some(true),
543            })
544            .expect("prompt");
545        assert!(matches!(response, Response::Bool(true)));
546        assert_eq!(sink.len(), 1);
547        assert!(matches!(
548            sink.facts().first(),
549            Some(StepFact::PromptAnswered { .. })
550        ));
551
552        drop(event_tx);
553        frontend.join().expect("frontend join");
554    }
555
556    #[test]
557    fn prompt_redacts_secret_in_record() {
558        let (event_tx, event_rx) = unbounded();
559        let (cmd_tx, cmd_rx) = unbounded();
560        let mut sink = InMemorySink::new();
561        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
562        reporter.set_current_step(Some(ids("s1")));
563
564        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
565            tx.send(UiCommand::PromptResponse {
566                prompt_id,
567                response: Response::Secret(SecretString::from("hunter2".to_string())),
568            })
569            .expect("send response");
570        });
571
572        reporter
573            .prompt(&Prompt::Secret {
574                label: "PIN".to_string(),
575            })
576            .expect("prompt");
577
578        match sink.facts().first().expect("fact") {
579            StepFact::PromptAnswered { response, .. } => match response {
580                ResponseRecord::SecretRedacted {
581                    sha256_of_plaintext,
582                } => {
583                    assert!(!sha256_of_plaintext.contains("hunter"));
584                    assert_eq!(sha256_of_plaintext.len(), 64);
585                }
586                other => panic!("expected redacted secret, got {other:?}"),
587            },
588            other => panic!("unexpected fact: {other:?}"),
589        }
590
591        drop(event_tx);
592        frontend.join().expect("frontend join");
593    }
594
595    #[test]
596    fn prompt_loop_handles_deviation_then_response() {
597        let (event_tx, event_rx) = unbounded();
598        let (cmd_tx, cmd_rx) = unbounded();
599        let mut sink = InMemorySink::new();
600        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
601        reporter.set_current_step(Some(ids("s1")));
602
603        let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
604            tx.send(UiCommand::LogDeviation {
605                text: "noted".to_string(),
606            })
607            .expect("send deviation");
608            tx.send(UiCommand::PromptResponse {
609                prompt_id,
610                response: Response::Acknowledge,
611            })
612            .expect("send response");
613        });
614
615        reporter
616            .prompt(&Prompt::Continue { hint: None })
617            .expect("prompt");
618
619        assert_eq!(sink.len(), 2);
620        assert!(matches!(
621            sink.facts().first(),
622            Some(StepFact::DeviationRecorded { .. })
623        ));
624        assert!(matches!(
625            sink.facts().get(1),
626            Some(StepFact::PromptAnswered { .. })
627        ));
628
629        drop(event_tx);
630        frontend.join().expect("frontend join");
631    }
632
633    #[test]
634    fn validator_signature_compiles_with_predefined_variant() {
635        // Sanity check: Predefined variant of ValidatorSpec is referenceable.
636        let _v = ValidatorSpec::Predefined("serial_number".to_string());
637    }
638
639    #[test]
640    fn non_empty_validator_rejects_empty_and_accepts_text() {
641        assert!(
642            super::validate(
643                &Prompt::Text {
644                    label: "name".to_string(),
645                    validator: ValidatorSpec::NonEmpty,
646                },
647                &Response::Text(String::new()),
648            )
649            .is_err()
650        );
651
652        assert!(
653            super::validate(
654                &Prompt::Text {
655                    label: "name".to_string(),
656                    validator: ValidatorSpec::NonEmpty,
657                },
658                &Response::Text("Alice".to_string()),
659            )
660            .is_ok()
661        );
662    }
663
664    #[test]
665    fn non_empty_validator_rejects_whitespace_only() {
666        let err = super::validate(
667            &Prompt::Text {
668                label: "name".to_string(),
669                validator: ValidatorSpec::NonEmpty,
670            },
671            &Response::Text("   \t".to_string()),
672        )
673        .expect_err("should reject whitespace");
674        assert!(err.contains("must not be empty"));
675    }
676
677    #[test]
678    fn literal_validator_requires_byte_for_byte_match() {
679        let prompt = Prompt::Literal {
680            label: "type 'attest'".to_string(),
681            expected: "attest".to_string(),
682        };
683        assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
684        let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
685            .expect_err("case-sensitive");
686        assert!(err.contains("attest"));
687    }
688
689    #[test]
690    fn regex_validator_not_yet_implemented_returns_rejection() {
691        let err = super::validate(
692            &Prompt::Text {
693                label: "id".to_string(),
694                validator: ValidatorSpec::Regex(r"^[a-z]+$".to_string()),
695            },
696            &Response::Text("abc".to_string()),
697        )
698        .expect_err("regex not implemented");
699        assert!(err.contains("regex"));
700    }
701
702    #[test]
703    fn predefined_validator_unknown_name_rejected() {
704        let err = super::validate(
705            &Prompt::Text {
706                label: "sn".to_string(),
707                validator: ValidatorSpec::Predefined("serial_number".to_string()),
708            },
709            &Response::Text("AB1234".to_string()),
710        )
711        .expect_err("predefined unknown");
712        assert!(err.contains("serial_number"));
713    }
714
715    #[test]
716    fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
717        let (event_tx, event_rx) = unbounded();
718        let (cmd_tx, cmd_rx) = unbounded();
719        let mut sink = InMemorySink::new();
720        let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink);
721        reporter.set_current_step(Some(ids("s1")));
722
723        // Frontend: first AwaitPrompt → reply empty (rejected by validator),
724        // second AwaitPrompt → carries the rejection reason, reply "Alice".
725        // Then keep `event_rx` alive until the reporter drops `event_tx` so
726        // its post-response `PromptAnswered` send doesn't race the channel.
727        let frontend = std::thread::spawn(move || {
728            let first = recv_await_prompt(&event_rx);
729            assert!(first.rejection.is_none(), "first prompt has no rejection");
730            cmd_tx
731                .send(UiCommand::PromptResponse {
732                    prompt_id: first.prompt_id,
733                    response: Response::Text(String::new()),
734                })
735                .expect("send empty");
736
737            let second = recv_await_prompt(&event_rx);
738            assert!(
739                second.rejection.is_some(),
740                "second prompt carries the rejection reason",
741            );
742            cmd_tx
743                .send(UiCommand::PromptResponse {
744                    prompt_id: second.prompt_id,
745                    response: Response::Text("Alice".to_string()),
746                })
747                .expect("send Alice");
748
749            while event_rx.recv().is_ok() {}
750        });
751
752        let response = reporter
753            .prompt(&Prompt::Text {
754                label: "name".to_string(),
755                validator: ValidatorSpec::NonEmpty,
756            })
757            .expect("prompt");
758        match response {
759            Response::Text(t) => assert_eq!(t, "Alice"),
760            _ => panic!("expected Text"),
761        }
762        drop(event_tx);
763        frontend.join().expect("frontend join");
764    }
765}