Skip to main content

matrix_rain/
config.rs

1use ratatui::style::Color;
2
3use crate::charset::CharSet;
4use crate::error::MatrixError;
5use crate::theme::Theme;
6
7#[derive(Clone, Debug)]
8pub struct MatrixConfig {
9    pub charset: CharSet,
10    pub theme: Theme,
11    pub fps: u16,
12    pub speed: f32,
13    pub density: f32,
14    pub min_trail: u16,
15    pub max_trail: u16,
16    pub mutation_rate: f32,
17    pub bold_head: bool,
18    pub head_white: bool,
19    pub glitch: f32,
20    pub background: Option<Color>,
21}
22
23impl MatrixConfig {
24    pub fn builder() -> MatrixConfigBuilder {
25        MatrixConfigBuilder::new()
26    }
27}
28
29impl Default for MatrixConfig {
30    fn default() -> Self {
31        Self {
32            charset: CharSet::Matrix,
33            theme: Theme::ClassicGreen,
34            fps: 30,
35            speed: 1.0,
36            density: 0.6,
37            min_trail: 6,
38            max_trail: 20,
39            mutation_rate: 0.05,
40            bold_head: true,
41            head_white: true,
42            glitch: 0.0,
43            background: None,
44        }
45    }
46}
47
48#[derive(Clone, Debug)]
49pub struct MatrixConfigBuilder {
50    config: MatrixConfig,
51}
52
53impl MatrixConfigBuilder {
54    pub fn new() -> Self {
55        Self {
56            config: MatrixConfig::default(),
57        }
58    }
59
60    pub fn charset(mut self, charset: CharSet) -> Self {
61        self.config.charset = charset;
62        self
63    }
64
65    pub fn theme(mut self, theme: Theme) -> Self {
66        self.config.theme = theme;
67        self
68    }
69
70    pub fn fps(mut self, fps: u16) -> Self {
71        self.config.fps = fps;
72        self
73    }
74
75    pub fn speed(mut self, speed: f32) -> Self {
76        self.config.speed = speed;
77        self
78    }
79
80    pub fn density(mut self, density: f32) -> Self {
81        self.config.density = density;
82        self
83    }
84
85    pub fn min_trail(mut self, min_trail: u16) -> Self {
86        self.config.min_trail = min_trail;
87        self
88    }
89
90    pub fn max_trail(mut self, max_trail: u16) -> Self {
91        self.config.max_trail = max_trail;
92        self
93    }
94
95    pub fn mutation_rate(mut self, mutation_rate: f32) -> Self {
96        self.config.mutation_rate = mutation_rate;
97        self
98    }
99
100    pub fn bold_head(mut self, bold_head: bool) -> Self {
101        self.config.bold_head = bold_head;
102        self
103    }
104
105    pub fn head_white(mut self, head_white: bool) -> Self {
106        self.config.head_white = head_white;
107        self
108    }
109
110    pub fn glitch(mut self, glitch: f32) -> Self {
111        self.config.glitch = glitch;
112        self
113    }
114
115    pub fn background(mut self, background: Option<Color>) -> Self {
116        self.config.background = background;
117        self
118    }
119
120    pub fn build(self) -> Result<MatrixConfig, MatrixError> {
121        let c = &self.config;
122
123        if c.fps < 1 {
124            return Err(invalid("fps must be >= 1"));
125        }
126        if !c.speed.is_finite() || c.speed <= 0.0 {
127            return Err(invalid(&format!(
128                "speed must be a positive finite number (got {})",
129                c.speed
130            )));
131        }
132        if !c.density.is_finite() || !(0.0..=1.0).contains(&c.density) {
133            return Err(invalid(&format!(
134                "density must be a finite number in [0.0, 1.0] (got {})",
135                c.density
136            )));
137        }
138        if c.min_trail < 1 {
139            return Err(invalid("min_trail must be >= 1"));
140        }
141        if c.min_trail > c.max_trail {
142            return Err(invalid(&format!(
143                "min_trail ({}) must be <= max_trail ({})",
144                c.min_trail, c.max_trail
145            )));
146        }
147        if c.max_trail > MAX_TRAIL_LIMIT {
148            return Err(invalid(&format!(
149                "max_trail ({}) exceeds limit of {}",
150                c.max_trail, MAX_TRAIL_LIMIT
151            )));
152        }
153        if !c.mutation_rate.is_finite() || !(0.0..=1.0).contains(&c.mutation_rate) {
154            return Err(invalid(&format!(
155                "mutation_rate must be a finite number in [0.0, 1.0] (got {})",
156                c.mutation_rate
157            )));
158        }
159        if !c.glitch.is_finite() || !(0.0..=1.0).contains(&c.glitch) {
160            return Err(invalid(&format!(
161                "glitch must be a finite number in [0.0, 1.0] (got {})",
162                c.glitch
163            )));
164        }
165        c.charset.validate()?;
166
167        Ok(self.config)
168    }
169}
170
171pub const MAX_TRAIL_LIMIT: u16 = 1024;
172
173fn invalid(msg: &str) -> MatrixError {
174    MatrixError::InvalidConfig(msg.to_string())
175}
176
177impl Default for MatrixConfigBuilder {
178    fn default() -> Self {
179        Self::new()
180    }
181}
182
183#[cfg(test)]
184mod tests {
185    use super::*;
186
187    #[test]
188    fn defaults_match_spec() {
189        let cfg = MatrixConfig::default();
190        assert_eq!(cfg.fps, 30);
191        assert_eq!(cfg.speed, 1.0);
192        assert_eq!(cfg.density, 0.6);
193        assert!(cfg.bold_head);
194        assert!(cfg.head_white);
195        assert!(matches!(cfg.charset, CharSet::Matrix));
196        assert!(matches!(cfg.theme, Theme::ClassicGreen));
197        assert_eq!(cfg.glitch, 0.0);
198        assert_eq!(cfg.background, None);
199        assert!(cfg.min_trail >= 1);
200        assert!(cfg.max_trail >= cfg.min_trail);
201    }
202
203    #[test]
204    fn builder_chains_overrides() {
205        let cfg = MatrixConfig::builder()
206            .fps(60)
207            .density(0.3)
208            .bold_head(false)
209            .build()
210            .expect("build should succeed");
211        assert_eq!(cfg.fps, 60);
212        assert_eq!(cfg.density, 0.3);
213        assert!(!cfg.bold_head);
214        assert!(cfg.head_white, "untouched fields keep defaults");
215    }
216
217    #[test]
218    fn builder_default_round_trip() {
219        let cfg = MatrixConfig::builder().build().unwrap();
220        let default = MatrixConfig::default();
221        assert_eq!(cfg.fps, default.fps);
222        assert_eq!(cfg.speed, default.speed);
223        assert_eq!(cfg.density, default.density);
224        assert_eq!(cfg.min_trail, default.min_trail);
225        assert_eq!(cfg.max_trail, default.max_trail);
226    }
227
228    #[test]
229    fn build_rejects_empty_custom_charset() {
230        let err = MatrixConfig::builder()
231            .charset(CharSet::Custom(vec![]))
232            .build()
233            .unwrap_err();
234        assert!(matches!(err, MatrixError::EmptyCharset));
235    }
236
237    #[test]
238    fn build_rejects_control_chars_in_custom() {
239        let err = MatrixConfig::builder()
240            .charset(CharSet::Custom(vec!['a', '\n']))
241            .build()
242            .unwrap_err();
243        assert!(matches!(err, MatrixError::InvalidConfig(_)));
244    }
245
246    fn invalid_err(r: Result<MatrixConfig, MatrixError>, expected_keyword: &str) {
247        match r {
248            Err(MatrixError::InvalidConfig(msg)) => assert!(
249                msg.contains(expected_keyword),
250                "expected '{expected_keyword}' in error, got: {msg}"
251            ),
252            other => panic!("expected InvalidConfig containing '{expected_keyword}', got {other:?}"),
253        }
254    }
255
256    #[test]
257    fn build_rejects_fps_zero() {
258        invalid_err(MatrixConfig::builder().fps(0).build(), "fps");
259    }
260
261    #[test]
262    fn build_rejects_speed_zero() {
263        invalid_err(MatrixConfig::builder().speed(0.0).build(), "speed");
264    }
265
266    #[test]
267    fn build_rejects_speed_negative() {
268        invalid_err(MatrixConfig::builder().speed(-0.5).build(), "speed");
269    }
270
271    #[test]
272    fn build_rejects_speed_nan() {
273        invalid_err(MatrixConfig::builder().speed(f32::NAN).build(), "speed");
274    }
275
276    #[test]
277    fn build_rejects_speed_infinite() {
278        invalid_err(MatrixConfig::builder().speed(f32::INFINITY).build(), "speed");
279    }
280
281    #[test]
282    fn build_rejects_density_above_one() {
283        invalid_err(MatrixConfig::builder().density(1.1).build(), "density");
284    }
285
286    #[test]
287    fn build_rejects_density_negative() {
288        invalid_err(MatrixConfig::builder().density(-0.1).build(), "density");
289    }
290
291    #[test]
292    fn build_rejects_density_nan() {
293        invalid_err(MatrixConfig::builder().density(f32::NAN).build(), "density");
294    }
295
296    #[test]
297    fn build_rejects_min_trail_zero() {
298        invalid_err(MatrixConfig::builder().min_trail(0).build(), "min_trail");
299    }
300
301    #[test]
302    fn build_rejects_min_greater_than_max_trail() {
303        invalid_err(
304            MatrixConfig::builder().min_trail(10).max_trail(5).build(),
305            "min_trail",
306        );
307    }
308
309    #[test]
310    fn build_rejects_max_trail_above_limit() {
311        invalid_err(
312            MatrixConfig::builder()
313                .min_trail(1)
314                .max_trail(MAX_TRAIL_LIMIT + 1)
315                .build(),
316            "max_trail",
317        );
318    }
319
320    #[test]
321    fn build_rejects_mutation_rate_above_one() {
322        invalid_err(
323            MatrixConfig::builder().mutation_rate(1.1).build(),
324            "mutation_rate",
325        );
326    }
327
328    #[test]
329    fn build_rejects_mutation_rate_negative() {
330        invalid_err(
331            MatrixConfig::builder().mutation_rate(-0.1).build(),
332            "mutation_rate",
333        );
334    }
335
336    #[test]
337    fn build_rejects_mutation_rate_nan() {
338        invalid_err(
339            MatrixConfig::builder().mutation_rate(f32::NAN).build(),
340            "mutation_rate",
341        );
342    }
343
344    #[test]
345    fn build_rejects_glitch_above_one() {
346        invalid_err(MatrixConfig::builder().glitch(1.1).build(), "glitch");
347    }
348
349    #[test]
350    fn build_rejects_glitch_negative() {
351        invalid_err(MatrixConfig::builder().glitch(-0.1).build(), "glitch");
352    }
353
354    #[test]
355    fn build_rejects_glitch_nan() {
356        invalid_err(MatrixConfig::builder().glitch(f32::NAN).build(), "glitch");
357    }
358
359    #[test]
360    fn build_accepts_density_boundaries() {
361        assert!(MatrixConfig::builder().density(0.0).build().is_ok());
362        assert!(MatrixConfig::builder().density(1.0).build().is_ok());
363    }
364
365    #[test]
366    fn build_accepts_min_equals_max_trail() {
367        assert!(MatrixConfig::builder()
368            .min_trail(5)
369            .max_trail(5)
370            .build()
371            .is_ok());
372    }
373
374    #[test]
375    fn build_accepts_mutation_rate_boundaries() {
376        assert!(MatrixConfig::builder().mutation_rate(0.0).build().is_ok());
377        assert!(MatrixConfig::builder().mutation_rate(1.0).build().is_ok());
378    }
379
380    #[test]
381    fn build_accepts_glitch_boundaries() {
382        assert!(MatrixConfig::builder().glitch(0.0).build().is_ok());
383        assert!(MatrixConfig::builder().glitch(1.0).build().is_ok());
384    }
385
386    #[test]
387    fn build_accepts_max_trail_at_limit() {
388        assert!(MatrixConfig::builder()
389            .min_trail(1)
390            .max_trail(MAX_TRAIL_LIMIT)
391            .build()
392            .is_ok());
393    }
394}