Skip to main content

entracte_lib/scheduler/commands/
breaks.rs

1use std::time::{Duration, Instant};
2
3use tauri::{AppHandle, Emitter, Manager};
4
5use crate::hooks::{self, HookContext, HookEvent};
6use crate::stats::{EventPayload, Outcome, SkipSource};
7
8use super::super::overlay::{deliver_break, fire_break};
9use super::super::pause::{persist_pause, PauseInfo, PauseState};
10use super::super::settings::{
11    delivery_for, effective_long_hints, effective_micro_hints, is_windowed_mode, Settings,
12};
13use super::super::timers::{clear_last_break, postpone_counter, reset_postpone_counter};
14use super::super::types::{BreakKind, LastBreakInfo, PostponeState};
15use super::super::Scheduler;
16
17/// Pause the scheduler. `duration_secs = None` pauses indefinitely;
18/// `Some(n)` pauses for `n` seconds. Fires `pause_start` hooks and
19/// emits the `pause:changed` event. Idempotent — a pause-while-paused
20/// updates the deadline but doesn't re-fire hooks.
21#[tauri::command]
22pub async fn pause(
23    app: AppHandle,
24    scheduler: tauri::State<'_, Scheduler>,
25    duration_secs: Option<u64>,
26) -> Result<(), String> {
27    pause_impl(scheduler.inner(), duration_secs).await;
28    let _ = app.emit("pause:changed", true);
29    Ok(())
30}
31
32/// Resume the scheduler from any pause state. Fires `pause_end` hooks
33/// and emits `pause:changed`. No-op if already running.
34#[tauri::command]
35pub async fn resume(app: AppHandle, scheduler: tauri::State<'_, Scheduler>) -> Result<(), String> {
36    resume_impl(scheduler.inner()).await;
37    let _ = app.emit("pause:changed", false);
38    Ok(())
39}
40
41/// Pause-state mutation core: updates the in-memory state, persists,
42/// logs the event, and fires `pause_start` hooks on a true running→paused
43/// edge. AppHandle-free so unit tests can drive it.
44pub async fn pause_impl(scheduler: &Scheduler, duration_secs: Option<u64>) {
45    let until = duration_secs.map(|s| Instant::now() + Duration::from_secs(s));
46    let new_state = PauseState::PausedUntil(until);
47    let was_running;
48    {
49        let mut guard = scheduler.pause_state.lock().await;
50        was_running = matches!(*guard, PauseState::Running);
51        *guard = new_state.clone();
52    }
53    persist_pause(&scheduler.pause_path, &new_state);
54    if was_running {
55        scheduler
56            .logger
57            .log(EventPayload::PauseStart { duration_secs });
58        let settings_snapshot = scheduler.settings.lock().await.clone();
59        hooks::run_hooks(
60            &settings_snapshot,
61            HookEvent::PauseStart,
62            HookContext::empty(),
63        );
64    }
65}
66
67/// Resume-state mutation core: clears the pause, persists, logs, and
68/// fires `pause_end` hooks on a true paused→running edge.
69pub async fn resume_impl(scheduler: &Scheduler) {
70    let was_paused;
71    {
72        let mut guard = scheduler.pause_state.lock().await;
73        was_paused = !matches!(*guard, PauseState::Running);
74        *guard = PauseState::Running;
75    }
76    persist_pause(&scheduler.pause_path, &PauseState::Running);
77    if was_paused {
78        scheduler.logger.log(EventPayload::PauseEnd);
79        let settings_snapshot = scheduler.settings.lock().await.clone();
80        hooks::run_hooks(
81            &settings_snapshot,
82            HookEvent::PauseEnd,
83            HookContext::empty(),
84        );
85    }
86}
87
88/// Current pause status for the renderer. While paused with a
89/// deadline, `remaining_secs` is the live countdown; while paused
90/// indefinitely it's `None`.
91#[tauri::command]
92pub async fn get_pause_info(scheduler: tauri::State<'_, Scheduler>) -> Result<PauseInfo, String> {
93    let state = scheduler.pause_state.lock().await;
94    Ok(match &*state {
95        PauseState::Running => PauseInfo {
96            paused: false,
97            remaining_secs: None,
98        },
99        PauseState::PausedUntil(None) => PauseInfo {
100            paused: true,
101            remaining_secs: None,
102        },
103        PauseState::PausedUntil(Some(t)) => {
104            let now = Instant::now();
105            let remaining = if *t > now { (*t - now).as_secs() } else { 0 };
106            PauseInfo {
107                paused: true,
108                remaining_secs: Some(remaining),
109            }
110        }
111    })
112}
113
114/// Mirrors the enforceability rule used by the scheduler's normal
115/// break-firing paths in `run_loop.rs`: micro/long obey their own
116/// `*_enforceable` flag OR `strict_mode`, while sleep is always
117/// enforceable.
118pub(crate) fn test_break_enforceable(kind: BreakKind, s: &Settings) -> bool {
119    match kind {
120        BreakKind::Micro => s.micro_enforceable || s.strict_mode,
121        BreakKind::Long => s.long_enforceable || s.strict_mode,
122        BreakKind::Sleep => true,
123    }
124}
125
126/// Fire a one-off break of the given kind right now. Shared by the
127/// renderer-facing `trigger_test_break` and the CLI's `trigger`
128/// command. Bypasses suppression checks (the user asked explicitly).
129pub async fn trigger_break_from_cli(
130    app: &AppHandle,
131    scheduler: &Scheduler,
132    kind: BreakKind,
133    duration_secs: u64,
134) {
135    let s = scheduler.settings.lock().await.clone();
136    let hints = match kind {
137        BreakKind::Micro => effective_micro_hints(&s),
138        BreakKind::Long => effective_long_hints(&s),
139        BreakKind::Sleep => s.sleep_hints.clone(),
140    };
141    let manual_finish = match kind {
142        BreakKind::Micro => s.micro_manual_finish,
143        BreakKind::Long => s.long_manual_finish,
144        BreakKind::Sleep => false,
145    };
146    let intensity = scheduler.stats.lock().await.intensity();
147    let delivery = delivery_for(kind, &s);
148    let enforceable = test_break_enforceable(kind, &s);
149    deliver_break(
150        app,
151        &scheduler.current_break,
152        delivery,
153        kind,
154        duration_secs,
155        enforceable,
156        s.monitor_placement,
157        manual_finish,
158        s.postpone_enabled && !s.strict_mode,
159        hints,
160        s.hint_rotate_seconds,
161        if s.break_health_enabled {
162            intensity
163        } else {
164            0.0
165        },
166    );
167    hooks::run_hooks(
168        &s,
169        HookEvent::BreakStart,
170        HookContext::with_kind_duration(kind, duration_secs),
171    );
172}
173
174/// Renderer hook to fire a break immediately — used by the "Test now"
175/// buttons on the Schedule tab.
176#[tauri::command]
177pub async fn trigger_test_break(
178    app: AppHandle,
179    scheduler: tauri::State<'_, Scheduler>,
180    kind: BreakKind,
181    duration_secs: u64,
182) -> Result<(), String> {
183    trigger_break_from_cli(&app, &scheduler, kind, duration_secs).await;
184    Ok(())
185}
186
187/// Conclude the currently-active break. `reason` distinguishes
188/// `"completed"` (taken in full), `"dismissed"` (user closed it
189/// early), and `"postponed"` (countdown wasn't honoured). Updates the
190/// session counters, fires `break_end` hooks, hides every overlay
191/// window, and emits `break:end`.
192#[tauri::command]
193pub async fn end_break(
194    app: AppHandle,
195    scheduler: tauri::State<'_, Scheduler>,
196    reason: Option<String>,
197) -> Result<(), String> {
198    let reason = reason.unwrap_or_else(|| "completed".to_string());
199    {
200        let mut stats = scheduler.stats.lock().await;
201        match reason.as_str() {
202            "completed" => stats.taken = stats.taken.saturating_add(1),
203            "dismissed" => stats.skipped = stats.skipped.saturating_add(1),
204            "postponed" => stats.postponed = stats.postponed.saturating_add(1),
205            _ => {}
206        }
207    }
208
209    let active_kind = {
210        let mut t = scheduler.timers.lock().await;
211        t.active_break.take()
212    };
213    if let Some(kind) = active_kind {
214        if reason == "completed" {
215            let mut t = scheduler.timers.lock().await;
216            reset_postpone_counter(&mut t, kind);
217            let cleared = clear_last_break(&mut t);
218            drop(t);
219            if cleared {
220                let _ = app.emit("last_break:changed", LastBreakInfo { kind: None });
221            }
222        }
223        let outcome = match reason.as_str() {
224            "dismissed" => Some(Outcome::Dismissed),
225            "completed" => Some(Outcome::Completed),
226            _ => None,
227        };
228        if let Some(o) = outcome {
229            scheduler
230                .logger
231                .log(EventPayload::BreakEnd { kind, outcome: o });
232        }
233        if matches!(reason.as_str(), "completed" | "dismissed") {
234            let settings_snapshot = scheduler.settings.lock().await.clone();
235            hooks::run_hooks(
236                &settings_snapshot,
237                HookEvent::BreakEnd,
238                HookContext::with_kind_outcome(kind, reason.clone()),
239            );
240        }
241    }
242
243    if let Ok(mut slot) = scheduler.current_break.lock() {
244        *slot = None;
245    }
246    for (label, window) in app.webview_windows() {
247        if label.starts_with("overlay-") {
248            let _ = window.hide();
249        }
250    }
251    let _ = app.emit("break:end", ());
252    let stats = scheduler.stats.lock().await.clone();
253    let _ = app.emit("stats:changed", &stats);
254    Ok(())
255}
256
257/// Push the active break out by the configured postpone interval
258/// (with optional escalation by previous postpone count). Errors when
259/// `strict_mode` / `postpone_enabled = false` block postpone or when
260/// the per-break postpone cap is reached. Side-effects: bumps the
261/// per-kind postpone counter, fires `break_postponed` hooks, hides
262/// overlays.
263#[tauri::command]
264pub async fn postpone_break(
265    app: AppHandle,
266    scheduler: tauri::State<'_, Scheduler>,
267    kind: BreakKind,
268) -> Result<(), String> {
269    postpone_break_impl(scheduler.inner(), kind).await?;
270    for (label, window) in app.webview_windows() {
271        if label.starts_with("overlay-") {
272            let _ = window.hide();
273        }
274    }
275    let _ = app.emit("break:end", ());
276    let _ = app.emit("last_break:changed", LastBreakInfo { kind: Some(kind) });
277    Ok(())
278}
279
280/// Returned by `postpone_break_impl` so test callers can see what the
281/// command would have emitted. The shim discards this in production.
282#[derive(Debug, Clone, Copy)]
283pub struct PostponeOutcome {
284    #[allow(dead_code)]
285    pub postpone_secs: u64,
286}
287
288/// Postpone-state mutation core: validates the request, updates timers,
289/// bumps counters, logs, fires hooks. AppHandle-free; the calling
290/// `#[tauri::command]` wrapper handles overlay hides + IPC emits.
291pub async fn postpone_break_impl(
292    scheduler: &Scheduler,
293    kind: BreakKind,
294) -> Result<PostponeOutcome, String> {
295    let s = scheduler.settings.lock().await.clone();
296    if s.strict_mode || !s.postpone_enabled {
297        return Err("postpone disabled".to_string());
298    }
299    let counter_before = {
300        let t = scheduler.timers.lock().await;
301        postpone_counter(&t, kind)
302    };
303    if s.postpone_escalation_enabled
304        && matches!(kind, BreakKind::Micro | BreakKind::Long)
305        && counter_before >= s.postpone_max_count
306    {
307        return Err("postpone exhausted".to_string());
308    }
309    let postpone_secs = effective_postpone_secs(&s, counter_before, kind);
310    {
311        let mut t = scheduler.timers.lock().await;
312        let now = Instant::now();
313        match kind {
314            BreakKind::Micro => {
315                let target = Duration::from_secs(s.micro_interval_secs)
316                    .saturating_sub(Duration::from_secs(postpone_secs));
317                t.last_micro = now.checked_sub(target).unwrap_or(now);
318                t.micro_warned = false;
319                t.micro_deferred_since = None;
320                t.micro_postpone_count = t.micro_postpone_count.saturating_add(1);
321            }
322            BreakKind::Long => {
323                let target = Duration::from_secs(s.long_interval_secs)
324                    .saturating_sub(Duration::from_secs(postpone_secs));
325                t.last_long = now.checked_sub(target).unwrap_or(now);
326                t.long_warned = false;
327                t.long_deferred_since = None;
328                let micro_target = Duration::from_secs(s.micro_interval_secs)
329                    .saturating_sub(Duration::from_secs(postpone_secs));
330                t.last_micro = now.checked_sub(micro_target).unwrap_or(now);
331                t.micro_warned = false;
332                t.micro_deferred_since = None;
333                t.long_postpone_count = t.long_postpone_count.saturating_add(1);
334            }
335            BreakKind::Sleep => {
336                t.last_sleep = Some(now);
337            }
338        }
339        t.last_skipped_or_postponed = Some((kind, now));
340    }
341    {
342        let mut stats = scheduler.stats.lock().await;
343        stats.postponed = stats.postponed.saturating_add(1);
344    }
345    let minutes_logged = (postpone_secs / 60) as u32;
346    scheduler.logger.log(EventPayload::BreakPostponed {
347        kind,
348        minutes: minutes_logged.max(1),
349    });
350    hooks::run_hooks(&s, HookEvent::BreakPostponed, HookContext::with_kind(kind));
351    {
352        let mut t = scheduler.timers.lock().await;
353        t.active_break = None;
354    }
355    if let Ok(mut slot) = scheduler.current_break.lock() {
356        *slot = None;
357    }
358    Ok(PostponeOutcome { postpone_secs })
359}
360
361fn effective_postpone_secs(s: &Settings, counter: u32, kind: BreakKind) -> u64 {
362    let base = (s.postpone_minutes as u64) * 60;
363    if !s.postpone_escalation_enabled || matches!(kind, BreakKind::Sleep) {
364        return base;
365    }
366    let step = s
367        .postpone_escalation_step_secs
368        .saturating_mul(counter as u64);
369    base.saturating_add(step)
370}
371
372/// Reset the next-break timer for `kind` so the user "skips" the
373/// upcoming break. Shared by the renderer command and the CLI's
374/// `skip` subcommand. Errors when `strict_mode` is on.
375pub async fn skip_next_from_cli(
376    app: &AppHandle,
377    scheduler: &Scheduler,
378    kind: BreakKind,
379) -> Result<(), String> {
380    skip_next_break_impl(scheduler, kind).await?;
381    let stats = scheduler.stats.lock().await.clone();
382    let _ = app.emit("stats:changed", &stats);
383    let _ = app.emit("last_break:changed", LastBreakInfo { kind: Some(kind) });
384    Ok(())
385}
386
387/// Skip-next mutation core. Resets the per-kind interval anchor to
388/// `Instant::now()`, clears warn/deferred flags, zeroes the postpone
389/// counter, bumps the session skip total, logs, and fires hooks.
390/// AppHandle-free; the wrapper handles IPC emits.
391pub async fn skip_next_break_impl(scheduler: &Scheduler, kind: BreakKind) -> Result<(), String> {
392    let s = scheduler.settings.lock().await.clone();
393    if s.strict_mode {
394        return Err("strict mode active".to_string());
395    }
396    let now = Instant::now();
397    {
398        let mut t = scheduler.timers.lock().await;
399        match kind {
400            BreakKind::Micro => {
401                t.last_micro = now;
402                t.micro_warned = false;
403                t.micro_deferred_since = None;
404                t.micro_postpone_count = 0;
405            }
406            BreakKind::Long => {
407                t.last_long = now;
408                t.last_micro = now;
409                t.long_warned = false;
410                t.micro_warned = false;
411                t.long_deferred_since = None;
412                t.micro_deferred_since = None;
413                t.long_postpone_count = 0;
414            }
415            BreakKind::Sleep => {
416                t.last_sleep = Some(now);
417            }
418        }
419        t.last_skipped_or_postponed = Some((kind, now));
420    }
421    {
422        let mut stats = scheduler.stats.lock().await;
423        stats.skipped = stats.skipped.saturating_add(1);
424    }
425    scheduler.logger.log(EventPayload::BreakSkipped {
426        kind,
427        source: SkipSource::User,
428    });
429    hooks::run_hooks(&s, HookEvent::BreakSkipped, HookContext::with_kind(kind));
430    Ok(())
431}
432
433/// Renderer-facing skip. Thin wrapper over `skip_next_from_cli`.
434#[tauri::command]
435pub async fn skip_next_break(
436    app: AppHandle,
437    scheduler: tauri::State<'_, Scheduler>,
438    kind: BreakKind,
439) -> Result<(), String> {
440    skip_next_from_cli(&app, &scheduler, kind).await
441}
442
443/// Per-kind postpone budget snapshot for the overlay button label
444/// (e.g. "Postpone (2 of 3)").
445#[tauri::command]
446pub async fn get_postpone_state(
447    scheduler: tauri::State<'_, Scheduler>,
448    kind: BreakKind,
449) -> Result<PostponeState, String> {
450    Ok(compute_postpone_state(&scheduler.settings, &scheduler.timers, kind).await)
451}
452
453/// Lock-then-snapshot helper: settings first (cloned and released),
454/// then timers (read and released). Never holds two scheduler mutex
455/// guards across an `.await`, so two concurrent callers can't deadlock
456/// even if the rest of the codebase locks them in the opposite order.
457async fn compute_postpone_state(
458    settings: &tokio::sync::Mutex<Settings>,
459    timers: &tokio::sync::Mutex<super::super::timers::BreakTimers>,
460    kind: BreakKind,
461) -> PostponeState {
462    // Drop each guard before acquiring the next so we never hold
463    // `settings` across `timers.lock().await`. Two concurrent callers
464    // taking the locks in opposite orders would otherwise be a deadlock
465    // hazard. Reading them sequentially is fine: the postpone state is
466    // a renderer convenience; tiny window between reads can't cross a
467    // user-visible boundary (postpone count is bumped from the same
468    // task that fires the overlay button).
469    let s = settings.lock().await.clone();
470    let count = {
471        let t = timers.lock().await;
472        postpone_counter(&t, kind)
473    };
474    let max = if s.postpone_escalation_enabled && matches!(kind, BreakKind::Micro | BreakKind::Long)
475    {
476        s.postpone_max_count
477    } else {
478        u32::MAX
479    };
480    let remaining = max.saturating_sub(count);
481    PostponeState {
482        count,
483        max,
484        remaining,
485    }
486}
487
488/// The most recently skipped or postponed break — drives the tray's
489/// "Resume last skipped break" menu item.
490#[tauri::command]
491pub async fn get_last_break_info(
492    scheduler: tauri::State<'_, Scheduler>,
493) -> Result<LastBreakInfo, String> {
494    let t = scheduler.timers.lock().await;
495    Ok(LastBreakInfo {
496        kind: t.last_skipped_or_postponed.map(|(k, _)| k),
497    })
498}
499
500/// Re-fire the most recently skipped/postponed break with the current
501/// profile's full settings (duration, hints, enforceability). Shared
502/// by the renderer command and the tray menu handler. Errors with
503/// `"no break to resume"` when the slot is empty.
504pub async fn resume_last_break_impl(app: &AppHandle, scheduler: &Scheduler) -> Result<(), String> {
505    let stored = {
506        let mut t = scheduler.timers.lock().await;
507        t.last_skipped_or_postponed.take()
508    };
509    let Some((kind, _)) = stored else {
510        return Err("no break to resume".to_string());
511    };
512    let s = scheduler.settings.lock().await.clone();
513    let (duration_secs, enforceable, manual_finish, hints) = match kind {
514        BreakKind::Micro => (
515            s.micro_duration_secs,
516            s.micro_enforceable || s.strict_mode,
517            s.micro_manual_finish,
518            effective_micro_hints(&s),
519        ),
520        BreakKind::Long => (
521            s.long_duration_secs,
522            s.long_enforceable || s.strict_mode,
523            s.long_manual_finish,
524            effective_long_hints(&s),
525        ),
526        BreakKind::Sleep => (s.bedtime_duration_secs, true, false, s.sleep_hints.clone()),
527    };
528    let intensity = scheduler.stats.lock().await.intensity();
529    fire_break(
530        app,
531        &scheduler.current_break,
532        kind,
533        duration_secs,
534        enforceable,
535        s.monitor_placement,
536        is_windowed_mode(kind, &s),
537        manual_finish,
538        s.postpone_enabled && !s.strict_mode,
539        hints,
540        s.hint_rotate_seconds,
541        if s.break_health_enabled {
542            intensity
543        } else {
544            0.0
545        },
546    );
547    scheduler.logger.log(EventPayload::BreakResumed { kind });
548    hooks::run_hooks(
549        &s,
550        HookEvent::BreakStart,
551        HookContext::with_kind_duration(kind, duration_secs),
552    );
553    {
554        let mut t = scheduler.timers.lock().await;
555        let now = Instant::now();
556        match kind {
557            BreakKind::Micro => {
558                t.last_micro = now;
559                t.micro_warned = false;
560            }
561            BreakKind::Long => {
562                t.last_long = now;
563                t.last_micro = now;
564                t.long_warned = false;
565                t.micro_warned = false;
566            }
567            BreakKind::Sleep => {
568                t.last_sleep = Some(now);
569            }
570        }
571        t.active_break = Some(kind);
572    }
573    let _ = app.emit("last_break:changed", LastBreakInfo { kind: None });
574    Ok(())
575}
576
577/// Renderer-facing resume. Thin wrapper over `resume_last_break_impl`.
578#[tauri::command]
579pub async fn resume_last_break(
580    app: AppHandle,
581    scheduler: tauri::State<'_, Scheduler>,
582) -> Result<(), String> {
583    resume_last_break_impl(&app, scheduler.inner()).await
584}
585
586#[cfg(test)]
587mod tests {
588    use super::super::super::screen_time::ScreenTimeState;
589    use super::super::super::timers::BreakTimers;
590    use super::super::super::BreakStats;
591    use super::*;
592    use crate::config::{Profile, DEFAULT_PROFILE_NAME};
593    use crate::stats::Logger;
594    use crate::test_support::{temp_dir, TempDir};
595    use std::sync::atomic::{AtomicBool, AtomicU8};
596    use std::sync::Arc;
597    use tokio::sync::Mutex;
598
599    fn settings_with_postpone(
600        escalation: bool,
601        minutes: u32,
602        step: u64,
603        max_count: u32,
604    ) -> Settings {
605        Settings {
606            postpone_escalation_enabled: escalation,
607            postpone_minutes: minutes,
608            postpone_escalation_step_secs: step,
609            postpone_max_count: max_count,
610            ..Settings::default()
611        }
612    }
613
614    #[test]
615    fn effective_postpone_secs_no_escalation_when_disabled() {
616        let s = settings_with_postpone(false, 5, 120, 3);
617        assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Micro), 300);
618        assert_eq!(effective_postpone_secs(&s, 3, BreakKind::Micro), 300);
619    }
620
621    #[test]
622    fn effective_postpone_secs_grows_with_counter() {
623        let s = settings_with_postpone(true, 5, 120, 3);
624        assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Micro), 300);
625        assert_eq!(effective_postpone_secs(&s, 1, BreakKind::Micro), 420);
626        assert_eq!(effective_postpone_secs(&s, 2, BreakKind::Micro), 540);
627        assert_eq!(effective_postpone_secs(&s, 1, BreakKind::Long), 420);
628    }
629
630    #[test]
631    fn test_break_enforceable_micro_off_when_no_strict_no_micro_enforceable() {
632        let s = Settings {
633            strict_mode: false,
634            micro_enforceable: false,
635            ..Settings::default()
636        };
637        assert!(!test_break_enforceable(BreakKind::Micro, &s));
638    }
639
640    #[test]
641    fn test_break_enforceable_micro_true_when_micro_enforceable() {
642        let s = Settings {
643            strict_mode: false,
644            micro_enforceable: true,
645            ..Settings::default()
646        };
647        assert!(test_break_enforceable(BreakKind::Micro, &s));
648    }
649
650    #[test]
651    fn test_break_enforceable_micro_true_when_strict_mode() {
652        let s = Settings {
653            strict_mode: true,
654            micro_enforceable: false,
655            ..Settings::default()
656        };
657        assert!(test_break_enforceable(BreakKind::Micro, &s));
658    }
659
660    #[test]
661    fn test_break_enforceable_long_mirrors_micro() {
662        let off = Settings {
663            strict_mode: false,
664            long_enforceable: false,
665            ..Settings::default()
666        };
667        assert!(!test_break_enforceable(BreakKind::Long, &off));
668
669        let opt_in = Settings {
670            strict_mode: false,
671            long_enforceable: true,
672            ..Settings::default()
673        };
674        assert!(test_break_enforceable(BreakKind::Long, &opt_in));
675
676        let strict = Settings {
677            strict_mode: true,
678            long_enforceable: false,
679            ..Settings::default()
680        };
681        assert!(test_break_enforceable(BreakKind::Long, &strict));
682    }
683
684    #[test]
685    fn test_break_enforceable_sleep_always_true() {
686        let lax = Settings {
687            strict_mode: false,
688            micro_enforceable: false,
689            long_enforceable: false,
690            ..Settings::default()
691        };
692        assert!(test_break_enforceable(BreakKind::Sleep, &lax));
693
694        let strict = Settings {
695            strict_mode: true,
696            ..Settings::default()
697        };
698        assert!(test_break_enforceable(BreakKind::Sleep, &strict));
699    }
700
701    #[test]
702    fn effective_postpone_secs_sleep_never_escalates() {
703        let s = settings_with_postpone(true, 5, 120, 3);
704        assert_eq!(effective_postpone_secs(&s, 0, BreakKind::Sleep), 300);
705        assert_eq!(effective_postpone_secs(&s, 3, BreakKind::Sleep), 300);
706    }
707
708    // Fix #3: `get_postpone_state` used to hold the `settings` guard
709    // across `timers.lock().await`. Two concurrent callers couldn't
710    // *actually* deadlock here (every other path takes settings first),
711    // but the doc convention says "no nested holds across await" — so
712    // the helper now drops settings first. These tests confirm both
713    // (a) no deadlock under concurrent calls and (b) consistent
714    // postpone-state values regardless of interleave.
715    use tokio::time::{timeout, Duration as TokioDuration};
716
717    fn timers_with_postpone(micro: u32, long: u32) -> BreakTimers {
718        let mut t = BreakTimers::new();
719        t.micro_postpone_count = micro;
720        t.long_postpone_count = long;
721        t
722    }
723
724    #[tokio::test]
725    async fn compute_postpone_state_does_not_deadlock_concurrent_callers() {
726        let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
727        let timers = Arc::new(Mutex::new(timers_with_postpone(2, 1)));
728
729        let mut handles = Vec::new();
730        for kind in [BreakKind::Micro, BreakKind::Long] {
731            for _ in 0..16 {
732                let s = settings.clone();
733                let t = timers.clone();
734                handles.push(tokio::spawn(async move {
735                    compute_postpone_state(&s, &t, kind).await
736                }));
737            }
738        }
739
740        // Bound the test so a real deadlock fails loudly instead of
741        // hanging the suite.
742        for h in handles {
743            let state = timeout(TokioDuration::from_secs(5), h)
744                .await
745                .expect("compute_postpone_state should not deadlock under concurrent calls")
746                .unwrap();
747            // Either kind's snapshot must be internally consistent.
748            assert_eq!(state.remaining, state.max.saturating_sub(state.count));
749        }
750    }
751
752    #[tokio::test]
753    async fn compute_postpone_state_returns_expected_snapshot() {
754        let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
755        let timers = Arc::new(Mutex::new(timers_with_postpone(2, 1)));
756
757        let micro = compute_postpone_state(&settings, &timers, BreakKind::Micro).await;
758        assert_eq!(micro.count, 2);
759        assert_eq!(micro.max, 3);
760        assert_eq!(micro.remaining, 1);
761
762        let long = compute_postpone_state(&settings, &timers, BreakKind::Long).await;
763        assert_eq!(long.count, 1);
764        assert_eq!(long.max, 3);
765        assert_eq!(long.remaining, 2);
766
767        // With escalation disabled the cap drops to u32::MAX.
768        let settings = Arc::new(Mutex::new(settings_with_postpone(false, 5, 120, 3)));
769        let micro_no_cap = compute_postpone_state(&settings, &timers, BreakKind::Micro).await;
770        assert_eq!(micro_no_cap.max, u32::MAX);
771        assert_eq!(micro_no_cap.remaining, u32::MAX - 2);
772
773        // Sleep is always uncapped because sleep prompts don't escalate.
774        let sleep = compute_postpone_state(&settings, &timers, BreakKind::Sleep).await;
775        assert_eq!(sleep.count, 0);
776        assert_eq!(sleep.max, u32::MAX);
777    }
778
779    /// Build a Scheduler instance without spinning up the
780    /// camera/video/run-loop side threads. The logger thread is still
781    /// started (it's how `EventPayload`s reach disk) but it writes into
782    /// the TempDir held by the caller, which is dropped on test exit.
783    fn build_test_scheduler(settings: Settings) -> (TempDir, Scheduler) {
784        let dir = temp_dir();
785        let config_path = dir.path().join("settings.json");
786        let pause_path = dir.path().join("pause.json");
787        let events_path = dir.path().join("events.jsonl");
788        let screen_time_path = dir.path().join("screen_time.json");
789        let logger = Logger::spawn(events_path.clone());
790        let sched = Scheduler {
791            settings: Arc::new(Mutex::new(settings)),
792            pause_state: Arc::new(Mutex::new(PauseState::Running)),
793            camera_active: Arc::new(AtomicBool::new(false)),
794            video_active: Arc::new(AtomicBool::new(false)),
795            auto_suppress_reason: Arc::new(AtomicU8::new(0)),
796            config_path,
797            pause_path,
798            events_path,
799            screen_time_path,
800            timers: Arc::new(Mutex::new(BreakTimers::new())),
801            stats: Arc::new(Mutex::new(BreakStats::default())),
802            screen_time: Arc::new(Mutex::new(ScreenTimeState::from_snapshot(
803                crate::screen_time_store::ScreenTimeSnapshot::default(),
804                "1970-01-01",
805            ))),
806            current_break: Arc::new(std::sync::Mutex::new(None)),
807            logger,
808            profiles: Arc::new(Mutex::new(vec![Profile {
809                name: DEFAULT_PROFILE_NAME.to_string(),
810                settings: Settings::default(),
811            }])),
812            active_profile_name: Arc::new(Mutex::new(DEFAULT_PROFILE_NAME.to_string())),
813            hook_dialog_busy: Arc::new(AtomicBool::new(false)),
814        };
815        (dir, sched)
816    }
817
818    #[tokio::test]
819    async fn pause_some_secs_transitions_running_to_timed_pause() {
820        let (_dir, sched) = build_test_scheduler(Settings::default());
821        pause_impl(&sched, Some(900)).await;
822        let state = sched.pause_state.lock().await.clone();
823        match state {
824            PauseState::PausedUntil(Some(deadline)) => {
825                let remaining = deadline.saturating_duration_since(Instant::now());
826                assert!(remaining.as_secs() >= 895 && remaining.as_secs() <= 900);
827            }
828            other => panic!("expected PausedUntil(Some), got {other:?}"),
829        }
830        // Persistence: the pause file on disk should report paused.
831        let snap = crate::pause_store::load(&sched.pause_path);
832        assert!(snap.paused);
833        assert!(snap.until_epoch_secs.is_some());
834    }
835
836    #[tokio::test]
837    async fn pause_none_transitions_running_to_indefinite() {
838        let (_dir, sched) = build_test_scheduler(Settings::default());
839        pause_impl(&sched, None).await;
840        assert!(matches!(
841            *sched.pause_state.lock().await,
842            PauseState::PausedUntil(None)
843        ));
844        let snap = crate::pause_store::load(&sched.pause_path);
845        assert!(snap.paused);
846        assert!(snap.until_epoch_secs.is_none());
847    }
848
849    #[tokio::test]
850    async fn resume_from_paused_returns_to_running() {
851        let (_dir, sched) = build_test_scheduler(Settings::default());
852        pause_impl(&sched, Some(60)).await;
853        resume_impl(&sched).await;
854        assert!(matches!(
855            *sched.pause_state.lock().await,
856            PauseState::Running
857        ));
858        let snap = crate::pause_store::load(&sched.pause_path);
859        assert!(!snap.paused);
860    }
861
862    #[tokio::test]
863    async fn postpone_break_bumps_counter_and_returns_delay() {
864        let settings = Settings {
865            postpone_enabled: true,
866            postpone_escalation_enabled: true,
867            postpone_minutes: 5,
868            postpone_escalation_step_secs: 120,
869            postpone_max_count: 3,
870            ..Settings::default()
871        };
872        let (_dir, sched) = build_test_scheduler(settings);
873        let out = postpone_break_impl(&sched, BreakKind::Micro).await.unwrap();
874        assert_eq!(out.postpone_secs, 300);
875        let t = sched.timers.lock().await;
876        assert_eq!(t.micro_postpone_count, 1);
877        assert!(matches!(
878            t.last_skipped_or_postponed,
879            Some((BreakKind::Micro, _))
880        ));
881        drop(t);
882        // Second postpone escalates per `postpone_escalation_step_secs`.
883        let out2 = postpone_break_impl(&sched, BreakKind::Micro).await.unwrap();
884        assert_eq!(out2.postpone_secs, 420);
885        assert_eq!(sched.timers.lock().await.micro_postpone_count, 2);
886    }
887
888    #[tokio::test]
889    async fn postpone_break_errors_when_max_reached() {
890        let settings = Settings {
891            postpone_enabled: true,
892            postpone_escalation_enabled: true,
893            postpone_minutes: 5,
894            postpone_escalation_step_secs: 120,
895            postpone_max_count: 2,
896            ..Settings::default()
897        };
898        let (_dir, sched) = build_test_scheduler(settings);
899        postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
900        postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
901        let err = postpone_break_impl(&sched, BreakKind::Long)
902            .await
903            .expect_err("third postpone should hit the cap");
904        assert_eq!(err, "postpone exhausted");
905    }
906
907    #[tokio::test]
908    async fn postpone_break_errors_when_strict_mode_or_disabled() {
909        let strict = Settings {
910            strict_mode: true,
911            postpone_enabled: true,
912            ..Settings::default()
913        };
914        let (_dir, sched) = build_test_scheduler(strict);
915        let err = postpone_break_impl(&sched, BreakKind::Micro)
916            .await
917            .expect_err("strict mode blocks postpone");
918        assert_eq!(err, "postpone disabled");
919
920        let disabled = Settings {
921            strict_mode: false,
922            postpone_enabled: false,
923            ..Settings::default()
924        };
925        let (_dir2, sched2) = build_test_scheduler(disabled);
926        let err = postpone_break_impl(&sched2, BreakKind::Micro)
927            .await
928            .expect_err("postpone_enabled=false blocks postpone");
929        assert_eq!(err, "postpone disabled");
930    }
931
932    #[tokio::test]
933    async fn skip_next_break_resets_anchor_and_increments_stats() {
934        let (_dir, sched) = build_test_scheduler(Settings::default());
935        // Pre-set an older anchor so we can verify it was bumped.
936        {
937            let mut t = sched.timers.lock().await;
938            t.last_micro = Instant::now()
939                .checked_sub(Duration::from_secs(3_600))
940                .unwrap_or_else(Instant::now);
941            t.micro_postpone_count = 5;
942            t.micro_warned = true;
943        }
944        skip_next_break_impl(&sched, BreakKind::Micro)
945            .await
946            .unwrap();
947        let t = sched.timers.lock().await;
948        assert_eq!(t.micro_postpone_count, 0);
949        assert!(!t.micro_warned);
950        // The anchor should be ~now (within 1s).
951        assert!(t.last_micro.elapsed() < Duration::from_secs(1));
952        assert!(matches!(
953            t.last_skipped_or_postponed,
954            Some((BreakKind::Micro, _))
955        ));
956        drop(t);
957        assert_eq!(sched.stats.lock().await.skipped, 1);
958    }
959
960    #[tokio::test]
961    async fn skip_next_break_errors_in_strict_mode() {
962        let strict = Settings {
963            strict_mode: true,
964            ..Settings::default()
965        };
966        let (_dir, sched) = build_test_scheduler(strict);
967        let err = skip_next_break_impl(&sched, BreakKind::Micro)
968            .await
969            .expect_err("strict mode blocks skip");
970        assert_eq!(err, "strict mode active");
971    }
972
973    #[tokio::test]
974    async fn skip_next_break_long_resets_both_anchors_and_counter() {
975        // Long-break skip resets micro state too: a long break "swallows"
976        // the upcoming micro, so the user shouldn't be hit with a micro
977        // a moment after skipping a long.
978        let (_dir, sched) = build_test_scheduler(Settings::default());
979        {
980            let mut t = sched.timers.lock().await;
981            t.last_long = Instant::now()
982                .checked_sub(Duration::from_secs(3_600))
983                .unwrap_or_else(Instant::now);
984            t.last_micro = Instant::now()
985                .checked_sub(Duration::from_secs(3_600))
986                .unwrap_or_else(Instant::now);
987            t.long_postpone_count = 4;
988            t.long_warned = true;
989            t.micro_warned = true;
990        }
991        skip_next_break_impl(&sched, BreakKind::Long).await.unwrap();
992        let t = sched.timers.lock().await;
993        assert_eq!(t.long_postpone_count, 0);
994        assert!(!t.long_warned);
995        assert!(!t.micro_warned);
996        assert!(t.last_long.elapsed() < Duration::from_secs(1));
997        assert!(t.last_micro.elapsed() < Duration::from_secs(1));
998        assert!(matches!(
999            t.last_skipped_or_postponed,
1000            Some((BreakKind::Long, _))
1001        ));
1002    }
1003
1004    #[tokio::test]
1005    async fn skip_next_break_sleep_sets_last_sleep_marker() {
1006        let (_dir, sched) = build_test_scheduler(Settings::default());
1007        assert!(sched.timers.lock().await.last_sleep.is_none());
1008        skip_next_break_impl(&sched, BreakKind::Sleep)
1009            .await
1010            .unwrap();
1011        let t = sched.timers.lock().await;
1012        assert!(t.last_sleep.is_some());
1013        assert!(matches!(
1014            t.last_skipped_or_postponed,
1015            Some((BreakKind::Sleep, _))
1016        ));
1017    }
1018
1019    #[tokio::test]
1020    async fn postpone_break_long_bumps_long_counter_and_resets_micro_anchor() {
1021        // Long-postpone bumps long's counter and also pushes back the
1022        // micro anchor so a micro doesn't fire inside the postpone window.
1023        let settings = Settings {
1024            postpone_enabled: true,
1025            postpone_escalation_enabled: true,
1026            postpone_minutes: 5,
1027            postpone_escalation_step_secs: 120,
1028            postpone_max_count: 3,
1029            ..Settings::default()
1030        };
1031        let (_dir, sched) = build_test_scheduler(settings);
1032        let out = postpone_break_impl(&sched, BreakKind::Long).await.unwrap();
1033        assert_eq!(out.postpone_secs, 300);
1034        let t = sched.timers.lock().await;
1035        assert_eq!(t.long_postpone_count, 1);
1036        // Micro state must be pushed back too, not just long's.
1037        assert!(!t.micro_warned);
1038        assert!(t.micro_deferred_since.is_none());
1039        assert!(matches!(
1040            t.last_skipped_or_postponed,
1041            Some((BreakKind::Long, _))
1042        ));
1043    }
1044
1045    #[tokio::test]
1046    async fn postpone_break_sleep_records_last_sleep() {
1047        let settings = Settings {
1048            postpone_enabled: true,
1049            // Escalation off so sleep doesn't hit the cap check.
1050            postpone_escalation_enabled: false,
1051            postpone_minutes: 5,
1052            ..Settings::default()
1053        };
1054        let (_dir, sched) = build_test_scheduler(settings);
1055        assert!(sched.timers.lock().await.last_sleep.is_none());
1056        postpone_break_impl(&sched, BreakKind::Sleep).await.unwrap();
1057        let t = sched.timers.lock().await;
1058        assert!(t.last_sleep.is_some());
1059        assert!(matches!(
1060            t.last_skipped_or_postponed,
1061            Some((BreakKind::Sleep, _))
1062        ));
1063    }
1064
1065    #[tokio::test]
1066    async fn postpone_break_with_escalation_disabled_ignores_cap() {
1067        // postpone_escalation_enabled=false bypasses the per-kind cap
1068        // even when the counter is already past it — escalation off means
1069        // each postpone is just the base duration with no limit.
1070        let settings = Settings {
1071            postpone_enabled: true,
1072            postpone_escalation_enabled: false,
1073            postpone_minutes: 5,
1074            postpone_escalation_step_secs: 120,
1075            postpone_max_count: 1,
1076            ..Settings::default()
1077        };
1078        let (_dir, sched) = build_test_scheduler(settings);
1079        for _ in 0..3 {
1080            let out = postpone_break_impl(&sched, BreakKind::Micro)
1081                .await
1082                .expect("escalation off uncaps postpone");
1083            assert_eq!(out.postpone_secs, 300, "no escalation = constant 5 min");
1084        }
1085        assert_eq!(sched.timers.lock().await.micro_postpone_count, 3);
1086    }
1087
1088    /// Poll the events.jsonl file until it contains the given marker
1089    /// substring or the timeout elapses. The logger writes on a
1090    /// background thread, so a fixed sleep is racy on loaded CI runners.
1091    async fn wait_for_log_substring(path: &std::path::Path, marker: &str) {
1092        let deadline = Instant::now() + Duration::from_secs(2);
1093        while Instant::now() < deadline {
1094            if let Ok(contents) = std::fs::read_to_string(path) {
1095                if contents.contains(marker) {
1096                    return;
1097                }
1098            }
1099            tokio::time::sleep(Duration::from_millis(25)).await;
1100        }
1101    }
1102
1103    #[tokio::test]
1104    async fn pause_when_already_paused_does_not_re_fire_hooks() {
1105        // pause_impl logs pause_start only on the running→paused edge.
1106        // A second pause-while-paused must not produce a second log entry.
1107        let (_dir, sched) = build_test_scheduler(Settings::default());
1108        pause_impl(&sched, Some(60)).await;
1109        wait_for_log_substring(&sched.events_path, "\"type\":\"pause_start\"").await;
1110        pause_impl(&sched, Some(900)).await;
1111        // Drain the logger queue: a second event would land within the
1112        // same window the first did, so polling a touch longer is enough.
1113        tokio::time::sleep(Duration::from_millis(150)).await;
1114        let log = std::fs::read_to_string(&sched.events_path).unwrap_or_default();
1115        let count = log.matches("\"type\":\"pause_start\"").count();
1116        assert_eq!(
1117            count, 1,
1118            "pause_start only fires on the running→paused edge, got log:\n{log}",
1119        );
1120    }
1121
1122    #[tokio::test]
1123    async fn resume_when_already_running_is_a_noop() {
1124        // resume_impl on a Running scheduler is a no-op — it must not
1125        // log a pause_end event when there was no pause to end.
1126        let (_dir, sched) = build_test_scheduler(Settings::default());
1127        resume_impl(&sched).await;
1128        // Wait long enough that a real pause_end log would have flushed.
1129        tokio::time::sleep(Duration::from_millis(150)).await;
1130        let log = std::fs::read_to_string(&sched.events_path).unwrap_or_default();
1131        assert!(
1132            !log.contains("\"type\":\"pause_end\""),
1133            "resume on a Running scheduler must not log pause_end, got log:\n{log}",
1134        );
1135        // State stays Running across the no-op.
1136        assert!(matches!(
1137            *sched.pause_state.lock().await,
1138            PauseState::Running
1139        ));
1140    }
1141
1142    #[tokio::test]
1143    async fn compute_postpone_state_sleep_is_uncapped_even_with_escalation_on() {
1144        // Sleep breaks never escalate; their postpone slot in
1145        // `compute_postpone_state` should always report `max = u32::MAX`
1146        // regardless of the `postpone_escalation_enabled` setting.
1147        let settings = Arc::new(Mutex::new(settings_with_postpone(true, 5, 120, 3)));
1148        let timers = Arc::new(Mutex::new(timers_with_postpone(0, 0)));
1149        let sleep = compute_postpone_state(&settings, &timers, BreakKind::Sleep).await;
1150        assert_eq!(sleep.max, u32::MAX);
1151        assert_eq!(sleep.count, 0);
1152        assert_eq!(sleep.remaining, u32::MAX);
1153    }
1154
1155    #[tokio::test]
1156    async fn end_break_completed_resets_postpone_counter_and_clears_last() {
1157        // The end_break command itself needs an AppHandle, but the
1158        // state mutations it triggers in the scheduler are observable
1159        // without one: stash an active break, then re-implement the
1160        // "completed" tail (stats + counter reset + clear_last) and
1161        // confirm the helpers leave the scheduler in the expected shape.
1162        let (_dir, sched) = build_test_scheduler(Settings::default());
1163        {
1164            let mut t = sched.timers.lock().await;
1165            t.active_break = Some(BreakKind::Micro);
1166            t.micro_postpone_count = 2;
1167            t.last_skipped_or_postponed = Some((BreakKind::Micro, Instant::now()));
1168        }
1169        // Drive the same path end_break's "completed" branch takes:
1170        // active_kind.take() + reset_postpone_counter + clear_last_break.
1171        let active_kind = {
1172            let mut t = sched.timers.lock().await;
1173            t.active_break.take()
1174        };
1175        assert_eq!(active_kind, Some(BreakKind::Micro));
1176        let mut t = sched.timers.lock().await;
1177        reset_postpone_counter(&mut t, BreakKind::Micro);
1178        let cleared = clear_last_break(&mut t);
1179        assert!(
1180            cleared,
1181            "clear_last_break returns true when slot was populated"
1182        );
1183        assert_eq!(t.micro_postpone_count, 0);
1184        assert!(t.last_skipped_or_postponed.is_none());
1185    }
1186}