use std::collections::HashSet;
use std::collections::hash_map::DefaultHasher;
use std::fmt;
use std::hash::{Hash, Hasher};
use std::sync::Mutex;
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
pub struct InputId(Uuid);
impl InputId {
#[must_use]
pub fn new() -> Self {
Self(Uuid::new_v4())
}
#[must_use]
pub fn from_uuid(uuid: Uuid) -> Self {
Self(uuid)
}
#[must_use]
pub fn as_uuid(&self) -> &Uuid {
&self.0
}
}
impl Default for InputId {
fn default() -> Self {
Self::new()
}
}
impl fmt::Display for InputId {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}", self.0)
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum InputState {
Submitted,
Admitted,
Processing,
Completed,
Rejected,
}
impl InputState {
#[must_use]
pub fn is_terminal(&self) -> bool {
matches!(self, Self::Completed | Self::Rejected)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InputRecord {
pub id: InputId,
pub session_id: Uuid,
pub state: InputState,
pub content: String,
pub fingerprint: u64,
pub rejection_reason: Option<String>,
pub submitted_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
impl InputRecord {
#[must_use]
pub fn new(session_id: Uuid, content: String) -> Self {
let fingerprint = Self::compute_fingerprint(&content);
let now = Utc::now();
Self {
id: InputId::new(),
session_id,
state: InputState::Submitted,
content,
fingerprint,
rejection_reason: None,
submitted_at: now,
updated_at: now,
}
}
fn compute_fingerprint(content: &str) -> u64 {
let mut hasher = DefaultHasher::new();
content.trim().hash(&mut hasher);
hasher.finish()
}
pub fn update_state(&mut self, state: InputState) {
self.state = state;
self.updated_at = Utc::now();
}
pub fn reject(&mut self, reason: String) {
self.state = InputState::Rejected;
self.rejection_reason = Some(reason);
self.updated_at = Utc::now();
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum InputEvent {
Submitted {
input_id: InputId,
session_id: Uuid,
content: String,
fingerprint: u64,
timestamp: DateTime<Utc>,
},
Admitted {
input_id: InputId,
timestamp: DateTime<Utc>,
},
Rejected {
input_id: InputId,
reason: String,
timestamp: DateTime<Utc>,
},
Processing {
input_id: InputId,
timestamp: DateTime<Utc>,
},
Completed {
input_id: InputId,
timestamp: DateTime<Utc>,
},
}
impl InputEvent {
#[must_use]
pub fn input_id(&self) -> InputId {
match self {
Self::Submitted { input_id, .. }
| Self::Admitted { input_id, .. }
| Self::Rejected { input_id, .. }
| Self::Processing { input_id, .. }
| Self::Completed { input_id, .. } => *input_id,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[non_exhaustive]
pub struct InputAdmissionConfig {
pub enabled: bool,
pub reject_empty: bool,
pub deduplicate: bool,
pub max_length: usize,
}
impl Default for InputAdmissionConfig {
fn default() -> Self {
Self {
enabled: true,
reject_empty: true,
deduplicate: true,
max_length: 0,
}
}
}
impl InputAdmissionConfig {
#[must_use]
pub fn with_enabled(mut self, enabled: bool) -> Self {
self.enabled = enabled;
self
}
#[must_use]
pub fn with_reject_empty(mut self, reject_empty: bool) -> Self {
self.reject_empty = reject_empty;
self
}
#[must_use]
pub fn with_deduplicate(mut self, deduplicate: bool) -> Self {
self.deduplicate = deduplicate;
self
}
#[must_use]
pub fn with_max_length(mut self, max_length: usize) -> Self {
self.max_length = max_length;
self
}
}
pub struct InputAdmission {
config: InputAdmissionConfig,
seen_fingerprints: Mutex<HashSet<u64>>,
}
impl InputAdmission {
#[must_use]
pub fn new(config: InputAdmissionConfig) -> Self {
Self {
config,
seen_fingerprints: Mutex::new(HashSet::new()),
}
}
pub fn admit(&self, record: &mut InputRecord) -> Result<Vec<InputEvent>, InputAdmissionError> {
if !self.config.enabled {
record.update_state(InputState::Admitted);
return Ok(vec![InputEvent::Admitted {
input_id: record.id,
timestamp: Utc::now(),
}]);
}
let mut events = vec![InputEvent::Submitted {
input_id: record.id,
session_id: record.session_id,
content: record.content.clone(),
fingerprint: record.fingerprint,
timestamp: record.submitted_at,
}];
if self.config.reject_empty && record.content.trim().is_empty() {
record.reject("empty input".to_string());
events.push(InputEvent::Rejected {
input_id: record.id,
reason: "empty input".to_string(),
timestamp: Utc::now(),
});
return Ok(events);
}
if self.config.max_length > 0 && record.content.len() > self.config.max_length {
let reason = format!("input exceeds maximum length of {}", self.config.max_length);
record.reject(reason.clone());
events.push(InputEvent::Rejected {
input_id: record.id,
reason,
timestamp: Utc::now(),
});
return Ok(events);
}
if self.config.deduplicate {
let mut seen = self
.seen_fingerprints
.lock()
.map_err(|_| InputAdmissionError::LockPoisoned)?;
if !seen.insert(record.fingerprint) {
record.reject("duplicate input".to_string());
events.push(InputEvent::Rejected {
input_id: record.id,
reason: "duplicate input".to_string(),
timestamp: Utc::now(),
});
return Ok(events);
}
}
record.update_state(InputState::Admitted);
events.push(InputEvent::Admitted {
input_id: record.id,
timestamp: Utc::now(),
});
Ok(events)
}
#[must_use]
pub fn mark_processing(&self, record: &mut InputRecord) -> InputEvent {
record.update_state(InputState::Processing);
InputEvent::Processing {
input_id: record.id,
timestamp: Utc::now(),
}
}
#[must_use]
pub fn mark_completed(&self, record: &mut InputRecord) -> InputEvent {
record.update_state(InputState::Completed);
InputEvent::Completed {
input_id: record.id,
timestamp: Utc::now(),
}
}
pub fn clear_cache(&self) -> Result<(), InputAdmissionError> {
let mut seen = self
.seen_fingerprints
.lock()
.map_err(|_| InputAdmissionError::LockPoisoned)?;
seen.clear();
Ok(())
}
}
#[derive(Debug, Clone)]
pub enum InputAdmissionError {
LockPoisoned,
}
impl fmt::Display for InputAdmissionError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::LockPoisoned => write!(f, "input admission lock poisoned"),
}
}
}
impl std::error::Error for InputAdmissionError {}
#[cfg(test)]
#[allow(clippy::unwrap_used, clippy::expect_used)]
mod tests {
use super::*;
#[test]
fn input_id_generation() {
let id1 = InputId::new();
let id2 = InputId::new();
assert_ne!(id1, id2);
}
#[test]
fn input_state_terminal() {
assert!(!InputState::Submitted.is_terminal());
assert!(!InputState::Admitted.is_terminal());
assert!(!InputState::Processing.is_terminal());
assert!(InputState::Completed.is_terminal());
assert!(InputState::Rejected.is_terminal());
}
#[test]
fn input_record_creation() {
let session_id = Uuid::new_v4();
let record = InputRecord::new(session_id, "hello world".to_string());
assert_eq!(record.state, InputState::Submitted);
assert_eq!(record.content, "hello world");
assert!(record.rejection_reason.is_none());
}
#[test]
fn admission_admits_valid_input() {
let config = InputAdmissionConfig::default();
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record = InputRecord::new(session_id, "hello".to_string());
let events = admission.admit(&mut record).unwrap();
assert_eq!(record.state, InputState::Admitted);
assert_eq!(events.len(), 2); }
#[test]
fn admission_rejects_empty_input() {
let config = InputAdmissionConfig::default();
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record = InputRecord::new(session_id, " ".to_string());
let events = admission.admit(&mut record).unwrap();
assert_eq!(record.state, InputState::Rejected);
assert_eq!(record.rejection_reason.as_deref(), Some("empty input"));
assert_eq!(events.len(), 2); }
#[test]
fn admission_rejects_duplicate_input() {
let config = InputAdmissionConfig::default();
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record1 = InputRecord::new(session_id, "hello".to_string());
let events1 = admission.admit(&mut record1).unwrap();
assert_eq!(record1.state, InputState::Admitted);
assert_eq!(events1.len(), 2);
let mut record2 = InputRecord::new(session_id, "hello".to_string());
let events2 = admission.admit(&mut record2).unwrap();
assert_eq!(record2.state, InputState::Rejected);
assert_eq!(record2.rejection_reason.as_deref(), Some("duplicate input"));
assert_eq!(events2.len(), 2); }
#[test]
fn admission_rejects_over_length_input() {
let config = InputAdmissionConfig::default().with_max_length(5);
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record = InputRecord::new(session_id, "hello world".to_string());
let events = admission.admit(&mut record).unwrap();
assert_eq!(record.state, InputState::Rejected);
assert!(
record
.rejection_reason
.as_ref()
.unwrap()
.contains("maximum length")
);
assert_eq!(events.len(), 2); }
#[test]
fn admission_disabled_admits_all() {
let config = InputAdmissionConfig::default().with_enabled(false);
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record = InputRecord::new(session_id, String::new());
let events = admission.admit(&mut record).unwrap();
assert_eq!(record.state, InputState::Admitted);
assert_eq!(events.len(), 1); }
#[test]
fn admission_dedup_disabled_allows_duplicates() {
let config = InputAdmissionConfig::default().with_deduplicate(false);
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record1 = InputRecord::new(session_id, "hello".to_string());
admission.admit(&mut record1).unwrap();
let mut record2 = InputRecord::new(session_id, "hello".to_string());
let events = admission.admit(&mut record2).unwrap();
assert_eq!(record2.state, InputState::Admitted);
assert_eq!(events.len(), 2); }
#[test]
fn mark_processing_and_completed() {
let config = InputAdmissionConfig::default();
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record = InputRecord::new(session_id, "hello".to_string());
admission.admit(&mut record).unwrap();
let event = admission.mark_processing(&mut record);
assert_eq!(record.state, InputState::Processing);
assert!(matches!(event, InputEvent::Processing { .. }));
let event = admission.mark_completed(&mut record);
assert_eq!(record.state, InputState::Completed);
assert!(matches!(event, InputEvent::Completed { .. }));
}
#[test]
fn clear_cache_allows_duplicate() {
let config = InputAdmissionConfig::default();
let admission = InputAdmission::new(config);
let session_id = Uuid::new_v4();
let mut record1 = InputRecord::new(session_id, "hello".to_string());
admission.admit(&mut record1).unwrap();
admission.clear_cache().unwrap();
let mut record2 = InputRecord::new(session_id, "hello".to_string());
let events = admission.admit(&mut record2).unwrap();
assert_eq!(record2.state, InputState::Admitted);
assert_eq!(events.len(), 2);
}
}