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#[derive(Debug, Clone, Default)]
22pub struct DerivedCaches {
23 pub micro_fixed_minutes: Vec<u32>,
27 pub long_fixed_minutes: Vec<u32>,
29 pub app_pause_targets_lower: Vec<String>,
34 pub micro_hints_resolved: Vec<String>,
39 pub long_hints_resolved: Vec<String>,
41}
42
43impl DerivedCaches {
44 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#[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#[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#[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#[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 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#[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 pub fn interval_active(self) -> bool {
191 matches!(self, Self::Interval | Self::Both)
192 }
193
194 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#[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
267fn 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
297fn 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
317fn 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#[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 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
383fn 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#[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 #[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 #[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 #[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 #[serde(default = "default_windowed_fraction")]
586 pub windowed_fraction: f64,
587 #[serde(default)]
590 pub micro_windowed_fraction: Option<f64>,
591 #[serde(default)]
595 pub long_windowed_fraction: Option<f64>,
596 pub strict_mode: bool,
597 pub postpone_enabled: bool,
598 #[serde(default = "default_true")]
605 pub micro_postpone_enabled: bool,
606 #[serde(default = "default_true")]
607 pub long_postpone_enabled: bool,
608 #[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 pub auto_check_updates: bool,
626 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 pub morning_chore_prompt_enabled: bool,
645 #[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 #[serde(default)]
659 pub micro_routine: String,
660 #[serde(default)]
661 pub long_routine: String,
662 #[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 #[serde(default)]
682 pub custom_routines: Vec<Routine>,
683 #[serde(default)]
690 pub routine_fill: bool,
691 #[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 #[serde(default)]
714 pub hotkeys_enabled: bool,
715 #[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 #[serde(default)]
734 pub custom_css: String,
735 #[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#[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 pub postpone_enabled: bool,
874 pub skip_enabled: bool,
878 pub fixed_minutes: &'a [u32],
881}
882
883impl Settings {
884 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 fn schedule_mode_for(&self, kind: BreakKind) -> Option<ScheduleMode> {
923 self.for_kind(kind).map(|b| b.schedule_mode)
924 }
925
926 pub fn interval_active(&self, kind: BreakKind) -> bool {
930 self.schedule_mode_for(kind)
931 .is_some_and(ScheduleMode::interval_active)
932 }
933
934 pub fn fixed_active(&self, kind: BreakKind) -> bool {
936 self.schedule_mode_for(kind)
937 .is_some_and(ScheduleMode::fixed_active)
938 }
939
940 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 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 pub fn rebuild_derived(&mut self) {
970 self.derived = DerivedCaches::rebuild(self);
971 }
972
973 pub fn clamp(&mut self) {
984 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 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 self.prebreak_notification_seconds = self.prebreak_notification_seconds.min(300);
1001 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 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 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 self.hint_rotate_seconds = self.hint_rotate_seconds.min(600);
1017 self.work_start_minutes = self.work_start_minutes.min(1_439);
1019 self.work_end_minutes = self.work_end_minutes.min(1_439);
1020 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 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 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 if self.clock_format != "12h" && self.clock_format != "24h" {
1041 self.clock_format = default_clock_format();
1042 }
1043 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 self.rebuild_derived();
1060 }
1061}
1062
1063pub 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, }
1098 }
1099 out.push_str(rest);
1100 out
1101}
1102
1103fn 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
1124fn 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
1141pub fn effective_micro_hints(s: &Settings) -> Vec<String> {
1148 s.derived.micro_hints_resolved.clone()
1149}
1150
1151pub fn effective_long_hints(s: &Settings) -> Vec<String> {
1153 s.derived.long_hints_resolved.clone()
1154}
1155
1156impl Settings {
1157 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 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
1182pub fn delivery_for(kind: BreakKind, s: &Settings) -> BreakDelivery {
1187 s.for_kind(kind)
1188 .map_or(BreakDelivery::Overlay, |b| b.mode.delivery())
1189}
1190
1191pub fn is_windowed_mode(kind: BreakKind, s: &Settings) -> bool {
1194 matches!(delivery_for(kind, s), BreakDelivery::Windowed)
1195}
1196
1197pub 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 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 let mut css = "a".repeat(65_535);
1281 css.push('🎉'); let mut s = Settings {
1283 custom_css: css,
1284 ..Settings::default()
1285 };
1286 s.clamp();
1287 assert!(s.custom_css.len() <= 65_536);
1288 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 #[test]
1329 fn clamp_fixes_zero_intervals() {
1330 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 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 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 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 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 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 assert_eq!(s.long_windowed_fraction, None);
1457 }
1458
1459 #[test]
1460 fn clamp_caps_transparency_at_twenty_percent() {
1461 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 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 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 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 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 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 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 #[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 #[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 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 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 #[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 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 assert_eq!(s.derived.micro_fixed_minutes, vec![540, 810]);
2047 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 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 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 const FLAT_FIXTURE: &str = include_str!("fixtures/default_settings_flat.json");
2121
2122 #[test]
2123 fn update_channel_defaults_to_stable() {
2124 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 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 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 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 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 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 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#[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 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 if trimmed.is_empty() || trimmed.starts_with("//") {
2436 continue;
2437 }
2438 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 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 #[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 assert!(!ts_settings_keys().is_empty());
2519 }
2520
2521 use super::{BreakMode, HintMix, ScheduleMode, UpdateChannel};
2532 use crate::scheduler::routines::{RoutineCategory, RoutineDifficulty};
2533
2534 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 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 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 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 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}