Skip to main content

koi_common/
ceremony.rs

1//! Generic server-driven ceremony framework.
2//!
3//! A **ceremony** is a server-controlled dialogue between a server and a
4//! client (CLI, web UI, SDK). The server owns validation, branching, and
5//! all domain logic. Clients are dumb render loops - they display
6//! whatever the server sends, collect input, and post it back.
7//!
8//! # Core model: bag of key-value + rules
9//!
10//! A ceremony is **not** a linear pipeline of stages. It is:
11//!
12//! - A **bag** of key-value pairs (the session state), and
13//! - A **rules function** that inspects the bag and decides what to do next.
14//!
15//! ```text
16//! evaluate(bag, render_hints) → { prompts[] + messages[] | complete | fatal }
17//! ```
18//!
19//! There is no stage index, no forward/backward cursor. The session is
20//! just a `Map<String, Value>` and the rules are a pure function over it.
21//! Every time the client submits data, it is merged into the bag, and the
22//! rules are re-evaluated.
23//!
24//! # Architecture
25//!
26//! ```text
27//! ┌──────────┐        ┌──────────────┐        ┌────────────────┐
28//! │  Client   │ ←────→ │ CeremonyHost │ ←────→ │ CeremonyRules  │
29//! │ (render   │ step() │ (sessions,   │ eval() │ (domain-       │
30//! │  loop)    │        │  lifecycle)  │        │  specific)     │
31//! └──────────┘        └──────────────┘        └────────────────┘
32//! ```
33//!
34//! The [`CeremonyHost`] manages sessions and delegates evaluation to a
35//! [`CeremonyRules`] implementation. Each domain (certmesh, storage,
36//! companions, etc.) provides its own `CeremonyRules`.
37//!
38//! # Usage
39//!
40//! ```ignore
41//! // 1. Implement CeremonyRules for your domain
42//! impl CeremonyRules for PondRules {
43//!     fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String> { ... }
44//!     fn evaluate(&self, ceremony_type: &str, bag: &mut Map<String, Value>,
45//!                 render: &RenderHints) -> EvalResult { ... }
46//! }
47//!
48//! // 2. Create a host and call step()
49//! let host = CeremonyHost::new(rules);
50//! let response = host.step(CeremonyRequest {
51//!     ceremony: Some("init".into()),
52//!     data: serde_json::Map::new(),
53//!     ..Default::default()
54//! });
55//! ```
56
57use std::collections::HashMap;
58use std::sync::Mutex;
59use std::time::{Duration, Instant};
60
61use serde::{Deserialize, Serialize};
62use uuid::Uuid;
63
64// ── Configuration ───────────────────────────────────────────────────
65
66/// Default session time-to-live (5 minutes).
67const DEFAULT_SESSION_TTL: Duration = Duration::from_secs(300);
68
69/// Default sweep interval for expired sessions (60 seconds).
70/// Consumers spawn a background task at this interval calling
71/// [`CeremonyHost::sweep_expired`].
72pub const SESSION_SWEEP_INTERVAL: Duration = Duration::from_secs(60);
73
74// ── Render hints ────────────────────────────────────────────────────
75
76/// Client-provided hints for how the server should render rich content.
77///
78/// Sent per-request so different clients (CLI vs browser) get appropriate
79/// output without the server needing to know who's calling.
80#[derive(Debug, Clone, Default, Serialize, Deserialize)]
81pub struct RenderHints {
82    /// Preferred QR code format. Absent = server's default.
83    #[serde(default, skip_serializing_if = "Option::is_none")]
84    pub qr: Option<QrFormat>,
85}
86
87/// QR code rendering format.
88#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
89#[serde(rename_all = "snake_case")]
90pub enum QrFormat {
91    /// Unicode block characters for terminal display.
92    #[default]
93    Utf8,
94    /// Base64-encoded PNG for `<img src="data:image/png;base64,...">`.
95    PngBase64,
96    /// Raw URI only - no visual rendering.
97    UriOnly,
98}
99
100// ── Protocol types (wire format) ────────────────────────────────────
101
102/// Inbound ceremony request from the client.
103///
104/// This is the universal request shape for every ceremony step.
105/// The client sends key-value data which is merged into the session bag.
106#[derive(Debug, Default, Serialize, Deserialize)]
107pub struct CeremonyRequest {
108    /// Session ID from a previous response. `None` to start a new ceremony.
109    #[serde(default)]
110    pub session_id: Option<Uuid>,
111
112    /// Ceremony type identifier (e.g. "init", "join").
113    /// Required on the first call; ignored on subsequent calls.
114    #[serde(default)]
115    pub ceremony: Option<String>,
116
117    /// Key-value pairs to merge into the session bag.
118    /// On the first call this can carry prefill data from CLI flags.
119    /// On subsequent calls this carries the user's answers to prompts.
120    #[serde(default)]
121    pub data: serde_json::Map<String, serde_json::Value>,
122
123    /// Client render preferences.
124    #[serde(default)]
125    pub render: Option<RenderHints>,
126}
127
128/// Outbound ceremony response to the client.
129///
130/// Contains prompts (what to ask the user), messages (what to show),
131/// completion status, and any errors.
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct CeremonyResponse {
134    /// Session ID - include in the next request.
135    pub session_id: Uuid,
136
137    /// Data the server wants the client to collect.
138    /// Empty only when `complete` is true or a fatal error occurred.
139    pub prompts: Vec<Prompt>,
140
141    /// Informational content to display (instructions, QR codes, summaries).
142    /// Can appear alongside prompts.
143    pub messages: Vec<Message>,
144
145    /// True when the ceremony is finished (success or fatal error).
146    pub complete: bool,
147
148    /// Validation or fatal error detail.
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub error: Option<String>,
151
152    /// The final bag state when the ceremony completes.
153    /// Only present when `complete` is true and no fatal error occurred.
154    /// Contains all collected data including internal keys (prefixed `_`).
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub result_data: Option<serde_json::Map<String, serde_json::Value>>,
157}
158
159// ── Prompts ─────────────────────────────────────────────────────────
160
161/// A single data request - tells the client exactly one thing to collect.
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct Prompt {
164    /// The bag key this prompt populates.
165    pub key: String,
166
167    /// Human-readable question or instruction.
168    pub prompt: String,
169
170    /// What kind of input widget the client should render.
171    pub input_type: InputType,
172
173    /// Options for `SelectOne` or `SelectMany` input types.
174    #[serde(default, skip_serializing_if = "Vec::is_empty")]
175    pub options: Vec<SelectOption>,
176
177    /// Whether the user must provide a value.
178    #[serde(default = "default_true")]
179    pub required: bool,
180}
181
182fn default_true() -> bool {
183    true
184}
185
186/// A selectable option within a `SelectOne` or `SelectMany` prompt.
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SelectOption {
189    /// The value stored in the bag when selected.
190    pub value: String,
191    /// Display label.
192    pub label: String,
193    /// Optional description shown below the label.
194    #[serde(default, skip_serializing_if = "Option::is_none")]
195    pub description: Option<String>,
196}
197
198/// The kind of input widget a prompt requires.
199#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
200#[serde(rename_all = "snake_case")]
201pub enum InputType {
202    /// Pick exactly one from `options`.
203    SelectOne,
204    /// Pick one or more from `options`.
205    SelectMany,
206    /// Free text input.
207    Text,
208    /// Masked text input (passphrases).
209    Secret,
210    /// Two masked inputs that must match (passphrase + confirmation).
211    SecretConfirm,
212    /// Short numeric/alphanumeric code (TOTP verification).
213    Code,
214    /// Raw entropy input (keyboard mashing, mouse movement).
215    Entropy,
216    /// Hardware key interaction (WebAuthn).
217    Fido2,
218}
219
220// ── Messages ────────────────────────────────────────────────────────
221
222/// An informational display item - not an input.
223///
224/// Messages carry content to show the user without requiring input.
225/// They can appear alongside prompts (e.g., QR code + code input).
226#[derive(Debug, Clone, Serialize, Deserialize)]
227pub struct Message {
228    /// What kind of content this is.
229    pub kind: MessageKind,
230
231    /// Short title or heading.
232    pub title: String,
233
234    /// The content body (plain text, base64 image, JSON summary, etc.).
235    pub content: String,
236}
237
238/// Message content type.
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
240#[serde(rename_all = "snake_case")]
241pub enum MessageKind {
242    /// Plain text instruction or guidance.
243    Info,
244    /// QR code image (format per `RenderHints::qr`).
245    QrCode,
246    /// Key-value summary of completed ceremony data.
247    Summary,
248    /// Error detail with context (non-fatal).
249    Error,
250}
251
252// ── Session ─────────────────────────────────────────────────────────
253
254/// A live ceremony session - just a bag of key-value pairs plus metadata.
255///
256/// There is no stage index, no stage name, no progress counter.
257/// The [`CeremonyRules`] derive everything from the bag contents.
258pub struct Session {
259    /// Unique session identifier (UUIDv7).
260    pub id: Uuid,
261
262    /// Ceremony type identifier string (e.g. "init", "join").
263    pub ceremony_type: String,
264
265    /// The accumulated key-value data. Rules read and write this.
266    pub bag: serde_json::Map<String, serde_json::Value>,
267
268    /// Client render hints (from the most recent request).
269    pub render: RenderHints,
270
271    /// Monotonic timestamp of creation.
272    pub created_at: Instant,
273
274    /// Monotonic timestamp of last activity.
275    pub last_active: Instant,
276
277    /// Whether this ceremony has completed.
278    pub complete: bool,
279}
280
281impl Session {
282    /// Store a value in the bag.
283    pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
284        self.bag.insert(key.into(), value);
285    }
286
287    /// Get a value from the bag.
288    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
289        self.bag.get(key)
290    }
291
292    /// Get a string value from the bag.
293    pub fn get_str(&self, key: &str) -> Option<&str> {
294        self.bag.get(key).and_then(|v| v.as_str())
295    }
296
297    /// Check whether a key exists in the bag.
298    pub fn has(&self, key: &str) -> bool {
299        self.bag.contains_key(key)
300    }
301
302    /// Remove a key from the bag (e.g. to force re-collection on conflict).
303    pub fn remove(&mut self, key: &str) -> Option<serde_json::Value> {
304        self.bag.remove(key)
305    }
306}
307
308// ── Eval result ─────────────────────────────────────────────────────
309
310/// Result of evaluating the ceremony rules against the current bag.
311///
312/// Returned by [`CeremonyRules::evaluate`] to tell the host what to
313/// present to the client next.
314pub enum EvalResult {
315    /// The ceremony needs more data. Return prompts and optional messages.
316    NeedInput {
317        /// Data the client should collect.
318        prompts: Vec<Prompt>,
319        /// Informational content to display alongside prompts.
320        messages: Vec<Message>,
321    },
322
323    /// Re-prompt with a validation error. The client shows the error
324    /// and re-renders the prompts for the user to correct.
325    ValidationError {
326        /// Prompts to re-display (typically the offending fields).
327        prompts: Vec<Prompt>,
328        /// Informational messages.
329        messages: Vec<Message>,
330        /// Human-readable error description.
331        error: String,
332    },
333
334    /// The bag is complete and consistent. The ceremony is done.
335    Complete {
336        /// Final messages (summary, results, etc.).
337        messages: Vec<Message>,
338    },
339
340    /// Something is terminally wrong (I/O failure, impossible state).
341    Fatal(String),
342}
343
344// ── Ceremony rules trait ────────────────────────────────────────────
345
346/// Domain-specific ceremony rules.
347///
348/// Each domain (certmesh, storage, companions, etc.) implements this
349/// trait to define its ceremony types and evaluation logic.
350///
351/// The rules function is essentially:
352/// ```text
353/// evaluate(ceremony_type, bag, render_hints) → EvalResult
354/// ```
355///
356/// Rules inspect the bag and decide what data is still needed, whether
357/// existing data conflicts, or whether the ceremony is complete.
358///
359/// # Thread safety
360///
361/// The host calls `evaluate` while holding a session lock. Keep
362/// implementations fast - do heavy I/O before returning, or collect
363/// parameters here and execute in a post-step hook.
364pub trait CeremonyRules: Send + Sync {
365    /// Validate a ceremony type string.
366    ///
367    /// Return `Ok(())` if the string is a known ceremony type,
368    /// or `Err("message")` if it isn't.
369    fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String>;
370
371    /// Evaluate the bag and determine what happens next.
372    ///
373    /// The rules may read and write the bag (e.g. to inject derived keys
374    /// like `_totp_secret`, or to remove conflicting keys). The bag
375    /// already contains any data the client sent in this request -
376    /// it was merged before `evaluate` is called.
377    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
385// ── Ceremony host ───────────────────────────────────────────────────
386
387/// Generic ceremony host - manages sessions and delegates evaluation
388/// to a [`CeremonyRules`] implementation.
389///
390/// Thread-safe. One host per domain, shared across HTTP handlers.
391pub 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    /// Create a new ceremony host with the given domain rules.
399    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    /// Create a ceremony host with a custom session TTL.
408    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    /// Access the domain rules (e.g. for diagnostics or testing).
417    pub fn rules(&self) -> &R {
418        &self.rules
419    }
420
421    /// Process a ceremony step.
422    ///
423    /// - If `session_id` is `None`, creates a new session, merges
424    ///   `data` into the bag, evaluates the rules, and returns prompts.
425    /// - If `session_id` is `Some`, merges `data` into the existing
426    ///   session bag, re-evaluates the rules, and returns prompts.
427    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    /// Remove expired sessions. Call periodically from a background task.
435    /// Returns the number of sessions removed.
436    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    /// Number of active sessions (for diagnostics).
456    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    // ── Internal ────────────────────────────────────────────────────
467
468    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        // Check expiry
510        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        // Update activity + render hints
521        session.last_active = now;
522        if let Some(render) = &request.render {
523            session.render = render.clone();
524        }
525
526        // Merge new data into the bag
527        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        // Extract session to finalize outside the lock
538        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    /// Convert an `EvalResult` into a `CeremonyResponse` and (re-)store
547    /// the session if it isn't complete.
548    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        // Capture the final bag before the session is dropped.
576        let result_data = if complete && error.is_none() {
577            Some(session.bag.clone())
578        } else {
579            None
580        };
581
582        // Only store if not complete
583        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// ── Errors ──────────────────────────────────────────────────────────
603
604/// Ceremony framework errors.
605#[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    /// Map to an HTTP status code.
628    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
640// ── Builder helpers ─────────────────────────────────────────────────
641
642impl Prompt {
643    /// Create a `SelectOne` prompt.
644    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    /// Create a `Secret` prompt (masked input).
659    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    /// Create a `SecretConfirm` prompt (passphrase + confirmation).
670    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    /// Create a `Code` prompt (short verification code).
681    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    /// Create a `Text` prompt (free text).
692    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    /// Create an `Entropy` prompt.
703    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    /// Create a `Fido2` prompt (WebAuthn hardware key interaction).
714    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    /// Create a select option.
727    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    /// Create a select option with a description.
736    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    /// Create an `Info` message.
751    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    /// Create a `QrCode` message.
760    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    /// Create a `Summary` message.
769    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    /// Create an `Error` message.
778    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// ── Tests ───────────────────────────────────────────────────────────
788
789#[cfg(test)]
790mod tests {
791    use super::*;
792
793    // ── Test rules ──────────────────────────────────────────────────
794    //
795    // A simple "greeting" ceremony:
796    //   - Needs "name" key in the bag
797    //   - Validates name is non-empty
798    //   - Returns Complete with a summary message when name is present
799
800    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            // Check if name is in the bag
817            match bag.get("name").and_then(|v| v.as_str()) {
818                None => {
819                    // No name yet - ask for it
820                    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                    // Empty name - validation error
827                    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                    // Name present and valid - done
836                    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    // ── Tests ───────────────────────────────────────────────────────
850
851    #[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        // Start
877        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        // Submit name
888        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        // Session cleaned up
905        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        // Start
935        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        // Submit empty name
945        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        // Retry with valid name
963        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        // Wait for TTL
1040        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        // error should be absent (skip_serializing_if)
1113        assert!(parsed.get("error").is_none());
1114    }
1115
1116    // ── Multi-prompt / multi-message test ───────────────────────────
1117
1118    /// Rules that ask for two things at once and return a message + prompt together.
1119    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                // Ask for both at once
1141                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                // Show summary message + ask for confirmation
1170                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        // Start with empty bag - should get 2 prompts
1192        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        // Submit both answers
1207        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        // Summary message alongside prompt
1222        assert_eq!(r2.messages.len(), 1);
1223        assert_eq!(r2.messages[0].kind, MessageKind::Summary);
1224
1225        // Confirm
1226        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        // Start with color already known
1244        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        // Only size should be prompted
1258        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}