Skip to main content

entracte_lib/scheduler/
settings.rs

1use serde::{Deserialize, Serialize};
2
3use crate::hooks::Hook;
4
5use super::types::{BreakDelivery, BreakKind};
6
7/// Which monitor(s) an overlay break should appear on.
8///
9/// `Primary` follows the OS-designated primary display, `Active` picks
10/// whichever monitor the cursor is on at break time, `All` mirrors the
11/// overlay across every connected monitor.
12#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
13#[serde(rename_all = "lowercase")]
14pub enum MonitorPlacement {
15    #[default]
16    Primary,
17    Active,
18    All,
19}
20
21/// What the overlay does with audio for a given break kind.
22///
23/// `Off` plays nothing, `EndChime` plays the configured chime once when
24/// the break ends, `Ambient` loops a track for the duration of the break.
25#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum BreakSoundMode {
28    #[default]
29    Off,
30    EndChime,
31    Ambient,
32}
33
34/// Per-break-kind audio configuration: mode + which bundled sound to play.
35/// `sound_id` is the numeric id from `src/assets/sounds/credits.json`, or
36/// the literal `"custom"` to use `custom_path` (a Supporter-pack feature).
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
38pub struct BreakSound {
39    #[serde(default)]
40    pub mode: BreakSoundMode,
41    #[serde(default)]
42    pub sound_id: String,
43    #[serde(default)]
44    pub custom_path: String,
45}
46
47impl BreakSound {
48    /// Build an `EndChime`-mode `BreakSound` pointing at the given sound id.
49    /// Used by `Settings::default` to seed the bundled chime.
50    pub fn end_chime(id: &str) -> Self {
51        Self {
52            mode: BreakSoundMode::EndChime,
53            sound_id: id.to_string(),
54            custom_path: String::new(),
55        }
56    }
57}
58
59fn default_break_mode() -> String {
60    "overlay".to_string()
61}
62
63fn default_schedule_mode() -> String {
64    "interval".to_string()
65}
66
67fn default_tray_countdown_target() -> String {
68    "next".to_string()
69}
70
71fn default_clock_format() -> String {
72    "24h".to_string()
73}
74
75fn default_micro_hint_mix() -> String {
76    "both".to_string()
77}
78
79fn default_long_hint_mix() -> String {
80    "both".to_string()
81}
82
83fn default_micro_physical_hints() -> Vec<String> {
84    vec![
85        "Look at something 20 feet away.",
86        "Blink slowly ten times.",
87        "Roll your shoulders backward, then forward.",
88        "Stretch your neck side to side.",
89        "Sip some water.",
90        "Wiggle your fingers and toes.",
91        "Look up, down, left, right.",
92        "Press your palms together and stretch your wrists.",
93        "Reach for the ceiling — both arms, slow stretch.",
94        "Stand up and shake out your hands.",
95        "Twist gently side to side in your chair.",
96        "Open and close your hands ten times.",
97        "Look out the nearest window.",
98        "Roll your ankles in slow circles.",
99        "Squeeze your shoulder blades together for five seconds.",
100        "Tilt your head ear-to-shoulder, both sides.",
101        "Stand up. Reach down. Tap your toes.",
102        "Trace the alphabet in the air with your nose.",
103    ]
104    .into_iter()
105    .map(String::from)
106    .collect()
107}
108
109fn default_micro_psychological_hints() -> Vec<String> {
110    vec![
111        "Unclench your jaw.",
112        "Take five slow, deep breaths.",
113        "Soften your gaze. Relax your face.",
114        "Sit back. Drop your shoulders.",
115        "Notice three things you can hear right now.",
116        "Name one thing going well today.",
117        "Let your tongue rest behind your front teeth.",
118        "Notice the weight of your body in the chair.",
119        "Smile, even a small one.",
120        "Take one breath in. Let it out twice as slowly.",
121        "Pause. Notice what your body needs.",
122        "Thank yourself for the work you've done so far.",
123    ]
124    .into_iter()
125    .map(String::from)
126    .collect()
127}
128
129fn default_long_hints() -> Vec<String> {
130    vec![
131        "Stand up. Look out a window. Stretch.",
132        "Take a short walk — even one minute counts.",
133        "Step away from the screen. Make a cup of tea.",
134        "Do a few full-body stretches.",
135        "Walk to a different room and back.",
136        "Stretch your back, shoulders, and legs.",
137        "Get a bit of fresh air if you can.",
138        "Refill your water bottle.",
139        "Do a quick body scan — where are you holding tension?",
140        "Try a minute of slow, deep breathing.",
141        "Roll out your wrists, ankles, and neck.",
142        "Stand tall and stretch your arms overhead.",
143        "Step outside for a few minutes of daylight.",
144        "Make a snack from real food — fruit, nuts, cheese.",
145        "Lie flat on the floor for a minute. Let gravity reset you.",
146        "Put on one song you love and just listen.",
147        "Tidy a small area near you.",
148        "Take the long way to wherever you're going.",
149        "Wash your face with cool water.",
150        "Step away. Look at the sky for a minute.",
151    ]
152    .into_iter()
153    .map(String::from)
154    .collect()
155}
156
157fn default_long_social_hints() -> Vec<String> {
158    vec![
159        "Call someone you love.",
160        "Text a friend just to say hi.",
161        "Step outside with a colleague.",
162        "Walk over to a coworker's desk for a chat.",
163        "Make a coffee with someone.",
164        "Sit outside with company if you can.",
165        "Ask a teammate how their day is going.",
166        "Drop a thank-you note to someone.",
167        "Eat your snack with someone, not at your desk.",
168        "Swap a quick story with whoever's around.",
169        "Reach out to someone you haven't spoken to in a while.",
170        "Take a short walk with a friend or partner.",
171        "Voice-message a friend instead of texting.",
172        "Pay someone a genuine compliment.",
173        "Invite someone to take the next break with you.",
174    ]
175    .into_iter()
176    .map(String::from)
177    .collect()
178}
179
180fn default_sleep_hints() -> Vec<String> {
181    vec![
182        "Time to wind down.",
183        "Step away from the screen for the night.",
184        "Sleep well — your work will be there tomorrow.",
185        "Dim the lights. Close the laptop. Rest.",
186        "Reading or stretching beats more screen time.",
187        "Be kind to your future self. Get some rest.",
188        "Tomorrow's focus starts with tonight's sleep.",
189        "Put work down. You've earned the rest.",
190        "Brew a small herbal tea instead of starting one more task.",
191        "Lay tomorrow's notebook out and close this one.",
192        "Pick the first thing for tomorrow — then stop.",
193        "Stretch slowly for five minutes, then bed.",
194    ]
195    .into_iter()
196    .map(String::from)
197    .collect()
198}
199
200/// Single source of truth for one profile's behaviour.
201///
202/// Deserialised from `settings.json` (one of these per profile in the
203/// `ProfilesFile` array) and sent to the renderer wholesale through
204/// `get_settings`. Field names line up 1:1 with the TypeScript
205/// `SchedulerSettings` type — keep the two in sync; a serde roundtrip
206/// parity test is on the backlog to enforce this in CI.
207///
208/// `#[serde(default)]` on the struct means each field falls back to
209/// `Default::default()` if missing — older `settings.json` files keep
210/// loading as new fields are added. Pre-split fields keep their old
211/// JSON keys via `#[serde(alias = "...")]`.
212#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(default)]
214pub struct Settings {
215    pub micro_interval_secs: u64,
216    pub micro_duration_secs: u64,
217    pub long_interval_secs: u64,
218    pub long_duration_secs: u64,
219    // alias keeps pre-split settings.json (single `idle_reset_secs`) loading cleanly into the micro field.
220    #[serde(alias = "idle_reset_secs")]
221    pub micro_idle_reset_secs: u64,
222    pub long_idle_reset_secs: u64,
223    pub micro_enabled: bool,
224    pub long_enabled: bool,
225    pub micro_enforceable: bool,
226    pub long_enforceable: bool,
227    pub pause_during_dnd: bool,
228    pub pause_during_camera: bool,
229    #[serde(default)]
230    pub pause_during_video: bool,
231    pub work_window_enabled: bool,
232    pub work_start_minutes: u32,
233    pub work_end_minutes: u32,
234    pub bedtime_enabled: bool,
235    pub bedtime_start_minutes: u32,
236    pub bedtime_end_minutes: u32,
237    pub bedtime_interval_secs: u64,
238    pub bedtime_duration_secs: u64,
239    pub prebreak_notification_enabled: bool,
240    pub prebreak_notification_seconds: u64,
241    pub overlay_opacity: f32,
242    pub overlay_color: String,
243    pub overlay_custom_rgb: String,
244    pub overlay_high_contrast: bool,
245    pub show_hint: bool,
246    pub monitor_placement: MonitorPlacement,
247    pub strict_mode: bool,
248    pub postpone_enabled: bool,
249    pub postpone_minutes: u32,
250    pub show_current_time: bool,
251    #[serde(default = "default_clock_format")]
252    pub clock_format: String,
253    pub micro_manual_finish: bool,
254    pub long_manual_finish: bool,
255    pub autostart_enabled: bool,
256    #[serde(default)]
257    pub micro_sound: BreakSound,
258    #[serde(default)]
259    pub long_sound: BreakSound,
260    pub sound_volume: f32,
261    pub app_pause_enabled: bool,
262    pub app_pause_list: Vec<String>,
263    pub break_health_enabled: bool,
264    // alias keeps pre-split settings.json (single `micro_hints`) loading cleanly into the physical pool.
265    #[serde(alias = "micro_hints")]
266    pub micro_physical_hints: Vec<String>,
267    pub micro_psychological_hints: Vec<String>,
268    pub micro_hint_mix: String,
269    pub long_hints: Vec<String>,
270    pub long_social_hints: Vec<String>,
271    pub long_hint_mix: String,
272    pub sleep_hints: Vec<String>,
273    pub hint_rotate_seconds: u64,
274    pub delay_break_if_typing: bool,
275    pub typing_grace_secs: u64,
276    pub typing_max_deferral_secs: u64,
277    pub pause_countdown_if_typing: bool,
278    pub postpone_escalation_enabled: bool,
279    pub postpone_escalation_step_secs: u64,
280    pub postpone_max_count: u32,
281    pub overlay_font_scale: f32,
282    pub micro_fixed_times: Vec<String>,
283    pub long_fixed_times: Vec<String>,
284    pub micro_schedule_mode: String,
285    pub long_schedule_mode: String,
286    pub hooks_enabled: bool,
287    pub hooks: Vec<Hook>,
288    pub daily_screen_time_enabled: bool,
289    pub daily_screen_time_budget_minutes: u64,
290    pub daily_screen_time_remind_again_minutes: u64,
291    pub tray_countdown_enabled: bool,
292    pub tray_countdown_target: String,
293    #[serde(default = "default_break_mode")]
294    pub micro_break_mode: String,
295    #[serde(default = "default_break_mode")]
296    pub long_break_mode: String,
297    /// Supporter-only freeform stylesheet, applied to both the settings
298    /// window and the break overlay via the renderer's
299    /// `useCustomStylesheet` hook (which uses `adoptedStyleSheets` so we
300    /// don't need to weaken the strict `style-src 'self'` CSP). The
301    /// supporter gate lives in `commands::settings::gate_custom_css`,
302    /// and `sanitize_custom_css` strips `@import` / `expression(` on
303    /// every read+write.
304    #[serde(default)]
305    pub custom_css: String,
306}
307
308impl Default for Settings {
309    fn default() -> Self {
310        Self {
311            micro_interval_secs: 20 * 60,
312            micro_duration_secs: 20,
313            long_interval_secs: 50 * 60,
314            long_duration_secs: 10 * 60,
315            micro_idle_reset_secs: 5 * 60,
316            long_idle_reset_secs: 5 * 60,
317            micro_enabled: true,
318            long_enabled: true,
319            micro_enforceable: false,
320            long_enforceable: true,
321            pause_during_dnd: true,
322            pause_during_camera: true,
323            pause_during_video: false,
324            work_window_enabled: false,
325            work_start_minutes: 9 * 60,
326            work_end_minutes: 17 * 60,
327            bedtime_enabled: false,
328            bedtime_start_minutes: 22 * 60,
329            bedtime_end_minutes: 23 * 60,
330            bedtime_interval_secs: 5 * 60,
331            bedtime_duration_secs: 30,
332            prebreak_notification_enabled: true,
333            prebreak_notification_seconds: 30,
334            overlay_opacity: 0.92,
335            overlay_color: "dark".to_string(),
336            overlay_custom_rgb: "20, 24, 32".to_string(),
337            overlay_high_contrast: false,
338            show_hint: true,
339            monitor_placement: MonitorPlacement::Primary,
340            strict_mode: false,
341            postpone_enabled: true,
342            postpone_minutes: 5,
343            show_current_time: true,
344            clock_format: default_clock_format(),
345            micro_manual_finish: false,
346            long_manual_finish: false,
347            autostart_enabled: false,
348            micro_sound: BreakSound::end_chime("337048"),
349            long_sound: BreakSound::end_chime("337048"),
350            sound_volume: 0.5,
351            app_pause_enabled: false,
352            app_pause_list: Vec::new(),
353            break_health_enabled: true,
354            micro_physical_hints: default_micro_physical_hints(),
355            micro_psychological_hints: default_micro_psychological_hints(),
356            micro_hint_mix: default_micro_hint_mix(),
357            long_hints: default_long_hints(),
358            long_social_hints: default_long_social_hints(),
359            long_hint_mix: default_long_hint_mix(),
360            sleep_hints: default_sleep_hints(),
361            hint_rotate_seconds: 0,
362            delay_break_if_typing: true,
363            typing_grace_secs: 10,
364            typing_max_deferral_secs: 60,
365            pause_countdown_if_typing: true,
366            postpone_escalation_enabled: true,
367            postpone_escalation_step_secs: 120,
368            postpone_max_count: 3,
369            overlay_font_scale: 1.0,
370            micro_fixed_times: Vec::new(),
371            long_fixed_times: Vec::new(),
372            micro_schedule_mode: default_schedule_mode(),
373            long_schedule_mode: default_schedule_mode(),
374            hooks_enabled: false,
375            hooks: Vec::new(),
376            daily_screen_time_enabled: false,
377            daily_screen_time_budget_minutes: 8 * 60,
378            daily_screen_time_remind_again_minutes: 60,
379            tray_countdown_enabled: true,
380            tray_countdown_target: default_tray_countdown_target(),
381            micro_break_mode: default_break_mode(),
382            long_break_mode: default_break_mode(),
383            custom_css: String::new(),
384        }
385    }
386}
387
388impl Settings {
389    /// Clamp every numeric field to a safe range. Called on every load
390    /// (post-deserialise) and on every write (post-merge) so that a
391    /// hand-edited or corrupted `settings.json` can't make the
392    /// scheduler misbehave — e.g. `micro_interval_secs: 0` would fire
393    /// a break every tick of the 1Hz loop. Values inside the range are
394    /// left untouched.
395    ///
396    /// The bounds are deliberately generous (the UI's `min` / `max`
397    /// attributes are tighter); we only catch the values that produce
398    /// pathological behaviour.
399    pub fn clamp(&mut self) {
400        // Interval / duration: bottom-stop high enough to prevent the
401        // 1Hz tick from re-firing instantly, top-stop at 24h (intervals)
402        // or 1h (durations) to keep `Duration::from_secs` arithmetic
403        // well away from u64::MAX.
404        self.micro_interval_secs = self.micro_interval_secs.clamp(30, 86_400);
405        self.long_interval_secs = self.long_interval_secs.clamp(30, 86_400);
406        self.micro_duration_secs = self.micro_duration_secs.clamp(1, 3_600);
407        self.long_duration_secs = self.long_duration_secs.clamp(1, 3_600);
408        self.bedtime_interval_secs = self.bedtime_interval_secs.clamp(60, 3_600);
409        self.bedtime_duration_secs = self.bedtime_duration_secs.clamp(1, 3_600);
410        // Idle-reset thresholds: at least 5s (anything less re-fires
411        // on micro keyboard pauses), at most 1h.
412        self.micro_idle_reset_secs = self.micro_idle_reset_secs.clamp(5, 3_600);
413        self.long_idle_reset_secs = self.long_idle_reset_secs.clamp(5, 3_600);
414        // Pre-break warn: 0 means "disabled", so the floor is 0 but we
415        // cap at 5min (any longer makes the warning useless).
416        self.prebreak_notification_seconds = self.prebreak_notification_seconds.min(300);
417        // Typing-defer: 0 grace = disabled (already special-cased in
418        // `should_defer_for_typing`); cap deferral at 1h.
419        self.typing_grace_secs = self.typing_grace_secs.min(300);
420        self.typing_max_deferral_secs = self.typing_max_deferral_secs.min(3_600);
421        // Postpone window / escalation / count: 1..120min, 0..1h step, 0..20 cap.
422        self.postpone_minutes = self.postpone_minutes.clamp(1, 120);
423        self.postpone_escalation_step_secs = self.postpone_escalation_step_secs.min(3_600);
424        self.postpone_max_count = self.postpone_max_count.min(20);
425        // Screen-time budgets: 0..24h budget, 1..12h re-remind interval.
426        self.daily_screen_time_budget_minutes = self.daily_screen_time_budget_minutes.min(1_440);
427        self.daily_screen_time_remind_again_minutes =
428            self.daily_screen_time_remind_again_minutes.clamp(1, 720);
429        // Hint rotation: 0 = disabled, otherwise capped at 10min. Must not
430        // clamp 0 up to 1 — the renderer treats 0 as "off" and `clamp(1, 600)`
431        // silently re-enables rotation for users who turned it off.
432        self.hint_rotate_seconds = self.hint_rotate_seconds.min(600);
433        // Time-of-day windows are minutes-since-midnight (0..1439).
434        self.work_start_minutes = self.work_start_minutes.min(1_439);
435        self.work_end_minutes = self.work_end_minutes.min(1_439);
436        self.bedtime_start_minutes = self.bedtime_start_minutes.min(1_439);
437        self.bedtime_end_minutes = self.bedtime_end_minutes.min(1_439);
438        // Visual: opacity / volume in [0, 1]; font scale in [0.5, 3.0].
439        // Opacity floor 0.8 caps UI transparency at 20%.
440        self.overlay_opacity = self.overlay_opacity.clamp(0.8, 1.0);
441        self.sound_volume = self.sound_volume.clamp(0.0, 1.0);
442        self.overlay_font_scale = self.overlay_font_scale.clamp(0.5, 3.0);
443        // Reject unknown clock_format values so the renderer's zod
444        // enum doesn't reject the entire settings payload.
445        if self.clock_format != "12h" && self.clock_format != "24h" {
446            self.clock_format = default_clock_format();
447        }
448        // Cap custom CSS at 64KiB so a corrupted or hand-edited
449        // settings.json can't bloat the renderer payload, then run the
450        // sanitiser so loaded-from-disk values get the same scrub as
451        // newly-saved ones. Walk back to a char boundary before
452        // truncating — `String::truncate` panics mid-codepoint.
453        if self.custom_css.len() > 65_536 {
454            let mut cut = 65_536;
455            while !self.custom_css.is_char_boundary(cut) {
456                cut -= 1;
457            }
458            self.custom_css.truncate(cut);
459        }
460        self.custom_css = sanitize_custom_css(&self.custom_css);
461    }
462}
463
464/// Defence-in-depth scrub for user-supplied CSS. Even with a strict CSP
465/// in place we belt-and-braces:
466///
467/// - drop `@import` rules entirely (they could pull in further styles
468///   that we don't want to audit, and CSP-bypass via stylesheet chains
469///   has historically been a footgun);
470/// - strip the legacy IE `expression(...)` construct, which old WebKit
471///   forks have re-introduced for compatibility.
472///
473/// Comments are normalised first so the patterns can't be hidden behind
474/// `/* */` splits. Operates on `&str` throughout — `bytes`-indexing
475/// would mojibake non-ASCII content like `content: "→"`.
476pub fn sanitize_custom_css(css: &str) -> String {
477    let stripped = strip_css_comments(css);
478    let mut out = String::with_capacity(stripped.len());
479    for raw in stripped.split_inclusive(';') {
480        let lower = raw.trim_start().to_ascii_lowercase();
481        if lower.starts_with("@import") || lower.contains("expression(") {
482            continue;
483        }
484        out.push_str(raw);
485    }
486    out
487}
488
489fn strip_css_comments(css: &str) -> String {
490    let mut out = String::with_capacity(css.len());
491    let mut rest = css;
492    while let Some(start) = rest.find("/*") {
493        out.push_str(&rest[..start]);
494        rest = &rest[start + 2..];
495        match rest.find("*/") {
496            Some(end) => rest = &rest[end + 2..],
497            None => return out, // unterminated comment swallows the tail
498        }
499    }
500    out.push_str(rest);
501    out
502}
503
504/// Resolve the micro-break hint pool, honouring `micro_hint_mix`.
505/// `"physical"` and `"psychological"` return only that pool; anything
506/// else (including `"both"`) concatenates both in physical-then-
507/// psychological order.
508pub fn effective_micro_hints(s: &Settings) -> Vec<String> {
509    match s.micro_hint_mix.as_str() {
510        "physical" => s.micro_physical_hints.clone(),
511        "psychological" => s.micro_psychological_hints.clone(),
512        _ => {
513            let mut combined = Vec::with_capacity(
514                s.micro_physical_hints.len() + s.micro_psychological_hints.len(),
515            );
516            combined.extend(s.micro_physical_hints.iter().cloned());
517            combined.extend(s.micro_psychological_hints.iter().cloned());
518            combined
519        }
520    }
521}
522
523/// Resolve the long-break hint pool, honouring `long_hint_mix`.
524/// `"solo"` / `"social"` filter to that pool; anything else (including
525/// `"both"`) concatenates them in solo-then-social order.
526pub fn effective_long_hints(s: &Settings) -> Vec<String> {
527    match s.long_hint_mix.as_str() {
528        "solo" => s.long_hints.clone(),
529        "social" => s.long_social_hints.clone(),
530        _ => {
531            let mut combined = Vec::with_capacity(s.long_hints.len() + s.long_social_hints.len());
532            combined.extend(s.long_hints.iter().cloned());
533            combined.extend(s.long_social_hints.iter().cloned());
534            combined
535        }
536    }
537}
538
539/// Resolve the delivery mode for the given break kind.
540///
541/// Sleep breaks always use `Overlay` (bedtime reminders ignore the
542/// per-kind mode). Unknown mode strings fall back to `Overlay`.
543pub fn delivery_for(kind: BreakKind, s: &Settings) -> BreakDelivery {
544    let mode = match kind {
545        BreakKind::Micro => s.micro_break_mode.as_str(),
546        BreakKind::Long => s.long_break_mode.as_str(),
547        BreakKind::Sleep => "overlay",
548    };
549    match mode {
550        "notification" => BreakDelivery::Notification,
551        "windowed" => BreakDelivery::Windowed,
552        _ => BreakDelivery::Overlay,
553    }
554}
555
556/// True iff the given break kind is currently configured for the
557/// `Windowed` delivery mode. Convenience wrapper around `delivery_for`.
558pub fn is_windowed_mode(kind: BreakKind, s: &Settings) -> bool {
559    matches!(delivery_for(kind, s), BreakDelivery::Windowed)
560}
561
562#[cfg(test)]
563mod sanitize_tests {
564    use super::sanitize_custom_css;
565
566    #[test]
567    fn passes_safe_css_through() {
568        let input = ".overlay-card { background: #111; color: white; }";
569        assert_eq!(sanitize_custom_css(input), input);
570    }
571
572    #[test]
573    fn drops_at_import_rules() {
574        let input = "@import url('https://evil.example/x.css'); .ok { color: red; }";
575        let out = sanitize_custom_css(input);
576        assert!(!out.contains("@import"), "got: {out}");
577        assert!(out.contains(".ok"));
578    }
579
580    #[test]
581    fn drops_at_import_even_when_obfuscated_with_comments() {
582        let input = "@/* hi */import url('https://evil/x.css'); .ok { color: red; }";
583        let out = sanitize_custom_css(input);
584        assert!(!out.to_ascii_lowercase().contains("@import"));
585        assert!(out.contains(".ok"));
586    }
587
588    #[test]
589    fn drops_expression_construct() {
590        let input = ".x { width: expression(alert(1)); } .ok { color: red; }";
591        let out = sanitize_custom_css(input);
592        assert!(!out.contains("expression("), "got: {out}");
593        assert!(out.contains(".ok"));
594    }
595
596    #[test]
597    fn empty_in_empty_out() {
598        assert_eq!(sanitize_custom_css(""), "");
599    }
600
601    #[test]
602    fn preserves_non_ascii_content() {
603        let input = ".x::before { content: \"→ café\"; } /* éhé */ .y { color: red; }";
604        let out = sanitize_custom_css(input);
605        assert!(out.contains("→ café"), "non-ASCII content corrupted: {out}");
606        assert!(out.contains(".y"));
607        assert!(!out.contains("éhé"), "comment should be stripped");
608    }
609
610    #[test]
611    fn unterminated_comment_swallows_tail() {
612        // Defensive: a hand-edited CSS with a runaway `/*` shouldn't
613        // panic or leak commented-out source into the output.
614        let out = sanitize_custom_css(".ok {} /* unterminated");
615        assert_eq!(out, ".ok {} ");
616    }
617}
618
619#[cfg(test)]
620mod clamp_custom_css_tests {
621    use super::*;
622
623    #[test]
624    fn truncates_at_64kib_without_panicking_on_multibyte_boundary() {
625        // Regression: `String::truncate(65_536)` panics if byte 65,536
626        // lands inside a multi-byte codepoint. Fill exactly to the cap
627        // with ASCII then append an emoji that straddles it.
628        let mut css = "a".repeat(65_535);
629        css.push('🎉'); // 4 bytes — pushes total to 65,539
630        let mut s = Settings {
631            custom_css: css,
632            ..Settings::default()
633        };
634        s.clamp();
635        assert!(s.custom_css.len() <= 65_536);
636        // Must remain valid UTF-8 — the test would already panic if
637        // truncate split the codepoint, but assert explicitly.
638        assert!(std::str::from_utf8(s.custom_css.as_bytes()).is_ok());
639    }
640}
641
642#[cfg(test)]
643mod tests {
644    use super::*;
645
646    #[test]
647    fn settings_default_sensible() {
648        let s = Settings::default();
649        assert!(s.micro_interval_secs > 0);
650        assert!(s.long_interval_secs > s.micro_interval_secs);
651        assert!(s.long_enforceable);
652        assert!(!s.micro_enforceable);
653        assert!(s.work_end_minutes > s.work_start_minutes);
654        assert!(s.overlay_opacity > 0.0 && s.overlay_opacity <= 1.0);
655        assert!(s.postpone_minutes > 0);
656        assert!(s.sound_volume >= 0.0 && s.sound_volume <= 1.0);
657        assert!(!s.micro_physical_hints.is_empty());
658        assert!(!s.micro_psychological_hints.is_empty());
659        assert_eq!(s.micro_hint_mix, "both");
660        assert!(!s.long_hints.is_empty());
661        assert!(!s.sleep_hints.is_empty());
662        assert_eq!(s.micro_idle_reset_secs, 300);
663        assert_eq!(s.long_idle_reset_secs, 300);
664        assert!((s.overlay_font_scale - 1.0).abs() < f32::EPSILON);
665    }
666
667    // `Settings::clamp` — the safety net for corrupted / hand-edited
668    // settings.json. Every numeric field that can produce pathological
669    // behaviour (1Hz break re-fires, division by zero, overflow) gets
670    // clamped into a safe range on load and on write.
671
672    #[test]
673    fn clamp_fixes_zero_intervals() {
674        // Pre-fix: `Duration::from_secs(0)` + `elapsed >= 0` is always
675        // true, so the scheduler fires a break every tick.
676        let mut s = Settings {
677            micro_interval_secs: 0,
678            long_interval_secs: 0,
679            bedtime_interval_secs: 0,
680            ..Settings::default()
681        };
682        s.clamp();
683        assert!(s.micro_interval_secs >= 30);
684        assert!(s.long_interval_secs >= 30);
685        assert!(s.bedtime_interval_secs >= 60);
686    }
687
688    #[test]
689    fn clamp_caps_max_intervals_below_u64_overflow_danger() {
690        let mut s = Settings {
691            micro_interval_secs: u64::MAX,
692            long_interval_secs: u64::MAX,
693            ..Settings::default()
694        };
695        s.clamp();
696        assert!(s.micro_interval_secs <= 86_400);
697        assert!(s.long_interval_secs <= 86_400);
698    }
699
700    #[test]
701    fn clamp_leaves_in_range_values_alone() {
702        let mut s = Settings::default();
703        let micro = s.micro_interval_secs;
704        let long = s.long_interval_secs;
705        let bedtime = s.bedtime_interval_secs;
706        s.clamp();
707        assert_eq!(s.micro_interval_secs, micro);
708        assert_eq!(s.long_interval_secs, long);
709        assert_eq!(s.bedtime_interval_secs, bedtime);
710    }
711
712    #[test]
713    fn clamp_keeps_zero_prebreak_lead_as_disabled() {
714        // 0 is a valid "no warning" value here — the run_loop also
715        // gates on `prebreak_notification_seconds > 0`. Clamp must not
716        // bump 0 up to a positive value or notifications would start
717        // firing for users who explicitly opted out.
718        let mut s = Settings {
719            prebreak_notification_seconds: 0,
720            ..Settings::default()
721        };
722        s.clamp();
723        assert_eq!(s.prebreak_notification_seconds, 0);
724    }
725
726    #[test]
727    fn clamp_keeps_zero_hint_rotation_as_disabled() {
728        // 0 = rotation off. The renderer's useHintRotation gates on
729        // `hint_rotate_seconds > 0`; clamping 0 up to 1 would silently
730        // re-enable rotation for users who unchecked the toggle.
731        let mut s = Settings {
732            hint_rotate_seconds: 0,
733            ..Settings::default()
734        };
735        s.clamp();
736        assert_eq!(s.hint_rotate_seconds, 0);
737    }
738
739    #[test]
740    fn clamp_pins_minutes_of_day_to_valid_range() {
741        let mut s = Settings {
742            work_start_minutes: 9_999,
743            bedtime_end_minutes: 5_000,
744            ..Settings::default()
745        };
746        s.clamp();
747        assert!(s.work_start_minutes <= 1_439);
748        assert!(s.bedtime_end_minutes <= 1_439);
749    }
750
751    #[test]
752    fn clamp_pins_floats_to_unit_interval() {
753        let mut s = Settings {
754            overlay_opacity: -0.5,
755            sound_volume: 10.0,
756            ..Settings::default()
757        };
758        s.clamp();
759        // Opacity floor is 0.8 (caps transparency at 20%).
760        assert!((0.8..=1.0).contains(&s.overlay_opacity));
761        assert!((0.0..=1.0).contains(&s.sound_volume));
762    }
763
764    #[test]
765    fn clamp_caps_transparency_at_twenty_percent() {
766        // Hand-edited settings.json with 50% transparency must be
767        // clamped back to the 20% cap (opacity 0.8).
768        let mut s = Settings {
769            overlay_opacity: 0.5,
770            ..Settings::default()
771        };
772        s.clamp();
773        assert!((s.overlay_opacity - 0.8).abs() < f32::EPSILON);
774    }
775
776    #[test]
777    fn clock_format_defaults_to_24h() {
778        let s = Settings::default();
779        assert_eq!(s.clock_format, "24h");
780    }
781
782    #[test]
783    fn clamp_normalises_unknown_clock_format() {
784        let mut s = Settings {
785            clock_format: "garbage".to_string(),
786            ..Settings::default()
787        };
788        s.clamp();
789        assert_eq!(s.clock_format, "24h");
790    }
791
792    #[test]
793    fn clamp_leaves_valid_clock_format_alone() {
794        let mut s = Settings {
795            clock_format: "12h".to_string(),
796            ..Settings::default()
797        };
798        s.clamp();
799        assert_eq!(s.clock_format, "12h");
800    }
801
802    #[test]
803    fn clamp_pins_font_scale_to_supported_range() {
804        let mut s = Settings {
805            overlay_font_scale: 0.01,
806            ..Settings::default()
807        };
808        s.clamp();
809        assert!(s.overlay_font_scale >= 0.5);
810        s.overlay_font_scale = 99.0;
811        s.clamp();
812        assert!(s.overlay_font_scale <= 3.0);
813    }
814
815    #[test]
816    fn clamp_caps_postpone_count_to_prevent_runaway() {
817        let mut s = Settings {
818            postpone_max_count: u32::MAX,
819            ..Settings::default()
820        };
821        s.clamp();
822        assert!(s.postpone_max_count <= 20);
823    }
824
825    #[test]
826    fn clamp_is_idempotent() {
827        // Clamping twice produces the same result as clamping once —
828        // important because we clamp on both load and write paths.
829        let mut a = Settings {
830            micro_interval_secs: 0,
831            overlay_opacity: 5.0,
832            ..Settings::default()
833        };
834        a.clamp();
835        let snapshot = a.clone();
836        a.clamp();
837        assert_eq!(snapshot.micro_interval_secs, a.micro_interval_secs);
838        assert!(
839            (snapshot.overlay_opacity - a.overlay_opacity).abs() < f32::EPSILON,
840            "clamp idempotent on overlay_opacity"
841        );
842    }
843
844    #[test]
845    fn legacy_idle_reset_secs_aliases_into_micro() {
846        let json = r#"{"idle_reset_secs": 123}"#;
847        let s: Settings = serde_json::from_str(json).unwrap();
848        assert_eq!(s.micro_idle_reset_secs, 123);
849        assert_eq!(
850            s.long_idle_reset_secs,
851            Settings::default().long_idle_reset_secs
852        );
853    }
854
855    #[test]
856    fn legacy_micro_hints_aliases_into_physical() {
857        let json = r#"{"micro_hints": ["Stretch", "Blink"]}"#;
858        let s: Settings = serde_json::from_str(json).unwrap();
859        assert_eq!(s.micro_physical_hints, vec!["Stretch", "Blink"]);
860        assert_eq!(
861            s.micro_psychological_hints,
862            Settings::default().micro_psychological_hints
863        );
864        assert_eq!(s.micro_hint_mix, "both");
865    }
866
867    #[test]
868    #[allow(clippy::field_reassign_with_default)]
869    fn effective_micro_hints_modes() {
870        let mut s = Settings::default();
871        s.micro_physical_hints = vec!["a".into(), "b".into()];
872        s.micro_psychological_hints = vec!["c".into()];
873
874        s.micro_hint_mix = "physical".into();
875        assert_eq!(effective_micro_hints(&s), vec!["a", "b"]);
876
877        s.micro_hint_mix = "psychological".into();
878        assert_eq!(effective_micro_hints(&s), vec!["c"]);
879
880        s.micro_hint_mix = "both".into();
881        assert_eq!(effective_micro_hints(&s), vec!["a", "b", "c"]);
882
883        s.micro_hint_mix = "garbage".into();
884        assert_eq!(effective_micro_hints(&s), vec!["a", "b", "c"]);
885    }
886
887    #[test]
888    #[allow(clippy::field_reassign_with_default)]
889    fn effective_long_hints_modes() {
890        let mut s = Settings::default();
891        s.long_hints = vec!["solo1".into(), "solo2".into()];
892        s.long_social_hints = vec!["soc1".into()];
893
894        s.long_hint_mix = "solo".into();
895        assert_eq!(effective_long_hints(&s), vec!["solo1", "solo2"]);
896
897        s.long_hint_mix = "social".into();
898        assert_eq!(effective_long_hints(&s), vec!["soc1"]);
899
900        s.long_hint_mix = "both".into();
901        assert_eq!(effective_long_hints(&s), vec!["solo1", "solo2", "soc1"]);
902
903        s.long_hint_mix = "garbage".into();
904        assert_eq!(effective_long_hints(&s), vec!["solo1", "solo2", "soc1"]);
905    }
906
907    #[test]
908    fn default_long_social_hints_are_populated() {
909        let s = Settings::default();
910        assert!(!s.long_social_hints.is_empty());
911        assert_eq!(s.long_hint_mix, "both");
912    }
913
914    #[test]
915    fn settings_default_fixed_times_empty_and_interval() {
916        let s = Settings::default();
917        assert!(s.micro_fixed_times.is_empty());
918        assert!(s.long_fixed_times.is_empty());
919        assert_eq!(s.micro_schedule_mode, "interval");
920        assert_eq!(s.long_schedule_mode, "interval");
921    }
922
923    #[test]
924    fn screen_time_defaults_off_with_eight_hour_budget() {
925        let s = Settings::default();
926        assert!(!s.daily_screen_time_enabled);
927        assert_eq!(s.daily_screen_time_budget_minutes, 480);
928        assert_eq!(s.daily_screen_time_remind_again_minutes, 60);
929    }
930
931    #[test]
932    fn tray_countdown_defaults() {
933        let s = Settings::default();
934        assert!(s.tray_countdown_enabled);
935        assert_eq!(s.tray_countdown_target, "next");
936    }
937
938    #[test]
939    fn monitor_placement_default_is_primary() {
940        assert_eq!(MonitorPlacement::default(), MonitorPlacement::Primary);
941    }
942
943    #[test]
944    fn break_mode_defaults_to_overlay() {
945        let s = Settings::default();
946        assert_eq!(s.micro_break_mode, "overlay");
947        assert_eq!(s.long_break_mode, "overlay");
948        assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Overlay);
949        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
950        assert_eq!(delivery_for(BreakKind::Sleep, &s), BreakDelivery::Overlay);
951    }
952
953    #[test]
954    #[allow(clippy::field_reassign_with_default)]
955    fn delivery_for_notification_per_kind() {
956        let mut s = Settings::default();
957        s.micro_break_mode = "notification".into();
958        assert_eq!(
959            delivery_for(BreakKind::Micro, &s),
960            BreakDelivery::Notification
961        );
962        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
963
964        s.long_break_mode = "notification".into();
965        assert_eq!(
966            delivery_for(BreakKind::Long, &s),
967            BreakDelivery::Notification
968        );
969    }
970
971    #[test]
972    #[allow(clippy::field_reassign_with_default)]
973    fn delivery_for_sleep_always_overlay() {
974        let mut s = Settings::default();
975        s.micro_break_mode = "notification".into();
976        s.long_break_mode = "notification".into();
977        assert_eq!(delivery_for(BreakKind::Sleep, &s), BreakDelivery::Overlay);
978    }
979
980    #[test]
981    #[allow(clippy::field_reassign_with_default)]
982    fn delivery_for_windowed_per_kind() {
983        let mut s = Settings::default();
984        s.micro_break_mode = "windowed".into();
985        assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Windowed);
986        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
987
988        s.long_break_mode = "windowed".into();
989        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Windowed);
990    }
991
992    #[test]
993    #[allow(clippy::field_reassign_with_default)]
994    fn delivery_for_unknown_mode_falls_back_to_overlay() {
995        let mut s = Settings::default();
996        s.micro_break_mode = "garbage".into();
997        s.long_break_mode = "".into();
998        assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Overlay);
999        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
1000    }
1001
1002    #[test]
1003    #[allow(clippy::field_reassign_with_default)]
1004    fn is_windowed_mode_tracks_per_kind_setting() {
1005        let mut s = Settings::default();
1006        assert!(!is_windowed_mode(BreakKind::Micro, &s));
1007        assert!(!is_windowed_mode(BreakKind::Long, &s));
1008        assert!(!is_windowed_mode(BreakKind::Sleep, &s));
1009
1010        s.micro_break_mode = "windowed".into();
1011        assert!(is_windowed_mode(BreakKind::Micro, &s));
1012        assert!(!is_windowed_mode(BreakKind::Long, &s));
1013
1014        s.long_break_mode = "windowed".into();
1015        assert!(is_windowed_mode(BreakKind::Long, &s));
1016
1017        s.micro_break_mode = "notification".into();
1018        assert!(!is_windowed_mode(BreakKind::Micro, &s));
1019    }
1020
1021    #[test]
1022    #[allow(clippy::field_reassign_with_default)]
1023    fn is_windowed_mode_for_sleep_is_always_false() {
1024        let mut s = Settings::default();
1025        s.micro_break_mode = "windowed".into();
1026        s.long_break_mode = "windowed".into();
1027        assert!(!is_windowed_mode(BreakKind::Sleep, &s));
1028    }
1029
1030    #[test]
1031    fn hint_rotation_is_off_by_default() {
1032        assert_eq!(Settings::default().hint_rotate_seconds, 0);
1033    }
1034
1035    #[test]
1036    fn legacy_settings_json_defaults_break_mode_to_overlay() {
1037        let json = r#"{"micro_interval_secs": 600}"#;
1038        let s: Settings = serde_json::from_str(json).unwrap();
1039        assert_eq!(s.micro_break_mode, "overlay");
1040        assert_eq!(s.long_break_mode, "overlay");
1041    }
1042
1043    #[test]
1044    fn typing_defer_settings_defaults() {
1045        let s = Settings::default();
1046        assert!(s.delay_break_if_typing);
1047        assert_eq!(s.typing_grace_secs, 10);
1048        assert_eq!(s.typing_max_deferral_secs, 60);
1049        assert!(s.pause_countdown_if_typing);
1050    }
1051}
1052
1053/// Rust ↔ TypeScript Settings parity test (issue #13).
1054///
1055/// Anyone adding a setting has to update:
1056///   1. `Settings` (this file) + `Default`
1057///   2. `SchedulerSettings` in `src/views/settings/types.ts`
1058///   3. The Zod schema in `src/views/settings/hooks/use-settings.ts`
1059///   4. (sometimes) `OverlaySettings` in `src/views/break-overlay/types.ts`
1060///   5. (sometimes) the a11y audit fixture
1061///
1062/// Forgetting (2) is a silent break — the renderer's IPC validation
1063/// rejects the response at runtime in a way CI doesn't catch on the
1064/// PR that introduced it (saw this happen with `custom_css` recently).
1065/// This test compares the *top-level* field-name sets of (1) and (2)
1066/// and fails with a useful diff so the drift surfaces at unit-test time.
1067///
1068/// What it does NOT check:
1069///   - Field types (Rust `u64` vs TS `number` — out of scope; the Zod
1070///     schema enforces this at runtime).
1071///   - Nested struct shapes (`BreakSound`, `HookConfig`) — those have
1072///     their own Zod schemas, and adding a nested field would still be
1073///     caught when it crosses the wire.
1074///   - The Zod schema or the OverlaySettings mirror — see issue #13
1075///     follow-ups if drift between (1) and (3)/(4) becomes a problem.
1076#[cfg(test)]
1077mod parity_tests {
1078    use std::collections::BTreeSet;
1079    use std::path::PathBuf;
1080
1081    use super::Settings;
1082
1083    fn rust_settings_keys() -> BTreeSet<String> {
1084        let value = serde_json::to_value(Settings::default())
1085            .expect("Settings serialises to a JSON object");
1086        let obj = value.as_object().expect("top-level Settings is an object");
1087        obj.keys().cloned().collect()
1088    }
1089
1090    fn ts_settings_keys() -> BTreeSet<String> {
1091        let manifest = env!("CARGO_MANIFEST_DIR");
1092        let path = PathBuf::from(manifest).join("../src/views/settings/types.ts");
1093        let source = std::fs::read_to_string(&path).unwrap_or_else(|e| {
1094            panic!(
1095                "could not read TS source at {} — has the layout moved? \
1096                 If yes, update the parity test path. ({e})",
1097                path.display()
1098            )
1099        });
1100        let body = extract_type_body(&source, "SchedulerSettings");
1101        extract_field_names(body)
1102    }
1103
1104    /// Pull the body of `export type <name> = { ... };` out of the
1105    /// source. Whitespace-tolerant; assumes the type is a flat
1106    /// `{ key: type; }` block with one field per line. If the TS file
1107    /// grows nested-object types inline (e.g., `foo: { bar: number }`),
1108    /// this needs to learn brace-depth tracking — but right now every
1109    /// nested type is named (BreakSound, HookConfig) so we're safe.
1110    fn extract_type_body<'a>(source: &'a str, name: &str) -> &'a str {
1111        let needle = format!("export type {name} = {{");
1112        let start = source
1113            .find(&needle)
1114            .unwrap_or_else(|| panic!("`{name}` not found in TS source"))
1115            + needle.len();
1116        let after_open = &source[start..];
1117        let end = after_open
1118            .find("\n};")
1119            .unwrap_or_else(|| panic!("end of `{name}` body not found"));
1120        &after_open[..end]
1121    }
1122
1123    fn extract_field_names(body: &str) -> BTreeSet<String> {
1124        let mut out = BTreeSet::new();
1125        for line in body.lines() {
1126            let trimmed = line.trim();
1127            // Skip blank lines and `//` comments.
1128            if trimmed.is_empty() || trimmed.starts_with("//") {
1129                continue;
1130            }
1131            // Match `field_name:` at the start of the trimmed line.
1132            // `take_while` over the chars is enough — no regex dep.
1133            let name: String = trimmed
1134                .chars()
1135                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1136                .collect();
1137            if name.is_empty() {
1138                continue;
1139            }
1140            // Confirm a `:` follows (possibly with whitespace).
1141            let after = &trimmed[name.len()..];
1142            if after.trim_start().starts_with(':') {
1143                out.insert(name);
1144            }
1145        }
1146        out
1147    }
1148
1149    #[test]
1150    fn rust_and_ts_settings_have_the_same_top_level_keys() {
1151        let rust = rust_settings_keys();
1152        let ts = ts_settings_keys();
1153
1154        let missing_from_ts: Vec<_> = rust.difference(&ts).collect();
1155        let missing_from_rust: Vec<_> = ts.difference(&rust).collect();
1156
1157        assert!(
1158            missing_from_ts.is_empty() && missing_from_rust.is_empty(),
1159            "Rust ↔ TS Settings parity drift:\n  \
1160             present in Rust, missing from TS ({} keys): {missing_from_ts:?}\n  \
1161             present in TS, missing from Rust ({} keys): {missing_from_rust:?}\n  \
1162             Add or remove the field in BOTH places. See `src-tauri/src/scheduler/settings.rs` \
1163             and `src/views/settings/types.ts`.",
1164            missing_from_ts.len(),
1165            missing_from_rust.len(),
1166        );
1167    }
1168
1169    // -- Tests for the TS-source extraction helpers, so a malformed
1170    //    types.ts (or a refactor of the helpers) doesn't silently
1171    //    return an empty set and make the parity test trivially pass.
1172
1173    #[test]
1174    fn extract_type_body_handles_typical_block() {
1175        let src = "import x from 'y';\n\
1176                   export type Foo = {\n  \
1177                     a: number;\n  \
1178                     b: string;\n\
1179                   };\n\
1180                   export type Bar = { c: boolean };\n";
1181        let body = extract_type_body(src, "Foo");
1182        assert!(body.contains("a: number;"));
1183        assert!(body.contains("b: string;"));
1184        assert!(!body.contains("Bar"));
1185    }
1186
1187    #[test]
1188    fn extract_field_names_parses_canonical_form() {
1189        let body = "\n  micro_interval_secs: number;\n  \
1190                    hooks: HookConfig[];\n  \
1191                    micro_sound: BreakSound;\n";
1192        let names = extract_field_names(body);
1193        assert!(names.contains("micro_interval_secs"));
1194        assert!(names.contains("hooks"));
1195        assert!(names.contains("micro_sound"));
1196        assert_eq!(names.len(), 3);
1197    }
1198
1199    #[test]
1200    fn extract_field_names_skips_blank_and_comment_lines() {
1201        let body = "\n  // intentionally a comment\n\n  foo: number;\n";
1202        let names = extract_field_names(body);
1203        assert_eq!(names.len(), 1);
1204        assert!(names.contains("foo"));
1205    }
1206
1207    #[test]
1208    fn ts_settings_keys_returns_nonempty_set() {
1209        // Sanity: if the extractor returns nothing, the parity test
1210        // would falsely "pass" the diff (both sides equal-empty).
1211        assert!(!ts_settings_keys().is_empty());
1212    }
1213}