Skip to main content

aurum_core/
domain.rs

1//! Validated domain primitives (JOE-1786).
2//!
3//! These wrappers make invalid sample rates / durations unrepresentable at the
4//! type level for new construction paths. Existing public structs may still
5//! expose raw fields during the 0.0.x transition; prefer these constructors.
6
7use crate::audio::WHISPER_SAMPLE_RATE;
8use crate::error::{Result, UserError};
9
10/// Sample rate in Hz that has been checked for non-zero / finite-ish range.
11#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
12pub struct SampleRateHz(u32);
13
14impl SampleRateHz {
15    /// Accept any positive rate (remote paths may differ from whisper).
16    pub fn try_new(hz: u32) -> Result<Self> {
17        if hz == 0 {
18            return Err(UserError::Other {
19                message: "sample rate must be positive".into(),
20            }
21            .into());
22        }
23        Ok(Self(hz))
24    }
25
26    /// Whisper / local STT required rate.
27    pub fn whisper() -> Self {
28        Self(WHISPER_SAMPLE_RATE)
29    }
30
31    pub fn get(self) -> u32 {
32        self.0
33    }
34
35    pub fn is_whisper(self) -> bool {
36        self.0 == WHISPER_SAMPLE_RATE
37    }
38}
39
40/// Non-negative finite duration in seconds.
41#[derive(Debug, Clone, Copy, PartialEq)]
42pub struct FiniteDurationSecs(f64);
43
44impl FiniteDurationSecs {
45    pub fn try_new(secs: f64) -> Result<Self> {
46        if !secs.is_finite() || secs < 0.0 {
47            return Err(UserError::Other {
48                message: format!("duration must be finite and non-negative (got {secs})"),
49            }
50            .into());
51        }
52        Ok(Self(secs))
53    }
54
55    pub fn get(self) -> f64 {
56        self.0
57    }
58
59    pub fn zero() -> Self {
60        Self(0.0)
61    }
62}
63
64/// Provider / model identifier: non-empty, no control characters.
65#[derive(Debug, Clone, PartialEq, Eq, Hash)]
66pub struct ModelId(String);
67
68impl ModelId {
69    pub fn try_new(id: impl Into<String>) -> Result<Self> {
70        let s = id.into();
71        let t = s.trim();
72        if t.is_empty() {
73            return Err(UserError::Other {
74                message: "model id must be non-empty".into(),
75            }
76            .into());
77        }
78        if t.chars().any(|c| c.is_control()) {
79            return Err(UserError::Other {
80                message: "model id must not contain control characters".into(),
81            }
82            .into());
83        }
84        Ok(Self(t.to_string()))
85    }
86
87    pub fn as_str(&self) -> &str {
88        &self.0
89    }
90
91    pub fn into_inner(self) -> String {
92        self.0
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn sample_rate_rejects_zero() {
102        assert!(SampleRateHz::try_new(0).is_err());
103        assert_eq!(SampleRateHz::whisper().get(), WHISPER_SAMPLE_RATE);
104    }
105
106    #[test]
107    fn sample_rate_is_whisper_only_for_16000() {
108        // Kills mutants that flip is_whisper to always-true or invert equality.
109        assert!(SampleRateHz::whisper().is_whisper());
110        assert!(SampleRateHz::try_new(WHISPER_SAMPLE_RATE)
111            .unwrap()
112            .is_whisper());
113        assert!(!SampleRateHz::try_new(8_000).unwrap().is_whisper());
114        assert!(!SampleRateHz::try_new(44_100).unwrap().is_whisper());
115    }
116
117    #[test]
118    fn duration_rejects_nan() {
119        assert!(FiniteDurationSecs::try_new(f64::NAN).is_err());
120        assert!(FiniteDurationSecs::try_new(-1.0).is_err());
121        assert_eq!(FiniteDurationSecs::try_new(1.5).unwrap().get(), 1.5);
122    }
123
124    #[test]
125    fn model_id_rejects_empty_and_control() {
126        assert!(ModelId::try_new("").is_err());
127        assert!(ModelId::try_new("  ").is_err());
128        assert!(ModelId::try_new("a\nb").is_err());
129        assert_eq!(ModelId::try_new("tiny-q5_1").unwrap().as_str(), "tiny-q5_1");
130    }
131
132    #[test]
133    fn model_id_into_inner_preserves_value() {
134        // Kills mutants that replace into_inner with a constant string.
135        let id = ModelId::try_new("tiny-q5_1").unwrap();
136        assert_eq!(id.into_inner(), "tiny-q5_1");
137    }
138}