Skip to main content

entracte_lib/scheduler/
settings.rs

1use log::warn;
2use serde::{Deserialize, Deserializer, Serialize};
3
4use crate::hooks::Hook;
5
6use super::hotkeys::Hotkey;
7use super::routines::{Routine, RoutineCategory, RoutineDifficulty};
8use super::timers::parse_hhmm;
9use super::types::{BreakDelivery, BreakKind};
10
11/// Stable, per-settings-load derivations resolved once at load/update
12/// time instead of being recomputed on every 1Hz scheduler tick or
13/// break fire.
14///
15/// These are pure functions of the owning `Settings`' source fields:
16/// `rebuild` re-derives the whole struct from a `Settings`, and the
17/// run loop / fire path read the cached vectors directly. The cache is
18/// `#[serde(skip)]` on `Settings` (it never hits disk and is rebuilt on
19/// deserialise via the `Default` it picks up), so adding it doesn't
20/// change the on-disk shape or the Rust↔TS parity surface.
21#[derive(Debug, Clone, Default)]
22pub struct DerivedCaches {
23    /// `micro_fixed_times` parsed to minutes-since-midnight, with
24    /// unparseable entries dropped — the per-tick compare becomes a plain
25    /// `== now_min` instead of re-running `parse_hhmm` on every string.
26    pub micro_fixed_minutes: Vec<u32>,
27    /// `long_fixed_times` parsed the same way.
28    pub long_fixed_minutes: Vec<u32>,
29    /// `app_pause_list` targets pre-lowercased so the per-refresh process
30    /// scan only has to lowercase the live process name once, instead of
31    /// re-lowercasing every configured target for every running process.
32    /// Empty targets are dropped (they never match).
33    pub app_pause_targets_lower: Vec<String>,
34    /// Micro-break hint pool already resolved for the active
35    /// `micro_hint_mix` (see [`effective_micro_hints`]). Resolving the mix
36    /// concatenates two pools on every break fire otherwise; caching it
37    /// turns the fire path into a single clone of the finished vector.
38    pub micro_hints_resolved: Vec<String>,
39    /// Long-break hint pool resolved for the active `long_hint_mix`.
40    pub long_hints_resolved: Vec<String>,
41}
42
43impl DerivedCaches {
44    /// Re-derive every cached value from `s`' source fields. Called at
45    /// each point where the stored `Settings` is replaced (see
46    /// `Settings::rebuild_derived`), so the caches can never drift from
47    /// the settings they summarise.
48    fn rebuild(s: &Settings) -> Self {
49        Self {
50            micro_fixed_minutes: s
51                .micro_fixed_times
52                .iter()
53                .filter_map(|t| parse_hhmm(t))
54                .collect(),
55            long_fixed_minutes: s
56                .long_fixed_times
57                .iter()
58                .filter_map(|t| parse_hhmm(t))
59                .collect(),
60            app_pause_targets_lower: s
61                .app_pause_list
62                .iter()
63                .map(|t| t.to_lowercase())
64                .filter(|t| !t.is_empty())
65                .collect(),
66            micro_hints_resolved: resolve_micro_hints(s),
67            long_hints_resolved: resolve_long_hints(s),
68        }
69    }
70}
71
72/// Which monitor(s) an overlay break should appear on.
73///
74/// `Primary` follows the OS-designated primary display, `Active` picks
75/// whichever monitor the cursor is on at break time, `All` mirrors the
76/// overlay across every connected monitor.
77///
78/// Defaults to `All`. A break the user can sidestep by glancing at a
79/// second screen isn't a break, so covering everything is the behaviour
80/// that matches the app's purpose on a multi-monitor desk (#315). Single
81/// -monitor users are unaffected — `All` and `Primary` are the same
82/// thing when there is one display.
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
84#[serde(rename_all = "lowercase")]
85pub enum MonitorPlacement {
86    Primary,
87    Active,
88    #[default]
89    All,
90}
91
92/// Which release line the in-app updater follows.
93///
94/// Defaults to `Stable`. The channel picks the manifest URL, and that is
95/// the *only* thing separating the two lines — the version comparator
96/// cannot help, because a prerelease sorts above the stable it precedes
97/// (`0.1.1-beta.1 > 0.1.0`). A beta manifest reaching a stable install
98/// would be installed. See [`crate::updater::channel_endpoint`].
99#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
100#[serde(rename_all = "lowercase")]
101pub enum UpdateChannel {
102    #[default]
103    Stable,
104    Beta,
105}
106
107/// What the overlay does with audio for a given break kind.
108///
109/// `Off` plays nothing, `EndChime` plays the configured chime once when
110/// the break ends, `Ambient` loops a track for the duration of the break.
111#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
112#[serde(rename_all = "snake_case")]
113pub enum BreakSoundMode {
114    #[default]
115    Off,
116    EndChime,
117    Ambient,
118}
119
120/// How a break surfaces to the user, per break kind
121/// (`micro_break_mode` / `long_break_mode`). Maps 1:1 onto the runtime
122/// [`BreakDelivery`] enum via [`BreakMode::delivery`]; the split exists
123/// because Sleep hard-codes `Overlay` and never reads a `BreakMode`.
124///
125/// On-disk strings are lowercase (`"overlay"` / `"windowed"` /
126/// `"notification"`); a corrupt or unknown value deserialises to
127/// [`BreakMode::Overlay`] with a logged warning rather than failing the
128/// whole settings load — see [`deserialize_with_fallback`].
129#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
130#[serde(rename_all = "lowercase")]
131pub enum BreakMode {
132    #[default]
133    Overlay,
134    Windowed,
135    Notification,
136}
137
138impl BreakMode {
139    /// Project onto the runtime delivery enum the overlay/notification
140    /// glue actually branches on.
141    pub fn delivery(self) -> BreakDelivery {
142        match self {
143            Self::Overlay => BreakDelivery::Overlay,
144            Self::Windowed => BreakDelivery::Windowed,
145            Self::Notification => BreakDelivery::Notification,
146        }
147    }
148
149    fn from_disk_str(raw: &str) -> Option<Self> {
150        match raw {
151            "overlay" => Some(Self::Overlay),
152            "windowed" => Some(Self::Windowed),
153            "notification" => Some(Self::Notification),
154            _ => None,
155        }
156    }
157}
158
159impl<'de> Deserialize<'de> for BreakMode {
160    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
161    where
162        D: Deserializer<'de>,
163    {
164        Ok(deserialize_with_fallback(
165            deserializer,
166            "break_mode",
167            Self::from_disk_str,
168        ))
169    }
170}
171
172/// Which scheduling strategy fires a break kind
173/// (`micro_schedule_mode` / `long_schedule_mode`). `Interval` is the
174/// repeating timer, `Fixed` is wall-clock times, `Both` runs them
175/// together. Sleep has no interval/fixed split and never reads this.
176///
177/// On-disk strings are lowercase; a corrupt or unknown value
178/// deserialises to [`ScheduleMode::Interval`] with a logged warning.
179#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
180#[serde(rename_all = "lowercase")]
181pub enum ScheduleMode {
182    #[default]
183    Interval,
184    Fixed,
185    Both,
186}
187
188impl ScheduleMode {
189    /// True iff this mode fires on the repeating interval timer.
190    pub fn interval_active(self) -> bool {
191        matches!(self, Self::Interval | Self::Both)
192    }
193
194    /// True iff this mode fires at fixed wall-clock times.
195    pub fn fixed_active(self) -> bool {
196        matches!(self, Self::Fixed | Self::Both)
197    }
198
199    fn from_disk_str(raw: &str) -> Option<Self> {
200        match raw {
201            "interval" => Some(Self::Interval),
202            "fixed" => Some(Self::Fixed),
203            "both" => Some(Self::Both),
204            _ => None,
205        }
206    }
207}
208
209impl<'de> Deserialize<'de> for ScheduleMode {
210    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
211    where
212        D: Deserializer<'de>,
213    {
214        Ok(deserialize_with_fallback(
215            deserializer,
216            "schedule_mode",
217            Self::from_disk_str,
218        ))
219    }
220}
221
222/// Which hint pool a break kind draws from. Micro mixes `physical` and
223/// `psychological`; long mixes `solo` and `social`; `Both` concatenates
224/// the kind's two pools. The vocabularies don't overlap, so one enum
225/// covers both kinds — `effective_*_hints` only ever asks "is this the
226/// mix, or one of my two pools?".
227///
228/// On-disk strings are lowercase; a corrupt or unknown value
229/// deserialises to [`HintMix::Both`] with a logged warning.
230#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq, Default)]
231#[serde(rename_all = "lowercase")]
232pub enum HintMix {
233    #[default]
234    Both,
235    Physical,
236    Psychological,
237    Solo,
238    Social,
239}
240
241impl HintMix {
242    fn from_disk_str(raw: &str) -> Option<Self> {
243        match raw {
244            "both" => Some(Self::Both),
245            "physical" => Some(Self::Physical),
246            "psychological" => Some(Self::Psychological),
247            "solo" => Some(Self::Solo),
248            "social" => Some(Self::Social),
249            _ => None,
250        }
251    }
252}
253
254impl<'de> Deserialize<'de> for HintMix {
255    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
256    where
257        D: Deserializer<'de>,
258    {
259        Ok(deserialize_with_fallback(
260            deserializer,
261            "hint_mix",
262            Self::from_disk_str,
263        ))
264    }
265}
266
267/// Deserialise a lowercase-tagged enum permissively: read the raw string
268/// and map it through `parse`, falling back to `T::default()` with a
269/// single logged warning on any unknown/corrupt value.
270///
271/// This preserves the pre-enum runtime behaviour exactly — the old
272/// `String` fields stored arbitrary text and the `*_for` matchers
273/// silently treated anything unrecognised as the fallback. Keeping that
274/// here means a hand-edited or stale `settings.json` still loads instead
275/// of having serde reject the whole profile. We can't lean on the
276/// derived `Deserialize` for the strict parse (it would recurse back
277/// into this custom impl), so each enum hands in its own `from_disk_str`.
278fn deserialize_with_fallback<'de, D, T>(
279    deserializer: D,
280    field: &str,
281    parse: fn(&str) -> Option<T>,
282) -> T
283where
284    D: Deserializer<'de>,
285    T: Default,
286{
287    let raw = match String::deserialize(deserializer) {
288        Ok(raw) => raw,
289        Err(_) => return T::default(),
290    };
291    parse(&raw).unwrap_or_else(|| {
292        warn!("settings: unknown {field} value {raw:?} — falling back to default");
293        T::default()
294    })
295}
296
297/// Deserialise a `*_routine_max_difficulty` permissively: an unknown or
298/// wrong-typed value (stale, hand-edited, or from a future build) falls back
299/// to [`RoutineDifficulty::default`] rather than failing the whole profile
300/// load (#212). Shares the [`deserialize_with_fallback`] helper with the
301/// other tolerant settings enums; kept as a field-level `deserialize_with`
302/// (not a type-level `Deserialize` impl) so content packs still parse
303/// routines through the strict derived `Deserialize`.
304fn deserialize_routine_max_difficulty<'de, D>(
305    deserializer: D,
306) -> Result<RoutineDifficulty, D::Error>
307where
308    D: Deserializer<'de>,
309{
310    Ok(deserialize_with_fallback(
311        deserializer,
312        "routine_max_difficulty",
313        RoutineDifficulty::from_disk_str,
314    ))
315}
316
317/// Deserialise a `*_routine_categories` filter list permissively: unknown
318/// entries are dropped (each with a logged warning) instead of failing the
319/// whole profile load (#212). A wholly malformed value (not an array of
320/// strings) degrades to an empty list rather than failing the load — matching
321/// the difficulty handler's tolerance. An empty result still means "all
322/// categories".
323fn deserialize_routine_categories<'de, D>(deserializer: D) -> Result<Vec<RoutineCategory>, D::Error>
324where
325    D: Deserializer<'de>,
326{
327    let raw = match Vec::<String>::deserialize(deserializer) {
328        Ok(raw) => raw,
329        Err(_) => {
330            warn!("settings: routine categories not an array of strings — falling back to all");
331            return Ok(Vec::new());
332        }
333    };
334    Ok(raw
335        .into_iter()
336        .filter_map(|s| {
337            let parsed = RoutineCategory::from_disk_str(&s);
338            if parsed.is_none() {
339                warn!("settings: dropping unknown routine category {s:?}");
340            }
341            parsed
342        })
343        .collect())
344}
345
346/// Per-break-kind audio configuration: mode + which bundled sound to play.
347/// `sound_id` is the numeric id from `src/assets/sounds/credits.json`, or
348/// the literal `"custom"` to use `custom_path` (a Supporter-pack feature).
349#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
350pub struct BreakSound {
351    #[serde(default)]
352    pub mode: BreakSoundMode,
353    #[serde(default)]
354    pub sound_id: String,
355    #[serde(default)]
356    pub custom_path: String,
357}
358
359impl BreakSound {
360    /// Build an `EndChime`-mode `BreakSound` pointing at the given sound id.
361    /// Used by `Settings::default` to seed the bundled chime.
362    pub fn end_chime(id: &str) -> Self {
363        Self {
364            mode: BreakSoundMode::EndChime,
365            sound_id: id.to_string(),
366            custom_path: String::new(),
367        }
368    }
369}
370
371fn default_tray_countdown_target() -> String {
372    "next".to_string()
373}
374
375fn default_true() -> bool {
376    true
377}
378
379fn default_clock_format() -> String {
380    "24h".to_string()
381}
382
383/// Every weekday enabled (`0b111_1111`). The default for `work_days_mask`,
384/// so a `settings.json` written before the field existed loads as
385/// "work window applies on all seven days" — preserving the prior
386/// behaviour where the work window had no day-of-week distinction.
387fn default_work_days_mask() -> u8 {
388    0b111_1111
389}
390
391fn default_windowed_fraction() -> f64 {
392    0.8
393}
394
395fn default_routine_max_difficulty() -> RoutineDifficulty {
396    RoutineDifficulty::Active
397}
398
399fn default_micro_physical_hints() -> Vec<String> {
400    vec![
401        "Look at something 20 feet away.",
402        "Blink slowly ten times.",
403        "Roll your shoulders backward, then forward.",
404        "Stretch your neck side to side.",
405        "Sip some water.",
406        "Wiggle your fingers and toes.",
407        "Look up, down, left, right.",
408        "Press your palms together and stretch your wrists.",
409        "Reach for the ceiling — both arms, slow stretch.",
410        "Stand up and shake out your hands.",
411        "Twist gently side to side in your chair.",
412        "Open and close your hands ten times.",
413        "Look out the nearest window.",
414        "Roll your ankles in slow circles.",
415        "Squeeze your shoulder blades together for five seconds.",
416        "Tilt your head ear-to-shoulder, both sides.",
417        "Stand up. Reach down. Tap your toes.",
418        "Trace the alphabet in the air with your nose.",
419    ]
420    .into_iter()
421    .map(String::from)
422    .collect()
423}
424
425fn default_micro_psychological_hints() -> Vec<String> {
426    vec![
427        "Unclench your jaw.",
428        "Take five slow, deep breaths.",
429        "Soften your gaze. Relax your face.",
430        "Sit back. Drop your shoulders.",
431        "Notice three things you can hear right now.",
432        "Name one thing going well today.",
433        "Let your tongue rest behind your front teeth.",
434        "Notice the weight of your body in the chair.",
435        "Smile, even a small one.",
436        "Take one breath in. Let it out twice as slowly.",
437        "Pause. Notice what your body needs.",
438        "Thank yourself for the work you've done so far.",
439    ]
440    .into_iter()
441    .map(String::from)
442    .collect()
443}
444
445fn default_long_hints() -> Vec<String> {
446    vec![
447        "Stand up. Look out a window. Stretch.",
448        "Take a short walk — even one minute counts.",
449        "Step away from the screen. Make a cup of tea.",
450        "Do a few full-body stretches.",
451        "Walk to a different room and back.",
452        "Stretch your back, shoulders, and legs.",
453        "Get a bit of fresh air if you can.",
454        "Refill your water bottle.",
455        "Do a quick body scan — where are you holding tension?",
456        "Try a minute of slow, deep breathing.",
457        "Roll out your wrists, ankles, and neck.",
458        "Stand tall and stretch your arms overhead.",
459        "Step outside for a few minutes of daylight.",
460        "Make a snack from real food — fruit, nuts, cheese.",
461        "Lie flat on the floor for a minute. Let gravity reset you.",
462        "Put on one song you love and just listen.",
463        "Tidy a small area near you.",
464        "Take the long way to wherever you're going.",
465        "Wash your face with cool water.",
466        "Step away. Look at the sky for a minute.",
467    ]
468    .into_iter()
469    .map(String::from)
470    .collect()
471}
472
473fn default_long_social_hints() -> Vec<String> {
474    vec![
475        "Call someone you love.",
476        "Text a friend just to say hi.",
477        "Step outside with a colleague.",
478        "Walk over to a coworker's desk for a chat.",
479        "Make a coffee with someone.",
480        "Sit outside with company if you can.",
481        "Ask a teammate how their day is going.",
482        "Drop a thank-you note to someone.",
483        "Eat your snack with someone, not at your desk.",
484        "Swap a quick story with whoever's around.",
485        "Reach out to someone you haven't spoken to in a while.",
486        "Take a short walk with a friend or partner.",
487        "Voice-message a friend instead of texting.",
488        "Pay someone a genuine compliment.",
489        "Invite someone to take the next break with you.",
490    ]
491    .into_iter()
492    .map(String::from)
493    .collect()
494}
495
496fn default_sleep_hints() -> Vec<String> {
497    vec![
498        "Time to wind down.",
499        "Step away from the screen for the night.",
500        "Sleep well — your work will be there tomorrow.",
501        "Dim the lights. Close the laptop. Rest.",
502        "Reading or stretching beats more screen time.",
503        "Be kind to your future self. Get some rest.",
504        "Tomorrow's focus starts with tonight's sleep.",
505        "Put work down. You've earned the rest.",
506        "Brew a small herbal tea instead of starting one more task.",
507        "Lay tomorrow's notebook out and close this one.",
508        "Pick the first thing for tomorrow — then stop.",
509        "Stretch slowly for five minutes, then bed.",
510    ]
511    .into_iter()
512    .map(String::from)
513    .collect()
514}
515
516/// Single source of truth for one profile's behaviour.
517///
518/// Deserialised from `settings.json` (one of these per profile in the
519/// `ProfilesFile` array) and sent to the renderer wholesale through
520/// `get_settings`. Field names line up 1:1 with the TypeScript
521/// `SchedulerSettings` type — keep the two in sync; a serde roundtrip
522/// parity test is on the backlog to enforce this in CI.
523///
524/// `#[serde(default)]` on the struct means each field falls back to
525/// `Default::default()` if missing — older `settings.json` files keep
526/// loading as new fields are added. Pre-split fields keep their old
527/// JSON keys via `#[serde(alias = "...")]`.
528#[derive(Debug, Clone, Serialize, Deserialize)]
529#[serde(default)]
530pub struct Settings {
531    pub micro_interval_secs: u64,
532    pub micro_duration_secs: u64,
533    pub long_interval_secs: u64,
534    pub long_duration_secs: u64,
535    // alias keeps pre-split settings.json (single `idle_reset_secs`) loading cleanly into the micro field.
536    #[serde(alias = "idle_reset_secs")]
537    pub micro_idle_reset_secs: u64,
538    pub long_idle_reset_secs: u64,
539    pub micro_enabled: bool,
540    pub long_enabled: bool,
541    pub micro_enforceable: bool,
542    pub long_enforceable: bool,
543    pub pause_during_dnd: bool,
544    pub pause_during_camera: bool,
545    #[serde(default)]
546    pub pause_during_video: bool,
547    /// When a break overlay opens, pause whatever media is playing and
548    /// resume it when the break ends. Distinct from the `pause_during_*`
549    /// guards above: those *suppress* breaks, this one lets the break
550    /// proceed while quieting your media. See `crate::media`.
551    #[serde(default)]
552    pub pause_media_during_breaks: bool,
553    pub work_window_enabled: bool,
554    pub work_start_minutes: u32,
555    pub work_end_minutes: u32,
556    /// Which weekdays the work window applies to, as a 7-bit mask: bit `i`
557    /// (`0` = Monday … `6` = Sunday, matching chrono's
558    /// `Weekday::num_days_from_monday`) set means breaks may fire on that
559    /// day. Only consulted when `work_window_enabled` is on. Defaults to
560    /// every day (`0b111_1111`) so an upgrading user — whose `settings.json`
561    /// predates this field — keeps the old "all days" behaviour. A wrapping
562    /// window (e.g. 22:00–06:00) gates its early-morning portion on the day
563    /// it *started*, not the calendar day it spills into; see
564    /// [`super::timers::work_window_active`].
565    #[serde(default = "default_work_days_mask")]
566    pub work_days_mask: u8,
567    pub bedtime_enabled: bool,
568    pub bedtime_start_minutes: u32,
569    pub bedtime_end_minutes: u32,
570    pub bedtime_interval_secs: u64,
571    pub bedtime_duration_secs: u64,
572    pub prebreak_notification_enabled: bool,
573    pub prebreak_notification_seconds: u64,
574    pub overlay_opacity: f32,
575    pub overlay_color: String,
576    pub overlay_custom_rgb: String,
577    pub overlay_high_contrast: bool,
578    pub show_hint: bool,
579    pub monitor_placement: MonitorPlacement,
580    /// Fraction of the monitor a windowed-mode break overlay fills,
581    /// clamped to `[0.1, 1.0]` by [`crate::scheduler::overlay::centered_windowed_rect`]. Defaults to
582    /// `0.8` — the historical hardcoded value — so existing users see no
583    /// change. Per-kind overrides below take precedence when set; the
584    /// effective value is resolved by [`windowed_fraction_for`].
585    #[serde(default = "default_windowed_fraction")]
586    pub windowed_fraction: f64,
587    /// Optional per-kind windowed-size override for micro breaks. `None`
588    /// falls back to `windowed_fraction`.
589    #[serde(default)]
590    pub micro_windowed_fraction: Option<f64>,
591    /// Optional per-kind windowed-size override for long breaks. `None`
592    /// falls back to `windowed_fraction`. Sleep never renders windowed, so
593    /// it has no override.
594    #[serde(default)]
595    pub long_windowed_fraction: Option<f64>,
596    pub strict_mode: bool,
597    pub postpone_enabled: bool,
598    /// Per-kind postpone master switch, ANDed with the global
599    /// `postpone_enabled` so an upgrading user with postpone globally off
600    /// still has it off everywhere. Defaults `true` so existing on-disk
601    /// settings (which predate these keys) keep their prior behaviour:
602    /// the global flag alone decided postpone, and `true && global ==
603    /// global`.
604    #[serde(default = "default_true")]
605    pub micro_postpone_enabled: bool,
606    #[serde(default = "default_true")]
607    pub long_postpone_enabled: bool,
608    /// Per-kind skip (overlay dismiss) switch. Defaults `true` to match
609    /// the pre-split behaviour where any non-enforceable break could be
610    /// skipped.
611    #[serde(default = "default_true")]
612    pub micro_skip_enabled: bool,
613    #[serde(default = "default_true")]
614    pub long_skip_enabled: bool,
615    pub postpone_minutes: u32,
616    pub show_current_time: bool,
617    #[serde(default = "default_clock_format")]
618    pub clock_format: String,
619    pub micro_manual_finish: bool,
620    pub long_manual_finish: bool,
621    pub autostart_enabled: bool,
622    /// Opt-in: on launch, silently check the updater endpoint once and post a
623    /// desktop notification if a newer build is available. Defaults on; the
624    /// About tab exposes the toggle and the manual "Check for updates" button.
625    pub auto_check_updates: bool,
626    /// Which release line the updater follows. `Stable` only ever sees
627    /// full releases; `Beta` also sees the weekly prerelease builds.
628    /// Switching back to `Stable` does not downgrade — semver ranks a
629    /// prerelease above the stable it precedes, so the build is kept
630    /// until a stable release overtakes it.
631    pub update_channel: UpdateChannel,
632    #[serde(default)]
633    pub micro_sound: BreakSound,
634    #[serde(default)]
635    pub long_sound: BreakSound,
636    pub sound_volume: f32,
637    pub app_pause_enabled: bool,
638    pub app_pause_list: Vec<String>,
639    pub break_health_enabled: bool,
640    /// When on, the first time the work window opens each day with an empty
641    /// chore list, Entracte opens Preferences to the chores input so the user
642    /// can plan the day's chores. On by default; the Breaks tab can disable
643    /// it. See `scheduler::chores::should_prompt_morning_chores`.
644    pub morning_chore_prompt_enabled: bool,
645    // alias keeps pre-split settings.json (single `micro_hints`) loading cleanly into the physical pool.
646    #[serde(alias = "micro_hints")]
647    pub micro_physical_hints: Vec<String>,
648    pub micro_psychological_hints: Vec<String>,
649    pub micro_hint_mix: HintMix,
650    pub long_hints: Vec<String>,
651    pub long_social_hints: Vec<String>,
652    pub long_hint_mix: HintMix,
653    pub sleep_hints: Vec<String>,
654    /// Guided-routine mode for micro / long breaks: `""` (off, fall back to
655    /// hint rotation), a routine id (always that routine), or `"random"` (the
656    /// engine picks one per break from the filtered pool). See
657    /// [`super::routines::resolve_routine`].
658    #[serde(default)]
659    pub micro_routine: String,
660    #[serde(default)]
661    pub long_routine: String,
662    /// Engine filters, applied only when the matching `*_routine` is
663    /// `"random"`: the categories to draw from (empty = all) and the maximum
664    /// difficulty to include.
665    #[serde(default, deserialize_with = "deserialize_routine_categories")]
666    pub micro_routine_categories: Vec<RoutineCategory>,
667    #[serde(default, deserialize_with = "deserialize_routine_categories")]
668    pub long_routine_categories: Vec<RoutineCategory>,
669    #[serde(
670        default = "default_routine_max_difficulty",
671        deserialize_with = "deserialize_routine_max_difficulty"
672    )]
673    pub micro_routine_max_difficulty: RoutineDifficulty,
674    #[serde(
675        default = "default_routine_max_difficulty",
676        deserialize_with = "deserialize_routine_max_difficulty"
677    )]
678    pub long_routine_max_difficulty: RoutineDifficulty,
679    /// User routines imported from content packs (#155), added to the bundled
680    /// starters by [`super::routines::all_routines`]. Empty by default.
681    #[serde(default)]
682    pub custom_routines: Vec<Routine>,
683    /// Default pacing for routines that do not declare their own
684    /// [`super::types::RoutinePacing`]. When `true`, step durations are
685    /// treated as relative weights and scaled to fill the break length
686    /// (`fill` mode). When `false` (default), steps run at their authored
687    /// duration and the last step holds until the break ends (`hold` mode).
688    /// A routine's own `pacing` field always takes precedence.
689    #[serde(default)]
690    pub routine_fill: bool,
691    /// Whether a routine's plugin-supplied sound cues may play (default
692    /// `true`). The user's master kill switch for plugin audio; cues always
693    /// route through `sound_volume` regardless.
694    #[serde(default = "default_true")]
695    pub allow_plugin_sounds: bool,
696    pub hint_rotate_seconds: u64,
697    pub delay_break_if_typing: bool,
698    pub typing_grace_secs: u64,
699    pub typing_max_deferral_secs: u64,
700    pub pause_countdown_if_typing: bool,
701    pub postpone_escalation_enabled: bool,
702    pub postpone_escalation_step_secs: u64,
703    pub postpone_max_count: u32,
704    pub overlay_font_scale: f32,
705    pub micro_fixed_times: Vec<String>,
706    pub long_fixed_times: Vec<String>,
707    pub micro_schedule_mode: ScheduleMode,
708    pub long_schedule_mode: ScheduleMode,
709    pub hooks_enabled: bool,
710    pub hooks: Vec<Hook>,
711    /// Master switch for the native global hotkeys; when off, nothing is
712    /// registered with the OS regardless of `hotkeys`.
713    #[serde(default)]
714    pub hotkeys_enabled: bool,
715    /// User-configured global-hotkey bindings (action + accelerator). See
716    /// [`super::hotkeys`]; resolved by `registrable_bindings`.
717    #[serde(default)]
718    pub hotkeys: Vec<Hotkey>,
719    pub daily_screen_time_enabled: bool,
720    pub daily_screen_time_budget_minutes: u64,
721    pub daily_screen_time_remind_again_minutes: u64,
722    pub tray_countdown_enabled: bool,
723    pub tray_countdown_target: String,
724    pub micro_break_mode: BreakMode,
725    pub long_break_mode: BreakMode,
726    /// Supporter-only freeform stylesheet, applied to both the settings
727    /// window and the break overlay via the renderer's
728    /// `useCustomStylesheet` hook (which uses `adoptedStyleSheets` so we
729    /// don't need to weaken the strict `style-src 'self'` CSP). The
730    /// supporter gate lives in `commands::settings::gate_custom_css`,
731    /// and `sanitize_custom_css` strips `@import` / `expression(` on
732    /// every read+write.
733    #[serde(default)]
734    pub custom_css: String,
735    /// Stable per-load derivations (parsed fixed times, etc.) resolved
736    /// once via [`Settings::rebuild_derived`] rather than on every tick /
737    /// fire. Never serialised: `#[serde(skip)]` keeps it off disk and out
738    /// of the Rust↔TS parity surface, and it's rebuilt explicitly at each
739    /// settings-replacement site. Defaults empty; callers that bypass
740    /// `rebuild_derived` (e.g. raw struct literals in tests) just see
741    /// empty caches, which the run loop treats as "no fixed times".
742    #[serde(skip)]
743    pub derived: DerivedCaches,
744}
745
746impl Default for Settings {
747    fn default() -> Self {
748        Self {
749            micro_interval_secs: 20 * 60,
750            micro_duration_secs: 20,
751            long_interval_secs: 50 * 60,
752            long_duration_secs: 10 * 60,
753            micro_idle_reset_secs: 5 * 60,
754            long_idle_reset_secs: 5 * 60,
755            micro_enabled: true,
756            long_enabled: true,
757            micro_enforceable: false,
758            long_enforceable: true,
759            pause_during_dnd: true,
760            pause_during_camera: true,
761            pause_during_video: false,
762            pause_media_during_breaks: false,
763            work_window_enabled: false,
764            work_start_minutes: 9 * 60,
765            work_end_minutes: 17 * 60,
766            work_days_mask: default_work_days_mask(),
767            bedtime_enabled: false,
768            bedtime_start_minutes: 22 * 60,
769            bedtime_end_minutes: 23 * 60,
770            bedtime_interval_secs: 5 * 60,
771            bedtime_duration_secs: 30,
772            prebreak_notification_enabled: true,
773            prebreak_notification_seconds: 30,
774            overlay_opacity: 0.92,
775            overlay_color: "dark".to_string(),
776            overlay_custom_rgb: "20, 24, 32".to_string(),
777            overlay_high_contrast: false,
778            show_hint: true,
779            monitor_placement: MonitorPlacement::All,
780            windowed_fraction: 0.8,
781            micro_windowed_fraction: None,
782            long_windowed_fraction: None,
783            strict_mode: false,
784            postpone_enabled: true,
785            micro_postpone_enabled: true,
786            long_postpone_enabled: true,
787            micro_skip_enabled: true,
788            long_skip_enabled: true,
789            postpone_minutes: 5,
790            show_current_time: true,
791            clock_format: default_clock_format(),
792            micro_manual_finish: false,
793            long_manual_finish: false,
794            autostart_enabled: false,
795            auto_check_updates: true,
796            update_channel: UpdateChannel::Stable,
797            micro_sound: BreakSound::end_chime("337048"),
798            long_sound: BreakSound::end_chime("337048"),
799            sound_volume: 0.5,
800            app_pause_enabled: false,
801            app_pause_list: Vec::new(),
802            break_health_enabled: true,
803            morning_chore_prompt_enabled: true,
804            micro_physical_hints: default_micro_physical_hints(),
805            micro_psychological_hints: default_micro_psychological_hints(),
806            micro_hint_mix: HintMix::default(),
807            long_hints: default_long_hints(),
808            long_social_hints: default_long_social_hints(),
809            long_hint_mix: HintMix::default(),
810            sleep_hints: default_sleep_hints(),
811            micro_routine: String::new(),
812            long_routine: String::new(),
813            micro_routine_categories: Vec::new(),
814            long_routine_categories: Vec::new(),
815            micro_routine_max_difficulty: default_routine_max_difficulty(),
816            long_routine_max_difficulty: default_routine_max_difficulty(),
817            custom_routines: Vec::new(),
818            routine_fill: false,
819            allow_plugin_sounds: true,
820            hint_rotate_seconds: 0,
821            delay_break_if_typing: true,
822            typing_grace_secs: 10,
823            typing_max_deferral_secs: 60,
824            pause_countdown_if_typing: true,
825            postpone_escalation_enabled: true,
826            postpone_escalation_step_secs: 120,
827            postpone_max_count: 3,
828            overlay_font_scale: 1.0,
829            micro_fixed_times: Vec::new(),
830            long_fixed_times: Vec::new(),
831            micro_schedule_mode: ScheduleMode::default(),
832            long_schedule_mode: ScheduleMode::default(),
833            hooks_enabled: false,
834            hooks: Vec::new(),
835            hotkeys_enabled: false,
836            hotkeys: Vec::new(),
837            daily_screen_time_enabled: false,
838            daily_screen_time_budget_minutes: 8 * 60,
839            daily_screen_time_remind_again_minutes: 60,
840            tray_countdown_enabled: true,
841            tray_countdown_target: default_tray_countdown_target(),
842            micro_break_mode: BreakMode::default(),
843            long_break_mode: BreakMode::default(),
844            custom_css: String::new(),
845            derived: DerivedCaches::default(),
846        }
847    }
848}
849
850/// A borrowed, per-kind view over the `micro_*` / `long_*` field pairs of
851/// a [`Settings`]. Built by [`Settings::for_kind`] to collapse the
852/// `match kind { Micro => s.micro_x, Long => s.long_x, … }` ladders that
853/// recur across `scheduler/` into a single `s.for_kind(kind)?.x` read.
854///
855/// Borrowing keeps this off the wire entirely: the owning `Settings` keeps
856/// its flat `micro_*` / `long_*` fields and its derived serde, so the
857/// on-disk `settings.json` shape and the Rust↔TS IPC contract are
858/// unchanged. Only Micro and Long have these paired fields; Sleep has its
859/// own dedicated fields (`bedtime_duration_secs`, `sleep_hints`, …) and so
860/// [`Settings::for_kind`] returns `None` for it.
861#[derive(Debug, Clone, Copy)]
862pub struct BreakKindSettings<'a> {
863    pub enabled: bool,
864    pub interval_secs: u64,
865    pub duration_secs: u64,
866    pub enforceable: bool,
867    pub manual_finish: bool,
868    pub mode: BreakMode,
869    pub schedule_mode: ScheduleMode,
870    /// Whether postpone is enabled for this kind, before the global
871    /// `postpone_enabled` / `strict_mode` gates are applied. Use
872    /// [`Settings::postpone_available_for`] for the fully-resolved value.
873    pub postpone_enabled: bool,
874    /// Whether the overlay skip (dismiss) control is enabled for this
875    /// kind, before the enforceable / strict gate. Use
876    /// [`Settings::skip_available_for`] for the fully-resolved value.
877    pub skip_enabled: bool,
878    /// The pre-parsed fixed-time minutes for this kind (see
879    /// [`DerivedCaches`]).
880    pub fixed_minutes: &'a [u32],
881}
882
883impl Settings {
884    /// Borrowed per-kind view collapsing the `micro_*` / `long_*` field
885    /// pairs (see [`BreakKindSettings`]). Returns `None` for
886    /// [`BreakKind::Sleep`], which has no interval/duration/mode pair —
887    /// callers handle the sleep case with its dedicated fields.
888    pub fn for_kind(&self, kind: BreakKind) -> Option<BreakKindSettings<'_>> {
889        match kind {
890            BreakKind::Micro => Some(BreakKindSettings {
891                enabled: self.micro_enabled,
892                interval_secs: self.micro_interval_secs,
893                duration_secs: self.micro_duration_secs,
894                enforceable: self.micro_enforceable,
895                manual_finish: self.micro_manual_finish,
896                mode: self.micro_break_mode,
897                schedule_mode: self.micro_schedule_mode,
898                postpone_enabled: self.micro_postpone_enabled,
899                skip_enabled: self.micro_skip_enabled,
900                fixed_minutes: &self.derived.micro_fixed_minutes,
901            }),
902            BreakKind::Long => Some(BreakKindSettings {
903                enabled: self.long_enabled,
904                interval_secs: self.long_interval_secs,
905                duration_secs: self.long_duration_secs,
906                enforceable: self.long_enforceable,
907                manual_finish: self.long_manual_finish,
908                mode: self.long_break_mode,
909                schedule_mode: self.long_schedule_mode,
910                postpone_enabled: self.long_postpone_enabled,
911                skip_enabled: self.long_skip_enabled,
912                fixed_minutes: &self.derived.long_fixed_minutes,
913            }),
914            BreakKind::Sleep => None,
915        }
916    }
917
918    /// The [`ScheduleMode`] for the given break kind. Sleep has no
919    /// interval/fixed split, so its mode never participates in either
920    /// active-check — both report false (see `interval_active` /
921    /// `fixed_active`).
922    fn schedule_mode_for(&self, kind: BreakKind) -> Option<ScheduleMode> {
923        self.for_kind(kind).map(|b| b.schedule_mode)
924    }
925
926    /// True iff this break kind's schedule fires on a repeating interval.
927    /// Centralises the dispatch the run loop would otherwise duplicate at
928    /// every call site.
929    pub fn interval_active(&self, kind: BreakKind) -> bool {
930        self.schedule_mode_for(kind)
931            .is_some_and(ScheduleMode::interval_active)
932    }
933
934    /// True iff this break kind's schedule fires at fixed clock times.
935    pub fn fixed_active(&self, kind: BreakKind) -> bool {
936        self.schedule_mode_for(kind)
937            .is_some_and(ScheduleMode::fixed_active)
938    }
939
940    /// Fully-resolved postpone availability for this kind: the per-kind
941    /// switch ANDed with the global `postpone_enabled` master and gated by
942    /// `strict_mode`. Sleep has no per-kind pair, so it falls back to the
943    /// global master alone — preserving the pre-split behaviour where the
944    /// bedtime postpone path was governed by `postpone_enabled` only.
945    pub fn postpone_available_for(&self, kind: BreakKind) -> bool {
946        self.postpone_enabled
947            && !self.strict_mode
948            && self.for_kind(kind).is_none_or(|b| b.postpone_enabled)
949    }
950
951    /// Fully-resolved overlay-skip availability for this kind: the
952    /// per-kind switch gated by `strict_mode`. Sleep has no per-kind pair
953    /// and is never skippable via the overlay (the sleep fire site sets
954    /// `skip_available: false` directly), so it falls back to `false`
955    /// here. The `enforceable` gate is applied separately at the overlay
956    /// fire site (an enforceable break can't be dismissed regardless).
957    pub fn skip_available_for(&self, kind: BreakKind) -> bool {
958        !self.strict_mode && self.for_kind(kind).is_some_and(|b| b.skip_enabled)
959    }
960
961    /// Re-derive the `derived` cache from the current source fields.
962    ///
963    /// Must be called at every point where a `Settings` value is stored
964    /// into the scheduler's mutex (settings update, profile switch /
965    /// reset, IPC set, backup import, construction) so the cache can
966    /// never lag the settings it summarises. `clamp` calls it last, so
967    /// any path that clamps (load + the renderer update path) is covered
968    /// automatically; the no-clamp replacement sites call it explicitly.
969    pub fn rebuild_derived(&mut self) {
970        self.derived = DerivedCaches::rebuild(self);
971    }
972
973    /// Clamp every numeric field to a safe range. Called on every load
974    /// (post-deserialise) and on every write (post-merge) so that a
975    /// hand-edited or corrupted `settings.json` can't make the
976    /// scheduler misbehave — e.g. `micro_interval_secs: 0` would fire
977    /// a break every tick of the 1Hz loop. Values inside the range are
978    /// left untouched.
979    ///
980    /// The bounds are deliberately generous (the UI's `min` / `max`
981    /// attributes are tighter); we only catch the values that produce
982    /// pathological behaviour.
983    pub fn clamp(&mut self) {
984        // Interval / duration: bottom-stop high enough to prevent the
985        // 1Hz tick from re-firing instantly, top-stop at 24h (intervals)
986        // or 1h (durations) to keep `Duration::from_secs` arithmetic
987        // well away from u64::MAX.
988        self.micro_interval_secs = self.micro_interval_secs.clamp(30, 86_400);
989        self.long_interval_secs = self.long_interval_secs.clamp(30, 86_400);
990        self.micro_duration_secs = self.micro_duration_secs.clamp(1, 3_600);
991        self.long_duration_secs = self.long_duration_secs.clamp(1, 3_600);
992        self.bedtime_interval_secs = self.bedtime_interval_secs.clamp(60, 3_600);
993        self.bedtime_duration_secs = self.bedtime_duration_secs.clamp(1, 3_600);
994        // Idle-reset thresholds: at least 5s (anything less re-fires
995        // on micro keyboard pauses), at most 1h.
996        self.micro_idle_reset_secs = self.micro_idle_reset_secs.clamp(5, 3_600);
997        self.long_idle_reset_secs = self.long_idle_reset_secs.clamp(5, 3_600);
998        // Pre-break warn: 0 means "disabled", so the floor is 0 but we
999        // cap at 5min (any longer makes the warning useless).
1000        self.prebreak_notification_seconds = self.prebreak_notification_seconds.min(300);
1001        // Typing-defer: 0 grace = disabled (already special-cased in
1002        // `should_defer_for_typing`); cap deferral at 1h.
1003        self.typing_grace_secs = self.typing_grace_secs.min(300);
1004        self.typing_max_deferral_secs = self.typing_max_deferral_secs.min(3_600);
1005        // Postpone window / escalation / count: 1..120min, 0..1h step, 0..20 cap.
1006        self.postpone_minutes = self.postpone_minutes.clamp(1, 120);
1007        self.postpone_escalation_step_secs = self.postpone_escalation_step_secs.min(3_600);
1008        self.postpone_max_count = self.postpone_max_count.min(20);
1009        // Screen-time budgets: 0..24h budget, 1..12h re-remind interval.
1010        self.daily_screen_time_budget_minutes = self.daily_screen_time_budget_minutes.min(1_440);
1011        self.daily_screen_time_remind_again_minutes =
1012            self.daily_screen_time_remind_again_minutes.clamp(1, 720);
1013        // Hint rotation: 0 = disabled, otherwise capped at 10min. Must not
1014        // clamp 0 up to 1 — the renderer treats 0 as "off" and `clamp(1, 600)`
1015        // silently re-enables rotation for users who turned it off.
1016        self.hint_rotate_seconds = self.hint_rotate_seconds.min(600);
1017        // Time-of-day windows are minutes-since-midnight (0..1439).
1018        self.work_start_minutes = self.work_start_minutes.min(1_439);
1019        self.work_end_minutes = self.work_end_minutes.min(1_439);
1020        // Only the low 7 bits are meaningful weekdays; drop any stray high
1021        // bits a hand-edited settings.json might set. `0` (no days) is a
1022        // valid "never inside the window" state and must survive untouched —
1023        // masking, not clamping, keeps it.
1024        self.work_days_mask &= 0b111_1111;
1025        self.bedtime_start_minutes = self.bedtime_start_minutes.min(1_439);
1026        self.bedtime_end_minutes = self.bedtime_end_minutes.min(1_439);
1027        // Visual: opacity / volume in [0, 1]; font scale in [0.5, 3.0].
1028        // Opacity floor 0.8 caps UI transparency at 20%.
1029        self.overlay_opacity = self.overlay_opacity.clamp(0.8, 1.0);
1030        self.sound_volume = self.sound_volume.clamp(0.0, 1.0);
1031        self.overlay_font_scale = self.overlay_font_scale.clamp(0.5, 3.0);
1032        // Windowed overlay size: the same [0.1, 1.0] fraction the renderer
1033        // and `centered_windowed_rect` use. Per-kind overrides clamp in
1034        // place when set; `None` (inherit the global) is left untouched.
1035        self.windowed_fraction = self.windowed_fraction.clamp(0.1, 1.0);
1036        self.micro_windowed_fraction = self.micro_windowed_fraction.map(|f| f.clamp(0.1, 1.0));
1037        self.long_windowed_fraction = self.long_windowed_fraction.map(|f| f.clamp(0.1, 1.0));
1038        // Reject unknown clock_format values so the renderer's zod
1039        // enum doesn't reject the entire settings payload.
1040        if self.clock_format != "12h" && self.clock_format != "24h" {
1041            self.clock_format = default_clock_format();
1042        }
1043        // Cap custom CSS at 64KiB so a corrupted or hand-edited
1044        // settings.json can't bloat the renderer payload, then run the
1045        // sanitiser so loaded-from-disk values get the same scrub as
1046        // newly-saved ones. Walk back to a char boundary before
1047        // truncating — `String::truncate` panics mid-codepoint.
1048        if self.custom_css.len() > 65_536 {
1049            let mut cut = 65_536;
1050            while !self.custom_css.is_char_boundary(cut) {
1051                cut -= 1;
1052            }
1053            self.custom_css.truncate(cut);
1054        }
1055        self.custom_css = sanitize_custom_css(&self.custom_css);
1056        // Source fields are now in their final clamped form; re-derive the
1057        // caches so a clamped load/update path never has to touch them
1058        // again.
1059        self.rebuild_derived();
1060    }
1061}
1062
1063/// Defence-in-depth scrub for user-supplied CSS. Even with a strict CSP
1064/// in place we belt-and-braces:
1065///
1066/// - drop `@import` rules entirely (they could pull in further styles
1067///   that we don't want to audit, and CSP-bypass via stylesheet chains
1068///   has historically been a footgun);
1069/// - strip the legacy IE `expression(...)` construct, which old WebKit
1070///   forks have re-introduced for compatibility.
1071///
1072/// Comments are normalised first so the patterns can't be hidden behind
1073/// `/* */` splits. Operates on `&str` throughout — `bytes`-indexing
1074/// would mojibake non-ASCII content like `content: "→"`.
1075pub fn sanitize_custom_css(css: &str) -> String {
1076    let stripped = strip_css_comments(css);
1077    let mut out = String::with_capacity(stripped.len());
1078    for raw in stripped.split_inclusive(';') {
1079        let lower = raw.trim_start().to_ascii_lowercase();
1080        if lower.starts_with("@import") || lower.contains("expression(") {
1081            continue;
1082        }
1083        out.push_str(raw);
1084    }
1085    out
1086}
1087
1088fn strip_css_comments(css: &str) -> String {
1089    let mut out = String::with_capacity(css.len());
1090    let mut rest = css;
1091    while let Some(start) = rest.find("/*") {
1092        out.push_str(&rest[..start]);
1093        rest = &rest[start + 2..];
1094        match rest.find("*/") {
1095            Some(end) => rest = &rest[end + 2..],
1096            None => return out, // unterminated comment swallows the tail
1097        }
1098    }
1099    out.push_str(rest);
1100    out
1101}
1102
1103/// Pure resolver for the micro-break hint pool, honouring
1104/// `micro_hint_mix`. `Physical` / `Psychological` return only that pool;
1105/// anything else (including `Both`) concatenates both in
1106/// physical-then-psychological order. This does the allocating /
1107/// concatenating work; [`DerivedCaches`] caches its result so the fire
1108/// path doesn't repeat it.
1109fn resolve_micro_hints(s: &Settings) -> Vec<String> {
1110    match s.micro_hint_mix {
1111        HintMix::Physical => s.micro_physical_hints.clone(),
1112        HintMix::Psychological => s.micro_psychological_hints.clone(),
1113        _ => {
1114            let mut combined = Vec::with_capacity(
1115                s.micro_physical_hints.len() + s.micro_psychological_hints.len(),
1116            );
1117            combined.extend(s.micro_physical_hints.iter().cloned());
1118            combined.extend(s.micro_psychological_hints.iter().cloned());
1119            combined
1120        }
1121    }
1122}
1123
1124/// Pure resolver for the long-break hint pool, honouring `long_hint_mix`.
1125/// `Solo` / `Social` filter to that pool; anything else (including
1126/// `Both`) concatenates them in solo-then-social order. Cached by
1127/// [`DerivedCaches`].
1128fn resolve_long_hints(s: &Settings) -> Vec<String> {
1129    match s.long_hint_mix {
1130        HintMix::Solo => s.long_hints.clone(),
1131        HintMix::Social => s.long_social_hints.clone(),
1132        _ => {
1133            let mut combined = Vec::with_capacity(s.long_hints.len() + s.long_social_hints.len());
1134            combined.extend(s.long_hints.iter().cloned());
1135            combined.extend(s.long_social_hints.iter().cloned());
1136            combined
1137        }
1138    }
1139}
1140
1141/// The resolved micro-break hint pool.
1142///
1143/// The hint *mix* is resolved (and its two pools concatenated) once at
1144/// settings load/update into the cache; this accessor just clones the
1145/// finished vector, so the per-fire cost is a single allocation rather
1146/// than the re-concatenation the pre-cache version did on every break.
1147pub fn effective_micro_hints(s: &Settings) -> Vec<String> {
1148    s.derived.micro_hints_resolved.clone()
1149}
1150
1151/// The resolved long-break hint pool. See [`effective_micro_hints`].
1152pub fn effective_long_hints(s: &Settings) -> Vec<String> {
1153    s.derived.long_hints_resolved.clone()
1154}
1155
1156impl Settings {
1157    /// The resolved hint pool to show for `kind`: the per-kind cache for
1158    /// micro/long (honouring its hint mix), or the sleep pool for Sleep.
1159    /// Collapses the `match kind { Micro => effective_micro_hints(s), … }`
1160    /// ladder the fire paths used to repeat.
1161    pub fn effective_hints(&self, kind: BreakKind) -> Vec<String> {
1162        match kind {
1163            BreakKind::Micro => effective_micro_hints(self),
1164            BreakKind::Long => effective_long_hints(self),
1165            BreakKind::Sleep => self.sleep_hints.clone(),
1166        }
1167    }
1168
1169    /// The `duration_secs` and `manual_finish` a break of `kind` fires with:
1170    /// the per-kind pair for micro/long, or the bedtime duration / no
1171    /// manual-finish for Sleep. The enforceability and hint pool are
1172    /// resolved separately (see `test_break_enforceable` / `effective_hints`)
1173    /// so the renderer and CLI paths share one enforceability rule.
1174    pub fn duration_and_manual_finish(&self, kind: BreakKind) -> (u64, bool) {
1175        match self.for_kind(kind) {
1176            Some(b) => (b.duration_secs, b.manual_finish),
1177            None => (self.bedtime_duration_secs, false),
1178        }
1179    }
1180}
1181
1182/// Resolve the delivery mode for the given break kind.
1183///
1184/// Sleep breaks always use `Overlay` (bedtime reminders ignore the
1185/// per-kind mode); micro/long dispatch straight off their `BreakMode`.
1186pub fn delivery_for(kind: BreakKind, s: &Settings) -> BreakDelivery {
1187    s.for_kind(kind)
1188        .map_or(BreakDelivery::Overlay, |b| b.mode.delivery())
1189}
1190
1191/// True iff the given break kind is currently configured for the
1192/// `Windowed` delivery mode. Convenience wrapper around `delivery_for`.
1193pub fn is_windowed_mode(kind: BreakKind, s: &Settings) -> bool {
1194    matches!(delivery_for(kind, s), BreakDelivery::Windowed)
1195}
1196
1197/// Resolve the windowed-overlay size fraction for a break kind: the
1198/// per-kind override when set, otherwise the global `windowed_fraction`.
1199/// The result is clamped to `[0.1, 1.0]` (matching
1200/// [`crate::scheduler::overlay::centered_windowed_rect`]) so a corrupt on-disk value can't size the
1201/// overlay off-screen. Sleep has no override and falls back to the global
1202/// value (it never renders windowed anyway).
1203pub fn windowed_fraction_for(kind: BreakKind, s: &Settings) -> f64 {
1204    let override_value = match kind {
1205        BreakKind::Micro => s.micro_windowed_fraction,
1206        BreakKind::Long => s.long_windowed_fraction,
1207        BreakKind::Sleep => None,
1208    };
1209    override_value
1210        .unwrap_or(s.windowed_fraction)
1211        .clamp(0.1, 1.0)
1212}
1213
1214#[cfg(test)]
1215mod sanitize_tests {
1216    use super::sanitize_custom_css;
1217
1218    #[test]
1219    fn passes_safe_css_through() {
1220        let input = ".overlay-card { background: #111; color: white; }";
1221        assert_eq!(sanitize_custom_css(input), input);
1222    }
1223
1224    #[test]
1225    fn drops_at_import_rules() {
1226        let input = "@import url('https://evil.example/x.css'); .ok { color: red; }";
1227        let out = sanitize_custom_css(input);
1228        assert!(!out.contains("@import"), "got: {out}");
1229        assert!(out.contains(".ok"));
1230    }
1231
1232    #[test]
1233    fn drops_at_import_even_when_obfuscated_with_comments() {
1234        let input = "@/* hi */import url('https://evil/x.css'); .ok { color: red; }";
1235        let out = sanitize_custom_css(input);
1236        assert!(!out.to_ascii_lowercase().contains("@import"));
1237        assert!(out.contains(".ok"));
1238    }
1239
1240    #[test]
1241    fn drops_expression_construct() {
1242        let input = ".x { width: expression(alert(1)); } .ok { color: red; }";
1243        let out = sanitize_custom_css(input);
1244        assert!(!out.contains("expression("), "got: {out}");
1245        assert!(out.contains(".ok"));
1246    }
1247
1248    #[test]
1249    fn empty_in_empty_out() {
1250        assert_eq!(sanitize_custom_css(""), "");
1251    }
1252
1253    #[test]
1254    fn preserves_non_ascii_content() {
1255        let input = ".x::before { content: \"→ café\"; } /* éhé */ .y { color: red; }";
1256        let out = sanitize_custom_css(input);
1257        assert!(out.contains("→ café"), "non-ASCII content corrupted: {out}");
1258        assert!(out.contains(".y"));
1259        assert!(!out.contains("éhé"), "comment should be stripped");
1260    }
1261
1262    #[test]
1263    fn unterminated_comment_swallows_tail() {
1264        // Defensive: a hand-edited CSS with a runaway `/*` shouldn't
1265        // panic or leak commented-out source into the output.
1266        let out = sanitize_custom_css(".ok {} /* unterminated");
1267        assert_eq!(out, ".ok {} ");
1268    }
1269}
1270
1271#[cfg(test)]
1272mod clamp_custom_css_tests {
1273    use super::*;
1274
1275    #[test]
1276    fn truncates_at_64kib_without_panicking_on_multibyte_boundary() {
1277        // Regression: `String::truncate(65_536)` panics if byte 65,536
1278        // lands inside a multi-byte codepoint. Fill exactly to the cap
1279        // with ASCII then append an emoji that straddles it.
1280        let mut css = "a".repeat(65_535);
1281        css.push('🎉'); // 4 bytes — pushes total to 65,539
1282        let mut s = Settings {
1283            custom_css: css,
1284            ..Settings::default()
1285        };
1286        s.clamp();
1287        assert!(s.custom_css.len() <= 65_536);
1288        // Must remain valid UTF-8 — the test would already panic if
1289        // truncate split the codepoint, but assert explicitly.
1290        assert!(std::str::from_utf8(s.custom_css.as_bytes()).is_ok());
1291    }
1292}
1293
1294#[cfg(test)]
1295mod tests {
1296    use super::*;
1297
1298    #[test]
1299    fn settings_default_sensible() {
1300        let s = Settings::default();
1301        assert!(s.micro_interval_secs > 0);
1302        assert!(s.long_interval_secs > s.micro_interval_secs);
1303        assert!(s.long_enforceable);
1304        assert!(!s.micro_enforceable);
1305        assert!(s.work_end_minutes > s.work_start_minutes);
1306        assert_eq!(
1307            s.work_days_mask, 0b111_1111,
1308            "work window defaults to every weekday enabled"
1309        );
1310        assert!(s.overlay_opacity > 0.0 && s.overlay_opacity <= 1.0);
1311        assert!(s.postpone_minutes > 0);
1312        assert!(s.sound_volume >= 0.0 && s.sound_volume <= 1.0);
1313        assert!(!s.micro_physical_hints.is_empty());
1314        assert!(!s.micro_psychological_hints.is_empty());
1315        assert_eq!(s.micro_hint_mix, HintMix::Both);
1316        assert!(!s.long_hints.is_empty());
1317        assert!(!s.sleep_hints.is_empty());
1318        assert_eq!(s.micro_idle_reset_secs, 300);
1319        assert_eq!(s.long_idle_reset_secs, 300);
1320        assert!((s.overlay_font_scale - 1.0).abs() < f32::EPSILON);
1321    }
1322
1323    // `Settings::clamp` — the safety net for corrupted / hand-edited
1324    // settings.json. Every numeric field that can produce pathological
1325    // behaviour (1Hz break re-fires, division by zero, overflow) gets
1326    // clamped into a safe range on load and on write.
1327
1328    #[test]
1329    fn clamp_fixes_zero_intervals() {
1330        // Pre-fix: `Duration::from_secs(0)` + `elapsed >= 0` is always
1331        // true, so the scheduler fires a break every tick.
1332        let mut s = Settings {
1333            micro_interval_secs: 0,
1334            long_interval_secs: 0,
1335            bedtime_interval_secs: 0,
1336            ..Settings::default()
1337        };
1338        s.clamp();
1339        assert!(s.micro_interval_secs >= 30);
1340        assert!(s.long_interval_secs >= 30);
1341        assert!(s.bedtime_interval_secs >= 60);
1342    }
1343
1344    #[test]
1345    fn clamp_caps_max_intervals_below_u64_overflow_danger() {
1346        let mut s = Settings {
1347            micro_interval_secs: u64::MAX,
1348            long_interval_secs: u64::MAX,
1349            ..Settings::default()
1350        };
1351        s.clamp();
1352        assert!(s.micro_interval_secs <= 86_400);
1353        assert!(s.long_interval_secs <= 86_400);
1354    }
1355
1356    #[test]
1357    fn clamp_leaves_in_range_values_alone() {
1358        let mut s = Settings::default();
1359        let micro = s.micro_interval_secs;
1360        let long = s.long_interval_secs;
1361        let bedtime = s.bedtime_interval_secs;
1362        s.clamp();
1363        assert_eq!(s.micro_interval_secs, micro);
1364        assert_eq!(s.long_interval_secs, long);
1365        assert_eq!(s.bedtime_interval_secs, bedtime);
1366    }
1367
1368    #[test]
1369    fn clamp_keeps_zero_prebreak_lead_as_disabled() {
1370        // 0 is a valid "no warning" value here — the run_loop also
1371        // gates on `prebreak_notification_seconds > 0`. Clamp must not
1372        // bump 0 up to a positive value or notifications would start
1373        // firing for users who explicitly opted out.
1374        let mut s = Settings {
1375            prebreak_notification_seconds: 0,
1376            ..Settings::default()
1377        };
1378        s.clamp();
1379        assert_eq!(s.prebreak_notification_seconds, 0);
1380    }
1381
1382    #[test]
1383    fn clamp_keeps_zero_hint_rotation_as_disabled() {
1384        // 0 = rotation off. The renderer's useHintRotation gates on
1385        // `hint_rotate_seconds > 0`; clamping 0 up to 1 would silently
1386        // re-enable rotation for users who unchecked the toggle.
1387        let mut s = Settings {
1388            hint_rotate_seconds: 0,
1389            ..Settings::default()
1390        };
1391        s.clamp();
1392        assert_eq!(s.hint_rotate_seconds, 0);
1393    }
1394
1395    #[test]
1396    fn clamp_pins_minutes_of_day_to_valid_range() {
1397        let mut s = Settings {
1398            work_start_minutes: 9_999,
1399            bedtime_end_minutes: 5_000,
1400            ..Settings::default()
1401        };
1402        s.clamp();
1403        assert!(s.work_start_minutes <= 1_439);
1404        assert!(s.bedtime_end_minutes <= 1_439);
1405    }
1406
1407    #[test]
1408    fn clamp_strips_stray_high_bits_from_work_days_mask() {
1409        // A hand-edited settings.json could set bits above the 7 weekday
1410        // bits; masking drops them so the bit-test never reads a phantom day.
1411        let mut s = Settings {
1412            work_days_mask: 0b1010_1010,
1413            ..Settings::default()
1414        };
1415        s.clamp();
1416        assert_eq!(s.work_days_mask, 0b010_1010);
1417    }
1418
1419    #[test]
1420    fn clamp_keeps_zero_work_days_mask_as_no_days() {
1421        // 0 = "no weekday enabled" is a deliberate state (work window on but
1422        // every day toggled off). Masking must not turn it into a real day.
1423        let mut s = Settings {
1424            work_days_mask: 0,
1425            ..Settings::default()
1426        };
1427        s.clamp();
1428        assert_eq!(s.work_days_mask, 0);
1429    }
1430
1431    #[test]
1432    fn clamp_pins_floats_to_unit_interval() {
1433        let mut s = Settings {
1434            overlay_opacity: -0.5,
1435            sound_volume: 10.0,
1436            ..Settings::default()
1437        };
1438        s.clamp();
1439        // Opacity floor is 0.8 (caps transparency at 20%).
1440        assert!((0.8..=1.0).contains(&s.overlay_opacity));
1441        assert!((0.0..=1.0).contains(&s.sound_volume));
1442    }
1443
1444    #[test]
1445    fn clamp_pins_windowed_fractions_to_valid_range() {
1446        let mut s = Settings {
1447            windowed_fraction: 5.0,
1448            micro_windowed_fraction: Some(0.0),
1449            long_windowed_fraction: None,
1450            ..Settings::default()
1451        };
1452        s.clamp();
1453        assert_eq!(s.windowed_fraction, 1.0);
1454        assert_eq!(s.micro_windowed_fraction, Some(0.1));
1455        // An unset (inherit) override stays None — never forced to a value.
1456        assert_eq!(s.long_windowed_fraction, None);
1457    }
1458
1459    #[test]
1460    fn clamp_caps_transparency_at_twenty_percent() {
1461        // Hand-edited settings.json with 50% transparency must be
1462        // clamped back to the 20% cap (opacity 0.8).
1463        let mut s = Settings {
1464            overlay_opacity: 0.5,
1465            ..Settings::default()
1466        };
1467        s.clamp();
1468        assert!((s.overlay_opacity - 0.8).abs() < f32::EPSILON);
1469    }
1470
1471    #[test]
1472    fn clock_format_defaults_to_24h() {
1473        let s = Settings::default();
1474        assert_eq!(s.clock_format, "24h");
1475    }
1476
1477    #[test]
1478    fn clamp_normalises_unknown_clock_format() {
1479        let mut s = Settings {
1480            clock_format: "garbage".to_string(),
1481            ..Settings::default()
1482        };
1483        s.clamp();
1484        assert_eq!(s.clock_format, "24h");
1485    }
1486
1487    #[test]
1488    fn clamp_leaves_valid_clock_format_alone() {
1489        let mut s = Settings {
1490            clock_format: "12h".to_string(),
1491            ..Settings::default()
1492        };
1493        s.clamp();
1494        assert_eq!(s.clock_format, "12h");
1495    }
1496
1497    #[test]
1498    fn clamp_pins_font_scale_to_supported_range() {
1499        let mut s = Settings {
1500            overlay_font_scale: 0.01,
1501            ..Settings::default()
1502        };
1503        s.clamp();
1504        assert!(s.overlay_font_scale >= 0.5);
1505        s.overlay_font_scale = 99.0;
1506        s.clamp();
1507        assert!(s.overlay_font_scale <= 3.0);
1508    }
1509
1510    #[test]
1511    fn clamp_caps_postpone_count_to_prevent_runaway() {
1512        let mut s = Settings {
1513            postpone_max_count: u32::MAX,
1514            ..Settings::default()
1515        };
1516        s.clamp();
1517        assert!(s.postpone_max_count <= 20);
1518    }
1519
1520    #[test]
1521    fn clamp_is_idempotent() {
1522        // Clamping twice produces the same result as clamping once —
1523        // important because we clamp on both load and write paths.
1524        let mut a = Settings {
1525            micro_interval_secs: 0,
1526            overlay_opacity: 5.0,
1527            ..Settings::default()
1528        };
1529        a.clamp();
1530        let snapshot = a.clone();
1531        a.clamp();
1532        assert_eq!(snapshot.micro_interval_secs, a.micro_interval_secs);
1533        assert!(
1534            (snapshot.overlay_opacity - a.overlay_opacity).abs() < f32::EPSILON,
1535            "clamp idempotent on overlay_opacity"
1536        );
1537    }
1538
1539    #[test]
1540    fn legacy_idle_reset_secs_aliases_into_micro() {
1541        let json = r#"{"idle_reset_secs": 123}"#;
1542        let s: Settings = serde_json::from_str(json).unwrap();
1543        assert_eq!(s.micro_idle_reset_secs, 123);
1544        assert_eq!(
1545            s.long_idle_reset_secs,
1546            Settings::default().long_idle_reset_secs
1547        );
1548    }
1549
1550    #[test]
1551    fn legacy_micro_hints_aliases_into_physical() {
1552        let json = r#"{"micro_hints": ["Stretch", "Blink"]}"#;
1553        let s: Settings = serde_json::from_str(json).unwrap();
1554        assert_eq!(s.micro_physical_hints, vec!["Stretch", "Blink"]);
1555        assert_eq!(
1556            s.micro_psychological_hints,
1557            Settings::default().micro_psychological_hints
1558        );
1559        assert_eq!(s.micro_hint_mix, HintMix::Both);
1560    }
1561
1562    #[test]
1563    #[allow(clippy::field_reassign_with_default)]
1564    fn effective_micro_hints_modes() {
1565        // `effective_micro_hints` reads the cache, so each mix change has
1566        // to be followed by `rebuild_derived` — exercising both the pure
1567        // resolver and the cache plumbing.
1568        let mut s = Settings::default();
1569        s.micro_physical_hints = vec!["a".into(), "b".into()];
1570        s.micro_psychological_hints = vec!["c".into()];
1571
1572        s.micro_hint_mix = HintMix::Physical;
1573        s.rebuild_derived();
1574        assert_eq!(effective_micro_hints(&s), ["a", "b"]);
1575
1576        s.micro_hint_mix = HintMix::Psychological;
1577        s.rebuild_derived();
1578        assert_eq!(effective_micro_hints(&s), ["c"]);
1579
1580        s.micro_hint_mix = HintMix::Both;
1581        s.rebuild_derived();
1582        assert_eq!(effective_micro_hints(&s), ["a", "b", "c"]);
1583
1584        // A long-only variant on a micro field falls through to the
1585        // concatenated pool, matching the pre-enum "anything else" arm.
1586        s.micro_hint_mix = HintMix::Social;
1587        s.rebuild_derived();
1588        assert_eq!(effective_micro_hints(&s), ["a", "b", "c"]);
1589    }
1590
1591    #[test]
1592    #[allow(clippy::field_reassign_with_default)]
1593    fn effective_long_hints_modes() {
1594        let mut s = Settings::default();
1595        s.long_hints = vec!["solo1".into(), "solo2".into()];
1596        s.long_social_hints = vec!["soc1".into()];
1597
1598        s.long_hint_mix = HintMix::Solo;
1599        s.rebuild_derived();
1600        assert_eq!(effective_long_hints(&s), ["solo1", "solo2"]);
1601
1602        s.long_hint_mix = HintMix::Social;
1603        s.rebuild_derived();
1604        assert_eq!(effective_long_hints(&s), ["soc1"]);
1605
1606        s.long_hint_mix = HintMix::Both;
1607        s.rebuild_derived();
1608        assert_eq!(effective_long_hints(&s), ["solo1", "solo2", "soc1"]);
1609
1610        // A micro-only variant on a long field falls through to the
1611        // concatenated pool, matching the pre-enum "anything else" arm.
1612        s.long_hint_mix = HintMix::Physical;
1613        s.rebuild_derived();
1614        assert_eq!(effective_long_hints(&s), ["solo1", "solo2", "soc1"]);
1615    }
1616
1617    #[test]
1618    fn clamp_rebuilds_resolved_hint_cache() {
1619        // The load/update path runs through `clamp`; the resolved hint
1620        // pools must be populated afterward without a separate call.
1621        let mut s = Settings::default();
1622        s.clamp();
1623        assert!(!effective_micro_hints(&s).is_empty());
1624        assert!(!effective_long_hints(&s).is_empty());
1625    }
1626
1627    #[test]
1628    fn default_long_social_hints_are_populated() {
1629        let s = Settings::default();
1630        assert!(!s.long_social_hints.is_empty());
1631        assert_eq!(s.long_hint_mix, HintMix::Both);
1632    }
1633
1634    #[test]
1635    fn settings_default_fixed_times_empty_and_interval() {
1636        let s = Settings::default();
1637        assert!(s.micro_fixed_times.is_empty());
1638        assert!(s.long_fixed_times.is_empty());
1639        assert_eq!(s.micro_schedule_mode, ScheduleMode::Interval);
1640        assert_eq!(s.long_schedule_mode, ScheduleMode::Interval);
1641    }
1642
1643    #[test]
1644    #[allow(clippy::field_reassign_with_default)]
1645    fn schedule_mode_helpers_match_interval_fixed_and_both() {
1646        let mut s = Settings::default();
1647
1648        s.micro_schedule_mode = ScheduleMode::Interval;
1649        assert!(s.interval_active(BreakKind::Micro));
1650        assert!(!s.fixed_active(BreakKind::Micro));
1651
1652        s.micro_schedule_mode = ScheduleMode::Fixed;
1653        assert!(!s.interval_active(BreakKind::Micro));
1654        assert!(s.fixed_active(BreakKind::Micro));
1655
1656        s.micro_schedule_mode = ScheduleMode::Both;
1657        assert!(s.interval_active(BreakKind::Micro));
1658        assert!(s.fixed_active(BreakKind::Micro));
1659
1660        s.long_schedule_mode = ScheduleMode::Interval;
1661        assert!(s.interval_active(BreakKind::Long));
1662        assert!(!s.fixed_active(BreakKind::Long));
1663    }
1664
1665    #[test]
1666    fn schedule_mode_helpers_report_false_for_sleep() {
1667        // Sleep has no interval/fixed split: both helpers report false
1668        // regardless of the micro/long modes.
1669        let s = Settings {
1670            micro_schedule_mode: ScheduleMode::Both,
1671            long_schedule_mode: ScheduleMode::Both,
1672            ..Settings::default()
1673        };
1674        assert!(!s.interval_active(BreakKind::Sleep));
1675        assert!(!s.fixed_active(BreakKind::Sleep));
1676    }
1677
1678    #[test]
1679    fn screen_time_defaults_off_with_eight_hour_budget() {
1680        let s = Settings::default();
1681        assert!(!s.daily_screen_time_enabled);
1682        assert_eq!(s.daily_screen_time_budget_minutes, 480);
1683        assert_eq!(s.daily_screen_time_remind_again_minutes, 60);
1684    }
1685
1686    #[test]
1687    fn tray_countdown_defaults() {
1688        let s = Settings::default();
1689        assert!(s.tray_countdown_enabled);
1690        assert_eq!(s.tray_countdown_target, "next");
1691    }
1692
1693    /// A break confined to one screen is trivially ignored on a
1694    /// multi-monitor desk (#315), so covering every display is the
1695    /// default. Both the enum's own default and the one baked into
1696    /// `Settings::default()` must agree, or a fresh install and a
1697    /// settings.json missing the key would disagree.
1698    #[test]
1699    fn monitor_placement_defaults_to_all() {
1700        assert_eq!(MonitorPlacement::default(), MonitorPlacement::All);
1701        assert_eq!(Settings::default().monitor_placement, MonitorPlacement::All);
1702    }
1703
1704    /// The default must not silently override a choice already on disk —
1705    /// an existing user who picked a single display keeps it.
1706    #[test]
1707    fn an_explicit_placement_survives_the_new_default() {
1708        let s: Settings = serde_json::from_str(r#"{"monitor_placement": "primary"}"#).unwrap();
1709        assert_eq!(s.monitor_placement, MonitorPlacement::Primary);
1710    }
1711
1712    #[test]
1713    fn break_mode_defaults_to_overlay() {
1714        let s = Settings::default();
1715        assert_eq!(s.micro_break_mode, BreakMode::Overlay);
1716        assert_eq!(s.long_break_mode, BreakMode::Overlay);
1717        assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Overlay);
1718        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
1719        assert_eq!(delivery_for(BreakKind::Sleep, &s), BreakDelivery::Overlay);
1720    }
1721
1722    #[test]
1723    #[allow(clippy::field_reassign_with_default)]
1724    fn delivery_for_notification_per_kind() {
1725        let mut s = Settings::default();
1726        s.micro_break_mode = BreakMode::Notification;
1727        assert_eq!(
1728            delivery_for(BreakKind::Micro, &s),
1729            BreakDelivery::Notification
1730        );
1731        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
1732
1733        s.long_break_mode = BreakMode::Notification;
1734        assert_eq!(
1735            delivery_for(BreakKind::Long, &s),
1736            BreakDelivery::Notification
1737        );
1738    }
1739
1740    #[test]
1741    #[allow(clippy::field_reassign_with_default)]
1742    fn delivery_for_sleep_always_overlay() {
1743        let mut s = Settings::default();
1744        s.micro_break_mode = BreakMode::Notification;
1745        s.long_break_mode = BreakMode::Notification;
1746        assert_eq!(delivery_for(BreakKind::Sleep, &s), BreakDelivery::Overlay);
1747    }
1748
1749    #[test]
1750    #[allow(clippy::field_reassign_with_default)]
1751    fn delivery_for_windowed_per_kind() {
1752        let mut s = Settings::default();
1753        s.micro_break_mode = BreakMode::Windowed;
1754        assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Windowed);
1755        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
1756
1757        s.long_break_mode = BreakMode::Windowed;
1758        assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Windowed);
1759    }
1760
1761    #[test]
1762    #[allow(clippy::field_reassign_with_default)]
1763    fn is_windowed_mode_tracks_per_kind_setting() {
1764        let mut s = Settings::default();
1765        assert!(!is_windowed_mode(BreakKind::Micro, &s));
1766        assert!(!is_windowed_mode(BreakKind::Long, &s));
1767        assert!(!is_windowed_mode(BreakKind::Sleep, &s));
1768
1769        s.micro_break_mode = BreakMode::Windowed;
1770        assert!(is_windowed_mode(BreakKind::Micro, &s));
1771        assert!(!is_windowed_mode(BreakKind::Long, &s));
1772
1773        s.long_break_mode = BreakMode::Windowed;
1774        assert!(is_windowed_mode(BreakKind::Long, &s));
1775
1776        s.micro_break_mode = BreakMode::Notification;
1777        assert!(!is_windowed_mode(BreakKind::Micro, &s));
1778    }
1779
1780    #[test]
1781    #[allow(clippy::field_reassign_with_default)]
1782    fn is_windowed_mode_for_sleep_is_always_false() {
1783        let mut s = Settings::default();
1784        s.micro_break_mode = BreakMode::Windowed;
1785        s.long_break_mode = BreakMode::Windowed;
1786        assert!(!is_windowed_mode(BreakKind::Sleep, &s));
1787    }
1788
1789    #[test]
1790    fn windowed_fraction_defaults_to_eighty_percent() {
1791        let s = Settings::default();
1792        assert_eq!(s.windowed_fraction, 0.8);
1793        assert_eq!(windowed_fraction_for(BreakKind::Micro, &s), 0.8);
1794        assert_eq!(windowed_fraction_for(BreakKind::Long, &s), 0.8);
1795        assert_eq!(windowed_fraction_for(BreakKind::Sleep, &s), 0.8);
1796    }
1797
1798    #[test]
1799    #[allow(clippy::field_reassign_with_default)]
1800    fn windowed_fraction_for_uses_global_when_no_override() {
1801        let mut s = Settings::default();
1802        s.windowed_fraction = 0.7;
1803        assert_eq!(windowed_fraction_for(BreakKind::Micro, &s), 0.7);
1804        assert_eq!(windowed_fraction_for(BreakKind::Long, &s), 0.7);
1805    }
1806
1807    #[test]
1808    #[allow(clippy::field_reassign_with_default)]
1809    fn windowed_fraction_for_prefers_per_kind_override() {
1810        let mut s = Settings::default();
1811        s.windowed_fraction = 0.8;
1812        s.micro_windowed_fraction = Some(0.5);
1813        // Micro takes its override; long still falls back to the global.
1814        assert_eq!(windowed_fraction_for(BreakKind::Micro, &s), 0.5);
1815        assert_eq!(windowed_fraction_for(BreakKind::Long, &s), 0.8);
1816
1817        s.long_windowed_fraction = Some(0.95);
1818        assert_eq!(windowed_fraction_for(BreakKind::Long, &s), 0.95);
1819        // The micro override is independent of the long one.
1820        assert_eq!(windowed_fraction_for(BreakKind::Micro, &s), 0.5);
1821    }
1822
1823    #[test]
1824    #[allow(clippy::field_reassign_with_default)]
1825    fn windowed_fraction_for_clamps_out_of_range_values() {
1826        let mut s = Settings::default();
1827        s.windowed_fraction = 5.0;
1828        assert_eq!(windowed_fraction_for(BreakKind::Long, &s), 1.0);
1829        s.micro_windowed_fraction = Some(0.0);
1830        assert_eq!(windowed_fraction_for(BreakKind::Micro, &s), 0.1);
1831    }
1832
1833    #[test]
1834    #[allow(clippy::field_reassign_with_default)]
1835    fn windowed_fraction_for_sleep_ignores_per_kind_overrides() {
1836        let mut s = Settings::default();
1837        s.windowed_fraction = 0.6;
1838        s.micro_windowed_fraction = Some(0.3);
1839        s.long_windowed_fraction = Some(0.9);
1840        assert_eq!(windowed_fraction_for(BreakKind::Sleep, &s), 0.6);
1841    }
1842
1843    #[test]
1844    fn hint_rotation_is_off_by_default() {
1845        assert_eq!(Settings::default().hint_rotate_seconds, 0);
1846    }
1847
1848    #[test]
1849    fn legacy_settings_json_defaults_break_mode_to_overlay() {
1850        let json = r#"{"micro_interval_secs": 600}"#;
1851        let s: Settings = serde_json::from_str(json).unwrap();
1852        assert_eq!(s.micro_break_mode, BreakMode::Overlay);
1853        assert_eq!(s.long_break_mode, BreakMode::Overlay);
1854    }
1855
1856    // -- Enum serde: on-disk strings stay lowercase so existing
1857    //    settings.json files round-trip, and unknown/corrupt values
1858    //    normalise to the fallback on load instead of failing the parse.
1859
1860    #[test]
1861    fn break_mode_serialises_to_lowercase_disk_strings() {
1862        assert_eq!(
1863            serde_json::to_value(BreakMode::Overlay).unwrap(),
1864            serde_json::json!("overlay")
1865        );
1866        assert_eq!(
1867            serde_json::to_value(BreakMode::Windowed).unwrap(),
1868            serde_json::json!("windowed")
1869        );
1870        assert_eq!(
1871            serde_json::to_value(BreakMode::Notification).unwrap(),
1872            serde_json::json!("notification")
1873        );
1874    }
1875
1876    #[test]
1877    fn schedule_mode_serialises_to_lowercase_disk_strings() {
1878        assert_eq!(
1879            serde_json::to_value(ScheduleMode::Interval).unwrap(),
1880            serde_json::json!("interval")
1881        );
1882        assert_eq!(
1883            serde_json::to_value(ScheduleMode::Fixed).unwrap(),
1884            serde_json::json!("fixed")
1885        );
1886        assert_eq!(
1887            serde_json::to_value(ScheduleMode::Both).unwrap(),
1888            serde_json::json!("both")
1889        );
1890    }
1891
1892    #[test]
1893    fn hint_mix_serialises_to_lowercase_disk_strings() {
1894        for (variant, expected) in [
1895            (HintMix::Both, "both"),
1896            (HintMix::Physical, "physical"),
1897            (HintMix::Psychological, "psychological"),
1898            (HintMix::Solo, "solo"),
1899            (HintMix::Social, "social"),
1900        ] {
1901            assert_eq!(
1902                serde_json::to_value(variant).unwrap(),
1903                serde_json::json!(expected)
1904            );
1905        }
1906    }
1907
1908    #[test]
1909    fn enum_fields_round_trip_through_settings_json() {
1910        let s = Settings {
1911            micro_break_mode: BreakMode::Notification,
1912            long_break_mode: BreakMode::Windowed,
1913            micro_schedule_mode: ScheduleMode::Fixed,
1914            long_schedule_mode: ScheduleMode::Both,
1915            micro_hint_mix: HintMix::Physical,
1916            long_hint_mix: HintMix::Social,
1917            ..Settings::default()
1918        };
1919
1920        let json = serde_json::to_string(&s).unwrap();
1921        let back: Settings = serde_json::from_str(&json).unwrap();
1922
1923        assert_eq!(back.micro_break_mode, BreakMode::Notification);
1924        assert_eq!(back.long_break_mode, BreakMode::Windowed);
1925        assert_eq!(back.micro_schedule_mode, ScheduleMode::Fixed);
1926        assert_eq!(back.long_schedule_mode, ScheduleMode::Both);
1927        assert_eq!(back.micro_hint_mix, HintMix::Physical);
1928        assert_eq!(back.long_hint_mix, HintMix::Social);
1929    }
1930
1931    #[test]
1932    fn corrupt_break_mode_normalises_to_overlay_on_load() {
1933        let json = r#"{"micro_break_mode": "garbage", "long_break_mode": ""}"#;
1934        let s: Settings = serde_json::from_str(json).unwrap();
1935        assert_eq!(s.micro_break_mode, BreakMode::Overlay);
1936        assert_eq!(s.long_break_mode, BreakMode::Overlay);
1937    }
1938
1939    #[test]
1940    fn corrupt_schedule_mode_normalises_to_interval_on_load() {
1941        let json = r#"{"micro_schedule_mode": "garbage", "long_schedule_mode": 42}"#;
1942        let s: Settings = serde_json::from_str(json).unwrap();
1943        assert_eq!(s.micro_schedule_mode, ScheduleMode::Interval);
1944        assert_eq!(s.long_schedule_mode, ScheduleMode::Interval);
1945    }
1946
1947    #[test]
1948    fn corrupt_hint_mix_normalises_to_both_on_load() {
1949        let json = r#"{"micro_hint_mix": "garbage", "long_hint_mix": null}"#;
1950        let s: Settings = serde_json::from_str(json).unwrap();
1951        assert_eq!(s.micro_hint_mix, HintMix::Both);
1952        assert_eq!(s.long_hint_mix, HintMix::Both);
1953    }
1954
1955    #[test]
1956    fn known_enum_disk_strings_deserialise_to_their_variant() {
1957        let json = r#"{
1958            "micro_break_mode": "windowed",
1959            "micro_schedule_mode": "fixed",
1960            "long_hint_mix": "social"
1961        }"#;
1962        let s: Settings = serde_json::from_str(json).unwrap();
1963        assert_eq!(s.micro_break_mode, BreakMode::Windowed);
1964        assert_eq!(s.micro_schedule_mode, ScheduleMode::Fixed);
1965        assert_eq!(s.long_hint_mix, HintMix::Social);
1966    }
1967
1968    #[test]
1969    fn corrupt_routine_max_difficulty_falls_back_to_default_on_load() {
1970        let json =
1971            r#"{"micro_routine_max_difficulty": "garbage", "long_routine_max_difficulty": 7}"#;
1972        let s: Settings = serde_json::from_str(json).unwrap();
1973        assert_eq!(
1974            s.micro_routine_max_difficulty,
1975            default_routine_max_difficulty()
1976        );
1977        assert_eq!(
1978            s.long_routine_max_difficulty,
1979            default_routine_max_difficulty()
1980        );
1981    }
1982
1983    #[test]
1984    fn known_routine_max_difficulty_deserialises_to_its_variant() {
1985        let json = r#"{"micro_routine_max_difficulty": "gentle", "long_routine_max_difficulty": "moderate"}"#;
1986        let s: Settings = serde_json::from_str(json).unwrap();
1987        assert_eq!(s.micro_routine_max_difficulty, RoutineDifficulty::Gentle);
1988        assert_eq!(s.long_routine_max_difficulty, RoutineDifficulty::Moderate);
1989    }
1990
1991    #[test]
1992    fn unknown_routine_categories_are_dropped_keeping_the_known_ones() {
1993        let json = r#"{
1994            "micro_routine_categories": ["eyes", "telepathy", "mobility"],
1995            "long_routine_categories": ["sky_diving"]
1996        }"#;
1997        let s: Settings = serde_json::from_str(json).unwrap();
1998        assert_eq!(
1999            s.micro_routine_categories,
2000            vec![RoutineCategory::Eyes, RoutineCategory::Mobility]
2001        );
2002        assert!(s.long_routine_categories.is_empty());
2003    }
2004
2005    #[test]
2006    fn one_bad_routine_enum_does_not_reset_the_whole_profile() {
2007        let json = r#"{
2008            "micro_interval_secs": 1234,
2009            "micro_routine": "random",
2010            "micro_routine_categories": ["eyes", "bogus"],
2011            "micro_routine_max_difficulty": "bogus"
2012        }"#;
2013        let s: Settings = serde_json::from_str(json).unwrap();
2014        assert_eq!(s.micro_interval_secs, 1234);
2015        assert_eq!(s.micro_routine, "random");
2016        assert_eq!(s.micro_routine_categories, vec![RoutineCategory::Eyes]);
2017        assert_eq!(
2018            s.micro_routine_max_difficulty,
2019            default_routine_max_difficulty()
2020        );
2021    }
2022
2023    #[test]
2024    fn malformed_routine_categories_fall_back_to_all_not_a_failed_load() {
2025        // Not an array, and an array with a non-string element: both degrade
2026        // to "all categories" rather than failing the whole profile load.
2027        let not_array = r#"{"micro_interval_secs": 99, "micro_routine_categories": "eyes"}"#;
2028        let s: Settings = serde_json::from_str(not_array).unwrap();
2029        assert_eq!(s.micro_interval_secs, 99);
2030        assert!(s.micro_routine_categories.is_empty());
2031
2032        let bad_element = r#"{"long_routine_categories": ["eyes", 42]}"#;
2033        let s: Settings = serde_json::from_str(bad_element).unwrap();
2034        assert!(s.long_routine_categories.is_empty());
2035    }
2036
2037    #[test]
2038    fn rebuild_derived_parses_fixed_times_to_minutes_dropping_garbage() {
2039        let mut s = Settings {
2040            micro_fixed_times: vec!["09:00".into(), "garbage".into(), "13:30".into()],
2041            long_fixed_times: vec!["7:05".into(), "24:00".into()],
2042            ..Settings::default()
2043        };
2044        s.rebuild_derived();
2045        // "09:00" → 540, "13:30" → 810; "garbage" dropped.
2046        assert_eq!(s.derived.micro_fixed_minutes, vec![540, 810]);
2047        // "7:05" → 425; "24:00" out of range, dropped.
2048        assert_eq!(s.derived.long_fixed_minutes, vec![425]);
2049    }
2050
2051    #[test]
2052    fn rebuild_derived_lowercases_app_pause_targets_dropping_empties() {
2053        let mut s = Settings {
2054            app_pause_list: vec!["Zoom".into(), "OBS Studio".into(), "".into()],
2055            ..Settings::default()
2056        };
2057        s.rebuild_derived();
2058        assert_eq!(
2059            s.derived.app_pause_targets_lower,
2060            vec!["zoom", "obs studio"]
2061        );
2062    }
2063
2064    #[test]
2065    fn rebuild_derived_refreshes_app_pause_cache_after_change() {
2066        let mut s = Settings {
2067            app_pause_list: vec!["Zoom".into()],
2068            ..Settings::default()
2069        };
2070        s.rebuild_derived();
2071        assert_eq!(s.derived.app_pause_targets_lower, vec!["zoom"]);
2072
2073        s.app_pause_list = vec!["Slack".into()];
2074        s.rebuild_derived();
2075        assert_eq!(s.derived.app_pause_targets_lower, vec!["slack"]);
2076    }
2077
2078    #[test]
2079    fn clamp_rebuilds_fixed_time_cache() {
2080        // The load + renderer-update paths run through `clamp`, which must
2081        // leave the derived cache populated without a separate call.
2082        let mut s = Settings {
2083            micro_fixed_times: vec!["10:00".into()],
2084            ..Settings::default()
2085        };
2086        s.clamp();
2087        assert_eq!(s.derived.micro_fixed_minutes, vec![600]);
2088    }
2089
2090    #[test]
2091    fn rebuild_derived_refreshes_cache_after_source_change() {
2092        // Correctness-critical: editing the fixed-time list and rebuilding
2093        // must replace the cache, not append or go stale.
2094        let mut s = Settings {
2095            micro_fixed_times: vec!["08:00".into()],
2096            ..Settings::default()
2097        };
2098        s.rebuild_derived();
2099        assert_eq!(s.derived.micro_fixed_minutes, vec![480]);
2100
2101        s.micro_fixed_times = vec!["15:45".into()];
2102        s.rebuild_derived();
2103        assert_eq!(s.derived.micro_fixed_minutes, vec![945]);
2104    }
2105
2106    #[test]
2107    fn typing_defer_settings_defaults() {
2108        let s = Settings::default();
2109        assert!(s.delay_break_if_typing);
2110        assert_eq!(s.typing_grace_secs, 10);
2111        assert_eq!(s.typing_max_deferral_secs, 60);
2112        assert!(s.pause_countdown_if_typing);
2113    }
2114
2115    /// A flat `settings.json` captured before the `BreakKindSettings`
2116    /// refactor. Proves the on-disk / IPC wire shape stays flat
2117    /// (`micro_enabled`, `long_interval_secs`, …) — never nested into
2118    /// `{ "micro": { … } }` — so existing settings files and the React
2119    /// frontend keep loading unchanged.
2120    const FLAT_FIXTURE: &str = include_str!("fixtures/default_settings_flat.json");
2121
2122    #[test]
2123    fn update_channel_defaults_to_stable() {
2124        // A new field on an existing struct: every settings.json written
2125        // before this shipped has no `update_channel` key, and those
2126        // installs must stay on the stable line rather than silently
2127        // opting into betas.
2128        assert_eq!(UpdateChannel::default(), UpdateChannel::Stable);
2129        assert_eq!(Settings::default().update_channel, UpdateChannel::Stable);
2130        let from_old_file: Settings = serde_json::from_str("{}").unwrap();
2131        assert_eq!(from_old_file.update_channel, UpdateChannel::Stable);
2132    }
2133
2134    #[test]
2135    fn update_channel_round_trips_over_the_wire_as_lowercase() {
2136        let s = Settings {
2137            update_channel: UpdateChannel::Beta,
2138            ..Settings::default()
2139        };
2140        let json = serde_json::to_string(&s).unwrap();
2141        assert!(
2142            json.contains(r#""update_channel":"beta""#),
2143            "wire form must be lowercase for the TS union"
2144        );
2145        let back: Settings = serde_json::from_str(&json).unwrap();
2146        assert_eq!(back.update_channel, UpdateChannel::Beta);
2147    }
2148
2149    #[test]
2150    fn flat_fixture_deserialises_into_settings() {
2151        let s: Settings = serde_json::from_str(FLAT_FIXTURE).unwrap();
2152        // Spot-check a per-kind pair survived the round-trip into the
2153        // (still flat) struct fields.
2154        assert_eq!(s.micro_interval_secs, 1200);
2155        assert_eq!(s.long_duration_secs, 600);
2156        assert!(s.micro_enabled);
2157        assert!(s.long_enforceable);
2158        assert_eq!(s.micro_break_mode, BreakMode::Overlay);
2159        assert_eq!(s.long_schedule_mode, ScheduleMode::Interval);
2160    }
2161
2162    #[test]
2163    fn flat_fixture_round_trips_byte_identical() {
2164        // Deserialise the captured pre-refactor file, then re-serialise it.
2165        // The output must equal the input verbatim, proving the refactor did
2166        // not change a single wire key or value. Line endings are normalised
2167        // to LF and the trailing newline trimmed: git may check the fixture
2168        // out as CRLF on Windows, but `to_string_pretty` always emits LF, and
2169        // the wire-shape guarantee is about keys/values/order, not newlines.
2170        let s: Settings = serde_json::from_str(FLAT_FIXTURE).unwrap();
2171        let out = serde_json::to_string_pretty(&s).unwrap();
2172        let expected = FLAT_FIXTURE.replace("\r\n", "\n");
2173        assert_eq!(out, expected.trim_end());
2174    }
2175
2176    #[test]
2177    fn settings_serialises_with_flat_keys_not_nested_substructs() {
2178        // Guards against an accidental switch to plain serde nesting,
2179        // which would emit `{"micro": {"enabled": …}}` and break every
2180        // `settings.micro_*` reader in the frontend and on disk.
2181        let value = serde_json::to_value(Settings::default()).unwrap();
2182        let obj = value.as_object().unwrap();
2183        assert!(obj.contains_key("micro_enabled"));
2184        assert!(obj.contains_key("long_interval_secs"));
2185        assert!(!obj.contains_key("micro"), "wire shape must stay flat");
2186        assert!(!obj.contains_key("long"), "wire shape must stay flat");
2187    }
2188
2189    #[test]
2190    fn for_kind_maps_micro_and_long_fields() {
2191        let mut s = Settings {
2192            micro_enabled: true,
2193            micro_duration_secs: 42,
2194            micro_enforceable: true,
2195            micro_manual_finish: true,
2196            micro_break_mode: BreakMode::Windowed,
2197            micro_schedule_mode: ScheduleMode::Fixed,
2198            long_enabled: false,
2199            long_duration_secs: 99,
2200            long_break_mode: BreakMode::Notification,
2201            ..Settings::default()
2202        };
2203        s.rebuild_derived();
2204
2205        let micro = s.for_kind(BreakKind::Micro).unwrap();
2206        assert!(micro.enabled);
2207        assert_eq!(micro.duration_secs, 42);
2208        assert!(micro.enforceable);
2209        assert!(micro.manual_finish);
2210        assert_eq!(micro.mode, BreakMode::Windowed);
2211        assert_eq!(micro.schedule_mode, ScheduleMode::Fixed);
2212
2213        let long = s.for_kind(BreakKind::Long).unwrap();
2214        assert!(!long.enabled);
2215        assert_eq!(long.duration_secs, 99);
2216        assert_eq!(long.mode, BreakMode::Notification);
2217    }
2218
2219    #[test]
2220    fn for_kind_returns_none_for_sleep() {
2221        assert!(Settings::default().for_kind(BreakKind::Sleep).is_none());
2222    }
2223
2224    #[test]
2225    fn for_kind_exposes_per_kind_postpone_and_skip_flags() {
2226        let mut s = Settings {
2227            micro_postpone_enabled: false,
2228            micro_skip_enabled: true,
2229            long_postpone_enabled: true,
2230            long_skip_enabled: false,
2231            ..Settings::default()
2232        };
2233        s.rebuild_derived();
2234
2235        let micro = s.for_kind(BreakKind::Micro).unwrap();
2236        assert!(!micro.postpone_enabled);
2237        assert!(micro.skip_enabled);
2238
2239        let long = s.for_kind(BreakKind::Long).unwrap();
2240        assert!(long.postpone_enabled);
2241        assert!(!long.skip_enabled);
2242    }
2243
2244    #[test]
2245    fn per_kind_postpone_skip_default_true_for_legacy_settings() {
2246        // A settings.json written before #132 has none of the four keys.
2247        // They must deserialise to `true` so an upgrading user keeps the
2248        // pre-split behaviour: postpone governed by the global master,
2249        // skip available on any non-enforceable break.
2250        let legacy = r#"{ "postpone_enabled": true }"#;
2251        let s: Settings = serde_json::from_str(legacy).unwrap();
2252        assert!(s.micro_postpone_enabled);
2253        assert!(s.long_postpone_enabled);
2254        assert!(s.micro_skip_enabled);
2255        assert!(s.long_skip_enabled);
2256    }
2257
2258    #[test]
2259    fn legacy_global_postpone_off_keeps_postpone_off_for_both_kinds() {
2260        // The per-kind switches default `true`, but the global master is
2261        // ANDed in, so a legacy user with postpone globally off still has
2262        // it off everywhere — no behaviour change on upgrade.
2263        let legacy = r#"{ "postpone_enabled": false }"#;
2264        let s: Settings = serde_json::from_str(legacy).unwrap();
2265        assert!(!s.postpone_available_for(BreakKind::Micro));
2266        assert!(!s.postpone_available_for(BreakKind::Long));
2267    }
2268
2269    #[test]
2270    fn postpone_available_for_requires_global_master_and_per_kind() {
2271        let mut s = Settings {
2272            postpone_enabled: true,
2273            micro_postpone_enabled: false,
2274            long_postpone_enabled: true,
2275            ..Settings::default()
2276        };
2277        assert!(!s.postpone_available_for(BreakKind::Micro));
2278        assert!(s.postpone_available_for(BreakKind::Long));
2279
2280        s.postpone_enabled = false;
2281        assert!(!s.postpone_available_for(BreakKind::Long));
2282    }
2283
2284    #[test]
2285    fn postpone_available_for_is_false_in_strict_mode() {
2286        let s = Settings {
2287            postpone_enabled: true,
2288            micro_postpone_enabled: true,
2289            strict_mode: true,
2290            ..Settings::default()
2291        };
2292        assert!(!s.postpone_available_for(BreakKind::Micro));
2293    }
2294
2295    #[test]
2296    fn postpone_available_for_sleep_falls_back_to_global_master() {
2297        // Sleep has no per-kind pair; it tracks the global master alone,
2298        // preserving the pre-split bedtime postpone behaviour.
2299        let mut s = Settings {
2300            postpone_enabled: true,
2301            ..Settings::default()
2302        };
2303        assert!(s.postpone_available_for(BreakKind::Sleep));
2304        s.postpone_enabled = false;
2305        assert!(!s.postpone_available_for(BreakKind::Sleep));
2306    }
2307
2308    #[test]
2309    fn skip_available_for_honours_per_kind_switch_and_strict_mode() {
2310        let mut s = Settings {
2311            micro_skip_enabled: false,
2312            long_skip_enabled: true,
2313            ..Settings::default()
2314        };
2315        assert!(!s.skip_available_for(BreakKind::Micro));
2316        assert!(s.skip_available_for(BreakKind::Long));
2317
2318        s.strict_mode = true;
2319        assert!(!s.skip_available_for(BreakKind::Long));
2320    }
2321
2322    #[test]
2323    fn skip_available_for_sleep_is_false() {
2324        assert!(!Settings::default().skip_available_for(BreakKind::Sleep));
2325    }
2326
2327    #[test]
2328    fn for_kind_exposes_fixed_minutes_cache_per_kind() {
2329        let mut s = Settings {
2330            micro_fixed_times: vec!["09:00".into()],
2331            long_fixed_times: vec!["18:30".into()],
2332            ..Settings::default()
2333        };
2334        s.rebuild_derived();
2335
2336        assert_eq!(s.for_kind(BreakKind::Micro).unwrap().fixed_minutes, [540]);
2337        assert_eq!(s.for_kind(BreakKind::Long).unwrap().fixed_minutes, [1110]);
2338    }
2339
2340    #[test]
2341    fn effective_hints_dispatches_per_kind() {
2342        let mut s = Settings {
2343            micro_physical_hints: vec!["m".into()],
2344            micro_psychological_hints: vec![],
2345            micro_hint_mix: HintMix::Physical,
2346            long_hints: vec!["l".into()],
2347            long_social_hints: vec![],
2348            long_hint_mix: HintMix::Solo,
2349            sleep_hints: vec!["z".into()],
2350            ..Settings::default()
2351        };
2352        s.rebuild_derived();
2353
2354        assert_eq!(s.effective_hints(BreakKind::Micro), ["m"]);
2355        assert_eq!(s.effective_hints(BreakKind::Long), ["l"]);
2356        assert_eq!(s.effective_hints(BreakKind::Sleep), ["z"]);
2357    }
2358}
2359
2360/// Rust ↔ TypeScript Settings parity test (issue #13).
2361///
2362/// Anyone adding a setting has to update:
2363///   1. `Settings` (this file) + `Default`
2364///   2. `SchedulerSettings` in `src/views/settings/types.ts`
2365///   3. The Zod schema in `src/views/settings/hooks/use-settings.ts`
2366///   4. (sometimes) `OverlaySettings` in `src/views/break-overlay/types.ts`
2367///   5. (sometimes) the a11y audit fixture
2368///
2369/// Forgetting (2) is a silent break — the renderer's IPC validation
2370/// rejects the response at runtime in a way CI doesn't catch on the
2371/// PR that introduced it (saw this happen with `custom_css` recently).
2372/// This test compares the *top-level* field-name sets of (1) and (2)
2373/// and fails with a useful diff so the drift surfaces at unit-test time.
2374///
2375/// What it does NOT check:
2376///   - Field types (Rust `u64` vs TS `number` — out of scope; the Zod
2377///     schema enforces this at runtime).
2378///   - Nested struct shapes (`BreakSound`, `HookConfig`) — those have
2379///     their own Zod schemas, and adding a nested field would still be
2380///     caught when it crosses the wire.
2381///   - The Zod schema or the OverlaySettings mirror — see issue #13
2382///     follow-ups if drift between (1) and (3)/(4) becomes a problem.
2383#[cfg(test)]
2384mod parity_tests {
2385    use std::collections::BTreeSet;
2386    use std::path::PathBuf;
2387
2388    use super::Settings;
2389
2390    fn rust_settings_keys() -> BTreeSet<String> {
2391        let value = serde_json::to_value(Settings::default())
2392            .expect("Settings serialises to a JSON object");
2393        let obj = value.as_object().expect("top-level Settings is an object");
2394        obj.keys().cloned().collect()
2395    }
2396
2397    fn ts_settings_keys() -> BTreeSet<String> {
2398        let manifest = env!("CARGO_MANIFEST_DIR");
2399        let path = PathBuf::from(manifest).join("../src/views/settings/types.ts");
2400        let source = std::fs::read_to_string(&path).unwrap_or_else(|e| {
2401            panic!(
2402                "could not read TS source at {} — has the layout moved? \
2403                 If yes, update the parity test path. ({e})",
2404                path.display()
2405            )
2406        });
2407        let body = extract_type_body(&source, "SchedulerSettings");
2408        extract_field_names(body)
2409    }
2410
2411    /// Pull the body of `export type <name> = { ... };` out of the
2412    /// source. Whitespace-tolerant; assumes the type is a flat
2413    /// `{ key: type; }` block with one field per line. If the TS file
2414    /// grows nested-object types inline (e.g., `foo: { bar: number }`),
2415    /// this needs to learn brace-depth tracking — but right now every
2416    /// nested type is named (BreakSound, HookConfig) so we're safe.
2417    fn extract_type_body<'a>(source: &'a str, name: &str) -> &'a str {
2418        let needle = format!("export type {name} = {{");
2419        let start = source
2420            .find(&needle)
2421            .unwrap_or_else(|| panic!("`{name}` not found in TS source"))
2422            + needle.len();
2423        let after_open = &source[start..];
2424        let end = after_open
2425            .find("\n};")
2426            .unwrap_or_else(|| panic!("end of `{name}` body not found"));
2427        &after_open[..end]
2428    }
2429
2430    fn extract_field_names(body: &str) -> BTreeSet<String> {
2431        let mut out = BTreeSet::new();
2432        for line in body.lines() {
2433            let trimmed = line.trim();
2434            // Skip blank lines and `//` comments.
2435            if trimmed.is_empty() || trimmed.starts_with("//") {
2436                continue;
2437            }
2438            // Match `field_name:` at the start of the trimmed line.
2439            // `take_while` over the chars is enough — no regex dep.
2440            let name: String = trimmed
2441                .chars()
2442                .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
2443                .collect();
2444            if name.is_empty() {
2445                continue;
2446            }
2447            // Confirm a `:` follows (possibly with whitespace).
2448            let after = &trimmed[name.len()..];
2449            if after.trim_start().starts_with(':') {
2450                out.insert(name);
2451            }
2452        }
2453        out
2454    }
2455
2456    #[test]
2457    fn rust_and_ts_settings_have_the_same_top_level_keys() {
2458        let rust = rust_settings_keys();
2459        let ts = ts_settings_keys();
2460
2461        let missing_from_ts: Vec<_> = rust.difference(&ts).collect();
2462        let missing_from_rust: Vec<_> = ts.difference(&rust).collect();
2463
2464        assert!(
2465            missing_from_ts.is_empty() && missing_from_rust.is_empty(),
2466            "Rust ↔ TS Settings parity drift:\n  \
2467             present in Rust, missing from TS ({} keys): {missing_from_ts:?}\n  \
2468             present in TS, missing from Rust ({} keys): {missing_from_rust:?}\n  \
2469             Add or remove the field in BOTH places. See `src-tauri/src/scheduler/settings.rs` \
2470             and `src/views/settings/types.ts`.",
2471            missing_from_ts.len(),
2472            missing_from_rust.len(),
2473        );
2474    }
2475
2476    // -- Tests for the TS-source extraction helpers, so a malformed
2477    //    types.ts (or a refactor of the helpers) doesn't silently
2478    //    return an empty set and make the parity test trivially pass.
2479
2480    #[test]
2481    fn extract_type_body_handles_typical_block() {
2482        let src = "import x from 'y';\n\
2483                   export type Foo = {\n  \
2484                     a: number;\n  \
2485                     b: string;\n\
2486                   };\n\
2487                   export type Bar = { c: boolean };\n";
2488        let body = extract_type_body(src, "Foo");
2489        assert!(body.contains("a: number;"));
2490        assert!(body.contains("b: string;"));
2491        assert!(!body.contains("Bar"));
2492    }
2493
2494    #[test]
2495    fn extract_field_names_parses_canonical_form() {
2496        let body = "\n  micro_interval_secs: number;\n  \
2497                    hooks: HookConfig[];\n  \
2498                    micro_sound: BreakSound;\n";
2499        let names = extract_field_names(body);
2500        assert!(names.contains("micro_interval_secs"));
2501        assert!(names.contains("hooks"));
2502        assert!(names.contains("micro_sound"));
2503        assert_eq!(names.len(), 3);
2504    }
2505
2506    #[test]
2507    fn extract_field_names_skips_blank_and_comment_lines() {
2508        let body = "\n  // intentionally a comment\n\n  foo: number;\n";
2509        let names = extract_field_names(body);
2510        assert_eq!(names.len(), 1);
2511        assert!(names.contains("foo"));
2512    }
2513
2514    #[test]
2515    fn ts_settings_keys_returns_nonempty_set() {
2516        // Sanity: if the extractor returns nothing, the parity test
2517        // would falsely "pass" the diff (both sides equal-empty).
2518        assert!(!ts_settings_keys().is_empty());
2519    }
2520
2521    // -- Enum *value* parity. The field-name test above proves the keys
2522    //    line up; this proves the typed on-disk enums (`BreakMode`,
2523    //    `ScheduleMode`, `HintMix`, `HotkeyAction`, `RoutineCategory`,
2524    //    `RoutineDifficulty`) serialise to exactly the string literals the TS
2525    //    unions accept. Each parity array is built with `all_variants!`, whose
2526    //    compile-time exhaustiveness gate makes a new Rust variant fail to
2527    //    *compile* until it's listed — so a missing TS counterpart (or vice
2528    //    versa) surfaces at unit-test time rather than as a runtime Zod
2529    //    rejection.
2530
2531    use super::{BreakMode, HintMix, ScheduleMode, UpdateChannel};
2532    use crate::scheduler::routines::{RoutineCategory, RoutineDifficulty};
2533
2534    /// All on-disk strings a Rust enum can serialise to. The caller passes the
2535    /// full variant array — built with [`all_variants!`], which also gates
2536    /// completeness at compile time — and this maps each through serde to its
2537    /// wire string.
2538    fn rust_enum_values<T, const N: usize>(variants: [T; N]) -> BTreeSet<String>
2539    where
2540        T: serde::Serialize,
2541    {
2542        variants
2543            .into_iter()
2544            .map(|v| {
2545                serde_json::to_value(v)
2546                    .expect("enum serialises")
2547                    .as_str()
2548                    .expect("enum serialises to a string")
2549                    .to_string()
2550            })
2551            .collect()
2552    }
2553
2554    /// Pull the string literals out of a TS union alias
2555    /// (`type Name = "a" | "b" | "c";`), tolerating line wraps.
2556    fn ts_union_values(source: &str, name: &str) -> BTreeSet<String> {
2557        let needle = format!("type {name} =");
2558        let start = source
2559            .find(&needle)
2560            .unwrap_or_else(|| panic!("`{name}` union not found in TS source"))
2561            + needle.len();
2562        let after = &source[start..];
2563        let end = after
2564            .find(';')
2565            .unwrap_or_else(|| panic!("end of `{name}` union not found"));
2566        let body = &after[..end];
2567        let mut out = BTreeSet::new();
2568        let mut rest = body;
2569        while let Some(open) = rest.find('"') {
2570            rest = &rest[open + 1..];
2571            let close = rest
2572                .find('"')
2573                .unwrap_or_else(|| panic!("unterminated literal in `{name}` union"));
2574            out.insert(rest[..close].to_string());
2575            rest = &rest[close + 1..];
2576        }
2577        out
2578    }
2579
2580    fn ts_source() -> String {
2581        let manifest = env!("CARGO_MANIFEST_DIR");
2582        let path = PathBuf::from(manifest).join("../src/views/settings/types.ts");
2583        std::fs::read_to_string(&path).expect("read TS settings types")
2584    }
2585
2586    /// Build the full variant array for a fieldless enum **and** an
2587    /// exhaustiveness gate from a single list. The `match` below covers
2588    /// exactly the listed variants, so adding a variant to the enum without
2589    /// listing it here fails to compile — the parity array can never silently
2590    /// omit one. Replaces the array + hand-written match the hotkey gate used
2591    /// to spell out per call site (#222 → #223).
2592    macro_rules! all_variants {
2593        ($ty:ident: $($variant:ident),+ $(,)?) => {{
2594            let all = [$($ty::$variant),+];
2595            for v in &all {
2596                match v {
2597                    $($ty::$variant => {}),+
2598                }
2599            }
2600            all
2601        }};
2602    }
2603
2604    #[test]
2605    fn break_mode_values_match_ts_union() {
2606        let rust = rust_enum_values(all_variants!(BreakMode: Overlay, Windowed, Notification));
2607        let ts = ts_union_values(&ts_source(), "BreakDeliveryMode");
2608        assert_eq!(rust, ts, "BreakMode ↔ BreakDeliveryMode value drift");
2609    }
2610
2611    #[test]
2612    fn update_channel_values_match_ts_union() {
2613        let rust = rust_enum_values(all_variants!(UpdateChannel: Stable, Beta));
2614        let ts = ts_union_values(&ts_source(), "UpdateChannel");
2615        assert_eq!(rust, ts, "UpdateChannel value drift");
2616    }
2617
2618    #[test]
2619    fn schedule_mode_values_match_ts_union() {
2620        let rust = rust_enum_values(all_variants!(ScheduleMode: Interval, Fixed, Both));
2621        let ts = ts_union_values(&ts_source(), "ScheduleMode");
2622        assert_eq!(rust, ts, "ScheduleMode value drift");
2623    }
2624
2625    #[test]
2626    fn hint_mix_values_match_ts_unions() {
2627        // HintMix is one Rust enum but two TS unions (micro vs long); its
2628        // value set is the union of both. Splitting per-kind is a TS-only
2629        // nicety — Rust accepts any variant on either field.
2630        let rust =
2631            rust_enum_values(all_variants!(HintMix: Both, Physical, Psychological, Solo, Social));
2632        let src = ts_source();
2633        let mut ts = ts_union_values(&src, "MicroHintMix");
2634        ts.extend(ts_union_values(&src, "LongHintMix"));
2635        assert_eq!(rust, ts, "HintMix ↔ Micro/LongHintMix value drift");
2636    }
2637
2638    #[test]
2639    fn hotkey_action_values_match_ts_union() {
2640        // The bindable actions are sync'd by hand across the Rust enum, the
2641        // TS union, the zod enum, and the HOTKEY_ACTIONS list; this guards the
2642        // Rust ↔ TS-union leg so a new variant can't silently drift.
2643        use crate::scheduler::hotkeys::HotkeyAction;
2644        let rust = rust_enum_values(all_variants!(HotkeyAction:
2645            Pause, Pause15m, Pause30m, Pause60m, Resume,
2646            TriggerMicro, TriggerLong, SkipMicro, SkipLong, CycleProfile));
2647        let ts = ts_union_values(&ts_source(), "HotkeyAction");
2648        assert_eq!(rust, ts, "HotkeyAction ↔ TS union value drift");
2649    }
2650
2651    #[test]
2652    fn routine_category_values_match_ts_union() {
2653        let rust =
2654            rust_enum_values(all_variants!(RoutineCategory: Eyes, Mobility, Breathing, DeskYoga));
2655        let ts = ts_union_values(&ts_source(), "RoutineCategory");
2656        assert_eq!(rust, ts, "RoutineCategory ↔ TS union value drift");
2657    }
2658
2659    #[test]
2660    fn routine_difficulty_values_match_ts_union() {
2661        let rust = rust_enum_values(all_variants!(RoutineDifficulty: Gentle, Moderate, Active));
2662        let ts = ts_union_values(&ts_source(), "RoutineDifficulty");
2663        assert_eq!(rust, ts, "RoutineDifficulty ↔ TS union value drift");
2664    }
2665
2666    #[test]
2667    fn ts_union_values_parses_canonical_form() {
2668        let src = "type Foo = \"a\" | \"b\" | \"c\";\n";
2669        let vals = ts_union_values(src, "Foo");
2670        assert_eq!(vals.len(), 3);
2671        assert!(vals.contains("a") && vals.contains("b") && vals.contains("c"));
2672    }
2673}