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}
217
218// ── Messages ────────────────────────────────────────────────────────
219
220/// An informational display item - not an input.
221///
222/// Messages carry content to show the user without requiring input.
223/// They can appear alongside prompts (e.g., QR code + code input).
224#[derive(Debug, Clone, Serialize, Deserialize)]
225pub struct Message {
226    /// What kind of content this is.
227    pub kind: MessageKind,
228
229    /// Short title or heading.
230    pub title: String,
231
232    /// The content body (plain text, base64 image, JSON summary, etc.).
233    pub content: String,
234}
235
236/// Message content type.
237#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
238#[serde(rename_all = "snake_case")]
239pub enum MessageKind {
240    /// Plain text instruction or guidance.
241    Info,
242    /// QR code image (format per `RenderHints::qr`).
243    QrCode,
244    /// Key-value summary of completed ceremony data.
245    Summary,
246    /// Error detail with context (non-fatal).
247    Error,
248}
249
250// ── Session ─────────────────────────────────────────────────────────
251
252/// A live ceremony session - just a bag of key-value pairs plus metadata.
253///
254/// There is no stage index, no stage name, no progress counter.
255/// The [`CeremonyRules`] derive everything from the bag contents.
256pub struct Session {
257    /// Unique session identifier (UUIDv7).
258    pub id: Uuid,
259
260    /// Ceremony type identifier string (e.g. "init", "join").
261    pub ceremony_type: String,
262
263    /// The accumulated key-value data. Rules read and write this.
264    pub bag: serde_json::Map<String, serde_json::Value>,
265
266    /// Client render hints (from the most recent request).
267    pub render: RenderHints,
268
269    /// Monotonic timestamp of creation.
270    pub created_at: Instant,
271
272    /// Monotonic timestamp of last activity.
273    pub last_active: Instant,
274
275    /// Whether this ceremony has completed.
276    pub complete: bool,
277}
278
279impl Session {
280    /// Store a value in the bag.
281    pub fn set(&mut self, key: impl Into<String>, value: serde_json::Value) {
282        self.bag.insert(key.into(), value);
283    }
284
285    /// Get a value from the bag.
286    pub fn get(&self, key: &str) -> Option<&serde_json::Value> {
287        self.bag.get(key)
288    }
289
290    /// Get a string value from the bag.
291    pub fn get_str(&self, key: &str) -> Option<&str> {
292        self.bag.get(key).and_then(|v| v.as_str())
293    }
294
295    /// Check whether a key exists in the bag.
296    pub fn has(&self, key: &str) -> bool {
297        self.bag.contains_key(key)
298    }
299
300    /// Remove a key from the bag (e.g. to force re-collection on conflict).
301    pub fn remove(&mut self, key: &str) -> Option<serde_json::Value> {
302        self.bag.remove(key)
303    }
304}
305
306// ── Eval result ─────────────────────────────────────────────────────
307
308/// Result of evaluating the ceremony rules against the current bag.
309///
310/// Returned by [`CeremonyRules::evaluate`] to tell the host what to
311/// present to the client next.
312pub enum EvalResult {
313    /// The ceremony needs more data. Return prompts and optional messages.
314    NeedInput {
315        /// Data the client should collect.
316        prompts: Vec<Prompt>,
317        /// Informational content to display alongside prompts.
318        messages: Vec<Message>,
319    },
320
321    /// Re-prompt with a validation error. The client shows the error
322    /// and re-renders the prompts for the user to correct.
323    ValidationError {
324        /// Prompts to re-display (typically the offending fields).
325        prompts: Vec<Prompt>,
326        /// Informational messages.
327        messages: Vec<Message>,
328        /// Human-readable error description.
329        error: String,
330    },
331
332    /// The bag is complete and consistent. The ceremony is done.
333    Complete {
334        /// Final messages (summary, results, etc.).
335        messages: Vec<Message>,
336    },
337
338    /// Something is terminally wrong (I/O failure, impossible state).
339    Fatal(String),
340}
341
342// ── Ceremony rules trait ────────────────────────────────────────────
343
344/// Domain-specific ceremony rules.
345///
346/// Each domain (certmesh, storage, companions, etc.) implements this
347/// trait to define its ceremony types and evaluation logic.
348///
349/// The rules function is essentially:
350/// ```text
351/// evaluate(ceremony_type, bag, render_hints) → EvalResult
352/// ```
353///
354/// Rules inspect the bag and decide what data is still needed, whether
355/// existing data conflicts, or whether the ceremony is complete.
356///
357/// # Thread safety
358///
359/// The host calls `evaluate` while holding a session lock. Keep
360/// implementations fast - do heavy I/O before returning, or collect
361/// parameters here and execute in a post-step hook.
362pub trait CeremonyRules: Send + Sync {
363    /// Validate a ceremony type string.
364    ///
365    /// Return `Ok(())` if the string is a known ceremony type,
366    /// or `Err("message")` if it isn't.
367    fn validate_ceremony_type(&self, ceremony: &str) -> Result<(), String>;
368
369    /// Evaluate the bag and determine what happens next.
370    ///
371    /// The rules may read and write the bag (e.g. to inject derived keys
372    /// like `_totp_secret`, or to remove conflicting keys). The bag
373    /// already contains any data the client sent in this request -
374    /// it was merged before `evaluate` is called.
375    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
383// ── Ceremony host ───────────────────────────────────────────────────
384
385/// Generic ceremony host - manages sessions and delegates evaluation
386/// to a [`CeremonyRules`] implementation.
387///
388/// Thread-safe. One host per domain, shared across HTTP handlers.
389pub 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    /// Create a new ceremony host with the given domain rules.
397    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    /// Create a ceremony host with a custom session TTL.
406    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    /// Access the domain rules (e.g. for diagnostics or testing).
415    pub fn rules(&self) -> &R {
416        &self.rules
417    }
418
419    /// Process a ceremony step.
420    ///
421    /// - If `session_id` is `None`, creates a new session, merges
422    ///   `data` into the bag, evaluates the rules, and returns prompts.
423    /// - If `session_id` is `Some`, merges `data` into the existing
424    ///   session bag, re-evaluates the rules, and returns prompts.
425    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    /// Remove expired sessions. Call periodically from a background task.
433    /// Returns the number of sessions removed.
434    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    /// Number of active sessions (for diagnostics).
454    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    // ── Internal ────────────────────────────────────────────────────
465
466    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        // Check expiry
508        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        // Update activity + render hints
519        session.last_active = now;
520        if let Some(render) = &request.render {
521            session.render = render.clone();
522        }
523
524        // Merge new data into the bag
525        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        // Extract session to finalize outside the lock
536        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    /// Convert an `EvalResult` into a `CeremonyResponse` and (re-)store
545    /// the session if it isn't complete.
546    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        // Capture the final bag before the session is dropped.
574        let result_data = if complete && error.is_none() {
575            Some(session.bag.clone())
576        } else {
577            None
578        };
579
580        // Only store if not complete
581        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// ── Errors ──────────────────────────────────────────────────────────
601
602/// Ceremony framework errors.
603#[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    /// Map to an HTTP status code.
626    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
638// ── Builder helpers ─────────────────────────────────────────────────
639
640impl Prompt {
641    /// Create a `SelectOne` prompt.
642    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    /// Create a `Secret` prompt (masked input).
657    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    /// Create a `SecretConfirm` prompt (passphrase + confirmation).
668    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    /// Create a `Code` prompt (short verification code).
679    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    /// Create a `Text` prompt (free text).
690    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    /// Create an `Entropy` prompt.
701    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    /// Create a select option.
714    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    /// Create a select option with a description.
723    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    /// Create an `Info` message.
738    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    /// Create a `QrCode` message.
747    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    /// Create a `Summary` message.
756    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    /// Create an `Error` message.
765    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// ── Tests ───────────────────────────────────────────────────────────
775
776#[cfg(test)]
777mod tests {
778    use super::*;
779
780    // ── Test rules ──────────────────────────────────────────────────
781    //
782    // A simple "greeting" ceremony:
783    //   - Needs "name" key in the bag
784    //   - Validates name is non-empty
785    //   - Returns Complete with a summary message when name is present
786
787    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            // Check if name is in the bag
804            match bag.get("name").and_then(|v| v.as_str()) {
805                None => {
806                    // No name yet - ask for it
807                    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                    // Empty name - validation error
814                    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                    // Name present and valid - done
823                    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    // ── Tests ───────────────────────────────────────────────────────
837
838    #[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        // Start
864        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        // Submit name
875        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        // Session cleaned up
892        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        // Start
922        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        // Submit empty name
932        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        // Retry with valid name
950        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        // Wait for TTL
1027        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        // error should be absent (skip_serializing_if)
1100        assert!(parsed.get("error").is_none());
1101    }
1102
1103    // ── Multi-prompt / multi-message test ───────────────────────────
1104
1105    /// Rules that ask for two things at once and return a message + prompt together.
1106    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                // Ask for both at once
1128                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                // Show summary message + ask for confirmation
1157                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        // Start with empty bag - should get 2 prompts
1179        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        // Submit both answers
1194        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        // Summary message alongside prompt
1209        assert_eq!(r2.messages.len(), 1);
1210        assert_eq!(r2.messages[0].kind, MessageKind::Summary);
1211
1212        // Confirm
1213        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        // Start with color already known
1231        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        // Only size should be prompted
1245        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}