1use 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#[derive(Debug, Error)]
38pub enum ReporterError {
39 #[error("ceremony aborted by operator")]
42 Aborted,
43 #[error("frontend channel disconnected")]
46 Disconnected,
47 #[error("transcript write failed: {0}")]
49 Transcript(#[from] std::io::Error),
50 #[error("internal: no current step set for {0}")]
52 NoCurrentStep(&'static str),
53}
54
55impl ReporterError {
56 #[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
69pub 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 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 pub fn set_current_step(&mut self, step: Option<StepId>) {
97 self.current_step = step;
98 }
99
100 #[must_use]
102 pub fn current_step(&self) -> Option<&StepId> {
103 self.current_step.as_ref()
104 }
105
106 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 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 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 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 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 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 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 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; }
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 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 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
312fn 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
328fn 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 ValidatorSpec::Regex(_) => Err("regex validation is not yet implemented".to_string()),
375 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 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 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 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 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}