1use serde::{Deserialize, Serialize};
2
3use crate::hooks::Hook;
4
5use super::types::{BreakDelivery, BreakKind};
6
7#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
13#[serde(rename_all = "lowercase")]
14pub enum MonitorPlacement {
15 #[default]
16 Primary,
17 Active,
18 All,
19}
20
21#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
26#[serde(rename_all = "snake_case")]
27pub enum BreakSoundMode {
28 #[default]
29 Off,
30 EndChime,
31 Ambient,
32}
33
34#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
38pub struct BreakSound {
39 #[serde(default)]
40 pub mode: BreakSoundMode,
41 #[serde(default)]
42 pub sound_id: String,
43 #[serde(default)]
44 pub custom_path: String,
45}
46
47impl BreakSound {
48 pub fn end_chime(id: &str) -> Self {
51 Self {
52 mode: BreakSoundMode::EndChime,
53 sound_id: id.to_string(),
54 custom_path: String::new(),
55 }
56 }
57}
58
59fn default_break_mode() -> String {
60 "overlay".to_string()
61}
62
63fn default_schedule_mode() -> String {
64 "interval".to_string()
65}
66
67fn default_tray_countdown_target() -> String {
68 "next".to_string()
69}
70
71fn default_clock_format() -> String {
72 "24h".to_string()
73}
74
75fn default_micro_hint_mix() -> String {
76 "both".to_string()
77}
78
79fn default_long_hint_mix() -> String {
80 "both".to_string()
81}
82
83fn default_micro_physical_hints() -> Vec<String> {
84 vec![
85 "Look at something 20 feet away.",
86 "Blink slowly ten times.",
87 "Roll your shoulders backward, then forward.",
88 "Stretch your neck side to side.",
89 "Sip some water.",
90 "Wiggle your fingers and toes.",
91 "Look up, down, left, right.",
92 "Press your palms together and stretch your wrists.",
93 "Reach for the ceiling — both arms, slow stretch.",
94 "Stand up and shake out your hands.",
95 "Twist gently side to side in your chair.",
96 "Open and close your hands ten times.",
97 "Look out the nearest window.",
98 "Roll your ankles in slow circles.",
99 "Squeeze your shoulder blades together for five seconds.",
100 "Tilt your head ear-to-shoulder, both sides.",
101 "Stand up. Reach down. Tap your toes.",
102 "Trace the alphabet in the air with your nose.",
103 ]
104 .into_iter()
105 .map(String::from)
106 .collect()
107}
108
109fn default_micro_psychological_hints() -> Vec<String> {
110 vec![
111 "Unclench your jaw.",
112 "Take five slow, deep breaths.",
113 "Soften your gaze. Relax your face.",
114 "Sit back. Drop your shoulders.",
115 "Notice three things you can hear right now.",
116 "Name one thing going well today.",
117 "Let your tongue rest behind your front teeth.",
118 "Notice the weight of your body in the chair.",
119 "Smile, even a small one.",
120 "Take one breath in. Let it out twice as slowly.",
121 "Pause. Notice what your body needs.",
122 "Thank yourself for the work you've done so far.",
123 ]
124 .into_iter()
125 .map(String::from)
126 .collect()
127}
128
129fn default_long_hints() -> Vec<String> {
130 vec![
131 "Stand up. Look out a window. Stretch.",
132 "Take a short walk — even one minute counts.",
133 "Step away from the screen. Make a cup of tea.",
134 "Do a few full-body stretches.",
135 "Walk to a different room and back.",
136 "Stretch your back, shoulders, and legs.",
137 "Get a bit of fresh air if you can.",
138 "Refill your water bottle.",
139 "Do a quick body scan — where are you holding tension?",
140 "Try a minute of slow, deep breathing.",
141 "Roll out your wrists, ankles, and neck.",
142 "Stand tall and stretch your arms overhead.",
143 "Step outside for a few minutes of daylight.",
144 "Make a snack from real food — fruit, nuts, cheese.",
145 "Lie flat on the floor for a minute. Let gravity reset you.",
146 "Put on one song you love and just listen.",
147 "Tidy a small area near you.",
148 "Take the long way to wherever you're going.",
149 "Wash your face with cool water.",
150 "Step away. Look at the sky for a minute.",
151 ]
152 .into_iter()
153 .map(String::from)
154 .collect()
155}
156
157fn default_long_social_hints() -> Vec<String> {
158 vec![
159 "Call someone you love.",
160 "Text a friend just to say hi.",
161 "Step outside with a colleague.",
162 "Walk over to a coworker's desk for a chat.",
163 "Make a coffee with someone.",
164 "Sit outside with company if you can.",
165 "Ask a teammate how their day is going.",
166 "Drop a thank-you note to someone.",
167 "Eat your snack with someone, not at your desk.",
168 "Swap a quick story with whoever's around.",
169 "Reach out to someone you haven't spoken to in a while.",
170 "Take a short walk with a friend or partner.",
171 "Voice-message a friend instead of texting.",
172 "Pay someone a genuine compliment.",
173 "Invite someone to take the next break with you.",
174 ]
175 .into_iter()
176 .map(String::from)
177 .collect()
178}
179
180fn default_sleep_hints() -> Vec<String> {
181 vec![
182 "Time to wind down.",
183 "Step away from the screen for the night.",
184 "Sleep well — your work will be there tomorrow.",
185 "Dim the lights. Close the laptop. Rest.",
186 "Reading or stretching beats more screen time.",
187 "Be kind to your future self. Get some rest.",
188 "Tomorrow's focus starts with tonight's sleep.",
189 "Put work down. You've earned the rest.",
190 "Brew a small herbal tea instead of starting one more task.",
191 "Lay tomorrow's notebook out and close this one.",
192 "Pick the first thing for tomorrow — then stop.",
193 "Stretch slowly for five minutes, then bed.",
194 ]
195 .into_iter()
196 .map(String::from)
197 .collect()
198}
199
200#[derive(Debug, Clone, Serialize, Deserialize)]
213#[serde(default)]
214pub struct Settings {
215 pub micro_interval_secs: u64,
216 pub micro_duration_secs: u64,
217 pub long_interval_secs: u64,
218 pub long_duration_secs: u64,
219 #[serde(alias = "idle_reset_secs")]
221 pub micro_idle_reset_secs: u64,
222 pub long_idle_reset_secs: u64,
223 pub micro_enabled: bool,
224 pub long_enabled: bool,
225 pub micro_enforceable: bool,
226 pub long_enforceable: bool,
227 pub pause_during_dnd: bool,
228 pub pause_during_camera: bool,
229 #[serde(default)]
230 pub pause_during_video: bool,
231 pub work_window_enabled: bool,
232 pub work_start_minutes: u32,
233 pub work_end_minutes: u32,
234 pub bedtime_enabled: bool,
235 pub bedtime_start_minutes: u32,
236 pub bedtime_end_minutes: u32,
237 pub bedtime_interval_secs: u64,
238 pub bedtime_duration_secs: u64,
239 pub prebreak_notification_enabled: bool,
240 pub prebreak_notification_seconds: u64,
241 pub overlay_opacity: f32,
242 pub overlay_color: String,
243 pub overlay_custom_rgb: String,
244 pub overlay_high_contrast: bool,
245 pub show_hint: bool,
246 pub monitor_placement: MonitorPlacement,
247 pub strict_mode: bool,
248 pub postpone_enabled: bool,
249 pub postpone_minutes: u32,
250 pub show_current_time: bool,
251 #[serde(default = "default_clock_format")]
252 pub clock_format: String,
253 pub micro_manual_finish: bool,
254 pub long_manual_finish: bool,
255 pub autostart_enabled: bool,
256 #[serde(default)]
257 pub micro_sound: BreakSound,
258 #[serde(default)]
259 pub long_sound: BreakSound,
260 pub sound_volume: f32,
261 pub app_pause_enabled: bool,
262 pub app_pause_list: Vec<String>,
263 pub break_health_enabled: bool,
264 #[serde(alias = "micro_hints")]
266 pub micro_physical_hints: Vec<String>,
267 pub micro_psychological_hints: Vec<String>,
268 pub micro_hint_mix: String,
269 pub long_hints: Vec<String>,
270 pub long_social_hints: Vec<String>,
271 pub long_hint_mix: String,
272 pub sleep_hints: Vec<String>,
273 pub hint_rotate_seconds: u64,
274 pub delay_break_if_typing: bool,
275 pub typing_grace_secs: u64,
276 pub typing_max_deferral_secs: u64,
277 pub pause_countdown_if_typing: bool,
278 pub postpone_escalation_enabled: bool,
279 pub postpone_escalation_step_secs: u64,
280 pub postpone_max_count: u32,
281 pub overlay_font_scale: f32,
282 pub micro_fixed_times: Vec<String>,
283 pub long_fixed_times: Vec<String>,
284 pub micro_schedule_mode: String,
285 pub long_schedule_mode: String,
286 pub hooks_enabled: bool,
287 pub hooks: Vec<Hook>,
288 pub daily_screen_time_enabled: bool,
289 pub daily_screen_time_budget_minutes: u64,
290 pub daily_screen_time_remind_again_minutes: u64,
291 pub tray_countdown_enabled: bool,
292 pub tray_countdown_target: String,
293 #[serde(default = "default_break_mode")]
294 pub micro_break_mode: String,
295 #[serde(default = "default_break_mode")]
296 pub long_break_mode: String,
297 #[serde(default)]
305 pub custom_css: String,
306}
307
308impl Default for Settings {
309 fn default() -> Self {
310 Self {
311 micro_interval_secs: 20 * 60,
312 micro_duration_secs: 20,
313 long_interval_secs: 50 * 60,
314 long_duration_secs: 10 * 60,
315 micro_idle_reset_secs: 5 * 60,
316 long_idle_reset_secs: 5 * 60,
317 micro_enabled: true,
318 long_enabled: true,
319 micro_enforceable: false,
320 long_enforceable: true,
321 pause_during_dnd: true,
322 pause_during_camera: true,
323 pause_during_video: false,
324 work_window_enabled: false,
325 work_start_minutes: 9 * 60,
326 work_end_minutes: 17 * 60,
327 bedtime_enabled: false,
328 bedtime_start_minutes: 22 * 60,
329 bedtime_end_minutes: 23 * 60,
330 bedtime_interval_secs: 5 * 60,
331 bedtime_duration_secs: 30,
332 prebreak_notification_enabled: true,
333 prebreak_notification_seconds: 30,
334 overlay_opacity: 0.92,
335 overlay_color: "dark".to_string(),
336 overlay_custom_rgb: "20, 24, 32".to_string(),
337 overlay_high_contrast: false,
338 show_hint: true,
339 monitor_placement: MonitorPlacement::Primary,
340 strict_mode: false,
341 postpone_enabled: true,
342 postpone_minutes: 5,
343 show_current_time: true,
344 clock_format: default_clock_format(),
345 micro_manual_finish: false,
346 long_manual_finish: false,
347 autostart_enabled: false,
348 micro_sound: BreakSound::end_chime("337048"),
349 long_sound: BreakSound::end_chime("337048"),
350 sound_volume: 0.5,
351 app_pause_enabled: false,
352 app_pause_list: Vec::new(),
353 break_health_enabled: true,
354 micro_physical_hints: default_micro_physical_hints(),
355 micro_psychological_hints: default_micro_psychological_hints(),
356 micro_hint_mix: default_micro_hint_mix(),
357 long_hints: default_long_hints(),
358 long_social_hints: default_long_social_hints(),
359 long_hint_mix: default_long_hint_mix(),
360 sleep_hints: default_sleep_hints(),
361 hint_rotate_seconds: 0,
362 delay_break_if_typing: true,
363 typing_grace_secs: 10,
364 typing_max_deferral_secs: 60,
365 pause_countdown_if_typing: true,
366 postpone_escalation_enabled: true,
367 postpone_escalation_step_secs: 120,
368 postpone_max_count: 3,
369 overlay_font_scale: 1.0,
370 micro_fixed_times: Vec::new(),
371 long_fixed_times: Vec::new(),
372 micro_schedule_mode: default_schedule_mode(),
373 long_schedule_mode: default_schedule_mode(),
374 hooks_enabled: false,
375 hooks: Vec::new(),
376 daily_screen_time_enabled: false,
377 daily_screen_time_budget_minutes: 8 * 60,
378 daily_screen_time_remind_again_minutes: 60,
379 tray_countdown_enabled: true,
380 tray_countdown_target: default_tray_countdown_target(),
381 micro_break_mode: default_break_mode(),
382 long_break_mode: default_break_mode(),
383 custom_css: String::new(),
384 }
385 }
386}
387
388impl Settings {
389 pub fn clamp(&mut self) {
400 self.micro_interval_secs = self.micro_interval_secs.clamp(30, 86_400);
405 self.long_interval_secs = self.long_interval_secs.clamp(30, 86_400);
406 self.micro_duration_secs = self.micro_duration_secs.clamp(1, 3_600);
407 self.long_duration_secs = self.long_duration_secs.clamp(1, 3_600);
408 self.bedtime_interval_secs = self.bedtime_interval_secs.clamp(60, 3_600);
409 self.bedtime_duration_secs = self.bedtime_duration_secs.clamp(1, 3_600);
410 self.micro_idle_reset_secs = self.micro_idle_reset_secs.clamp(5, 3_600);
413 self.long_idle_reset_secs = self.long_idle_reset_secs.clamp(5, 3_600);
414 self.prebreak_notification_seconds = self.prebreak_notification_seconds.min(300);
417 self.typing_grace_secs = self.typing_grace_secs.min(300);
420 self.typing_max_deferral_secs = self.typing_max_deferral_secs.min(3_600);
421 self.postpone_minutes = self.postpone_minutes.clamp(1, 120);
423 self.postpone_escalation_step_secs = self.postpone_escalation_step_secs.min(3_600);
424 self.postpone_max_count = self.postpone_max_count.min(20);
425 self.daily_screen_time_budget_minutes = self.daily_screen_time_budget_minutes.min(1_440);
427 self.daily_screen_time_remind_again_minutes =
428 self.daily_screen_time_remind_again_minutes.clamp(1, 720);
429 self.hint_rotate_seconds = self.hint_rotate_seconds.min(600);
433 self.work_start_minutes = self.work_start_minutes.min(1_439);
435 self.work_end_minutes = self.work_end_minutes.min(1_439);
436 self.bedtime_start_minutes = self.bedtime_start_minutes.min(1_439);
437 self.bedtime_end_minutes = self.bedtime_end_minutes.min(1_439);
438 self.overlay_opacity = self.overlay_opacity.clamp(0.8, 1.0);
441 self.sound_volume = self.sound_volume.clamp(0.0, 1.0);
442 self.overlay_font_scale = self.overlay_font_scale.clamp(0.5, 3.0);
443 if self.clock_format != "12h" && self.clock_format != "24h" {
446 self.clock_format = default_clock_format();
447 }
448 if self.custom_css.len() > 65_536 {
454 let mut cut = 65_536;
455 while !self.custom_css.is_char_boundary(cut) {
456 cut -= 1;
457 }
458 self.custom_css.truncate(cut);
459 }
460 self.custom_css = sanitize_custom_css(&self.custom_css);
461 }
462}
463
464pub fn sanitize_custom_css(css: &str) -> String {
477 let stripped = strip_css_comments(css);
478 let mut out = String::with_capacity(stripped.len());
479 for raw in stripped.split_inclusive(';') {
480 let lower = raw.trim_start().to_ascii_lowercase();
481 if lower.starts_with("@import") || lower.contains("expression(") {
482 continue;
483 }
484 out.push_str(raw);
485 }
486 out
487}
488
489fn strip_css_comments(css: &str) -> String {
490 let mut out = String::with_capacity(css.len());
491 let mut rest = css;
492 while let Some(start) = rest.find("/*") {
493 out.push_str(&rest[..start]);
494 rest = &rest[start + 2..];
495 match rest.find("*/") {
496 Some(end) => rest = &rest[end + 2..],
497 None => return out, }
499 }
500 out.push_str(rest);
501 out
502}
503
504pub fn effective_micro_hints(s: &Settings) -> Vec<String> {
509 match s.micro_hint_mix.as_str() {
510 "physical" => s.micro_physical_hints.clone(),
511 "psychological" => s.micro_psychological_hints.clone(),
512 _ => {
513 let mut combined = Vec::with_capacity(
514 s.micro_physical_hints.len() + s.micro_psychological_hints.len(),
515 );
516 combined.extend(s.micro_physical_hints.iter().cloned());
517 combined.extend(s.micro_psychological_hints.iter().cloned());
518 combined
519 }
520 }
521}
522
523pub fn effective_long_hints(s: &Settings) -> Vec<String> {
527 match s.long_hint_mix.as_str() {
528 "solo" => s.long_hints.clone(),
529 "social" => s.long_social_hints.clone(),
530 _ => {
531 let mut combined = Vec::with_capacity(s.long_hints.len() + s.long_social_hints.len());
532 combined.extend(s.long_hints.iter().cloned());
533 combined.extend(s.long_social_hints.iter().cloned());
534 combined
535 }
536 }
537}
538
539pub fn delivery_for(kind: BreakKind, s: &Settings) -> BreakDelivery {
544 let mode = match kind {
545 BreakKind::Micro => s.micro_break_mode.as_str(),
546 BreakKind::Long => s.long_break_mode.as_str(),
547 BreakKind::Sleep => "overlay",
548 };
549 match mode {
550 "notification" => BreakDelivery::Notification,
551 "windowed" => BreakDelivery::Windowed,
552 _ => BreakDelivery::Overlay,
553 }
554}
555
556pub fn is_windowed_mode(kind: BreakKind, s: &Settings) -> bool {
559 matches!(delivery_for(kind, s), BreakDelivery::Windowed)
560}
561
562#[cfg(test)]
563mod sanitize_tests {
564 use super::sanitize_custom_css;
565
566 #[test]
567 fn passes_safe_css_through() {
568 let input = ".overlay-card { background: #111; color: white; }";
569 assert_eq!(sanitize_custom_css(input), input);
570 }
571
572 #[test]
573 fn drops_at_import_rules() {
574 let input = "@import url('https://evil.example/x.css'); .ok { color: red; }";
575 let out = sanitize_custom_css(input);
576 assert!(!out.contains("@import"), "got: {out}");
577 assert!(out.contains(".ok"));
578 }
579
580 #[test]
581 fn drops_at_import_even_when_obfuscated_with_comments() {
582 let input = "@/* hi */import url('https://evil/x.css'); .ok { color: red; }";
583 let out = sanitize_custom_css(input);
584 assert!(!out.to_ascii_lowercase().contains("@import"));
585 assert!(out.contains(".ok"));
586 }
587
588 #[test]
589 fn drops_expression_construct() {
590 let input = ".x { width: expression(alert(1)); } .ok { color: red; }";
591 let out = sanitize_custom_css(input);
592 assert!(!out.contains("expression("), "got: {out}");
593 assert!(out.contains(".ok"));
594 }
595
596 #[test]
597 fn empty_in_empty_out() {
598 assert_eq!(sanitize_custom_css(""), "");
599 }
600
601 #[test]
602 fn preserves_non_ascii_content() {
603 let input = ".x::before { content: \"→ café\"; } /* éhé */ .y { color: red; }";
604 let out = sanitize_custom_css(input);
605 assert!(out.contains("→ café"), "non-ASCII content corrupted: {out}");
606 assert!(out.contains(".y"));
607 assert!(!out.contains("éhé"), "comment should be stripped");
608 }
609
610 #[test]
611 fn unterminated_comment_swallows_tail() {
612 let out = sanitize_custom_css(".ok {} /* unterminated");
615 assert_eq!(out, ".ok {} ");
616 }
617}
618
619#[cfg(test)]
620mod clamp_custom_css_tests {
621 use super::*;
622
623 #[test]
624 fn truncates_at_64kib_without_panicking_on_multibyte_boundary() {
625 let mut css = "a".repeat(65_535);
629 css.push('🎉'); let mut s = Settings {
631 custom_css: css,
632 ..Settings::default()
633 };
634 s.clamp();
635 assert!(s.custom_css.len() <= 65_536);
636 assert!(std::str::from_utf8(s.custom_css.as_bytes()).is_ok());
639 }
640}
641
642#[cfg(test)]
643mod tests {
644 use super::*;
645
646 #[test]
647 fn settings_default_sensible() {
648 let s = Settings::default();
649 assert!(s.micro_interval_secs > 0);
650 assert!(s.long_interval_secs > s.micro_interval_secs);
651 assert!(s.long_enforceable);
652 assert!(!s.micro_enforceable);
653 assert!(s.work_end_minutes > s.work_start_minutes);
654 assert!(s.overlay_opacity > 0.0 && s.overlay_opacity <= 1.0);
655 assert!(s.postpone_minutes > 0);
656 assert!(s.sound_volume >= 0.0 && s.sound_volume <= 1.0);
657 assert!(!s.micro_physical_hints.is_empty());
658 assert!(!s.micro_psychological_hints.is_empty());
659 assert_eq!(s.micro_hint_mix, "both");
660 assert!(!s.long_hints.is_empty());
661 assert!(!s.sleep_hints.is_empty());
662 assert_eq!(s.micro_idle_reset_secs, 300);
663 assert_eq!(s.long_idle_reset_secs, 300);
664 assert!((s.overlay_font_scale - 1.0).abs() < f32::EPSILON);
665 }
666
667 #[test]
673 fn clamp_fixes_zero_intervals() {
674 let mut s = Settings {
677 micro_interval_secs: 0,
678 long_interval_secs: 0,
679 bedtime_interval_secs: 0,
680 ..Settings::default()
681 };
682 s.clamp();
683 assert!(s.micro_interval_secs >= 30);
684 assert!(s.long_interval_secs >= 30);
685 assert!(s.bedtime_interval_secs >= 60);
686 }
687
688 #[test]
689 fn clamp_caps_max_intervals_below_u64_overflow_danger() {
690 let mut s = Settings {
691 micro_interval_secs: u64::MAX,
692 long_interval_secs: u64::MAX,
693 ..Settings::default()
694 };
695 s.clamp();
696 assert!(s.micro_interval_secs <= 86_400);
697 assert!(s.long_interval_secs <= 86_400);
698 }
699
700 #[test]
701 fn clamp_leaves_in_range_values_alone() {
702 let mut s = Settings::default();
703 let micro = s.micro_interval_secs;
704 let long = s.long_interval_secs;
705 let bedtime = s.bedtime_interval_secs;
706 s.clamp();
707 assert_eq!(s.micro_interval_secs, micro);
708 assert_eq!(s.long_interval_secs, long);
709 assert_eq!(s.bedtime_interval_secs, bedtime);
710 }
711
712 #[test]
713 fn clamp_keeps_zero_prebreak_lead_as_disabled() {
714 let mut s = Settings {
719 prebreak_notification_seconds: 0,
720 ..Settings::default()
721 };
722 s.clamp();
723 assert_eq!(s.prebreak_notification_seconds, 0);
724 }
725
726 #[test]
727 fn clamp_keeps_zero_hint_rotation_as_disabled() {
728 let mut s = Settings {
732 hint_rotate_seconds: 0,
733 ..Settings::default()
734 };
735 s.clamp();
736 assert_eq!(s.hint_rotate_seconds, 0);
737 }
738
739 #[test]
740 fn clamp_pins_minutes_of_day_to_valid_range() {
741 let mut s = Settings {
742 work_start_minutes: 9_999,
743 bedtime_end_minutes: 5_000,
744 ..Settings::default()
745 };
746 s.clamp();
747 assert!(s.work_start_minutes <= 1_439);
748 assert!(s.bedtime_end_minutes <= 1_439);
749 }
750
751 #[test]
752 fn clamp_pins_floats_to_unit_interval() {
753 let mut s = Settings {
754 overlay_opacity: -0.5,
755 sound_volume: 10.0,
756 ..Settings::default()
757 };
758 s.clamp();
759 assert!((0.8..=1.0).contains(&s.overlay_opacity));
761 assert!((0.0..=1.0).contains(&s.sound_volume));
762 }
763
764 #[test]
765 fn clamp_caps_transparency_at_twenty_percent() {
766 let mut s = Settings {
769 overlay_opacity: 0.5,
770 ..Settings::default()
771 };
772 s.clamp();
773 assert!((s.overlay_opacity - 0.8).abs() < f32::EPSILON);
774 }
775
776 #[test]
777 fn clock_format_defaults_to_24h() {
778 let s = Settings::default();
779 assert_eq!(s.clock_format, "24h");
780 }
781
782 #[test]
783 fn clamp_normalises_unknown_clock_format() {
784 let mut s = Settings {
785 clock_format: "garbage".to_string(),
786 ..Settings::default()
787 };
788 s.clamp();
789 assert_eq!(s.clock_format, "24h");
790 }
791
792 #[test]
793 fn clamp_leaves_valid_clock_format_alone() {
794 let mut s = Settings {
795 clock_format: "12h".to_string(),
796 ..Settings::default()
797 };
798 s.clamp();
799 assert_eq!(s.clock_format, "12h");
800 }
801
802 #[test]
803 fn clamp_pins_font_scale_to_supported_range() {
804 let mut s = Settings {
805 overlay_font_scale: 0.01,
806 ..Settings::default()
807 };
808 s.clamp();
809 assert!(s.overlay_font_scale >= 0.5);
810 s.overlay_font_scale = 99.0;
811 s.clamp();
812 assert!(s.overlay_font_scale <= 3.0);
813 }
814
815 #[test]
816 fn clamp_caps_postpone_count_to_prevent_runaway() {
817 let mut s = Settings {
818 postpone_max_count: u32::MAX,
819 ..Settings::default()
820 };
821 s.clamp();
822 assert!(s.postpone_max_count <= 20);
823 }
824
825 #[test]
826 fn clamp_is_idempotent() {
827 let mut a = Settings {
830 micro_interval_secs: 0,
831 overlay_opacity: 5.0,
832 ..Settings::default()
833 };
834 a.clamp();
835 let snapshot = a.clone();
836 a.clamp();
837 assert_eq!(snapshot.micro_interval_secs, a.micro_interval_secs);
838 assert!(
839 (snapshot.overlay_opacity - a.overlay_opacity).abs() < f32::EPSILON,
840 "clamp idempotent on overlay_opacity"
841 );
842 }
843
844 #[test]
845 fn legacy_idle_reset_secs_aliases_into_micro() {
846 let json = r#"{"idle_reset_secs": 123}"#;
847 let s: Settings = serde_json::from_str(json).unwrap();
848 assert_eq!(s.micro_idle_reset_secs, 123);
849 assert_eq!(
850 s.long_idle_reset_secs,
851 Settings::default().long_idle_reset_secs
852 );
853 }
854
855 #[test]
856 fn legacy_micro_hints_aliases_into_physical() {
857 let json = r#"{"micro_hints": ["Stretch", "Blink"]}"#;
858 let s: Settings = serde_json::from_str(json).unwrap();
859 assert_eq!(s.micro_physical_hints, vec!["Stretch", "Blink"]);
860 assert_eq!(
861 s.micro_psychological_hints,
862 Settings::default().micro_psychological_hints
863 );
864 assert_eq!(s.micro_hint_mix, "both");
865 }
866
867 #[test]
868 #[allow(clippy::field_reassign_with_default)]
869 fn effective_micro_hints_modes() {
870 let mut s = Settings::default();
871 s.micro_physical_hints = vec!["a".into(), "b".into()];
872 s.micro_psychological_hints = vec!["c".into()];
873
874 s.micro_hint_mix = "physical".into();
875 assert_eq!(effective_micro_hints(&s), vec!["a", "b"]);
876
877 s.micro_hint_mix = "psychological".into();
878 assert_eq!(effective_micro_hints(&s), vec!["c"]);
879
880 s.micro_hint_mix = "both".into();
881 assert_eq!(effective_micro_hints(&s), vec!["a", "b", "c"]);
882
883 s.micro_hint_mix = "garbage".into();
884 assert_eq!(effective_micro_hints(&s), vec!["a", "b", "c"]);
885 }
886
887 #[test]
888 #[allow(clippy::field_reassign_with_default)]
889 fn effective_long_hints_modes() {
890 let mut s = Settings::default();
891 s.long_hints = vec!["solo1".into(), "solo2".into()];
892 s.long_social_hints = vec!["soc1".into()];
893
894 s.long_hint_mix = "solo".into();
895 assert_eq!(effective_long_hints(&s), vec!["solo1", "solo2"]);
896
897 s.long_hint_mix = "social".into();
898 assert_eq!(effective_long_hints(&s), vec!["soc1"]);
899
900 s.long_hint_mix = "both".into();
901 assert_eq!(effective_long_hints(&s), vec!["solo1", "solo2", "soc1"]);
902
903 s.long_hint_mix = "garbage".into();
904 assert_eq!(effective_long_hints(&s), vec!["solo1", "solo2", "soc1"]);
905 }
906
907 #[test]
908 fn default_long_social_hints_are_populated() {
909 let s = Settings::default();
910 assert!(!s.long_social_hints.is_empty());
911 assert_eq!(s.long_hint_mix, "both");
912 }
913
914 #[test]
915 fn settings_default_fixed_times_empty_and_interval() {
916 let s = Settings::default();
917 assert!(s.micro_fixed_times.is_empty());
918 assert!(s.long_fixed_times.is_empty());
919 assert_eq!(s.micro_schedule_mode, "interval");
920 assert_eq!(s.long_schedule_mode, "interval");
921 }
922
923 #[test]
924 fn screen_time_defaults_off_with_eight_hour_budget() {
925 let s = Settings::default();
926 assert!(!s.daily_screen_time_enabled);
927 assert_eq!(s.daily_screen_time_budget_minutes, 480);
928 assert_eq!(s.daily_screen_time_remind_again_minutes, 60);
929 }
930
931 #[test]
932 fn tray_countdown_defaults() {
933 let s = Settings::default();
934 assert!(s.tray_countdown_enabled);
935 assert_eq!(s.tray_countdown_target, "next");
936 }
937
938 #[test]
939 fn monitor_placement_default_is_primary() {
940 assert_eq!(MonitorPlacement::default(), MonitorPlacement::Primary);
941 }
942
943 #[test]
944 fn break_mode_defaults_to_overlay() {
945 let s = Settings::default();
946 assert_eq!(s.micro_break_mode, "overlay");
947 assert_eq!(s.long_break_mode, "overlay");
948 assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Overlay);
949 assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
950 assert_eq!(delivery_for(BreakKind::Sleep, &s), BreakDelivery::Overlay);
951 }
952
953 #[test]
954 #[allow(clippy::field_reassign_with_default)]
955 fn delivery_for_notification_per_kind() {
956 let mut s = Settings::default();
957 s.micro_break_mode = "notification".into();
958 assert_eq!(
959 delivery_for(BreakKind::Micro, &s),
960 BreakDelivery::Notification
961 );
962 assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
963
964 s.long_break_mode = "notification".into();
965 assert_eq!(
966 delivery_for(BreakKind::Long, &s),
967 BreakDelivery::Notification
968 );
969 }
970
971 #[test]
972 #[allow(clippy::field_reassign_with_default)]
973 fn delivery_for_sleep_always_overlay() {
974 let mut s = Settings::default();
975 s.micro_break_mode = "notification".into();
976 s.long_break_mode = "notification".into();
977 assert_eq!(delivery_for(BreakKind::Sleep, &s), BreakDelivery::Overlay);
978 }
979
980 #[test]
981 #[allow(clippy::field_reassign_with_default)]
982 fn delivery_for_windowed_per_kind() {
983 let mut s = Settings::default();
984 s.micro_break_mode = "windowed".into();
985 assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Windowed);
986 assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
987
988 s.long_break_mode = "windowed".into();
989 assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Windowed);
990 }
991
992 #[test]
993 #[allow(clippy::field_reassign_with_default)]
994 fn delivery_for_unknown_mode_falls_back_to_overlay() {
995 let mut s = Settings::default();
996 s.micro_break_mode = "garbage".into();
997 s.long_break_mode = "".into();
998 assert_eq!(delivery_for(BreakKind::Micro, &s), BreakDelivery::Overlay);
999 assert_eq!(delivery_for(BreakKind::Long, &s), BreakDelivery::Overlay);
1000 }
1001
1002 #[test]
1003 #[allow(clippy::field_reassign_with_default)]
1004 fn is_windowed_mode_tracks_per_kind_setting() {
1005 let mut s = Settings::default();
1006 assert!(!is_windowed_mode(BreakKind::Micro, &s));
1007 assert!(!is_windowed_mode(BreakKind::Long, &s));
1008 assert!(!is_windowed_mode(BreakKind::Sleep, &s));
1009
1010 s.micro_break_mode = "windowed".into();
1011 assert!(is_windowed_mode(BreakKind::Micro, &s));
1012 assert!(!is_windowed_mode(BreakKind::Long, &s));
1013
1014 s.long_break_mode = "windowed".into();
1015 assert!(is_windowed_mode(BreakKind::Long, &s));
1016
1017 s.micro_break_mode = "notification".into();
1018 assert!(!is_windowed_mode(BreakKind::Micro, &s));
1019 }
1020
1021 #[test]
1022 #[allow(clippy::field_reassign_with_default)]
1023 fn is_windowed_mode_for_sleep_is_always_false() {
1024 let mut s = Settings::default();
1025 s.micro_break_mode = "windowed".into();
1026 s.long_break_mode = "windowed".into();
1027 assert!(!is_windowed_mode(BreakKind::Sleep, &s));
1028 }
1029
1030 #[test]
1031 fn hint_rotation_is_off_by_default() {
1032 assert_eq!(Settings::default().hint_rotate_seconds, 0);
1033 }
1034
1035 #[test]
1036 fn legacy_settings_json_defaults_break_mode_to_overlay() {
1037 let json = r#"{"micro_interval_secs": 600}"#;
1038 let s: Settings = serde_json::from_str(json).unwrap();
1039 assert_eq!(s.micro_break_mode, "overlay");
1040 assert_eq!(s.long_break_mode, "overlay");
1041 }
1042
1043 #[test]
1044 fn typing_defer_settings_defaults() {
1045 let s = Settings::default();
1046 assert!(s.delay_break_if_typing);
1047 assert_eq!(s.typing_grace_secs, 10);
1048 assert_eq!(s.typing_max_deferral_secs, 60);
1049 assert!(s.pause_countdown_if_typing);
1050 }
1051}
1052
1053#[cfg(test)]
1077mod parity_tests {
1078 use std::collections::BTreeSet;
1079 use std::path::PathBuf;
1080
1081 use super::Settings;
1082
1083 fn rust_settings_keys() -> BTreeSet<String> {
1084 let value = serde_json::to_value(Settings::default())
1085 .expect("Settings serialises to a JSON object");
1086 let obj = value.as_object().expect("top-level Settings is an object");
1087 obj.keys().cloned().collect()
1088 }
1089
1090 fn ts_settings_keys() -> BTreeSet<String> {
1091 let manifest = env!("CARGO_MANIFEST_DIR");
1092 let path = PathBuf::from(manifest).join("../src/views/settings/types.ts");
1093 let source = std::fs::read_to_string(&path).unwrap_or_else(|e| {
1094 panic!(
1095 "could not read TS source at {} — has the layout moved? \
1096 If yes, update the parity test path. ({e})",
1097 path.display()
1098 )
1099 });
1100 let body = extract_type_body(&source, "SchedulerSettings");
1101 extract_field_names(body)
1102 }
1103
1104 fn extract_type_body<'a>(source: &'a str, name: &str) -> &'a str {
1111 let needle = format!("export type {name} = {{");
1112 let start = source
1113 .find(&needle)
1114 .unwrap_or_else(|| panic!("`{name}` not found in TS source"))
1115 + needle.len();
1116 let after_open = &source[start..];
1117 let end = after_open
1118 .find("\n};")
1119 .unwrap_or_else(|| panic!("end of `{name}` body not found"));
1120 &after_open[..end]
1121 }
1122
1123 fn extract_field_names(body: &str) -> BTreeSet<String> {
1124 let mut out = BTreeSet::new();
1125 for line in body.lines() {
1126 let trimmed = line.trim();
1127 if trimmed.is_empty() || trimmed.starts_with("//") {
1129 continue;
1130 }
1131 let name: String = trimmed
1134 .chars()
1135 .take_while(|c| c.is_ascii_alphanumeric() || *c == '_')
1136 .collect();
1137 if name.is_empty() {
1138 continue;
1139 }
1140 let after = &trimmed[name.len()..];
1142 if after.trim_start().starts_with(':') {
1143 out.insert(name);
1144 }
1145 }
1146 out
1147 }
1148
1149 #[test]
1150 fn rust_and_ts_settings_have_the_same_top_level_keys() {
1151 let rust = rust_settings_keys();
1152 let ts = ts_settings_keys();
1153
1154 let missing_from_ts: Vec<_> = rust.difference(&ts).collect();
1155 let missing_from_rust: Vec<_> = ts.difference(&rust).collect();
1156
1157 assert!(
1158 missing_from_ts.is_empty() && missing_from_rust.is_empty(),
1159 "Rust ↔ TS Settings parity drift:\n \
1160 present in Rust, missing from TS ({} keys): {missing_from_ts:?}\n \
1161 present in TS, missing from Rust ({} keys): {missing_from_rust:?}\n \
1162 Add or remove the field in BOTH places. See `src-tauri/src/scheduler/settings.rs` \
1163 and `src/views/settings/types.ts`.",
1164 missing_from_ts.len(),
1165 missing_from_rust.len(),
1166 );
1167 }
1168
1169 #[test]
1174 fn extract_type_body_handles_typical_block() {
1175 let src = "import x from 'y';\n\
1176 export type Foo = {\n \
1177 a: number;\n \
1178 b: string;\n\
1179 };\n\
1180 export type Bar = { c: boolean };\n";
1181 let body = extract_type_body(src, "Foo");
1182 assert!(body.contains("a: number;"));
1183 assert!(body.contains("b: string;"));
1184 assert!(!body.contains("Bar"));
1185 }
1186
1187 #[test]
1188 fn extract_field_names_parses_canonical_form() {
1189 let body = "\n micro_interval_secs: number;\n \
1190 hooks: HookConfig[];\n \
1191 micro_sound: BreakSound;\n";
1192 let names = extract_field_names(body);
1193 assert!(names.contains("micro_interval_secs"));
1194 assert!(names.contains("hooks"));
1195 assert!(names.contains("micro_sound"));
1196 assert_eq!(names.len(), 3);
1197 }
1198
1199 #[test]
1200 fn extract_field_names_skips_blank_and_comment_lines() {
1201 let body = "\n // intentionally a comment\n\n foo: number;\n";
1202 let names = extract_field_names(body);
1203 assert_eq!(names.len(), 1);
1204 assert!(names.contains("foo"));
1205 }
1206
1207 #[test]
1208 fn ts_settings_keys_returns_nonempty_set() {
1209 assert!(!ts_settings_keys().is_empty());
1212 }
1213}