1use std::collections::HashMap;
58use std::sync::Mutex;
59use std::time::{Duration, Instant};
60
61use serde::{Deserialize, Serialize};
62use uuid::Uuid;
63
64const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
68
69pub const SESSION_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
73
74#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub struct RenderHints {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
84 pub qr: Option<QrFormat>,
85}
86
87#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum QrFormat {
91 #[default]
93 Utf8,
94 PngBase64,
96 UriOnly,
98}
99
100#[derive(Debug, Default, Serialize, Deserialize)]
107pub struct CeremonyRequest {
108 #[serde(default)]
110 pub session_id: Option<Uuid>,
111
112 #[serde(default)]
115 pub ceremony: Option<String>,
116
117 #[serde(default)]
121 pub data: serde_json::Map<String, serde_json::Value>,
122
123 #[serde(default)]
125 pub render: Option<RenderHints>,
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CeremonyResponse {
134 pub session_id: Uuid,
136
137 pub prompts: Vec<Prompt>,
140
141 pub messages: Vec<Message>,
144
145 pub complete: bool,
147
148 #[serde(skip_serializing_if = "Option::is_none")]
150 pub error: Option<String>,
151
152 #[serde(default, skip_serializing_if = "Option::is_none")]
156 pub result_data: Option<serde_json::Map<String, serde_json::Value>>,
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Prompt {
164 pub key: String,
166
167 pub prompt: String,
169
170 pub input_type: InputType,
172
173 #[serde(default, skip_serializing_if = "Vec::is_empty")]
175 pub options: Vec<SelectOption>,
176
177 #[serde(default = "default_true")]
179 pub required: bool,
180}
181
182fn default_true() -> bool {
183 true
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SelectOption {
189 pub value: String,
191 pub label: String,
193 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub description: Option<String>,
196}
197
198#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum InputType {
202 SelectOne,
204 SelectMany,
206 Text,
208 Secret,
210 SecretConfirm,
212 Code,
214 Entropy,
216 Fido2,
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Message {
228 pub kind: MessageKind,
230
231 pub title: String,
233
234 pub content: String,
236}
237
238#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum MessageKind {
242 Info,
244 QrCode,
246 Summary,
248 Error,
250}
251
252pub struct Session {
259 pub id: Uuid,
261
262 pub ceremony_type: String,
264
265 pub bag: serde_json::Map<String, serde_json::Value>,
267
268 pub render: RenderHints,
270
271 pub created_at: Instant,
273
274 pub last_active: Instant,
276
277 pub complete: bool,
279}
280
281impl Session {
282 pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
284 self.bag.insert(key.into(), value);
285 }
286
287 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
289 self.bag.get(key)
290 }
291
292 pub fn get_str(&self, key: &str) -> Option<&str> {
294 self.bag.get(key).and_then(|v| v.as_str())
295 }
296
297 pub fn has(&self, key: &str) -> bool {
299 self.bag.contains_key(key)
300 }
301
302 pub fn remove(&mut self, key: &str) -> Option<serde_json::Value> {
304 self.bag.remove(key)
305 }
306}
307
308pub enum EvalResult {
315 NeedInput {
317 prompts: Vec<Prompt>,
319 messages: Vec<Message>,
321 },
322
323 ValidationError {
326 prompts: Vec<Prompt>,
328 messages: Vec<Message>,
330 error: String,
332 },
333
334 Complete {
336 messages: Vec<Message>,
338 },
339
340 Fatal(String),
342}
343
344pub trait CeremonyRules: Send + Sync {
365 fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String>;
370
371 fn evaluate(
378 &self,
379 ceremony_type: &str,
380 bag: &mut serde_json::Map<String, serde_json::Value>,
381 render: &RenderHints,
382 ) -> EvalResult;
383}
384
385pub struct CeremonyHost<R: CeremonyRules> {
392 rules: R,
393 sessions: Mutex<HashMap<Uuid, Session>>,
394 session_ttl: Duration,
395}
396
397impl<R: CeremonyRules> CeremonyHost<R> {
398 pub fn new(rules: R) -> Self {
400 Self {
401 rules,
402 sessions: Mutex::new(HashMap::new()),
403 session_ttl: DEFAULT_SESSION_TTL,
404 }
405 }
406
407 pub fn with_ttl(rules: R, ttl: Duration) -> Self {
409 Self {
410 rules,
411 sessions: Mutex::new(HashMap::new()),
412 session_ttl: ttl,
413 }
414 }
415
416 pub fn rules(&self) -> &R {
418 &self.rules
419 }
420
421 pub fn step(&self, request: CeremonyRequest) -> Result<CeremonyResponse, CeremonyError> {
428 match request.session_id {
429 None => self.start_new(request),
430 Some(id) => self.continue_existing(id, request),
431 }
432 }
433
434 pub fn sweep_expired(&self) -> usize {
437 let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
438 tracing::warn!("ceremony session lock was poisoned, recovering");
439 e.into_inner()
440 });
441 let now = Instant::now();
442 let before = sessions.len();
443 sessions.retain(|_id, session| now.duration_since(session.last_active) < self.session_ttl);
444 let removed = before - sessions.len();
445 if removed > 0 {
446 tracing::debug!(
447 removed,
448 remaining = sessions.len(),
449 "Swept expired ceremony sessions"
450 );
451 }
452 removed
453 }
454
455 pub fn active_session_count(&self) -> usize {
457 self.sessions
458 .lock()
459 .unwrap_or_else(|e| {
460 tracing::warn!("ceremony session lock was poisoned, recovering");
461 e.into_inner()
462 })
463 .len()
464 }
465
466 fn start_new(&self, request: CeremonyRequest) -> Result<CeremonyResponse, CeremonyError> {
469 let ceremony = request
470 .ceremony
471 .as_deref()
472 .ok_or_else(|| CeremonyError::MissingField("ceremony".into()))?;
473
474 self.rules
475 .validate_ceremony_type(ceremony)
476 .map_err(CeremonyError::InvalidCeremony)?;
477
478 let render = request.render.unwrap_or_default();
479 let now = Instant::now();
480
481 let mut session = Session {
482 id: Uuid::now_v7(),
483 ceremony_type: ceremony.to_string(),
484 bag: request.data,
485 render: render.clone(),
486 created_at: now,
487 last_active: now,
488 complete: false,
489 };
490
491 let result = self.rules.evaluate(ceremony, &mut session.bag, &render);
492 self.finalize(session, result)
493 }
494
495 fn continue_existing(
496 &self,
497 session_id: Uuid,
498 request: CeremonyRequest,
499 ) -> Result<CeremonyResponse, CeremonyError> {
500 let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
501 tracing::warn!("ceremony session lock was poisoned, recovering");
502 e.into_inner()
503 });
504
505 let session = sessions
506 .get_mut(&session_id)
507 .ok_or(CeremonyError::SessionNotFound(session_id))?;
508
509 let now = Instant::now();
511 if now.duration_since(session.last_active) >= self.session_ttl {
512 sessions.remove(&session_id);
513 return Err(CeremonyError::SessionExpired);
514 }
515
516 if session.complete {
517 return Err(CeremonyError::AlreadyComplete);
518 }
519
520 session.last_active = now;
522 if let Some(render) = &request.render {
523 session.render = render.clone();
524 }
525
526 for (key, value) in request.data {
528 session.bag.insert(key, value);
529 }
530
531 let render = session.render.clone();
532 let ceremony_type = session.ceremony_type.clone();
533 let result = self
534 .rules
535 .evaluate(&ceremony_type, &mut session.bag, &render);
536
537 let Some(session) = sessions.remove(&session_id) else {
539 return Err(CeremonyError::SessionNotFound(session_id));
540 };
541 drop(sessions);
542
543 self.finalize(session, result)
544 }
545
546 fn finalize(
549 &self,
550 mut session: Session,
551 result: EvalResult,
552 ) -> Result<CeremonyResponse, CeremonyError> {
553 let session_id = session.id;
554
555 let (prompts, messages, complete, error) = match result {
556 EvalResult::NeedInput { prompts, messages } => (prompts, messages, false, None),
557 EvalResult::ValidationError {
558 prompts,
559 messages,
560 error,
561 } => (prompts, messages, false, Some(error)),
562 EvalResult::Complete { messages } => (Vec::new(), messages, true, None),
563 EvalResult::Fatal(msg) => {
564 let messages = vec![Message {
565 kind: MessageKind::Error,
566 title: "Ceremony failed".into(),
567 content: msg.clone(),
568 }];
569 (Vec::new(), messages, true, Some(msg))
570 }
571 };
572
573 session.complete = complete;
574
575 let result_data = if complete && error.is_none() {
577 Some(session.bag.clone())
578 } else {
579 None
580 };
581
582 if !complete {
584 let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
585 tracing::warn!("ceremony session lock was poisoned, recovering");
586 e.into_inner()
587 });
588 sessions.insert(session_id, session);
589 }
590
591 Ok(CeremonyResponse {
592 session_id,
593 prompts,
594 messages,
595 complete,
596 error,
597 result_data,
598 })
599 }
600}
601
602#[derive(Debug, thiserror::Error)]
606pub enum CeremonyError {
607 #[error("session not found: {0}")]
608 SessionNotFound(Uuid),
609
610 #[error("session expired")]
611 SessionExpired,
612
613 #[error("missing required field: {0}")]
614 MissingField(String),
615
616 #[error("invalid ceremony type: {0}")]
617 InvalidCeremony(String),
618
619 #[error("ceremony already complete")]
620 AlreadyComplete,
621
622 #[error("internal error: {0}")]
623 Internal(String),
624}
625
626impl CeremonyError {
627 pub fn http_status(&self) -> u16 {
629 match self {
630 Self::SessionNotFound(_) => 404,
631 Self::SessionExpired => 410,
632 Self::MissingField(_) => 400,
633 Self::InvalidCeremony(_) => 400,
634 Self::AlreadyComplete => 409,
635 Self::Internal(_) => 500,
636 }
637 }
638}
639
640impl Prompt {
643 pub fn select_one(
645 key: impl Into<String>,
646 prompt: impl Into<String>,
647 options: Vec<SelectOption>,
648 ) -> Self {
649 Self {
650 key: key.into(),
651 prompt: prompt.into(),
652 input_type: InputType::SelectOne,
653 options,
654 required: true,
655 }
656 }
657
658 pub fn secret(key: impl Into<String>, prompt: impl Into<String>) -> Self {
660 Self {
661 key: key.into(),
662 prompt: prompt.into(),
663 input_type: InputType::Secret,
664 options: Vec::new(),
665 required: true,
666 }
667 }
668
669 pub fn secret_confirm(key: impl Into<String>, prompt: impl Into<String>) -> Self {
671 Self {
672 key: key.into(),
673 prompt: prompt.into(),
674 input_type: InputType::SecretConfirm,
675 options: Vec::new(),
676 required: true,
677 }
678 }
679
680 pub fn code(key: impl Into<String>, prompt: impl Into<String>) -> Self {
682 Self {
683 key: key.into(),
684 prompt: prompt.into(),
685 input_type: InputType::Code,
686 options: Vec::new(),
687 required: true,
688 }
689 }
690
691 pub fn text(key: impl Into<String>, prompt: impl Into<String>) -> Self {
693 Self {
694 key: key.into(),
695 prompt: prompt.into(),
696 input_type: InputType::Text,
697 options: Vec::new(),
698 required: true,
699 }
700 }
701
702 pub fn entropy(key: impl Into<String>, prompt: impl Into<String>) -> Self {
704 Self {
705 key: key.into(),
706 prompt: prompt.into(),
707 input_type: InputType::Entropy,
708 options: Vec::new(),
709 required: true,
710 }
711 }
712
713 pub fn fido2(key: impl Into<String>, prompt: impl Into<String>) -> Self {
715 Self {
716 key: key.into(),
717 prompt: prompt.into(),
718 input_type: InputType::Fido2,
719 options: Vec::new(),
720 required: true,
721 }
722 }
723}
724
725impl SelectOption {
726 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
728 Self {
729 value: value.into(),
730 label: label.into(),
731 description: None,
732 }
733 }
734
735 pub fn with_description(
737 value: impl Into<String>,
738 label: impl Into<String>,
739 description: impl Into<String>,
740 ) -> Self {
741 Self {
742 value: value.into(),
743 label: label.into(),
744 description: Some(description.into()),
745 }
746 }
747}
748
749impl Message {
750 pub fn info(title: impl Into<String>, content: impl Into<String>) -> Self {
752 Self {
753 kind: MessageKind::Info,
754 title: title.into(),
755 content: content.into(),
756 }
757 }
758
759 pub fn qr_code(title: impl Into<String>, content: impl Into<String>) -> Self {
761 Self {
762 kind: MessageKind::QrCode,
763 title: title.into(),
764 content: content.into(),
765 }
766 }
767
768 pub fn summary(title: impl Into<String>, content: impl Into<String>) -> Self {
770 Self {
771 kind: MessageKind::Summary,
772 title: title.into(),
773 content: content.into(),
774 }
775 }
776
777 pub fn error(title: impl Into<String>, content: impl Into<String>) -> Self {
779 Self {
780 kind: MessageKind::Error,
781 title: title.into(),
782 content: content.into(),
783 }
784 }
785}
786
787#[cfg(test)]
790mod tests {
791 use super::*;
792
793 struct GreetRules;
801
802 impl CeremonyRules for GreetRules {
803 fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> {
804 match ceremony {
805 "greet" => Ok(()),
806 other => Err(format!("unknown ceremony: {other}")),
807 }
808 }
809
810 fn evaluate(
811 &self,
812 _ceremony_type: &str,
813 bag: &mut serde_json::Map<String, serde_json::Value>,
814 _render: &RenderHints,
815 ) -> EvalResult {
816 match bag.get("name").and_then(|v| v.as_str()) {
818 None => {
819 EvalResult::NeedInput {
821 prompts: vec![Prompt::text("name", "What is your name?")],
822 messages: vec![Message::info("Welcome", "Please introduce yourself.")],
823 }
824 }
825 Some("") => {
826 bag.remove("name");
828 EvalResult::ValidationError {
829 prompts: vec![Prompt::text("name", "What is your name?")],
830 messages: Vec::new(),
831 error: "Name cannot be empty".into(),
832 }
833 }
834 Some(name) => {
835 let summary = format!("Hello, {name}!");
837 EvalResult::Complete {
838 messages: vec![Message::summary("Greeting complete", &summary)],
839 }
840 }
841 }
842 }
843 }
844
845 fn make_host() -> CeremonyHost<GreetRules> {
846 CeremonyHost::new(GreetRules)
847 }
848
849 #[test]
852 fn start_new_ceremony_returns_prompts() {
853 let host = make_host();
854 let resp = host
855 .step(CeremonyRequest {
856 session_id: None,
857 ceremony: Some("greet".into()),
858 data: serde_json::Map::new(),
859 render: None,
860 })
861 .unwrap();
862
863 assert!(!resp.complete);
864 assert_eq!(resp.prompts.len(), 1);
865 assert_eq!(resp.prompts[0].key, "name");
866 assert_eq!(resp.prompts[0].input_type, InputType::Text);
867 assert_eq!(resp.messages.len(), 1);
868 assert_eq!(resp.messages[0].kind, MessageKind::Info);
869 assert_eq!(host.active_session_count(), 1);
870 }
871
872 #[test]
873 fn complete_ceremony_with_data() {
874 let host = make_host();
875
876 let r1 = host
878 .step(CeremonyRequest {
879 session_id: None,
880 ceremony: Some("greet".into()),
881 data: serde_json::Map::new(),
882 render: None,
883 })
884 .unwrap();
885 assert!(!r1.complete);
886
887 let mut data = serde_json::Map::new();
889 data.insert("name".into(), serde_json::json!("Alice"));
890 let r2 = host
891 .step(CeremonyRequest {
892 session_id: Some(r1.session_id),
893 ceremony: None,
894 data,
895 render: None,
896 })
897 .unwrap();
898 assert!(r2.complete);
899 assert!(r2.prompts.is_empty());
900 assert_eq!(r2.messages.len(), 1);
901 assert_eq!(r2.messages[0].kind, MessageKind::Summary);
902 assert!(r2.messages[0].content.contains("Alice"));
903
904 assert_eq!(host.active_session_count(), 0);
906 }
907
908 #[test]
909 fn prefill_completes_in_one_step() {
910 let host = make_host();
911
912 let mut data = serde_json::Map::new();
913 data.insert("name".into(), serde_json::json!("Bob"));
914
915 let resp = host
916 .step(CeremonyRequest {
917 session_id: None,
918 ceremony: Some("greet".into()),
919 data,
920 render: None,
921 })
922 .unwrap();
923
924 assert!(resp.complete);
925 assert!(resp.prompts.is_empty());
926 assert!(resp.messages[0].content.contains("Bob"));
927 assert_eq!(host.active_session_count(), 0);
928 }
929
930 #[test]
931 fn validation_error_re_prompts() {
932 let host = make_host();
933
934 let r1 = host
936 .step(CeremonyRequest {
937 session_id: None,
938 ceremony: Some("greet".into()),
939 data: serde_json::Map::new(),
940 render: None,
941 })
942 .unwrap();
943
944 let mut data = serde_json::Map::new();
946 data.insert("name".into(), serde_json::json!(""));
947 let r2 = host
948 .step(CeremonyRequest {
949 session_id: Some(r1.session_id),
950 ceremony: None,
951 data,
952 render: None,
953 })
954 .unwrap();
955
956 assert!(!r2.complete);
957 assert_eq!(r2.error.as_deref(), Some("Name cannot be empty"));
958 assert_eq!(r2.prompts.len(), 1);
959 assert_eq!(r2.prompts[0].key, "name");
960 assert_eq!(host.active_session_count(), 1);
961
962 let mut data = serde_json::Map::new();
964 data.insert("name".into(), serde_json::json!("Charlie"));
965 let r3 = host
966 .step(CeremonyRequest {
967 session_id: Some(r2.session_id),
968 ceremony: None,
969 data,
970 render: None,
971 })
972 .unwrap();
973 assert!(r3.complete);
974 assert!(r3.messages[0].content.contains("Charlie"));
975 }
976
977 #[test]
978 fn invalid_ceremony_type() {
979 let host = make_host();
980 let err = host
981 .step(CeremonyRequest {
982 session_id: None,
983 ceremony: Some("bogus".into()),
984 data: serde_json::Map::new(),
985 render: None,
986 })
987 .unwrap_err();
988
989 assert!(matches!(err, CeremonyError::InvalidCeremony(_)));
990 assert_eq!(err.http_status(), 400);
991 }
992
993 #[test]
994 fn missing_ceremony_field() {
995 let host = make_host();
996 let err = host
997 .step(CeremonyRequest {
998 session_id: None,
999 ceremony: None,
1000 data: serde_json::Map::new(),
1001 render: None,
1002 })
1003 .unwrap_err();
1004
1005 assert!(matches!(err, CeremonyError::MissingField(_)));
1006 }
1007
1008 #[test]
1009 fn unknown_session_returns_not_found() {
1010 let host = make_host();
1011 let err = host
1012 .step(CeremonyRequest {
1013 session_id: Some(Uuid::now_v7()),
1014 ceremony: None,
1015 data: serde_json::Map::new(),
1016 render: None,
1017 })
1018 .unwrap_err();
1019
1020 assert!(matches!(err, CeremonyError::SessionNotFound(_)));
1021 assert_eq!(err.http_status(), 404);
1022 }
1023
1024 #[test]
1025 fn sweep_removes_expired() {
1026 let host = CeremonyHost::with_ttl(GreetRules, Duration::from_millis(1));
1027
1028 let _ = host
1029 .step(CeremonyRequest {
1030 session_id: None,
1031 ceremony: Some("greet".into()),
1032 data: serde_json::Map::new(),
1033 render: None,
1034 })
1035 .unwrap();
1036
1037 assert_eq!(host.active_session_count(), 1);
1038
1039 std::thread::sleep(Duration::from_millis(10));
1041
1042 let removed = host.sweep_expired();
1043 assert_eq!(removed, 1);
1044 assert_eq!(host.active_session_count(), 0);
1045 }
1046
1047 #[test]
1048 fn render_hints_propagate() {
1049 let host = make_host();
1050 let resp = host
1051 .step(CeremonyRequest {
1052 session_id: None,
1053 ceremony: Some("greet".into()),
1054 data: serde_json::Map::new(),
1055 render: Some(RenderHints {
1056 qr: Some(QrFormat::PngBase64),
1057 }),
1058 })
1059 .unwrap();
1060
1061 let sessions = host.sessions.lock().unwrap();
1062 let session = sessions.get(&resp.session_id).unwrap();
1063 assert_eq!(session.render.qr, Some(QrFormat::PngBase64));
1064 }
1065
1066 #[test]
1067 fn qr_format_serde_round_trip() {
1068 let hints = RenderHints {
1069 qr: Some(QrFormat::PngBase64),
1070 };
1071 let json = serde_json::to_string(&hints).unwrap();
1072 assert!(json.contains("png_base64"));
1073 let parsed: RenderHints = serde_json::from_str(&json).unwrap();
1074 assert_eq!(parsed.qr, Some(QrFormat::PngBase64));
1075 }
1076
1077 #[test]
1078 fn prompt_and_message_serde() {
1079 let prompt = Prompt::select_one(
1080 "color",
1081 "Pick a color",
1082 vec![
1083 SelectOption::new("red", "Red"),
1084 SelectOption::with_description("blue", "Blue", "The color of the sky"),
1085 ],
1086 );
1087 let json = serde_json::to_value(&prompt).unwrap();
1088 assert_eq!(json["key"], "color");
1089 assert_eq!(json["input_type"], "select_one");
1090 assert_eq!(json["options"].as_array().unwrap().len(), 2);
1091
1092 let msg = Message::qr_code("Scan me", "data:image/png;base64,abc123");
1093 let json = serde_json::to_value(&msg).unwrap();
1094 assert_eq!(json["kind"], "qr_code");
1095 }
1096
1097 #[test]
1098 fn complete_response_serde() {
1099 let resp = CeremonyResponse {
1100 session_id: Uuid::now_v7(),
1101 prompts: vec![Prompt::text("foo", "Enter foo")],
1102 messages: vec![Message::info("Note", "Something")],
1103 complete: false,
1104 error: None,
1105 result_data: None,
1106 };
1107 let json = serde_json::to_string(&resp).unwrap();
1108 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1109 assert_eq!(parsed["complete"], false);
1110 assert!(parsed["prompts"].is_array());
1111 assert!(parsed["messages"].is_array());
1112 assert!(parsed.get("error").is_none());
1114 }
1115
1116 struct MultiRules;
1120
1121 impl CeremonyRules for MultiRules {
1122 fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> {
1123 match ceremony {
1124 "multi" => Ok(()),
1125 other => Err(format!("unknown: {other}")),
1126 }
1127 }
1128
1129 fn evaluate(
1130 &self,
1131 _ceremony_type: &str,
1132 bag: &mut serde_json::Map<String, serde_json::Value>,
1133 _render: &RenderHints,
1134 ) -> EvalResult {
1135 let has_color = bag.get("color").and_then(|v| v.as_str()).is_some();
1136 let has_size = bag.get("size").and_then(|v| v.as_str()).is_some();
1137 let has_confirm = bag.get("confirm").and_then(|v| v.as_str()).is_some();
1138
1139 if !has_color || !has_size {
1140 let mut prompts = Vec::new();
1142 if !has_color {
1143 prompts.push(Prompt::select_one(
1144 "color",
1145 "Pick a color",
1146 vec![
1147 SelectOption::new("red", "Red"),
1148 SelectOption::new("blue", "Blue"),
1149 ],
1150 ));
1151 }
1152 if !has_size {
1153 prompts.push(Prompt::select_one(
1154 "size",
1155 "Pick a size",
1156 vec![
1157 SelectOption::new("s", "Small"),
1158 SelectOption::new("l", "Large"),
1159 ],
1160 ));
1161 }
1162 return EvalResult::NeedInput {
1163 prompts,
1164 messages: vec![Message::info("Setup", "Choose your preferences.")],
1165 };
1166 }
1167
1168 if !has_confirm {
1169 let summary = format!(
1171 "Color: {}, Size: {}",
1172 bag["color"].as_str().unwrap(),
1173 bag["size"].as_str().unwrap()
1174 );
1175 return EvalResult::NeedInput {
1176 prompts: vec![Prompt::text("confirm", "Type 'yes' to confirm")],
1177 messages: vec![Message::summary("Review", &summary)],
1178 };
1179 }
1180
1181 EvalResult::Complete {
1182 messages: vec![Message::summary("Done", "Order placed.")],
1183 }
1184 }
1185 }
1186
1187 #[test]
1188 fn multi_prompt_returns_multiple_fields() {
1189 let host = CeremonyHost::new(MultiRules);
1190
1191 let r1 = host
1193 .step(CeremonyRequest {
1194 session_id: None,
1195 ceremony: Some("multi".into()),
1196 data: serde_json::Map::new(),
1197 render: None,
1198 })
1199 .unwrap();
1200 assert!(!r1.complete);
1201 assert_eq!(r1.prompts.len(), 2);
1202 assert_eq!(r1.prompts[0].key, "color");
1203 assert_eq!(r1.prompts[1].key, "size");
1204 assert_eq!(r1.messages.len(), 1);
1205
1206 let mut data = serde_json::Map::new();
1208 data.insert("color".into(), serde_json::json!("red"));
1209 data.insert("size".into(), serde_json::json!("l"));
1210 let r2 = host
1211 .step(CeremonyRequest {
1212 session_id: Some(r1.session_id),
1213 ceremony: None,
1214 data,
1215 render: None,
1216 })
1217 .unwrap();
1218 assert!(!r2.complete);
1219 assert_eq!(r2.prompts.len(), 1);
1220 assert_eq!(r2.prompts[0].key, "confirm");
1221 assert_eq!(r2.messages.len(), 1);
1223 assert_eq!(r2.messages[0].kind, MessageKind::Summary);
1224
1225 let mut data = serde_json::Map::new();
1227 data.insert("confirm".into(), serde_json::json!("yes"));
1228 let r3 = host
1229 .step(CeremonyRequest {
1230 session_id: Some(r2.session_id),
1231 ceremony: None,
1232 data,
1233 render: None,
1234 })
1235 .unwrap();
1236 assert!(r3.complete);
1237 }
1238
1239 #[test]
1240 fn partial_prefill_asks_only_for_missing() {
1241 let host = CeremonyHost::new(MultiRules);
1242
1243 let mut data = serde_json::Map::new();
1245 data.insert("color".into(), serde_json::json!("blue"));
1246
1247 let resp = host
1248 .step(CeremonyRequest {
1249 session_id: None,
1250 ceremony: Some("multi".into()),
1251 data,
1252 render: None,
1253 })
1254 .unwrap();
1255
1256 assert!(!resp.complete);
1257 assert_eq!(resp.prompts.len(), 1);
1259 assert_eq!(resp.prompts[0].key, "size");
1260 }
1261
1262 #[test]
1263 fn fatal_error_completes_with_error() {
1264 struct FatalRules;
1265
1266 impl CeremonyRules for FatalRules {
1267 fn validate_ceremony_type(&self, _: &str) -> Result<(), String> {
1268 Ok(())
1269 }
1270 fn evaluate(
1271 &self,
1272 _: &str,
1273 _: &mut serde_json::Map<String, serde_json::Value>,
1274 _: &RenderHints,
1275 ) -> EvalResult {
1276 EvalResult::Fatal("disk full".into())
1277 }
1278 }
1279
1280 let host = CeremonyHost::new(FatalRules);
1281 let resp = host
1282 .step(CeremonyRequest {
1283 session_id: None,
1284 ceremony: Some("boom".into()),
1285 data: serde_json::Map::new(),
1286 render: None,
1287 })
1288 .unwrap();
1289
1290 assert!(resp.complete);
1291 assert_eq!(resp.error.as_deref(), Some("disk full"));
1292 assert_eq!(resp.messages.len(), 1);
1293 assert_eq!(resp.messages[0].kind, MessageKind::Error);
1294 assert_eq!(host.active_session_count(), 0);
1295 }
1296}