Skip to main content

behest_runtime/
input.rs

1//! Input admission and promotion pipeline.
2//!
3//! Provides event-sourced input lifecycle management: inputs are submitted,
4//! validated, deduplicated, and admitted before entering the run loop.
5
6use std::collections::HashSet;
7use std::collections::hash_map::DefaultHasher;
8use std::fmt;
9use std::hash::{Hash, Hasher};
10use std::sync::Mutex;
11
12use chrono::{DateTime, Utc};
13use serde::{Deserialize, Serialize};
14use uuid::Uuid;
15
16/// Unique identifier for an input submission.
17#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
18pub struct InputId(Uuid);
19
20impl InputId {
21    /// Creates a new input identifier.
22    #[must_use]
23    pub fn new() -> Self {
24        Self(Uuid::new_v4())
25    }
26
27    /// Creates an input identifier from an existing UUID.
28    #[must_use]
29    pub fn from_uuid(uuid: Uuid) -> Self {
30        Self(uuid)
31    }
32
33    /// Returns the underlying UUID.
34    #[must_use]
35    pub fn as_uuid(&self) -> &Uuid {
36        &self.0
37    }
38}
39
40impl Default for InputId {
41    fn default() -> Self {
42        Self::new()
43    }
44}
45
46impl fmt::Display for InputId {
47    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
48        write!(f, "{}", self.0)
49    }
50}
51
52/// Lifecycle state of an input submission.
53#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum InputState {
55    /// Input has been submitted but not yet validated.
56    Submitted,
57    /// Input passed validation and is admitted for processing.
58    Admitted,
59    /// Input is currently being processed by a run.
60    Processing,
61    /// Input processing completed successfully.
62    Completed,
63    /// Input was rejected during validation.
64    Rejected,
65}
66
67impl InputState {
68    /// Returns true if the input is in a terminal state.
69    #[must_use]
70    pub fn is_terminal(&self) -> bool {
71        matches!(self, Self::Completed | Self::Rejected)
72    }
73}
74
75/// Persistent record of an input submission.
76#[derive(Debug, Clone, Serialize, Deserialize)]
77pub struct InputRecord {
78    /// Unique input identifier.
79    pub id: InputId,
80    /// Session this input belongs to.
81    pub session_id: Uuid,
82    /// Current state of the input.
83    pub state: InputState,
84    /// Input content.
85    pub content: String,
86    /// Fingerprint for deduplication.
87    pub fingerprint: u64,
88    /// Rejection reason (if rejected).
89    pub rejection_reason: Option<String>,
90    /// When the input was submitted.
91    pub submitted_at: DateTime<Utc>,
92    /// When the input was last updated.
93    pub updated_at: DateTime<Utc>,
94}
95
96impl InputRecord {
97    /// Creates a new input record in `Submitted` state.
98    #[must_use]
99    pub fn new(session_id: Uuid, content: String) -> Self {
100        let fingerprint = Self::compute_fingerprint(&content);
101        let now = Utc::now();
102        Self {
103            id: InputId::new(),
104            session_id,
105            state: InputState::Submitted,
106            content,
107            fingerprint,
108            rejection_reason: None,
109            submitted_at: now,
110            updated_at: now,
111        }
112    }
113
114    /// Computes a fingerprint for deduplication.
115    fn compute_fingerprint(content: &str) -> u64 {
116        let mut hasher = DefaultHasher::new();
117        content.trim().hash(&mut hasher);
118        hasher.finish()
119    }
120
121    /// Updates the state and timestamp.
122    pub fn update_state(&mut self, state: InputState) {
123        self.state = state;
124        self.updated_at = Utc::now();
125    }
126
127    /// Rejects the input with a reason.
128    pub fn reject(&mut self, reason: String) {
129        self.state = InputState::Rejected;
130        self.rejection_reason = Some(reason);
131        self.updated_at = Utc::now();
132    }
133}
134
135/// Event in the input lifecycle.
136#[derive(Debug, Clone, Serialize, Deserialize)]
137pub enum InputEvent {
138    /// Input was submitted.
139    Submitted {
140        /// Unique input identifier.
141        input_id: InputId,
142        /// Session this input belongs to.
143        session_id: Uuid,
144        /// Raw input content.
145        content: String,
146        /// Content fingerprint for deduplication.
147        fingerprint: u64,
148        /// Submission timestamp.
149        timestamp: DateTime<Utc>,
150    },
151    /// Input was admitted for processing.
152    Admitted {
153        /// Unique input identifier.
154        input_id: InputId,
155        /// Admission timestamp.
156        timestamp: DateTime<Utc>,
157    },
158    /// Input was rejected.
159    Rejected {
160        /// Unique input identifier.
161        input_id: InputId,
162        /// Rejection reason.
163        reason: String,
164        /// Rejection timestamp.
165        timestamp: DateTime<Utc>,
166    },
167    /// Input processing started.
168    Processing {
169        /// Unique input identifier.
170        input_id: InputId,
171        /// Processing start timestamp.
172        timestamp: DateTime<Utc>,
173    },
174    /// Input processing completed.
175    Completed {
176        /// Unique input identifier.
177        input_id: InputId,
178        /// Processing completion timestamp.
179        timestamp: DateTime<Utc>,
180    },
181}
182
183impl InputEvent {
184    /// Returns the input ID associated with this event.
185    #[must_use]
186    pub fn input_id(&self) -> InputId {
187        match self {
188            Self::Submitted { input_id, .. }
189            | Self::Admitted { input_id, .. }
190            | Self::Rejected { input_id, .. }
191            | Self::Processing { input_id, .. }
192            | Self::Completed { input_id, .. } => *input_id,
193        }
194    }
195}
196
197/// Configuration for input admission.
198#[derive(Debug, Clone, Serialize, Deserialize)]
199#[non_exhaustive]
200pub struct InputAdmissionConfig {
201    /// Whether admission is enabled.
202    pub enabled: bool,
203    /// Whether to reject empty inputs.
204    pub reject_empty: bool,
205    /// Whether to deduplicate inputs within a session.
206    pub deduplicate: bool,
207    /// Maximum input length (0 = unlimited).
208    pub max_length: usize,
209}
210
211impl Default for InputAdmissionConfig {
212    fn default() -> Self {
213        Self {
214            enabled: true,
215            reject_empty: true,
216            deduplicate: true,
217            max_length: 0,
218        }
219    }
220}
221
222impl InputAdmissionConfig {
223    /// Sets whether admission is enabled.
224    #[must_use]
225    pub fn with_enabled(mut self, enabled: bool) -> Self {
226        self.enabled = enabled;
227        self
228    }
229
230    /// Sets whether to reject empty inputs.
231    #[must_use]
232    pub fn with_reject_empty(mut self, reject_empty: bool) -> Self {
233        self.reject_empty = reject_empty;
234        self
235    }
236
237    /// Sets whether to deduplicate inputs.
238    #[must_use]
239    pub fn with_deduplicate(mut self, deduplicate: bool) -> Self {
240        self.deduplicate = deduplicate;
241        self
242    }
243
244    /// Sets the maximum input length.
245    #[must_use]
246    pub fn with_max_length(mut self, max_length: usize) -> Self {
247        self.max_length = max_length;
248        self
249    }
250}
251
252/// Input admission pipeline implementing an event-sourced lifecycle.
253///
254/// Validates, deduplicates, and admits inputs before they enter the run
255/// loop. Emits lifecycle events (`Submitted`, `Admitted`, `Rejected`,
256/// `Processing`, `Completed`) that can be consumed by downstream observers.
257///
258/// # Lifecycle
259///
260/// ```text
261/// Submitted → Admitted → Processing → Completed
262///      ↓
263///   Rejected
264/// ```
265pub struct InputAdmission {
266    config: InputAdmissionConfig,
267    seen_fingerprints: Mutex<HashSet<u64>>,
268}
269
270impl InputAdmission {
271    /// Creates a new input admission pipeline.
272    #[must_use]
273    pub fn new(config: InputAdmissionConfig) -> Self {
274        Self {
275            config,
276            seen_fingerprints: Mutex::new(HashSet::new()),
277        }
278    }
279
280    /// Admits an input record, returning events generated.
281    ///
282    /// If the input is rejected, the record is mutated and a `Rejected` event is returned.
283    /// If admitted, an `Admitted` event is returned.
284    ///
285    /// # Errors
286    ///
287    /// Returns `InputAdmissionError` if the lock is poisoned.
288    pub fn admit(&self, record: &mut InputRecord) -> Result<Vec<InputEvent>, InputAdmissionError> {
289        if !self.config.enabled {
290            record.update_state(InputState::Admitted);
291            return Ok(vec![InputEvent::Admitted {
292                input_id: record.id,
293                timestamp: Utc::now(),
294            }]);
295        }
296
297        let mut events = vec![InputEvent::Submitted {
298            input_id: record.id,
299            session_id: record.session_id,
300            content: record.content.clone(),
301            fingerprint: record.fingerprint,
302            timestamp: record.submitted_at,
303        }];
304
305        // Validate: reject empty
306        if self.config.reject_empty && record.content.trim().is_empty() {
307            record.reject("empty input".to_string());
308            events.push(InputEvent::Rejected {
309                input_id: record.id,
310                reason: "empty input".to_string(),
311                timestamp: Utc::now(),
312            });
313            return Ok(events);
314        }
315
316        // Validate: max length
317        if self.config.max_length > 0 && record.content.len() > self.config.max_length {
318            let reason = format!("input exceeds maximum length of {}", self.config.max_length);
319            record.reject(reason.clone());
320            events.push(InputEvent::Rejected {
321                input_id: record.id,
322                reason,
323                timestamp: Utc::now(),
324            });
325            return Ok(events);
326        }
327
328        // Deduplicate
329        if self.config.deduplicate {
330            let mut seen = self
331                .seen_fingerprints
332                .lock()
333                .map_err(|_| InputAdmissionError::LockPoisoned)?;
334            if !seen.insert(record.fingerprint) {
335                record.reject("duplicate input".to_string());
336                events.push(InputEvent::Rejected {
337                    input_id: record.id,
338                    reason: "duplicate input".to_string(),
339                    timestamp: Utc::now(),
340                });
341                return Ok(events);
342            }
343        }
344
345        // Admit
346        record.update_state(InputState::Admitted);
347        events.push(InputEvent::Admitted {
348            input_id: record.id,
349            timestamp: Utc::now(),
350        });
351
352        Ok(events)
353    }
354
355    /// Marks an input as processing.
356    #[must_use]
357    pub fn mark_processing(&self, record: &mut InputRecord) -> InputEvent {
358        record.update_state(InputState::Processing);
359        InputEvent::Processing {
360            input_id: record.id,
361            timestamp: Utc::now(),
362        }
363    }
364
365    /// Marks an input as completed.
366    #[must_use]
367    pub fn mark_completed(&self, record: &mut InputRecord) -> InputEvent {
368        record.update_state(InputState::Completed);
369        InputEvent::Completed {
370            input_id: record.id,
371            timestamp: Utc::now(),
372        }
373    }
374
375    /// Clears the deduplication cache.
376    ///
377    /// # Errors
378    ///
379    /// Returns [`InputAdmissionError::LockPoisoned`] if the internal
380    /// fingerprint mutex has been poisoned by a panicking thread.
381    pub fn clear_cache(&self) -> Result<(), InputAdmissionError> {
382        let mut seen = self
383            .seen_fingerprints
384            .lock()
385            .map_err(|_| InputAdmissionError::LockPoisoned)?;
386        seen.clear();
387        Ok(())
388    }
389}
390
391/// Error from input admission.
392#[derive(Debug, Clone)]
393pub enum InputAdmissionError {
394    /// Lock was poisoned.
395    LockPoisoned,
396}
397
398impl fmt::Display for InputAdmissionError {
399    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
400        match self {
401            Self::LockPoisoned => write!(f, "input admission lock poisoned"),
402        }
403    }
404}
405
406impl std::error::Error for InputAdmissionError {}
407
408#[cfg(test)]
409#[allow(clippy::unwrap_used, clippy::expect_used)]
410mod tests {
411    use super::*;
412
413    #[test]
414    fn input_id_generation() {
415        let id1 = InputId::new();
416        let id2 = InputId::new();
417        assert_ne!(id1, id2);
418    }
419
420    #[test]
421    fn input_state_terminal() {
422        assert!(!InputState::Submitted.is_terminal());
423        assert!(!InputState::Admitted.is_terminal());
424        assert!(!InputState::Processing.is_terminal());
425        assert!(InputState::Completed.is_terminal());
426        assert!(InputState::Rejected.is_terminal());
427    }
428
429    #[test]
430    fn input_record_creation() {
431        let session_id = Uuid::new_v4();
432        let record = InputRecord::new(session_id, "hello world".to_string());
433        assert_eq!(record.state, InputState::Submitted);
434        assert_eq!(record.content, "hello world");
435        assert!(record.rejection_reason.is_none());
436    }
437
438    #[test]
439    fn admission_admits_valid_input() {
440        let config = InputAdmissionConfig::default();
441        let admission = InputAdmission::new(config);
442        let session_id = Uuid::new_v4();
443        let mut record = InputRecord::new(session_id, "hello".to_string());
444
445        let events = admission.admit(&mut record).unwrap();
446        assert_eq!(record.state, InputState::Admitted);
447        assert_eq!(events.len(), 2); // Submitted + Admitted
448    }
449
450    #[test]
451    fn admission_rejects_empty_input() {
452        let config = InputAdmissionConfig::default();
453        let admission = InputAdmission::new(config);
454        let session_id = Uuid::new_v4();
455        let mut record = InputRecord::new(session_id, "   ".to_string());
456
457        let events = admission.admit(&mut record).unwrap();
458        assert_eq!(record.state, InputState::Rejected);
459        assert_eq!(record.rejection_reason.as_deref(), Some("empty input"));
460        assert_eq!(events.len(), 2); // Submitted + Rejected
461    }
462
463    #[test]
464    fn admission_rejects_duplicate_input() {
465        let config = InputAdmissionConfig::default();
466        let admission = InputAdmission::new(config);
467        let session_id = Uuid::new_v4();
468
469        let mut record1 = InputRecord::new(session_id, "hello".to_string());
470        let events1 = admission.admit(&mut record1).unwrap();
471        assert_eq!(record1.state, InputState::Admitted);
472        assert_eq!(events1.len(), 2);
473
474        let mut record2 = InputRecord::new(session_id, "hello".to_string());
475        let events2 = admission.admit(&mut record2).unwrap();
476        assert_eq!(record2.state, InputState::Rejected);
477        assert_eq!(record2.rejection_reason.as_deref(), Some("duplicate input"));
478        assert_eq!(events2.len(), 2); // Submitted + Rejected
479    }
480
481    #[test]
482    fn admission_rejects_over_length_input() {
483        let config = InputAdmissionConfig::default().with_max_length(5);
484        let admission = InputAdmission::new(config);
485        let session_id = Uuid::new_v4();
486        let mut record = InputRecord::new(session_id, "hello world".to_string());
487
488        let events = admission.admit(&mut record).unwrap();
489        assert_eq!(record.state, InputState::Rejected);
490        assert!(
491            record
492                .rejection_reason
493                .as_ref()
494                .unwrap()
495                .contains("maximum length")
496        );
497        assert_eq!(events.len(), 2); // Submitted + Rejected
498    }
499
500    #[test]
501    fn admission_disabled_admits_all() {
502        let config = InputAdmissionConfig::default().with_enabled(false);
503        let admission = InputAdmission::new(config);
504        let session_id = Uuid::new_v4();
505        let mut record = InputRecord::new(session_id, String::new());
506
507        let events = admission.admit(&mut record).unwrap();
508        assert_eq!(record.state, InputState::Admitted);
509        assert_eq!(events.len(), 1); // Only Admitted (no Submitted event when disabled)
510    }
511
512    #[test]
513    fn admission_dedup_disabled_allows_duplicates() {
514        let config = InputAdmissionConfig::default().with_deduplicate(false);
515        let admission = InputAdmission::new(config);
516        let session_id = Uuid::new_v4();
517
518        let mut record1 = InputRecord::new(session_id, "hello".to_string());
519        admission.admit(&mut record1).unwrap();
520
521        let mut record2 = InputRecord::new(session_id, "hello".to_string());
522        let events = admission.admit(&mut record2).unwrap();
523        assert_eq!(record2.state, InputState::Admitted);
524        assert_eq!(events.len(), 2); // Submitted + Admitted
525    }
526
527    #[test]
528    fn mark_processing_and_completed() {
529        let config = InputAdmissionConfig::default();
530        let admission = InputAdmission::new(config);
531        let session_id = Uuid::new_v4();
532        let mut record = InputRecord::new(session_id, "hello".to_string());
533        admission.admit(&mut record).unwrap();
534
535        let event = admission.mark_processing(&mut record);
536        assert_eq!(record.state, InputState::Processing);
537        assert!(matches!(event, InputEvent::Processing { .. }));
538
539        let event = admission.mark_completed(&mut record);
540        assert_eq!(record.state, InputState::Completed);
541        assert!(matches!(event, InputEvent::Completed { .. }));
542    }
543
544    #[test]
545    fn clear_cache_allows_duplicate() {
546        let config = InputAdmissionConfig::default();
547        let admission = InputAdmission::new(config);
548        let session_id = Uuid::new_v4();
549
550        let mut record1 = InputRecord::new(session_id, "hello".to_string());
551        admission.admit(&mut record1).unwrap();
552
553        admission.clear_cache().unwrap();
554
555        let mut record2 = InputRecord::new(session_id, "hello".to_string());
556        let events = admission.admit(&mut record2).unwrap();
557        assert_eq!(record2.state, InputState::Admitted);
558        assert_eq!(events.len(), 2);
559    }
560}