alpine/
profile.rs

1use sha2::{Digest, Sha256};
2
3/// Declares intent for streaming behavior.
4///
5/// The value is emitted into the config ID calculation so runtime decisions stay deterministic.
6#[repr(u8)]
7#[derive(Debug, Clone, Copy, PartialEq, Eq)]
8pub enum StreamIntent {
9    /// Safe default balancing latency and resilience.
10    Auto,
11    /// Low-latency intent; favors quick delivery over smoothing.
12    Realtime,
13    /// Install/resilience intent; favors smoothness over instant updates.
14    Install,
15}
16
17/// Error produced when stream profile parameters fail validation.
18#[derive(Debug, thiserror::Error)]
19pub enum ProfileError {
20    #[error("latency weight must be between 0 and 100 inclusive")]
21    LatencyWeightOutOfRange,
22    #[error("resilience weight must be between 0 and 100 inclusive")]
23    ResilienceWeightOutOfRange,
24    #[error("latency and resilience weights cannot both be zero")]
25    ZeroTotalWeight,
26}
27
28/// High-level description of stream behavior selected by callers.
29///
30/// The profile is immutable and compiles into a concrete runtime configuration.
31#[derive(Debug, Clone)]
32pub struct StreamProfile {
33    intent: StreamIntent,
34    latency_weight: u8,
35    resilience_weight: u8,
36}
37
38impl StreamProfile {
39    /// Returns the safe default profile (Auto).
40    pub fn auto() -> Self {
41        Self {
42            intent: StreamIntent::Auto,
43            latency_weight: 50,
44            resilience_weight: 50,
45        }
46    }
47
48    /// Low-latency profile that prioritizes speedy delivery over smoothing.
49    pub fn realtime() -> Self {
50        Self {
51            intent: StreamIntent::Realtime,
52            latency_weight: 80,
53            resilience_weight: 20,
54        }
55    }
56
57    /// Install profile that prioritizes smoothness and resilience.
58    pub fn install() -> Self {
59        Self {
60            intent: StreamIntent::Install,
61            latency_weight: 25,
62            resilience_weight: 75,
63        }
64    }
65
66    /// Creates a profile with explicit weights; useful for testing or advanced audience.
67    pub fn with_weights(intent: StreamIntent, latency_weight: u8, resilience_weight: u8) -> Self {
68        Self {
69            intent,
70            latency_weight,
71            resilience_weight,
72        }
73    }
74
75    /// Normalizes and compiles the profile into a runtime configuration.
76    ///
77    /// # Guarantees
78    /// * Validates each weight and rejects unsafe combinations with explicit errors.
79    /// * Produces a deterministic `config_id` derived from the normalized weights and intent.
80    pub fn compile(self) -> Result<CompiledStreamProfile, ProfileError> {
81        if self.latency_weight > 100 {
82            return Err(ProfileError::LatencyWeightOutOfRange);
83        }
84        if self.resilience_weight > 100 {
85            return Err(ProfileError::ResilienceWeightOutOfRange);
86        }
87        if self.latency_weight == 0 && self.resilience_weight == 0 {
88            return Err(ProfileError::ZeroTotalWeight);
89        }
90
91        let mut hasher = Sha256::new();
92        hasher.update(&[self.latency_weight, self.resilience_weight]);
93        hasher.update(&[self.intent as u8]);
94        let digest = hasher.finalize();
95        let config_id = digest.iter().map(|byte| format!("{:02x}", byte)).collect();
96
97        Ok(CompiledStreamProfile {
98            intent: self.intent,
99            latency_weight: self.latency_weight,
100            resilience_weight: self.resilience_weight,
101            config_id,
102        })
103    }
104
105    /// Returns the declared intent of the profile (Auto/Realtime/Install).
106    pub fn intent(&self) -> StreamIntent {
107        self.intent
108    }
109}
110
111/// Deterministic representation of a validated stream profile.
112///
113/// Users consume this via the SDK to bind runtime behavior and inspect `config_id`.
114#[derive(Debug, Clone)]
115pub struct CompiledStreamProfile {
116    intent: StreamIntent,
117    latency_weight: u8,
118    resilience_weight: u8,
119    config_id: String,
120}
121
122impl CompiledStreamProfile {
123    /// Returns the stable config ID representing this profile.
124    pub fn config_id(&self) -> &str {
125        &self.config_id
126    }
127
128    /// Latency weight applied by the runtime.
129    pub fn latency_weight(&self) -> u8 {
130        self.latency_weight
131    }
132
133    /// Resilience weight applied by the runtime.
134    pub fn resilience_weight(&self) -> u8 {
135        self.resilience_weight
136    }
137
138    /// Returns the declared intent of the compiled profile.
139    pub fn intent(&self) -> StreamIntent {
140        self.intent
141    }
142}
143
144impl Default for StreamProfile {
145    fn default() -> Self {
146        Self::auto()
147    }
148}
149
150#[cfg(test)]
151mod tests {
152    use super::*;
153
154    #[test]
155    fn compile_non_zero_weights() {
156        let profile = StreamProfile::with_weights(StreamIntent::Auto, 10, 40);
157        let compiled = profile.compile().expect("must compile");
158        assert_eq!(compiled.latency_weight(), 10);
159        assert_eq!(compiled.resilience_weight(), 40);
160    }
161
162    #[test]
163    fn compile_config_id_deterministic() {
164        let a = StreamProfile::auto().compile().unwrap();
165        let b = StreamProfile::auto().compile().unwrap();
166        assert_eq!(a.config_id(), b.config_id());
167    }
168
169    #[test]
170    fn default_profile_falls_back_to_auto() {
171        let default = StreamProfile::default().compile().unwrap();
172        let auto = StreamProfile::auto().compile().unwrap();
173        assert_eq!(default.config_id(), auto.config_id());
174    }
175
176    #[test]
177    fn builtin_profiles_have_distinct_ids() {
178        let realtime = StreamProfile::realtime().compile().unwrap();
179        let install = StreamProfile::install().compile().unwrap();
180        assert_ne!(realtime.config_id(), install.config_id());
181    }
182
183    #[test]
184    fn reject_zero_weights() {
185        let profile = StreamProfile::with_weights(StreamIntent::Auto, 0, 0);
186        assert!(matches!(
187            profile.compile(),
188            Err(ProfileError::ZeroTotalWeight)
189        ));
190    }
191
192    #[test]
193    fn reject_overflow_lat() {
194        let profile = StreamProfile::with_weights(StreamIntent::Auto, 200, 0);
195        assert!(matches!(
196            profile.compile(),
197            Err(ProfileError::LatencyWeightOutOfRange)
198        ));
199    }
200}