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 self.prompt_checked(prompt, |_| Ok(()))
427 }
428
429 pub fn prompt_checked(
438 &mut self,
439 prompt: &Prompt,
440 mut check: impl FnMut(&Response) -> Result<(), String>,
441 ) -> Result<Response, ReporterError> {
442 let step = self.current_step.clone();
443 let prompt_id = self.allocate_prompt_id();
444 let mut previous_rejection: Option<String> = None;
445
446 loop {
447 self.event_tx
448 .send(ExecEvent::AwaitPrompt {
449 step: step.clone(),
450 prompt_id,
451 prompt: prompt.clone(),
452 previous_attempt_rejected_because: previous_rejection.take(),
453 })
454 .map_err(|_| ReporterError::Disconnected)?;
455
456 loop {
458 let cmd = self
459 .cmd_rx
460 .recv()
461 .map_err(|_| ReporterError::Disconnected)?;
462 match cmd {
463 UiCommand::Abort => return Err(ReporterError::Aborted),
464 UiCommand::LogDeviation { text } => {
465 self.emit_deviation(step.clone(), text)?;
466 }
467 UiCommand::PromptResponse {
468 prompt_id: incoming_id,
469 response,
470 } => {
471 if incoming_id != prompt_id {
472 continue;
474 }
475 match validate(prompt, &response).and_then(|()| check(&response)) {
476 Ok(()) => {
477 let record = response_to_record(&response);
478 self.fact(StepFact::PromptAnswered {
479 step: step.clone(),
480 prompt: prompt.for_record(),
481 response: record,
482 })?;
483 return Ok(response);
484 }
485 Err(reason) => {
486 previous_rejection = Some(reason);
487 break; }
489 }
490 }
491 }
492 }
493 }
494 }
495
496 fn allocate_prompt_id(&mut self) -> PromptId {
497 let id = PromptId::new(self.next_prompt_id);
498 self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
499 id
500 }
501
502 fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
504 match cmd {
505 UiCommand::Abort => Err(ReporterError::Aborted),
506 UiCommand::LogDeviation { text } => {
507 let step = self.current_step.clone();
508 self.emit_deviation(step, text)
509 }
510 UiCommand::PromptResponse { .. } => Ok(()),
512 }
513 }
514
515 fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
516 self.fact(StepFact::DeviationRecorded { step, text })
517 }
518}
519
520fn response_to_record(response: &Response) -> ResponseRecord {
526 match response {
527 Response::Bool(b) => ResponseRecord::Bool { value: *b },
528 Response::Text(t) => ResponseRecord::Text { value: t.clone() },
529 Response::Secret(_) => ResponseRecord::SecretRedacted {},
530 Response::Acknowledge => ResponseRecord::Acknowledged,
531 }
532}
533
534fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
549 match (prompt, response) {
550 (Prompt::Confirm { .. }, Response::Bool(_))
551 | (Prompt::Continue { .. } | Prompt::Reveal { .. }, Response::Acknowledge)
552 | (
553 Prompt::EnterRows {
554 validator: None, ..
555 },
556 Response::Secret(_),
557 ) => Ok(()),
558
559 (Prompt::Text { validator, .. }, Response::Text(value)) => validator.check(value),
560 (
561 Prompt::Secret { validator, .. }
562 | Prompt::EnterRows {
563 validator: Some(validator),
564 ..
565 },
566 Response::Secret(value),
567 ) => validator.check(value.expose_secret()),
568
569 (Prompt::Literal { expected, .. }, Response::Text(value)) => {
570 if value == expected {
571 Ok(())
572 } else {
573 Err(format!("expected exactly: {expected}"))
574 }
575 }
576
577 _ => Err("response shape does not match prompt shape".to_string()),
578 }
579}
580
581#[cfg(test)]
582mod tests {
583 use crossbeam_channel::unbounded;
584 use secrecy::SecretString;
585
586 use super::*;
587 use crate::clock::SystemClock;
588 use rite_model::ValidatorSpec;
589
590 fn ids(step: &str) -> StepId {
591 StepId::new(step)
592 }
593
594 fn test_clock() -> Arc<dyn Clock> {
595 Arc::new(SystemClock)
596 }
597
598 #[test]
599 fn fact_stamps_event_from_the_injected_clock() {
600 use crate::test_support::{FixedClock, fixed_test_time};
601
602 let (event_tx, event_rx) = unbounded();
603 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
604 let mut sink = crate::test_support::begun_sink();
605 let fixed = fixed_test_time();
606 let mut reporter =
607 Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
608
609 reporter
610 .fact(crate::test_support::ceremony_started("T"))
611 .expect("fact");
612
613 match event_rx.recv().expect("event") {
614 ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
615 other => panic!("expected Fact, got {other:?}"),
616 }
617 }
618
619 #[test]
620 fn fact_writes_to_sink_and_forwards_to_ui() {
621 let (event_tx, event_rx) = unbounded();
622 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
623 let mut sink = crate::test_support::begun_sink();
624 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
625 reporter.set_current_step(Some(ids("s1")));
626
627 reporter
628 .fact(StepFact::DeviationRecorded {
629 step: Some(ids("s1")),
630 text: "minor".to_string(),
631 })
632 .expect("emit fact");
633
634 assert_eq!(sink.len(), 1);
635 match event_rx.try_recv().expect("ui event") {
636 ExecEvent::Fact {
637 fact: StepFact::DeviationRecorded { text, .. },
638 ..
639 } => {
640 assert_eq!(text, "minor");
641 }
642 other => panic!("unexpected event: {other:?}"),
643 }
644 }
645
646 #[test]
647 fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
648 let (event_tx, _event_rx) = unbounded();
652 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
653 let mut sink = crate::test_support::begun_sink();
654 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
655 reporter.set_current_step(Some(ids("s1")));
656
657 let err = reporter
658 .draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
659 .expect_err("oversized draw");
660 assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
661 if len == crate::entropy::MAX_DRAW_LEN + 1));
662 }
663
664 #[test]
665 fn log_does_not_touch_transcript() {
666 let (event_tx, event_rx) = unbounded();
667 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
668 let mut sink = crate::test_support::begun_sink();
669 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
670 reporter.set_current_step(Some(ids("s1")));
671
672 reporter.log(Icon::Info, "hello").expect("log");
673
674 assert_eq!(sink.len(), 0);
675 match event_rx.try_recv().expect("ui event") {
676 ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
677 other => panic!("unexpected event: {other:?}"),
678 }
679 }
680
681 #[test]
682 fn check_abort_returns_aborted_when_abort_in_queue() {
683 let (event_tx, _event_rx) = unbounded::<ExecEvent>();
684 let (cmd_tx, cmd_rx) = unbounded();
685 let mut sink = crate::test_support::begun_sink();
686 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
687 reporter.set_current_step(Some(ids("s1")));
688
689 cmd_tx.send(UiCommand::Abort).expect("send");
690 let err = reporter.check_abort().expect_err("abort");
691 assert!(matches!(err, ReporterError::Aborted));
692 }
693
694 #[test]
695 fn check_abort_processes_deviation_then_returns_ok() {
696 let (event_tx, event_rx) = unbounded();
697 let (cmd_tx, cmd_rx) = unbounded();
698 let mut sink = crate::test_support::begun_sink();
699 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
700 reporter.set_current_step(Some(ids("s1")));
701
702 cmd_tx
703 .send(UiCommand::LogDeviation {
704 text: "phone rang".to_string(),
705 })
706 .expect("send");
707 reporter.check_abort().expect("ok");
708
709 assert_eq!(sink.len(), 1);
710 match event_rx.try_recv().expect("ui event") {
711 ExecEvent::Fact {
712 fact: StepFact::DeviationRecorded { text, .. },
713 ..
714 } => {
715 assert_eq!(text, "phone rang");
716 }
717 other => panic!("unexpected event: {other:?}"),
718 }
719 }
720
721 struct AwaitedPrompt {
722 prompt_id: PromptId,
723 rejection: Option<String>,
724 }
725
726 fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
729 loop {
730 if let ExecEvent::AwaitPrompt {
731 prompt_id,
732 previous_attempt_rejected_because,
733 ..
734 } = event_rx
735 .recv()
736 .expect("event channel closed before AwaitPrompt")
737 {
738 return AwaitedPrompt {
739 prompt_id,
740 rejection: previous_attempt_rejected_because,
741 };
742 }
743 }
744 }
745
746 fn spawn_frontend<F>(
751 event_rx: Receiver<ExecEvent>,
752 cmd_tx: Sender<UiCommand>,
753 mut respond: F,
754 ) -> std::thread::JoinHandle<()>
755 where
756 F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
757 {
758 std::thread::spawn(move || {
759 while let Ok(ev) = event_rx.recv() {
760 if let ExecEvent::AwaitPrompt {
761 prompt_id, prompt, ..
762 } = ev
763 {
764 respond(prompt_id, &prompt, &cmd_tx);
765 }
766 }
767 })
768 }
769
770 #[test]
771 fn prompt_round_trip_emits_await_and_records_fact() {
772 let (event_tx, event_rx) = unbounded();
773 let (cmd_tx, cmd_rx) = unbounded();
774 let mut sink = crate::test_support::begun_sink();
775 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
776 reporter.set_current_step(Some(ids("s1")));
777
778 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
779 tx.send(UiCommand::PromptResponse {
780 prompt_id,
781 response: Response::Bool(true),
782 })
783 .expect("send response");
784 });
785
786 let response = reporter
787 .prompt(&Prompt::Confirm {
788 question: "proceed?".to_string(),
789 default: Some(true),
790 })
791 .expect("prompt");
792 assert!(matches!(response, Response::Bool(true)));
793 assert_eq!(sink.len(), 1);
794 assert!(matches!(
795 sink.facts().first(),
796 Some(StepFact::PromptAnswered { .. })
797 ));
798
799 drop(event_tx);
800 frontend.join().expect("frontend join");
801 }
802
803 #[test]
804 fn prompt_redacts_secret_in_record() {
805 let (event_tx, event_rx) = unbounded();
806 let (cmd_tx, cmd_rx) = unbounded();
807 let mut sink = crate::test_support::begun_sink();
808 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
809 reporter.set_current_step(Some(ids("s1")));
810
811 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
812 tx.send(UiCommand::PromptResponse {
813 prompt_id,
814 response: Response::Secret(SecretString::from("hunter2".to_string())),
815 })
816 .expect("send response");
817 });
818
819 reporter
820 .prompt(&Prompt::Secret {
821 label: "PIN".to_string(),
822 validator: ValidatorSpec::NonEmpty,
823 })
824 .expect("prompt");
825
826 match sink.facts().first().expect("fact") {
827 StepFact::PromptAnswered { response, .. } => match response {
828 ResponseRecord::SecretRedacted {} => {}
829 other => panic!("expected redacted secret, got {other:?}"),
830 },
831 other => panic!("unexpected fact: {other:?}"),
832 }
833
834 drop(event_tx);
835 frontend.join().expect("frontend join");
836 }
837
838 #[test]
839 fn prompt_loop_handles_deviation_then_response() {
840 let (event_tx, event_rx) = unbounded();
841 let (cmd_tx, cmd_rx) = unbounded();
842 let mut sink = crate::test_support::begun_sink();
843 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
844 reporter.set_current_step(Some(ids("s1")));
845
846 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
847 tx.send(UiCommand::LogDeviation {
848 text: "noted".to_string(),
849 })
850 .expect("send deviation");
851 tx.send(UiCommand::PromptResponse {
852 prompt_id,
853 response: Response::Acknowledge,
854 })
855 .expect("send response");
856 });
857
858 reporter
859 .prompt(&Prompt::Continue { hint: None })
860 .expect("prompt");
861
862 assert_eq!(sink.len(), 2);
863 assert!(matches!(
864 sink.facts().first(),
865 Some(StepFact::DeviationRecorded { .. })
866 ));
867 assert!(matches!(
868 sink.facts().get(1),
869 Some(StepFact::PromptAnswered { .. })
870 ));
871
872 drop(event_tx);
873 frontend.join().expect("frontend join");
874 }
875
876 #[test]
877 fn validator_signature_compiles_with_predefined_variant() {
878 let _v = ValidatorSpec::Predefined("serial_number".to_string());
880 }
881
882 #[test]
883 fn non_empty_validator_rejects_empty_and_accepts_text() {
884 assert!(
885 super::validate(
886 &Prompt::Text {
887 label: "name".to_string(),
888 validator: ValidatorSpec::NonEmpty,
889 },
890 &Response::Text(String::new()),
891 )
892 .is_err()
893 );
894
895 assert!(
896 super::validate(
897 &Prompt::Text {
898 label: "name".to_string(),
899 validator: ValidatorSpec::NonEmpty,
900 },
901 &Response::Text("Alice".to_string()),
902 )
903 .is_ok()
904 );
905 }
906
907 #[test]
908 fn non_empty_validator_rejects_whitespace_only() {
909 let err = super::validate(
910 &Prompt::Text {
911 label: "name".to_string(),
912 validator: ValidatorSpec::NonEmpty,
913 },
914 &Response::Text(" \t".to_string()),
915 )
916 .expect_err("should reject whitespace");
917 assert!(err.contains("must not be empty"));
918 }
919
920 #[test]
921 fn literal_validator_requires_byte_for_byte_match() {
922 let prompt = Prompt::Literal {
923 label: "type 'attest'".to_string(),
924 expected: "attest".to_string(),
925 };
926 assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
927 let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
928 .expect_err("case-sensitive");
929 assert!(err.contains("attest"));
930 }
931
932 #[test]
933 fn regex_validator_applies_to_text_and_secret_alike() {
934 let text = Prompt::Text {
935 label: "id".to_string(),
936 validator: ValidatorSpec::Regex(r"[a-z]+".to_string()),
937 };
938 assert!(super::validate(&text, &Response::Text("abc".to_string())).is_ok());
939 let err = super::validate(&text, &Response::Text("abc1".to_string()))
940 .expect_err("digit outside the pattern");
941 assert!(err.contains("[a-z]+"), "{err}");
942
943 let secret = Prompt::Secret {
946 label: "PIN".to_string(),
947 validator: ValidatorSpec::Regex(r"[0-9]{6}".to_string()),
948 };
949 assert!(super::validate(&secret, &Response::Secret("123456".to_string().into())).is_ok());
950 let err = super::validate(&secret, &Response::Secret("12345".to_string().into()))
951 .expect_err("too short");
952 assert!(!err.contains("12345"));
954 }
955
956 #[test]
957 fn predefined_validator_unknown_name_rejected() {
958 let err = super::validate(
959 &Prompt::Text {
960 label: "sn".to_string(),
961 validator: ValidatorSpec::Predefined("serial_number".to_string()),
962 },
963 &Response::Text("AB1234".to_string()),
964 )
965 .expect_err("predefined unknown");
966 assert!(err.contains("serial_number"));
967 }
968
969 #[test]
970 fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
971 let (event_tx, event_rx) = unbounded();
972 let (cmd_tx, cmd_rx) = unbounded();
973 let mut sink = crate::test_support::begun_sink();
974 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
975 reporter.set_current_step(Some(ids("s1")));
976
977 let frontend = std::thread::spawn(move || {
982 let first = recv_await_prompt(&event_rx);
983 assert!(first.rejection.is_none(), "first prompt has no rejection");
984 cmd_tx
985 .send(UiCommand::PromptResponse {
986 prompt_id: first.prompt_id,
987 response: Response::Text(String::new()),
988 })
989 .expect("send empty");
990
991 let second = recv_await_prompt(&event_rx);
992 assert!(
993 second.rejection.is_some(),
994 "second prompt carries the rejection reason",
995 );
996 cmd_tx
997 .send(UiCommand::PromptResponse {
998 prompt_id: second.prompt_id,
999 response: Response::Text("Alice".to_string()),
1000 })
1001 .expect("send Alice");
1002
1003 while event_rx.recv().is_ok() {}
1004 });
1005
1006 let response = reporter
1007 .prompt(&Prompt::Text {
1008 label: "name".to_string(),
1009 validator: ValidatorSpec::NonEmpty,
1010 })
1011 .expect("prompt");
1012 match response {
1013 Response::Text(t) => assert_eq!(t, "Alice"),
1014 _ => panic!("expected Text"),
1015 }
1016 drop(event_tx);
1017 frontend.join().expect("frontend join");
1018 }
1019}