1use std::sync::Arc;
27
28use crossbeam_channel::{Receiver, Sender, TryRecvError};
29use rite_model::{ErrorRecord, Prompt, ResponseRecord, StepFact, StepId};
30use secrecy::ExposeSecret;
31use thiserror::Error;
32
33use crate::clock::Clock;
34use crate::entropy::CeremonyRandom;
35use crate::protocol::{ExecEvent, Icon, PromptId, Response, UiCommand, UiSignal};
36use crate::transcript::sha256_hex;
37use crate::transcript_sink::TranscriptSink;
38
39#[derive(Debug, Error)]
41pub enum ReporterError {
42 #[error("ceremony aborted by operator")]
45 Aborted,
46 #[error("frontend channel disconnected")]
49 Disconnected,
50 #[error("transcript write failed: {0}")]
52 Transcript(#[from] std::io::Error),
53 #[error("internal: no current step set for {0}")]
55 NoCurrentStep(&'static str),
56 #[error("duplicate entropy draw for purpose '{purpose}' in step '{step}'")]
60 DuplicateDraw {
61 step: StepId,
63 purpose: String,
65 },
66 #[error("internal: entropy source drawn before it was seeded")]
70 Unseeded,
71 #[error("entropy draw of {len} bytes exceeds the {max}-byte HKDF-Expand limit")]
77 DrawTooLong {
78 len: usize,
80 max: usize,
82 },
83}
84
85impl ReporterError {
86 #[must_use]
88 pub fn to_error_record(&self) -> ErrorRecord {
89 let kind = match self {
90 ReporterError::Aborted => "aborted",
91 ReporterError::Disconnected => "frontend_disconnected",
92 ReporterError::Transcript(_) => "transcript_io",
93 ReporterError::NoCurrentStep(_) => "internal_no_current_step",
94 ReporterError::DuplicateDraw { .. } => "duplicate_entropy_draw",
95 ReporterError::Unseeded => "internal_entropy_unseeded",
96 ReporterError::DrawTooLong { .. } => "entropy_draw_too_long",
97 };
98 ErrorRecord::new(kind, self.to_string())
99 }
100}
101
102pub struct Reporter<'a> {
104 event_tx: &'a Sender<ExecEvent>,
105 cmd_rx: &'a Receiver<UiCommand>,
106 transcript: &'a mut dyn TranscriptSink,
107 next_prompt_id: u64,
108 current_step: Option<StepId>,
109 random: Option<CeremonyRandom>,
114 clock: Arc<dyn Clock>,
117}
118
119impl<'a> Reporter<'a> {
120 pub fn new(
122 event_tx: &'a Sender<ExecEvent>,
123 cmd_rx: &'a Receiver<UiCommand>,
124 transcript: &'a mut dyn TranscriptSink,
125 clock: Arc<dyn Clock>,
126 ) -> Self {
127 Self {
128 event_tx,
129 cmd_rx,
130 transcript,
131 next_prompt_id: 0,
132 current_step: None,
133 random: None,
134 clock,
135 }
136 }
137
138 pub fn seed_entropy(&mut self, m: &[u8]) {
145 self.random = Some(CeremonyRandom::from_machine_seed(m));
146 }
147
148 pub fn set_current_step(&mut self, step: Option<StepId>) {
151 self.current_step = step;
152 }
153
154 #[must_use]
156 pub fn current_step(&self) -> Option<&StepId> {
157 self.current_step.as_ref()
158 }
159
160 pub fn fact(&mut self, fact: StepFact) -> Result<(), ReporterError> {
174 let at = self.clock.now();
177 self.transcript.record(at, &fact)?;
178 self.event_tx
179 .send(ExecEvent::Fact { at, fact })
180 .map_err(|_| ReporterError::Disconnected)?;
181 Ok(())
182 }
183
184 pub fn draw(&mut self, purpose: &str, len: usize) -> Result<Vec<u8>, ReporterError> {
198 if len > crate::entropy::MAX_DRAW_LEN {
199 return Err(ReporterError::DrawTooLong {
200 len,
201 max: crate::entropy::MAX_DRAW_LEN,
202 });
203 }
204 let step = self
205 .current_step
206 .clone()
207 .ok_or(ReporterError::NoCurrentStep("draw"))?;
208 let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
209 let drawn =
210 random
211 .draw(&step, purpose, len)
212 .ok_or_else(|| ReporterError::DuplicateDraw {
213 step: step.clone(),
214 purpose: purpose.to_string(),
215 })?;
216 self.fact(StepFact::EntropyDrawn {
217 step,
218 path: drawn.path,
219 value: base16ct::lower::encode_string(&drawn.value),
220 })?;
221 Ok(drawn.value)
222 }
223
224 pub fn fold_entropy(&mut self, contribution: &str) -> Result<(), ReporterError> {
238 let step = self
239 .current_step
240 .clone()
241 .ok_or(ReporterError::NoCurrentStep("fold_entropy"))?;
242 let random = self.random.as_mut().ok_or(ReporterError::Unseeded)?;
243 random.fold(contribution.as_bytes());
244 let epoch = random.epoch();
245 self.fact(StepFact::EntropyContributed {
246 step,
247 epoch,
248 contribution: contribution.to_string(),
249 })
250 }
251
252 pub fn signal(&mut self, signal: UiSignal) -> Result<(), ReporterError> {
266 self.event_tx
267 .send(ExecEvent::Signal(signal))
268 .map_err(|_| ReporterError::Disconnected)?;
269 Ok(())
270 }
271
272 pub fn log(&mut self, icon: Icon, text: impl Into<String>) -> Result<(), ReporterError> {
278 let signal = UiSignal::LogLine {
279 step: self.current_step.clone(),
280 icon,
281 text: text.into(),
282 };
283 self.event_tx
284 .send(ExecEvent::Signal(signal))
285 .map_err(|_| ReporterError::Disconnected)?;
286 Ok(())
287 }
288
289 pub fn progress(
298 &mut self,
299 phase: impl Into<String>,
300 fraction: Option<f32>,
301 ) -> Result<(), ReporterError> {
302 let step = self
303 .current_step
304 .clone()
305 .ok_or(ReporterError::NoCurrentStep("progress"))?;
306 let signal = UiSignal::Progress {
307 step,
308 phase: phase.into(),
309 fraction,
310 };
311 self.event_tx
312 .send(ExecEvent::Signal(signal))
313 .map_err(|_| ReporterError::Disconnected)?;
314 Ok(())
315 }
316
317 pub fn check_abort(&mut self) -> Result<(), ReporterError> {
329 loop {
330 match self.cmd_rx.try_recv() {
331 Ok(cmd) => self.handle_offline_command(cmd)?,
332 Err(TryRecvError::Empty) => return Ok(()),
333 Err(TryRecvError::Disconnected) => return Err(ReporterError::Disconnected),
334 }
335 }
336 }
337
338 pub fn prompt(&mut self, prompt: &Prompt) -> Result<Response, ReporterError> {
354 let step = self.current_step.clone();
355 let prompt_id = self.allocate_prompt_id();
356 let mut previous_rejection: Option<String> = None;
357
358 loop {
359 self.event_tx
360 .send(ExecEvent::AwaitPrompt {
361 step: step.clone(),
362 prompt_id,
363 prompt: prompt.clone(),
364 previous_attempt_rejected_because: previous_rejection.take(),
365 })
366 .map_err(|_| ReporterError::Disconnected)?;
367
368 loop {
370 let cmd = self
371 .cmd_rx
372 .recv()
373 .map_err(|_| ReporterError::Disconnected)?;
374 match cmd {
375 UiCommand::Abort => return Err(ReporterError::Aborted),
376 UiCommand::LogDeviation { text } => {
377 self.emit_deviation(step.clone(), text)?;
378 }
379 UiCommand::PromptResponse {
380 prompt_id: incoming_id,
381 response,
382 } => {
383 if incoming_id != prompt_id {
384 continue;
386 }
387 match validate(prompt, &response) {
388 Ok(()) => {
389 let record = response_to_record(&response);
390 self.fact(StepFact::PromptAnswered {
391 step: step.clone(),
392 prompt: prompt.clone(),
393 response: record,
394 })?;
395 return Ok(response);
396 }
397 Err(reason) => {
398 previous_rejection = Some(reason);
399 break; }
401 }
402 }
403 }
404 }
405 }
406 }
407
408 fn allocate_prompt_id(&mut self) -> PromptId {
409 let id = PromptId::new(self.next_prompt_id);
410 self.next_prompt_id = self.next_prompt_id.wrapping_add(1);
411 id
412 }
413
414 fn handle_offline_command(&mut self, cmd: UiCommand) -> Result<(), ReporterError> {
416 match cmd {
417 UiCommand::Abort => Err(ReporterError::Aborted),
418 UiCommand::LogDeviation { text } => {
419 let step = self.current_step.clone();
420 self.emit_deviation(step, text)
421 }
422 UiCommand::PromptResponse { .. } => Ok(()),
424 }
425 }
426
427 fn emit_deviation(&mut self, step: Option<StepId>, text: String) -> Result<(), ReporterError> {
428 self.fact(StepFact::DeviationRecorded { step, text })
429 }
430}
431
432fn response_to_record(response: &Response) -> ResponseRecord {
438 match response {
439 Response::Bool(b) => ResponseRecord::Bool { value: *b },
440 Response::Text(t) => ResponseRecord::Text { value: t.clone() },
441 Response::Secret(s) => ResponseRecord::SecretRedacted {
442 sha256_of_plaintext: sha256_hex(s.expose_secret().as_bytes()),
443 },
444 Response::Acknowledge => ResponseRecord::Acknowledged,
445 }
446}
447
448fn validate(prompt: &Prompt, response: &Response) -> Result<(), String> {
462 match (prompt, response) {
463 (Prompt::Confirm { .. }, Response::Bool(_))
464 | (Prompt::Continue { .. }, Response::Acknowledge)
465 | (Prompt::Secret { .. }, Response::Secret(_)) => Ok(()),
466
467 (Prompt::Text { validator, .. }, Response::Text(value)) => {
468 apply_validator(validator, value)
469 }
470
471 (Prompt::Literal { expected, .. }, Response::Text(value)) => {
472 if value == expected {
473 Ok(())
474 } else {
475 Err(format!("expected exactly: {expected}"))
476 }
477 }
478
479 _ => Err("response shape does not match prompt shape".to_string()),
480 }
481}
482
483fn apply_validator(spec: &rite_model::ValidatorSpec, value: &str) -> Result<(), String> {
484 use rite_model::ValidatorSpec;
485 match spec {
486 ValidatorSpec::NonEmpty => {
487 if value.trim().is_empty() {
488 Err("value must not be empty".to_string())
489 } else {
490 Ok(())
491 }
492 }
493 ValidatorSpec::Regex(_) => Err("regex validation is not yet implemented".to_string()),
495 ValidatorSpec::Predefined(name) => Err(format!("unknown validator: {name}")),
498 _ => Err("unknown validator variant".to_string()),
499 }
500}
501
502#[cfg(test)]
503mod tests {
504 use crossbeam_channel::unbounded;
505 use secrecy::SecretString;
506
507 use super::*;
508 use crate::clock::SystemClock;
509 use crate::transcript_sink::InMemorySink;
510 use rite_model::ValidatorSpec;
511
512 fn ids(step: &str) -> StepId {
513 StepId::new(step)
514 }
515
516 fn test_clock() -> Arc<dyn Clock> {
517 Arc::new(SystemClock)
518 }
519
520 #[test]
521 fn fact_stamps_event_from_the_injected_clock() {
522 use crate::test_support::{FixedClock, fixed_test_time};
523
524 let (event_tx, event_rx) = unbounded();
525 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
526 let mut sink = InMemorySink::new();
527 let fixed = fixed_test_time();
528 let mut reporter =
529 Reporter::new(&event_tx, &cmd_rx, &mut sink, Arc::new(FixedClock(fixed)));
530
531 reporter
532 .fact(StepFact::CeremonyStarted {
533 name: "T".to_string(),
534 })
535 .expect("fact");
536
537 match event_rx.recv().expect("event") {
538 ExecEvent::Fact { at, .. } => assert_eq!(at, fixed),
539 other => panic!("expected Fact, got {other:?}"),
540 }
541 }
542
543 #[test]
544 fn fact_writes_to_sink_and_forwards_to_ui() {
545 let (event_tx, event_rx) = unbounded();
546 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
547 let mut sink = InMemorySink::new();
548 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
549 reporter.set_current_step(Some(ids("s1")));
550
551 reporter
552 .fact(StepFact::DeviationRecorded {
553 step: Some(ids("s1")),
554 text: "minor".to_string(),
555 })
556 .expect("emit fact");
557
558 assert_eq!(sink.len(), 1);
559 match event_rx.try_recv().expect("ui event") {
560 ExecEvent::Fact {
561 fact: StepFact::DeviationRecorded { text, .. },
562 ..
563 } => {
564 assert_eq!(text, "minor");
565 }
566 other => panic!("unexpected event: {other:?}"),
567 }
568 }
569
570 #[test]
571 fn draw_rejects_a_length_past_the_hkdf_expand_limit() {
572 let (event_tx, _event_rx) = unbounded();
576 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
577 let mut sink = InMemorySink::new();
578 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
579 reporter.set_current_step(Some(ids("s1")));
580
581 let err = reporter
582 .draw("oversized", crate::entropy::MAX_DRAW_LEN.saturating_add(1))
583 .expect_err("oversized draw");
584 assert!(matches!(err, ReporterError::DrawTooLong { len, .. }
585 if len == crate::entropy::MAX_DRAW_LEN + 1));
586 }
587
588 #[test]
589 fn log_does_not_touch_transcript() {
590 let (event_tx, event_rx) = unbounded();
591 let (_cmd_tx, cmd_rx) = unbounded::<UiCommand>();
592 let mut sink = InMemorySink::new();
593 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
594 reporter.set_current_step(Some(ids("s1")));
595
596 reporter.log(Icon::Info, "hello").expect("log");
597
598 assert_eq!(sink.len(), 0);
599 match event_rx.try_recv().expect("ui event") {
600 ExecEvent::Signal(UiSignal::LogLine { text, .. }) => assert_eq!(text, "hello"),
601 other => panic!("unexpected event: {other:?}"),
602 }
603 }
604
605 #[test]
606 fn check_abort_returns_aborted_when_abort_in_queue() {
607 let (event_tx, _event_rx) = unbounded::<ExecEvent>();
608 let (cmd_tx, cmd_rx) = unbounded();
609 let mut sink = InMemorySink::new();
610 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
611 reporter.set_current_step(Some(ids("s1")));
612
613 cmd_tx.send(UiCommand::Abort).expect("send");
614 let err = reporter.check_abort().expect_err("abort");
615 assert!(matches!(err, ReporterError::Aborted));
616 }
617
618 #[test]
619 fn check_abort_processes_deviation_then_returns_ok() {
620 let (event_tx, event_rx) = unbounded();
621 let (cmd_tx, cmd_rx) = unbounded();
622 let mut sink = InMemorySink::new();
623 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
624 reporter.set_current_step(Some(ids("s1")));
625
626 cmd_tx
627 .send(UiCommand::LogDeviation {
628 text: "phone rang".to_string(),
629 })
630 .expect("send");
631 reporter.check_abort().expect("ok");
632
633 assert_eq!(sink.len(), 1);
634 match event_rx.try_recv().expect("ui event") {
635 ExecEvent::Fact {
636 fact: StepFact::DeviationRecorded { text, .. },
637 ..
638 } => {
639 assert_eq!(text, "phone rang");
640 }
641 other => panic!("unexpected event: {other:?}"),
642 }
643 }
644
645 struct AwaitedPrompt {
646 prompt_id: PromptId,
647 rejection: Option<String>,
648 }
649
650 fn recv_await_prompt(event_rx: &Receiver<ExecEvent>) -> AwaitedPrompt {
653 loop {
654 if let ExecEvent::AwaitPrompt {
655 prompt_id,
656 previous_attempt_rejected_because,
657 ..
658 } = event_rx
659 .recv()
660 .expect("event channel closed before AwaitPrompt")
661 {
662 return AwaitedPrompt {
663 prompt_id,
664 rejection: previous_attempt_rejected_because,
665 };
666 }
667 }
668 }
669
670 fn spawn_frontend<F>(
675 event_rx: Receiver<ExecEvent>,
676 cmd_tx: Sender<UiCommand>,
677 mut respond: F,
678 ) -> std::thread::JoinHandle<()>
679 where
680 F: FnMut(PromptId, &Prompt, &Sender<UiCommand>) + Send + 'static,
681 {
682 std::thread::spawn(move || {
683 while let Ok(ev) = event_rx.recv() {
684 if let ExecEvent::AwaitPrompt {
685 prompt_id, prompt, ..
686 } = ev
687 {
688 respond(prompt_id, &prompt, &cmd_tx);
689 }
690 }
691 })
692 }
693
694 #[test]
695 fn prompt_round_trip_emits_await_and_records_fact() {
696 let (event_tx, event_rx) = unbounded();
697 let (cmd_tx, cmd_rx) = unbounded();
698 let mut sink = InMemorySink::new();
699 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
700 reporter.set_current_step(Some(ids("s1")));
701
702 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
703 tx.send(UiCommand::PromptResponse {
704 prompt_id,
705 response: Response::Bool(true),
706 })
707 .expect("send response");
708 });
709
710 let response = reporter
711 .prompt(&Prompt::Confirm {
712 question: "proceed?".to_string(),
713 default: Some(true),
714 })
715 .expect("prompt");
716 assert!(matches!(response, Response::Bool(true)));
717 assert_eq!(sink.len(), 1);
718 assert!(matches!(
719 sink.facts().first(),
720 Some(StepFact::PromptAnswered { .. })
721 ));
722
723 drop(event_tx);
724 frontend.join().expect("frontend join");
725 }
726
727 #[test]
728 fn prompt_redacts_secret_in_record() {
729 let (event_tx, event_rx) = unbounded();
730 let (cmd_tx, cmd_rx) = unbounded();
731 let mut sink = InMemorySink::new();
732 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
733 reporter.set_current_step(Some(ids("s1")));
734
735 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
736 tx.send(UiCommand::PromptResponse {
737 prompt_id,
738 response: Response::Secret(SecretString::from("hunter2".to_string())),
739 })
740 .expect("send response");
741 });
742
743 reporter
744 .prompt(&Prompt::Secret {
745 label: "PIN".to_string(),
746 })
747 .expect("prompt");
748
749 match sink.facts().first().expect("fact") {
750 StepFact::PromptAnswered { response, .. } => match response {
751 ResponseRecord::SecretRedacted {
752 sha256_of_plaintext,
753 } => {
754 assert!(!sha256_of_plaintext.contains("hunter"));
755 assert_eq!(sha256_of_plaintext.len(), 64);
756 }
757 other => panic!("expected redacted secret, got {other:?}"),
758 },
759 other => panic!("unexpected fact: {other:?}"),
760 }
761
762 drop(event_tx);
763 frontend.join().expect("frontend join");
764 }
765
766 #[test]
767 fn prompt_loop_handles_deviation_then_response() {
768 let (event_tx, event_rx) = unbounded();
769 let (cmd_tx, cmd_rx) = unbounded();
770 let mut sink = InMemorySink::new();
771 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
772 reporter.set_current_step(Some(ids("s1")));
773
774 let frontend = spawn_frontend(event_rx, cmd_tx, |prompt_id, _, tx| {
775 tx.send(UiCommand::LogDeviation {
776 text: "noted".to_string(),
777 })
778 .expect("send deviation");
779 tx.send(UiCommand::PromptResponse {
780 prompt_id,
781 response: Response::Acknowledge,
782 })
783 .expect("send response");
784 });
785
786 reporter
787 .prompt(&Prompt::Continue { hint: None })
788 .expect("prompt");
789
790 assert_eq!(sink.len(), 2);
791 assert!(matches!(
792 sink.facts().first(),
793 Some(StepFact::DeviationRecorded { .. })
794 ));
795 assert!(matches!(
796 sink.facts().get(1),
797 Some(StepFact::PromptAnswered { .. })
798 ));
799
800 drop(event_tx);
801 frontend.join().expect("frontend join");
802 }
803
804 #[test]
805 fn validator_signature_compiles_with_predefined_variant() {
806 let _v = ValidatorSpec::Predefined("serial_number".to_string());
808 }
809
810 #[test]
811 fn non_empty_validator_rejects_empty_and_accepts_text() {
812 assert!(
813 super::validate(
814 &Prompt::Text {
815 label: "name".to_string(),
816 validator: ValidatorSpec::NonEmpty,
817 },
818 &Response::Text(String::new()),
819 )
820 .is_err()
821 );
822
823 assert!(
824 super::validate(
825 &Prompt::Text {
826 label: "name".to_string(),
827 validator: ValidatorSpec::NonEmpty,
828 },
829 &Response::Text("Alice".to_string()),
830 )
831 .is_ok()
832 );
833 }
834
835 #[test]
836 fn non_empty_validator_rejects_whitespace_only() {
837 let err = super::validate(
838 &Prompt::Text {
839 label: "name".to_string(),
840 validator: ValidatorSpec::NonEmpty,
841 },
842 &Response::Text(" \t".to_string()),
843 )
844 .expect_err("should reject whitespace");
845 assert!(err.contains("must not be empty"));
846 }
847
848 #[test]
849 fn literal_validator_requires_byte_for_byte_match() {
850 let prompt = Prompt::Literal {
851 label: "type 'attest'".to_string(),
852 expected: "attest".to_string(),
853 };
854 assert!(super::validate(&prompt, &Response::Text("attest".to_string())).is_ok());
855 let err = super::validate(&prompt, &Response::Text("ATTEST".to_string()))
856 .expect_err("case-sensitive");
857 assert!(err.contains("attest"));
858 }
859
860 #[test]
861 fn regex_validator_not_yet_implemented_returns_rejection() {
862 let err = super::validate(
863 &Prompt::Text {
864 label: "id".to_string(),
865 validator: ValidatorSpec::Regex(r"^[a-z]+$".to_string()),
866 },
867 &Response::Text("abc".to_string()),
868 )
869 .expect_err("regex not implemented");
870 assert!(err.contains("regex"));
871 }
872
873 #[test]
874 fn predefined_validator_unknown_name_rejected() {
875 let err = super::validate(
876 &Prompt::Text {
877 label: "sn".to_string(),
878 validator: ValidatorSpec::Predefined("serial_number".to_string()),
879 },
880 &Response::Text("AB1234".to_string()),
881 )
882 .expect_err("predefined unknown");
883 assert!(err.contains("serial_number"));
884 }
885
886 #[test]
887 fn prompt_re_emits_with_rejection_reason_when_validator_fails() {
888 let (event_tx, event_rx) = unbounded();
889 let (cmd_tx, cmd_rx) = unbounded();
890 let mut sink = InMemorySink::new();
891 let mut reporter = Reporter::new(&event_tx, &cmd_rx, &mut sink, test_clock());
892 reporter.set_current_step(Some(ids("s1")));
893
894 let frontend = std::thread::spawn(move || {
899 let first = recv_await_prompt(&event_rx);
900 assert!(first.rejection.is_none(), "first prompt has no rejection");
901 cmd_tx
902 .send(UiCommand::PromptResponse {
903 prompt_id: first.prompt_id,
904 response: Response::Text(String::new()),
905 })
906 .expect("send empty");
907
908 let second = recv_await_prompt(&event_rx);
909 assert!(
910 second.rejection.is_some(),
911 "second prompt carries the rejection reason",
912 );
913 cmd_tx
914 .send(UiCommand::PromptResponse {
915 prompt_id: second.prompt_id,
916 response: Response::Text("Alice".to_string()),
917 })
918 .expect("send Alice");
919
920 while event_rx.recv().is_ok() {}
921 });
922
923 let response = reporter
924 .prompt(&Prompt::Text {
925 label: "name".to_string(),
926 validator: ValidatorSpec::NonEmpty,
927 })
928 .expect("prompt");
929 match response {
930 Response::Text(t) => assert_eq!(t, "Alice"),
931 _ => panic!("expected Text"),
932 }
933 drop(event_tx);
934 frontend.join().expect("frontend join");
935 }
936}