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}
217
218#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Message {
226 pub kind: MessageKind,
228
229 pub title: String,
231
232 pub content: String,
234}
235
236#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "snake_case")]
239pub enum MessageKind {
240 Info,
242 QrCode,
244 Summary,
246 Error,
248}
249
250pub struct Session {
257 pub id: Uuid,
259
260 pub ceremony_type: String,
262
263 pub bag: serde_json::Map<String, serde_json::Value>,
265
266 pub render: RenderHints,
268
269 pub created_at: Instant,
271
272 pub last_active: Instant,
274
275 pub complete: bool,
277}
278
279impl Session {
280 pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
282 self.bag.insert(key.into(), value);
283 }
284
285 pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
287 self.bag.get(key)
288 }
289
290 pub fn get_str(&self, key: &str) -> Option<&str> {
292 self.bag.get(key).and_then(|v| v.as_str())
293 }
294
295 pub fn has(&self, key: &str) -> bool {
297 self.bag.contains_key(key)
298 }
299
300 pub fn remove(&mut self, key: &str) -> Option<serde_json::Value> {
302 self.bag.remove(key)
303 }
304}
305
306pub enum EvalResult {
313 NeedInput {
315 prompts: Vec<Prompt>,
317 messages: Vec<Message>,
319 },
320
321 ValidationError {
324 prompts: Vec<Prompt>,
326 messages: Vec<Message>,
328 error: String,
330 },
331
332 Complete {
334 messages: Vec<Message>,
336 },
337
338 Fatal(String),
340}
341
342pub trait CeremonyRules: Send + Sync {
363 fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String>;
368
369 fn evaluate(
376 &self,
377 ceremony_type: &str,
378 bag: &mut serde_json::Map<String, serde_json::Value>,
379 render: &RenderHints,
380 ) -> EvalResult;
381}
382
383pub struct CeremonyHost<R: CeremonyRules> {
390 rules: R,
391 sessions: Mutex<HashMap<Uuid, Session>>,
392 session_ttl: Duration,
393}
394
395impl<R: CeremonyRules> CeremonyHost<R> {
396 pub fn new(rules: R) -> Self {
398 Self {
399 rules,
400 sessions: Mutex::new(HashMap::new()),
401 session_ttl: DEFAULT_SESSION_TTL,
402 }
403 }
404
405 pub fn with_ttl(rules: R, ttl: Duration) -> Self {
407 Self {
408 rules,
409 sessions: Mutex::new(HashMap::new()),
410 session_ttl: ttl,
411 }
412 }
413
414 pub fn rules(&self) -> &R {
416 &self.rules
417 }
418
419 pub fn step(&self, request: CeremonyRequest) -> Result<CeremonyResponse, CeremonyError> {
426 match request.session_id {
427 None => self.start_new(request),
428 Some(id) => self.continue_existing(id, request),
429 }
430 }
431
432 pub fn sweep_expired(&self) -> usize {
435 let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
436 tracing::warn!("ceremony session lock was poisoned, recovering");
437 e.into_inner()
438 });
439 let now = Instant::now();
440 let before = sessions.len();
441 sessions.retain(|_id, session| now.duration_since(session.last_active) < self.session_ttl);
442 let removed = before - sessions.len();
443 if removed > 0 {
444 tracing::debug!(
445 removed,
446 remaining = sessions.len(),
447 "Swept expired ceremony sessions"
448 );
449 }
450 removed
451 }
452
453 pub fn active_session_count(&self) -> usize {
455 self.sessions
456 .lock()
457 .unwrap_or_else(|e| {
458 tracing::warn!("ceremony session lock was poisoned, recovering");
459 e.into_inner()
460 })
461 .len()
462 }
463
464 fn start_new(&self, request: CeremonyRequest) -> Result<CeremonyResponse, CeremonyError> {
467 let ceremony = request
468 .ceremony
469 .as_deref()
470 .ok_or_else(|| CeremonyError::MissingField("ceremony".into()))?;
471
472 self.rules
473 .validate_ceremony_type(ceremony)
474 .map_err(CeremonyError::InvalidCeremony)?;
475
476 let render = request.render.unwrap_or_default();
477 let now = Instant::now();
478
479 let mut session = Session {
480 id: Uuid::now_v7(),
481 ceremony_type: ceremony.to_string(),
482 bag: request.data,
483 render: render.clone(),
484 created_at: now,
485 last_active: now,
486 complete: false,
487 };
488
489 let result = self.rules.evaluate(ceremony, &mut session.bag, &render);
490 self.finalize(session, result)
491 }
492
493 fn continue_existing(
494 &self,
495 session_id: Uuid,
496 request: CeremonyRequest,
497 ) -> Result<CeremonyResponse, CeremonyError> {
498 let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
499 tracing::warn!("ceremony session lock was poisoned, recovering");
500 e.into_inner()
501 });
502
503 let session = sessions
504 .get_mut(&session_id)
505 .ok_or(CeremonyError::SessionNotFound(session_id))?;
506
507 let now = Instant::now();
509 if now.duration_since(session.last_active) >= self.session_ttl {
510 sessions.remove(&session_id);
511 return Err(CeremonyError::SessionExpired);
512 }
513
514 if session.complete {
515 return Err(CeremonyError::AlreadyComplete);
516 }
517
518 session.last_active = now;
520 if let Some(render) = &request.render {
521 session.render = render.clone();
522 }
523
524 for (key, value) in request.data {
526 session.bag.insert(key, value);
527 }
528
529 let render = session.render.clone();
530 let ceremony_type = session.ceremony_type.clone();
531 let result = self
532 .rules
533 .evaluate(&ceremony_type, &mut session.bag, &render);
534
535 let Some(session) = sessions.remove(&session_id) else {
537 return Err(CeremonyError::SessionNotFound(session_id));
538 };
539 drop(sessions);
540
541 self.finalize(session, result)
542 }
543
544 fn finalize(
547 &self,
548 mut session: Session,
549 result: EvalResult,
550 ) -> Result<CeremonyResponse, CeremonyError> {
551 let session_id = session.id;
552
553 let (prompts, messages, complete, error) = match result {
554 EvalResult::NeedInput { prompts, messages } => (prompts, messages, false, None),
555 EvalResult::ValidationError {
556 prompts,
557 messages,
558 error,
559 } => (prompts, messages, false, Some(error)),
560 EvalResult::Complete { messages } => (Vec::new(), messages, true, None),
561 EvalResult::Fatal(msg) => {
562 let messages = vec![Message {
563 kind: MessageKind::Error,
564 title: "Ceremony failed".into(),
565 content: msg.clone(),
566 }];
567 (Vec::new(), messages, true, Some(msg))
568 }
569 };
570
571 session.complete = complete;
572
573 let result_data = if complete && error.is_none() {
575 Some(session.bag.clone())
576 } else {
577 None
578 };
579
580 if !complete {
582 let mut sessions = self.sessions.lock().unwrap_or_else(|e| {
583 tracing::warn!("ceremony session lock was poisoned, recovering");
584 e.into_inner()
585 });
586 sessions.insert(session_id, session);
587 }
588
589 Ok(CeremonyResponse {
590 session_id,
591 prompts,
592 messages,
593 complete,
594 error,
595 result_data,
596 })
597 }
598}
599
600#[derive(Debug, thiserror::Error)]
604pub enum CeremonyError {
605 #[error("session not found: {0}")]
606 SessionNotFound(Uuid),
607
608 #[error("session expired")]
609 SessionExpired,
610
611 #[error("missing required field: {0}")]
612 MissingField(String),
613
614 #[error("invalid ceremony type: {0}")]
615 InvalidCeremony(String),
616
617 #[error("ceremony already complete")]
618 AlreadyComplete,
619
620 #[error("internal error: {0}")]
621 Internal(String),
622}
623
624impl CeremonyError {
625 pub fn http_status(&self) -> u16 {
627 match self {
628 Self::SessionNotFound(_) => 404,
629 Self::SessionExpired => 410,
630 Self::MissingField(_) => 400,
631 Self::InvalidCeremony(_) => 400,
632 Self::AlreadyComplete => 409,
633 Self::Internal(_) => 500,
634 }
635 }
636}
637
638impl Prompt {
641 pub fn select_one(
643 key: impl Into<String>,
644 prompt: impl Into<String>,
645 options: Vec<SelectOption>,
646 ) -> Self {
647 Self {
648 key: key.into(),
649 prompt: prompt.into(),
650 input_type: InputType::SelectOne,
651 options,
652 required: true,
653 }
654 }
655
656 pub fn secret(key: impl Into<String>, prompt: impl Into<String>) -> Self {
658 Self {
659 key: key.into(),
660 prompt: prompt.into(),
661 input_type: InputType::Secret,
662 options: Vec::new(),
663 required: true,
664 }
665 }
666
667 pub fn secret_confirm(key: impl Into<String>, prompt: impl Into<String>) -> Self {
669 Self {
670 key: key.into(),
671 prompt: prompt.into(),
672 input_type: InputType::SecretConfirm,
673 options: Vec::new(),
674 required: true,
675 }
676 }
677
678 pub fn code(key: impl Into<String>, prompt: impl Into<String>) -> Self {
680 Self {
681 key: key.into(),
682 prompt: prompt.into(),
683 input_type: InputType::Code,
684 options: Vec::new(),
685 required: true,
686 }
687 }
688
689 pub fn text(key: impl Into<String>, prompt: impl Into<String>) -> Self {
691 Self {
692 key: key.into(),
693 prompt: prompt.into(),
694 input_type: InputType::Text,
695 options: Vec::new(),
696 required: true,
697 }
698 }
699
700 pub fn entropy(key: impl Into<String>, prompt: impl Into<String>) -> Self {
702 Self {
703 key: key.into(),
704 prompt: prompt.into(),
705 input_type: InputType::Entropy,
706 options: Vec::new(),
707 required: true,
708 }
709 }
710}
711
712impl SelectOption {
713 pub fn new(value: impl Into<String>, label: impl Into<String>) -> Self {
715 Self {
716 value: value.into(),
717 label: label.into(),
718 description: None,
719 }
720 }
721
722 pub fn with_description(
724 value: impl Into<String>,
725 label: impl Into<String>,
726 description: impl Into<String>,
727 ) -> Self {
728 Self {
729 value: value.into(),
730 label: label.into(),
731 description: Some(description.into()),
732 }
733 }
734}
735
736impl Message {
737 pub fn info(title: impl Into<String>, content: impl Into<String>) -> Self {
739 Self {
740 kind: MessageKind::Info,
741 title: title.into(),
742 content: content.into(),
743 }
744 }
745
746 pub fn qr_code(title: impl Into<String>, content: impl Into<String>) -> Self {
748 Self {
749 kind: MessageKind::QrCode,
750 title: title.into(),
751 content: content.into(),
752 }
753 }
754
755 pub fn summary(title: impl Into<String>, content: impl Into<String>) -> Self {
757 Self {
758 kind: MessageKind::Summary,
759 title: title.into(),
760 content: content.into(),
761 }
762 }
763
764 pub fn error(title: impl Into<String>, content: impl Into<String>) -> Self {
766 Self {
767 kind: MessageKind::Error,
768 title: title.into(),
769 content: content.into(),
770 }
771 }
772}
773
774#[cfg(test)]
777mod tests {
778 use super::*;
779
780 struct GreetRules;
788
789 impl CeremonyRules for GreetRules {
790 fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> {
791 match ceremony {
792 "greet" => Ok(()),
793 other => Err(format!("unknown ceremony: {other}")),
794 }
795 }
796
797 fn evaluate(
798 &self,
799 _ceremony_type: &str,
800 bag: &mut serde_json::Map<String, serde_json::Value>,
801 _render: &RenderHints,
802 ) -> EvalResult {
803 match bag.get("name").and_then(|v| v.as_str()) {
805 None => {
806 EvalResult::NeedInput {
808 prompts: vec![Prompt::text("name", "What is your name?")],
809 messages: vec![Message::info("Welcome", "Please introduce yourself.")],
810 }
811 }
812 Some("") => {
813 bag.remove("name");
815 EvalResult::ValidationError {
816 prompts: vec![Prompt::text("name", "What is your name?")],
817 messages: Vec::new(),
818 error: "Name cannot be empty".into(),
819 }
820 }
821 Some(name) => {
822 let summary = format!("Hello, {name}!");
824 EvalResult::Complete {
825 messages: vec![Message::summary("Greeting complete", &summary)],
826 }
827 }
828 }
829 }
830 }
831
832 fn make_host() -> CeremonyHost<GreetRules> {
833 CeremonyHost::new(GreetRules)
834 }
835
836 #[test]
839 fn start_new_ceremony_returns_prompts() {
840 let host = make_host();
841 let resp = host
842 .step(CeremonyRequest {
843 session_id: None,
844 ceremony: Some("greet".into()),
845 data: serde_json::Map::new(),
846 render: None,
847 })
848 .unwrap();
849
850 assert!(!resp.complete);
851 assert_eq!(resp.prompts.len(), 1);
852 assert_eq!(resp.prompts[0].key, "name");
853 assert_eq!(resp.prompts[0].input_type, InputType::Text);
854 assert_eq!(resp.messages.len(), 1);
855 assert_eq!(resp.messages[0].kind, MessageKind::Info);
856 assert_eq!(host.active_session_count(), 1);
857 }
858
859 #[test]
860 fn complete_ceremony_with_data() {
861 let host = make_host();
862
863 let r1 = host
865 .step(CeremonyRequest {
866 session_id: None,
867 ceremony: Some("greet".into()),
868 data: serde_json::Map::new(),
869 render: None,
870 })
871 .unwrap();
872 assert!(!r1.complete);
873
874 let mut data = serde_json::Map::new();
876 data.insert("name".into(), serde_json::json!("Alice"));
877 let r2 = host
878 .step(CeremonyRequest {
879 session_id: Some(r1.session_id),
880 ceremony: None,
881 data,
882 render: None,
883 })
884 .unwrap();
885 assert!(r2.complete);
886 assert!(r2.prompts.is_empty());
887 assert_eq!(r2.messages.len(), 1);
888 assert_eq!(r2.messages[0].kind, MessageKind::Summary);
889 assert!(r2.messages[0].content.contains("Alice"));
890
891 assert_eq!(host.active_session_count(), 0);
893 }
894
895 #[test]
896 fn prefill_completes_in_one_step() {
897 let host = make_host();
898
899 let mut data = serde_json::Map::new();
900 data.insert("name".into(), serde_json::json!("Bob"));
901
902 let resp = host
903 .step(CeremonyRequest {
904 session_id: None,
905 ceremony: Some("greet".into()),
906 data,
907 render: None,
908 })
909 .unwrap();
910
911 assert!(resp.complete);
912 assert!(resp.prompts.is_empty());
913 assert!(resp.messages[0].content.contains("Bob"));
914 assert_eq!(host.active_session_count(), 0);
915 }
916
917 #[test]
918 fn validation_error_re_prompts() {
919 let host = make_host();
920
921 let r1 = host
923 .step(CeremonyRequest {
924 session_id: None,
925 ceremony: Some("greet".into()),
926 data: serde_json::Map::new(),
927 render: None,
928 })
929 .unwrap();
930
931 let mut data = serde_json::Map::new();
933 data.insert("name".into(), serde_json::json!(""));
934 let r2 = host
935 .step(CeremonyRequest {
936 session_id: Some(r1.session_id),
937 ceremony: None,
938 data,
939 render: None,
940 })
941 .unwrap();
942
943 assert!(!r2.complete);
944 assert_eq!(r2.error.as_deref(), Some("Name cannot be empty"));
945 assert_eq!(r2.prompts.len(), 1);
946 assert_eq!(r2.prompts[0].key, "name");
947 assert_eq!(host.active_session_count(), 1);
948
949 let mut data = serde_json::Map::new();
951 data.insert("name".into(), serde_json::json!("Charlie"));
952 let r3 = host
953 .step(CeremonyRequest {
954 session_id: Some(r2.session_id),
955 ceremony: None,
956 data,
957 render: None,
958 })
959 .unwrap();
960 assert!(r3.complete);
961 assert!(r3.messages[0].content.contains("Charlie"));
962 }
963
964 #[test]
965 fn invalid_ceremony_type() {
966 let host = make_host();
967 let err = host
968 .step(CeremonyRequest {
969 session_id: None,
970 ceremony: Some("bogus".into()),
971 data: serde_json::Map::new(),
972 render: None,
973 })
974 .unwrap_err();
975
976 assert!(matches!(err, CeremonyError::InvalidCeremony(_)));
977 assert_eq!(err.http_status(), 400);
978 }
979
980 #[test]
981 fn missing_ceremony_field() {
982 let host = make_host();
983 let err = host
984 .step(CeremonyRequest {
985 session_id: None,
986 ceremony: None,
987 data: serde_json::Map::new(),
988 render: None,
989 })
990 .unwrap_err();
991
992 assert!(matches!(err, CeremonyError::MissingField(_)));
993 }
994
995 #[test]
996 fn unknown_session_returns_not_found() {
997 let host = make_host();
998 let err = host
999 .step(CeremonyRequest {
1000 session_id: Some(Uuid::now_v7()),
1001 ceremony: None,
1002 data: serde_json::Map::new(),
1003 render: None,
1004 })
1005 .unwrap_err();
1006
1007 assert!(matches!(err, CeremonyError::SessionNotFound(_)));
1008 assert_eq!(err.http_status(), 404);
1009 }
1010
1011 #[test]
1012 fn sweep_removes_expired() {
1013 let host = CeremonyHost::with_ttl(GreetRules, Duration::from_millis(1));
1014
1015 let _ = host
1016 .step(CeremonyRequest {
1017 session_id: None,
1018 ceremony: Some("greet".into()),
1019 data: serde_json::Map::new(),
1020 render: None,
1021 })
1022 .unwrap();
1023
1024 assert_eq!(host.active_session_count(), 1);
1025
1026 std::thread::sleep(Duration::from_millis(10));
1028
1029 let removed = host.sweep_expired();
1030 assert_eq!(removed, 1);
1031 assert_eq!(host.active_session_count(), 0);
1032 }
1033
1034 #[test]
1035 fn render_hints_propagate() {
1036 let host = make_host();
1037 let resp = host
1038 .step(CeremonyRequest {
1039 session_id: None,
1040 ceremony: Some("greet".into()),
1041 data: serde_json::Map::new(),
1042 render: Some(RenderHints {
1043 qr: Some(QrFormat::PngBase64),
1044 }),
1045 })
1046 .unwrap();
1047
1048 let sessions = host.sessions.lock().unwrap();
1049 let session = sessions.get(&resp.session_id).unwrap();
1050 assert_eq!(session.render.qr, Some(QrFormat::PngBase64));
1051 }
1052
1053 #[test]
1054 fn qr_format_serde_round_trip() {
1055 let hints = RenderHints {
1056 qr: Some(QrFormat::PngBase64),
1057 };
1058 let json = serde_json::to_string(&hints).unwrap();
1059 assert!(json.contains("png_base64"));
1060 let parsed: RenderHints = serde_json::from_str(&json).unwrap();
1061 assert_eq!(parsed.qr, Some(QrFormat::PngBase64));
1062 }
1063
1064 #[test]
1065 fn prompt_and_message_serde() {
1066 let prompt = Prompt::select_one(
1067 "color",
1068 "Pick a color",
1069 vec![
1070 SelectOption::new("red", "Red"),
1071 SelectOption::with_description("blue", "Blue", "The color of the sky"),
1072 ],
1073 );
1074 let json = serde_json::to_value(&prompt).unwrap();
1075 assert_eq!(json["key"], "color");
1076 assert_eq!(json["input_type"], "select_one");
1077 assert_eq!(json["options"].as_array().unwrap().len(), 2);
1078
1079 let msg = Message::qr_code("Scan me", "data:image/png;base64,abc123");
1080 let json = serde_json::to_value(&msg).unwrap();
1081 assert_eq!(json["kind"], "qr_code");
1082 }
1083
1084 #[test]
1085 fn complete_response_serde() {
1086 let resp = CeremonyResponse {
1087 session_id: Uuid::now_v7(),
1088 prompts: vec![Prompt::text("foo", "Enter foo")],
1089 messages: vec![Message::info("Note", "Something")],
1090 complete: false,
1091 error: None,
1092 result_data: None,
1093 };
1094 let json = serde_json::to_string(&resp).unwrap();
1095 let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
1096 assert_eq!(parsed["complete"], false);
1097 assert!(parsed["prompts"].is_array());
1098 assert!(parsed["messages"].is_array());
1099 assert!(parsed.get("error").is_none());
1101 }
1102
1103 struct MultiRules;
1107
1108 impl CeremonyRules for MultiRules {
1109 fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> {
1110 match ceremony {
1111 "multi" => Ok(()),
1112 other => Err(format!("unknown: {other}")),
1113 }
1114 }
1115
1116 fn evaluate(
1117 &self,
1118 _ceremony_type: &str,
1119 bag: &mut serde_json::Map<String, serde_json::Value>,
1120 _render: &RenderHints,
1121 ) -> EvalResult {
1122 let has_color = bag.get("color").and_then(|v| v.as_str()).is_some();
1123 let has_size = bag.get("size").and_then(|v| v.as_str()).is_some();
1124 let has_confirm = bag.get("confirm").and_then(|v| v.as_str()).is_some();
1125
1126 if !has_color || !has_size {
1127 let mut prompts = Vec::new();
1129 if !has_color {
1130 prompts.push(Prompt::select_one(
1131 "color",
1132 "Pick a color",
1133 vec![
1134 SelectOption::new("red", "Red"),
1135 SelectOption::new("blue", "Blue"),
1136 ],
1137 ));
1138 }
1139 if !has_size {
1140 prompts.push(Prompt::select_one(
1141 "size",
1142 "Pick a size",
1143 vec![
1144 SelectOption::new("s", "Small"),
1145 SelectOption::new("l", "Large"),
1146 ],
1147 ));
1148 }
1149 return EvalResult::NeedInput {
1150 prompts,
1151 messages: vec![Message::info("Setup", "Choose your preferences.")],
1152 };
1153 }
1154
1155 if !has_confirm {
1156 let summary = format!(
1158 "Color: {}, Size: {}",
1159 bag["color"].as_str().unwrap(),
1160 bag["size"].as_str().unwrap()
1161 );
1162 return EvalResult::NeedInput {
1163 prompts: vec![Prompt::text("confirm", "Type 'yes' to confirm")],
1164 messages: vec![Message::summary("Review", &summary)],
1165 };
1166 }
1167
1168 EvalResult::Complete {
1169 messages: vec![Message::summary("Done", "Order placed.")],
1170 }
1171 }
1172 }
1173
1174 #[test]
1175 fn multi_prompt_returns_multiple_fields() {
1176 let host = CeremonyHost::new(MultiRules);
1177
1178 let r1 = host
1180 .step(CeremonyRequest {
1181 session_id: None,
1182 ceremony: Some("multi".into()),
1183 data: serde_json::Map::new(),
1184 render: None,
1185 })
1186 .unwrap();
1187 assert!(!r1.complete);
1188 assert_eq!(r1.prompts.len(), 2);
1189 assert_eq!(r1.prompts[0].key, "color");
1190 assert_eq!(r1.prompts[1].key, "size");
1191 assert_eq!(r1.messages.len(), 1);
1192
1193 let mut data = serde_json::Map::new();
1195 data.insert("color".into(), serde_json::json!("red"));
1196 data.insert("size".into(), serde_json::json!("l"));
1197 let r2 = host
1198 .step(CeremonyRequest {
1199 session_id: Some(r1.session_id),
1200 ceremony: None,
1201 data,
1202 render: None,
1203 })
1204 .unwrap();
1205 assert!(!r2.complete);
1206 assert_eq!(r2.prompts.len(), 1);
1207 assert_eq!(r2.prompts[0].key, "confirm");
1208 assert_eq!(r2.messages.len(), 1);
1210 assert_eq!(r2.messages[0].kind, MessageKind::Summary);
1211
1212 let mut data = serde_json::Map::new();
1214 data.insert("confirm".into(), serde_json::json!("yes"));
1215 let r3 = host
1216 .step(CeremonyRequest {
1217 session_id: Some(r2.session_id),
1218 ceremony: None,
1219 data,
1220 render: None,
1221 })
1222 .unwrap();
1223 assert!(r3.complete);
1224 }
1225
1226 #[test]
1227 fn partial_prefill_asks_only_for_missing() {
1228 let host = CeremonyHost::new(MultiRules);
1229
1230 let mut data = serde_json::Map::new();
1232 data.insert("color".into(), serde_json::json!("blue"));
1233
1234 let resp = host
1235 .step(CeremonyRequest {
1236 session_id: None,
1237 ceremony: Some("multi".into()),
1238 data,
1239 render: None,
1240 })
1241 .unwrap();
1242
1243 assert!(!resp.complete);
1244 assert_eq!(resp.prompts.len(), 1);
1246 assert_eq!(resp.prompts[0].key, "size");
1247 }
1248
1249 #[test]
1250 fn fatal_error_completes_with_error() {
1251 struct FatalRules;
1252
1253 impl CeremonyRules for FatalRules {
1254 fn validate_ceremony_type(&self, _: &str) -> Result<(), String> {
1255 Ok(())
1256 }
1257 fn evaluate(
1258 &self,
1259 _: &str,
1260 _: &mut serde_json::Map<String, serde_json::Value>,
1261 _: &RenderHints,
1262 ) -> EvalResult {
1263 EvalResult::Fatal("disk full".into())
1264 }
1265 }
1266
1267 let host = CeremonyHost::new(FatalRules);
1268 let resp = host
1269 .step(CeremonyRequest {
1270 session_id: None,
1271 ceremony: Some("boom".into()),
1272 data: serde_json::Map::new(),
1273 render: None,
1274 })
1275 .unwrap();
1276
1277 assert!(resp.complete);
1278 assert_eq!(resp.error.as_deref(), Some("disk full"));
1279 assert_eq!(resp.messages.len(), 1);
1280 assert_eq!(resp.messages[0].kind, MessageKind::Error);
1281 assert_eq!(host.active_session_count(), 0);
1282 }
1283}