1use std::sync::Arc;
27
28use crossbeam_channel::{Receiver, Sender, TryRecvError};
29use rite_model::{ErrorClass, ErrorRecord, Level, Prompt, ResponseRecord, StepFact, StepId};
30use secrecy::ExposeSecret;
31use thiserror::Error;
32
33use crate::clock::Clock;
34use crate::entropy::CeremonyRandom;
35use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal};
36use crate::transcript_sink::TranscriptSink;
37
38#[derive(Debug, Error)]
40pub enum ReporterError {
41 #[error("ceremony aborted by operator")]
44 Aborted,
45 #[error("frontend channel disconnected")]
48 Disconnected,
49 #[error("transcript write failed: {0}")]
51 Transcript(#[from] std::io::Error),
52 #[error("internal: no current step set for {0}")]
54 NoCurrentStep(&'static str),
55 #[error("duplicate entropy draw for purpose '{purpose}' in step '{step}'")]
59 DuplicateDraw {
60 step: StepId,
62 purpose: String,
64 },
65 #[error("internal: entropy source drawn before it was seeded")]
69 Unseeded,
70 #[error("entropy draw of {len} bytes exceeds the {max}-byte HKDF-Expand limit")]
76 DrawTooLong {
77 len: usize,
79 max: usize,
81 },
82}
83
84impl ReporterError {
85 #[must_use]
87 pub fn to_error_record(&self) -> ErrorRecord {
88 let (class, kind) = match self {
89 ReporterError::Aborted => (ErrorClass::Abort, "aborted"),
90 ReporterError::Disconnected => (ErrorClass::Integrity, "frontend_disconnected"),
91 ReporterError::Transcript(_) => (ErrorClass::Integrity, "transcript_io"),
92 ReporterError::NoCurrentStep(_) => (ErrorClass::Integrity, "internal_no_current_step"),
93 ReporterError::DuplicateDraw { .. } => {
94 (ErrorClass::Integrity, "duplicate_entropy_draw")
95 }
96 ReporterError::Unseeded => (ErrorClass::Integrity, "internal_entropy_unseeded"),
97 ReporterError::DrawTooLong { .. } => (ErrorClass::Integrity, "entropy_draw_too_long"),
98 };
99 ErrorRecord::new(class, kind, self.to_string())
100 }
101}
102
103pub struct Reporter<'a> {
105 event_tx: &'a Sender<ExecEvent>,
106 cmd_rx: &'a Receiver<UiCommand>,
107 transcript: &'a mut dyn TranscriptSink,
108 next_prompt_id: u64,
109 current_step: Option<StepId>,
110 current_backend: Option<String>,
113 random: Option<CeremonyRandom>,
118 clock: Arc<dyn Clock>,
121 side_effects_emitted: u64,
129}
130
131impl<'a> Reporter<'a> {
132 pub fn new(
134 event_tx: &'a Sender<ExecEvent>,
135 cmd_rx: &'a Receiver<UiCommand>,
136 transcript: &'a mut dyn TranscriptSink,
137 clock: Arc<dyn Clock>,
138 ) -> Self {
139 Self {
140 event_tx,
141 cmd_rx,
142 transcript,
143 next_prompt_id: 0,
144 current_step: None,
145 current_backend: None,
146 random: None,
147 clock,
148 side_effects_emitted: 0,
149 }
150 }
151
152 #[must_use]
156 pub fn side_effects_emitted(&self) -> u64 {
157 self.side_effects_emitted
158 }
159
160 pub fn seed_entropy(&mut self, m: &[u8]) {
167 self.random = Some(CeremonyRandom::from_machine_seed(m));
168 }
169
170 pub fn set_current_step(&mut self, step: Option<StepId>) {
173 self.current_step = step;
174 }
175
176 pub fn set_current_backend(&mut self, backend: Option<String>) {
179 self.current_backend = backend;
180 }
181
182 #[must_use]
184 pub fn current_step(&self) -> Option<&StepId> {
185 self.current_step.as_ref()
186 }
187
188 pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> {
202 let level = fact.default_level();
203 self.fact_at(level, fact)
204 }
205
206 pub fn fact_at(&mut self, level: Level, fact: StepFact) -> Result<(), ReporterError> {
215 let at = self.clock.now();
218 self.transcript.record(at, level, &fact)?;
219 if fact.is_side_effect() {
220 self.side_effects_emitted = self.side_effects_emitted.saturating_add(1);
221 }
222 self.event_tx
223 .send(ExecEvent::Fact { at, fact })
224 .map_err(|_| ReporterError::Disconnected)?;
225 Ok(())
226 }
227
228 pub fn backend_operation(
236 &mut self,
237 kind: &str,
238 inputs: serde_json::Value,
239 outputs: serde_json::Value,
240 fingerprint: Option<String>,
241 ) -> Result<(), ReporterError> {
242 let step = self
243 .current_step
244 .clone()
245 .ok_or(ReporterError::NoCurrentStep("backend_operation"))?;
246 self.fact(StepFact::BackendOperation {
247 step,
248 kind: kind.to_string(),
249 backend: self.current_backend.clone(),
250 inputs,
251 outputs,
252 fingerprint,
253 })
254 }
255
256 pub fn draw(&mut self, purpose: &str, len: usize) -> Result<Vec<u8>, ReporterError> {
270 if len > crate::entropy::MAX_DRAW_LEN {
271 return Err(ReporterError::DrawTooLong {
272 len,
273 max: crate::entropy::MAX_DRAW_LEN,
274 });
275 }
276 let step = self
277 .current_step
278 .clone()
279 .ok_or(ReporterError::NoCurrentStep("draw"))?;
280 let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
281 let drawn =
282 random
283 .draw(&step, purpose, len)
284 .ok_or_else(|| ReporterError::DuplicateDraw {
285 step: step.clone(),
286 purpose: purpose.to_string(),
287 })?;
288 self.fact(StepFact::EntropyDrawn {
289 step,
290 path: drawn.path,
291 value: base16ct::lower::encode_string(&drawn.value),
292 })?;
293 Ok(drawn.value)
294 }
295
296 pub fn fold_entropy(&mut self, contribution: &str) -> Result<(), ReporterError> {
310 let step = self
311 .current_step
312 .clone()
313 .ok_or(ReporterError::NoCurrentStep("fold_entropy"))?;
314 let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
315 random.fold(contribution.as_bytes());
316 let epoch = random.epoch();
317 self.fact(StepFact::EntropyContributed {
318 step,
319 epoch,
320 contribution: contribution.to_string(),
321 })
322 }
323
324 pub fn signal(&mut self, signal: UiSignal) -> Result<(), ReporterError> {
338 self.event_tx
339 .send(ExecEvent::Signal(signal))
340 .map_err(|_| ReporterError::Disconnected)?;
341 Ok(())
342 }
343
344 pub fn log(&mut self, icon: Icon, text: impl Into<String>) -> Result<(), ReporterError> {
350 let signal = UiSignal::LogLine {
351 step: self.current_step.clone(),
352 icon,
353 text: text.into(),
354 };
355 self.event_tx
356 .send(ExecEvent::Signal(signal))
357 .map_err(|_| ReporterError::Disconnected)?;
358 Ok(())
359 }
360
361 pub fn progress(
370 &mut self,
371 phase: impl Into<String>,
372 fraction: Option<f32>,
373 ) -> Result<(), ReporterError> {
374 let step = self
375 .current_step
376 .clone()
377 .ok_or(ReporterError::NoCurrentStep("progress"))?;
378 let signal = UiSignal::Progress {
379 step,
380 phase: phase.into(),
381 fraction,
382 };
383 self.event_tx
384 .send(ExecEvent::Signal(signal))
385 .map_err(|_| ReporterError::Disconnected)?;
386 Ok(())
387 }
388
389 pub fn check_abort(&mut self) -> Result<(), ReporterError> {
401 loop {
402 match self.cmd_rx.try_recv() {
403 Ok(cmd) => self.handle_offline_command(cmd)?,
404 Err(TryRecvError::Empty) => return Ok(()),
405 Err(TryRecvError::Disconnected) => return Err(ReporterError::Disconnected),
406 }
407 }
408 }
409
410 pub fn prompt(&mut self, prompt: &Prompt) -> Result<Response, ReporterError> {
426 let step = self.current_step.clone();
427 let prompt_id = self.allocate_prompt_id();
428 let mut previous_rejection: Option<String> = None;
429
430 loop {
431 self.event_tx
432 .send(ExecEvent::AwaitPrompt {
433 step: step.clone(),
434 prompt_id,
435 prompt: prompt.clone(),
436 previous_attempt_rejected_because: previous_rejection.take(),
437 })
438 .map_err(|_| ReporterError::Disconnected)?;
439
440 loop {
442 let cmd = self
443 .cmd_rx
444 .recv()
445 .map_err(|_| ReporterError::Disconnected)?;
446 match cmd {
447 UiCommand::Abort => return Err(ReporterError::Aborted),
448 UiCommand::LogDeviation { text } => {
449 self.emit_deviation(step.clone(), text)?;
450 }
451 UiCommand::PromptResponse {
452 prompt_id: incoming_id,
453 response,
454 } => {
455 if incoming_id != prompt_id {
456 continue;
458 }
459 match validate(prompt, &response) {
460 Ok(()) => {
461 let record = response_to_record(&response);
462 self.fact(StepFact::PromptAnswered {
463 step: step.clone(),
464 prompt: prompt.clone(),
465 response: record,
466 })?;
467 return Ok(response);
468 }
469 Err(reason) => {
470 previous_rejection = Some(reason);
471 break; }
473 }
474 }
475 }
476 }
477 }
478 }
479
480 fn allocate_prompt_id(&mut self) -> PromptId {
481 let id = PromptId::new(self.next_prompt_id);
482 self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
483 id
484 }
485
486 fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
488 match cmd {
489 UiCommand::Abort => Err(ReporterError::Aborted),
490 UiCommand::LogDeviation { text } => {
491 let step = self.current_step.clone();
492 self.emit_deviation(step, text)
493 }
494 UiCommand::PromptResponse { .. } => Ok(()),
496 }
497 }
498
499 fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
500 self.fact(StepFact::DeviationRecorded { step, text })
501 }
502}
503
504fn response_to_record(response: &Response) -> ResponseRecord {
510 match response {
511 Response::Bool(b) => ResponseRecord::Bool { value: *b },
512 Response::Text(t) => ResponseRecord::Text { value: t.clone() },
513 Response::Secret(_) => ResponseRecord::SecretRedacted {},
514 Response::Acknowledge => ResponseRecord::Acknowledged,
515 }
516}
517
518fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
533 match (prompt, response) {
534 (Prompt::Confirm { .. }, Response::Bool(_))
535 | (Prompt::Continue { .. }, Response::Acknowledge) => Ok(()),
536
537 (Prompt::Text { validator, .. }, Response::Text(value)) => validator.check(value),
538 (Prompt::Secret { validator, .. }, Response::Secret(value)) => {
539 validator.check(value.expose_secret())
540 }
541
542 (Prompt::Literal { expected, .. }, Response::Text(value)) => {
543 if value == expected {
544 Ok(())
545 } else {
546 Err(format!("expected exactly: {expected}"))
547 }
548 }
549
550 _ => Err("response shape does not match prompt shape".to_string()),
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use crossbeam_channel::unbounded;
557 use secrecy::SecretString;
558
559 use super::*;
560 use crate::clock::SystemClock;
561 use rite_model::ValidatorSpec;
562
563 fn ids(step: &str) -> StepId {
564 StepId::new(step)
565 }
566
567 fn test_clock() -> Arc<dyn Clock> {
568 Arc::new(SystemClock)
569 }
570
571 #[test]
572 fn fact_stamps_event_from_the_injected_clock() {
573 use crate::test_support::{FixedClock, fixed_test_time};
574
575 let (event_tx, event_rx) = unbounded();
576 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
577 let mut sink = crate::test_support::begun_sink();
578 let fixed = fixed_test_time();
579 let mut reporter =
580 Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
581
582 reporter
583 .fact(crate::test_support::ceremony_started("T"))
584 .expect("fact");
585
586 match event_rx.recv().expect("event") {
587 ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
588 other => panic!("expected Fact, got {other:?}"),
589 }
590 }
591
592 #[test]
593 fn fact_writes_to_sink_and_forwards_to_ui() {
594 let (event_tx, event_rx) = unbounded();
595 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
596 let mut sink = crate::test_support::begun_sink();
597 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
598 reporter.set_current_step(Some(ids("s1")));
599
600 reporter
601 .fact(StepFact::DeviationRecorded {
602 step: Some(ids("s1")),
603 text: "minor".to_string(),
604 })
605 .expect("emit fact");
606
607 assert_eq!(sink.len(), 1);
608 match event_rx.try_recv().expect("ui event") {
609 ExecEvent::Fact {
610 fact: StepFact::DeviationRecorded { text, .. },
611 ..
612 } => {
613 assert_eq!(text, "minor");
614 }
615 other => panic!("unexpected event: {other:?}"),
616 }
617 }
618
619 #[test]
620 fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
621 let (event_tx, _event_rx) = unbounded();
625 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
626 let mut sink = crate::test_support::begun_sink();
627 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
628 reporter.set_current_step(Some(ids("s1")));
629
630 let err = reporter
631 .draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
632 .expect_err("oversized draw");
633 assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
634 if len == crate::entropy::MAX_DRAW_LEN + 1));
635 }
636
637 #[test]
638 fn log_does_not_touch_transcript() {
639 let (event_tx, event_rx) = unbounded();
640 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
641 let mut sink = crate::test_support::begun_sink();
642 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
643 reporter.set_current_step(Some(ids("s1")));
644
645 reporter.log(Icon::Info, "hello").expect("log");
646
647 assert_eq!(sink.len(), 0);
648 match event_rx.try_recv().expect("ui event") {
649 ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
650 other => panic!("unexpected event: {other:?}"),
651 }
652 }
653
654 #[test]
655 fn check_abort_returns_aborted_when_abort_in_queue() {
656 let (event_tx, _event_rx) = unbounded::<ExecEvent>();
657 let (cmd_tx, cmd_rx) = unbounded();
658 let mut sink = crate::test_support::begun_sink();
659 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
660 reporter.set_current_step(Some(ids("s1")));
661
662 cmd_tx.send(UiCommand::Abort).expect("send");
663 let err = reporter.check_abort().expect_err("abort");
664 assert!(matches!(err, ReporterError::Aborted));
665 }
666
667 #[test]
668 fn check_abort_processes_deviation_then_returns_ok() {
669 let (event_tx, event_rx) = unbounded();
670 let (cmd_tx, cmd_rx) = unbounded();
671 let mut sink = crate::test_support::begun_sink();
672 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
673 reporter.set_current_step(Some(ids("s1")));
674
675 cmd_tx
676 .send(UiCommand::LogDeviation {
677 text: "phone rang".to_string(),
678 })
679 .expect("send");
680 reporter.check_abort().expect("ok");
681
682 assert_eq!(sink.len(), 1);
683 match event_rx.try_recv().expect("ui event") {
684 ExecEvent::Fact {
685 fact: StepFact::DeviationRecorded { text, .. },
686 ..
687 } => {
688 assert_eq!(text, "phone rang");
689 }
690 other => panic!("unexpected event: {other:?}"),
691 }
692 }
693
694 struct AwaitedPrompt {
695 prompt_id: PromptId,
696 rejection: Option<String>,
697 }
698
699 fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
702 loop {
703 if let ExecEvent::AwaitPrompt {
704 prompt_id,
705 previous_attempt_rejected_because,
706 ..
707 } = event_rx
708 .recv()
709 .expect("event channel closed before AwaitPrompt")
710 {
711 return AwaitedPrompt {
712 prompt_id,
713 rejection: previous_attempt_rejected_because,
714 };
715 }
716 }
717 }
718
719 fn spawn_frontend<F>(
724 event_rx: Receiver<ExecEvent>,
725 cmd_tx: Sender<UiCommand>,
726 mut respond: F,
727 ) -> std::thread::JoinHandle<()>
728 where
729 F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
730 {
731 std::thread::spawn(move || {
732 while let Ok(ev) = event_rx.recv() {
733 if let ExecEvent::AwaitPrompt {
734 prompt_id, prompt, ..
735 } = ev
736 {
737 respond(prompt_id, &prompt, &cmd_tx);
738 }
739 }
740 })
741 }
742
743 #[test]
744 fn prompt_round_trip_emits_await_and_records_fact() {
745 let (event_tx, event_rx) = unbounded();
746 let (cmd_tx, cmd_rx) = unbounded();
747 let mut sink = crate::test_support::begun_sink();
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::Bool(true),
755 })
756 .expect("send response");
757 });
758
759 let response = reporter
760 .prompt(&Prompt::Confirm {
761 question: "proceed?".to_string(),
762 default: Some(true),
763 })
764 .expect("prompt");
765 assert!(matches!(response, Response::Bool(true)));
766 assert_eq!(sink.len(), 1);
767 assert!(matches!(
768 sink.facts().first(),
769 Some(StepFact::PromptAnswered { .. })
770 ));
771
772 drop(event_tx);
773 frontend.join().expect("frontend join");
774 }
775
776 #[test]
777 fn prompt_redacts_secret_in_record() {
778 let (event_tx, event_rx) = unbounded();
779 let (cmd_tx, cmd_rx) = unbounded();
780 let mut sink = crate::test_support::begun_sink();
781 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
782 reporter.set_current_step(Some(ids("s1")));
783
784 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
785 tx.send(UiCommand::PromptResponse {
786 prompt_id,
787 response: Response::Secret(SecretString::from("hunter2".to_string())),
788 })
789 .expect("send response");
790 });
791
792 reporter
793 .prompt(&Prompt::Secret {
794 label: "PIN".to_string(),
795 validator: ValidatorSpec::NonEmpty,
796 })
797 .expect("prompt");
798
799 match sink.facts().first().expect("fact") {
800 StepFact::PromptAnswered { response, .. } => match response {
801 ResponseRecord::SecretRedacted {} => {}
802 other => panic!("expected redacted secret, got {other:?}"),
803 },
804 other => panic!("unexpected fact: {other:?}"),
805 }
806
807 drop(event_tx);
808 frontend.join().expect("frontend join");
809 }
810
811 #[test]
812 fn prompt_loop_handles_deviation_then_response() {
813 let (event_tx, event_rx) = unbounded();
814 let (cmd_tx, cmd_rx) = unbounded();
815 let mut sink = crate::test_support::begun_sink();
816 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
817 reporter.set_current_step(Some(ids("s1")));
818
819 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
820 tx.send(UiCommand::LogDeviation {
821 text: "noted".to_string(),
822 })
823 .expect("send deviation");
824 tx.send(UiCommand::PromptResponse {
825 prompt_id,
826 response: Response::Acknowledge,
827 })
828 .expect("send response");
829 });
830
831 reporter
832 .prompt(&Prompt::Continue { hint: None })
833 .expect("prompt");
834
835 assert_eq!(sink.len(), 2);
836 assert!(matches!(
837 sink.facts().first(),
838 Some(StepFact::DeviationRecorded { .. })
839 ));
840 assert!(matches!(
841 sink.facts().get(1),
842 Some(StepFact::PromptAnswered { .. })
843 ));
844
845 drop(event_tx);
846 frontend.join().expect("frontend join");
847 }
848
849 #[test]
850 fn validator_signature_compiles_with_predefined_variant() {
851 let _v = ValidatorSpec::Predefined("serial_number".to_string());
853 }
854
855 #[test]
856 fn non_empty_validator_rejects_empty_and_accepts_text() {
857 assert!(
858 super::validate(
859 &Prompt::Text {
860 label: "name".to_string(),
861 validator: ValidatorSpec::NonEmpty,
862 },
863 &Response::Text(String::new()),
864 )
865 .is_err()
866 );
867
868 assert!(
869 super::validate(
870 &Prompt::Text {
871 label: "name".to_string(),
872 validator: ValidatorSpec::NonEmpty,
873 },
874 &Response::Text("Alice".to_string()),
875 )
876 .is_ok()
877 );
878 }
879
880 #[test]
881 fn non_empty_validator_rejects_whitespace_only() {
882 let err = super::validate(
883 &Prompt::Text {
884 label: "name".to_string(),
885 validator: ValidatorSpec::NonEmpty,
886 },
887 &Response::Text(" \t".to_string()),
888 )
889 .expect_err("should reject whitespace");
890 assert!(err.contains("must not be empty"));
891 }
892
893 #[test]
894 fn literal_validator_requires_byte_for_byte_match() {
895 let prompt = Prompt::Literal {
896 label: "type 'attest'".to_string(),
897 expected: "attest".to_string(),
898 };
899 assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
900 let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
901 .expect_err("case-sensitive");
902 assert!(err.contains("attest"));
903 }
904
905 #[test]
906 fn regex_validator_applies_to_text_and_secret_alike() {
907 let text = Prompt::Text {
908 label: "id".to_string(),
909 validator: ValidatorSpec::Regex(r"[a-z]+".to_string()),
910 };
911 assert!(super::validate(&text, &Response::Text("abc".to_string())).is_ok());
912 let err = super::validate(&text, &Response::Text("abc1".to_string()))
913 .expect_err("digit outside the pattern");
914 assert!(err.contains("[a-z]+"), "{err}");
915
916 let secret = Prompt::Secret {
919 label: "PIN".to_string(),
920 validator: ValidatorSpec::Regex(r"[0-9]{6}".to_string()),
921 };
922 assert!(super::validate(&secret, &Response::Secret("123456".to_string().into())).is_ok());
923 let err = super::validate(&secret, &Response::Secret("12345".to_string().into()))
924 .expect_err("too short");
925 assert!(!err.contains("12345"));
927 }
928
929 #[test]
930 fn predefined_validator_unknown_name_rejected() {
931 let err = super::validate(
932 &Prompt::Text {
933 label: "sn".to_string(),
934 validator: ValidatorSpec::Predefined("serial_number".to_string()),
935 },
936 &Response::Text("AB1234".to_string()),
937 )
938 .expect_err("predefined unknown");
939 assert!(err.contains("serial_number"));
940 }
941
942 #[test]
943 fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
944 let (event_tx, event_rx) = unbounded();
945 let (cmd_tx, cmd_rx) = unbounded();
946 let mut sink = crate::test_support::begun_sink();
947 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
948 reporter.set_current_step(Some(ids("s1")));
949
950 let frontend = std::thread::spawn(move || {
955 let first = recv_await_prompt(&event_rx);
956 assert!(first.rejection.is_none(), "first prompt has no rejection");
957 cmd_tx
958 .send(UiCommand::PromptResponse {
959 prompt_id: first.prompt_id,
960 response: Response::Text(String::new()),
961 })
962 .expect("send empty");
963
964 let second = recv_await_prompt(&event_rx);
965 assert!(
966 second.rejection.is_some(),
967 "second prompt carries the rejection reason",
968 );
969 cmd_tx
970 .send(UiCommand::PromptResponse {
971 prompt_id: second.prompt_id,
972 response: Response::Text("Alice".to_string()),
973 })
974 .expect("send Alice");
975
976 while event_rx.recv().is_ok() {}
977 });
978
979 let response = reporter
980 .prompt(&Prompt::Text {
981 label: "name".to_string(),
982 validator: ValidatorSpec::NonEmpty,
983 })
984 .expect("prompt");
985 match response {
986 Response::Text(t) => assert_eq!(t, "Alice"),
987 _ => panic!("expected Text"),
988 }
989 drop(event_tx);
990 frontend.join().expect("frontend join");
991 }
992}