Skip to main content

entracte_lib/scheduler/commands/
profiles.rs

1use tauri::{AppHandle, Emitter, Runtime};
2
3use crate::config::Profile;
4
5use super::super::settings::Settings;
6use super::super::timers::reset_timers_keep_sleep;
7use super::super::Scheduler;
8
9async fn emit_profile_changed<R: Runtime>(
10    app: &AppHandle<R>,
11    scheduler: &Scheduler,
12) -> tauri::Result<()> {
13    let name = scheduler.active_profile_name.lock().await.clone();
14    app.emit("profile:changed", &name)
15}
16
17/// List the names of every saved profile, in the order they appear
18/// in the tray menu and the Profiles tab.
19#[tauri::command]
20pub async fn list_profiles(scheduler: tauri::State<'_, Scheduler>) -> Result<Vec<String>, String> {
21    Ok(scheduler
22        .profiles
23        .lock()
24        .await
25        .iter()
26        .map(|p| p.name.clone())
27        .collect())
28}
29
30/// Name of the currently active profile (drives every setting tab).
31#[tauri::command]
32pub async fn get_active_profile(scheduler: tauri::State<'_, Scheduler>) -> Result<String, String> {
33    Ok(scheduler.active_profile_name.lock().await.clone())
34}
35
36/// Switch the active profile to `name`. Shared by the Tauri command,
37/// the tray-menu handler, and the local-IPC entry point. Resets the
38/// per-profile timers (keeping `last_sleep`) and emits `profile:changed`.
39pub async fn set_active_profile_impl<R: Runtime>(
40    app: &AppHandle<R>,
41    scheduler: &Scheduler,
42    name: String,
43) -> Result<(), String> {
44    let next_settings = {
45        let profiles = scheduler.profiles.lock().await;
46        profiles
47            .iter()
48            .find(|p| p.name == name)
49            .map(|p| p.settings.clone())
50            .ok_or_else(|| format!("profile not found: {name}"))?
51    };
52    {
53        let current = scheduler.active_profile_name.lock().await.clone();
54        if current == name {
55            return Ok(());
56        }
57    }
58    {
59        let mut s = scheduler.settings.lock().await;
60        *s = next_settings;
61        // Profile settings are stored clamped, but the `derived` cache is
62        // `#[serde(skip)]` and arrives default-empty from the profile clone,
63        // so rebuild it from the new source fields before the run loop reads it.
64        s.rebuild_derived();
65    }
66    *scheduler.active_profile_name.lock().await = name.clone();
67    {
68        let mut t = scheduler.timers.lock().await;
69        reset_timers_keep_sleep(&mut t);
70    }
71    super::super::persist_profiles(scheduler).await;
72    {
73        // The new profile may carry different hotkey bindings — re-register
74        // from its settings so a profile switch keeps the chords in sync.
75        let settings = scheduler.settings.lock().await.clone();
76        super::super::apply_hotkeys(app, &settings);
77    }
78    let _ = app.emit("profile:changed", &name);
79    Ok(())
80}
81
82/// Renderer-facing `set_active_profile`. Thin wrapper over
83/// `set_active_profile_impl`.
84#[tauri::command]
85pub async fn set_active_profile<R: Runtime>(
86    app: AppHandle<R>,
87    scheduler: tauri::State<'_, Scheduler>,
88    name: String,
89) -> Result<(), String> {
90    set_active_profile_impl(&app, scheduler.inner(), name).await
91}
92
93fn validate_profile_name(name: &str) -> Result<String, String> {
94    let trimmed = name.trim();
95    if trimmed.is_empty() {
96        return Err("profile name cannot be empty".to_string());
97    }
98    Ok(trimmed.to_string())
99}
100
101fn validate_delete(profiles: &[Profile], active: &str, target: &str) -> Result<(), String> {
102    if profiles.len() <= 1 {
103        return Err("cannot delete the only profile".to_string());
104    }
105    if active == target {
106        return Err("cannot delete the active profile".to_string());
107    }
108    if !profiles.iter().any(|p| p.name == target) {
109        return Err(format!("profile not found: {target}"));
110    }
111    Ok(())
112}
113
114fn validate_rename(profiles: &[Profile], from: &str, to: &str) -> Result<(), String> {
115    if from == to {
116        return Ok(());
117    }
118    if !profiles.iter().any(|p| p.name == from) {
119        return Err(format!("profile not found: {from}"));
120    }
121    if profiles.iter().any(|p| p.name == to) {
122        return Err(format!("profile already exists: {to}"));
123    }
124    Ok(())
125}
126
127fn validate_reorder(profiles: &[Profile], desired: &[String]) -> Result<(), String> {
128    if desired.len() != profiles.len() {
129        return Err(format!(
130            "reorder list length {} does not match profile count {}",
131            desired.len(),
132            profiles.len()
133        ));
134    }
135    for (i, name) in desired.iter().enumerate() {
136        if desired[..i].iter().any(|other| other == name) {
137            return Err(format!("duplicate profile in reorder list: {name}"));
138        }
139        if !profiles.iter().any(|p| &p.name == name) {
140            return Err(format!("profile not found: {name}"));
141        }
142    }
143    Ok(())
144}
145
146/// State-mutation core for `create_profile`. AppHandle-free so the
147/// validation + copy + push + persist path can be unit-tested without
148/// a Tauri runtime; the command wrapper layers the `profile:changed`
149/// emit on top.
150pub async fn create_profile_impl(scheduler: &Scheduler, name: String) -> Result<(), String> {
151    let name = validate_profile_name(&name)?;
152    {
153        let profiles = scheduler.profiles.lock().await;
154        if profiles.iter().any(|p| p.name == name) {
155            return Err(format!("profile already exists: {name}"));
156        }
157    }
158    let source = {
159        let active = scheduler.active_profile_name.lock().await.clone();
160        let profiles = scheduler.profiles.lock().await;
161        profiles
162            .iter()
163            .find(|p| p.name == active)
164            .map(|p| p.settings.clone())
165            .unwrap_or_default()
166    };
167    scheduler.profiles.lock().await.push(Profile {
168        name: name.clone(),
169        settings: source,
170    });
171    super::super::persist_profiles(scheduler).await;
172    Ok(())
173}
174
175/// Create a brand-new profile copied from the currently active one.
176/// `name` must be non-empty (after trim) and not already in use.
177#[tauri::command]
178pub async fn create_profile<R: Runtime>(
179    app: AppHandle<R>,
180    scheduler: tauri::State<'_, Scheduler>,
181    name: String,
182) -> Result<(), String> {
183    create_profile_impl(scheduler.inner(), name).await?;
184    let _ = emit_profile_changed(&app, scheduler.inner()).await;
185    Ok(())
186}
187
188/// State-mutation core for `duplicate_profile`.
189pub async fn duplicate_profile_impl(
190    scheduler: &Scheduler,
191    source: String,
192    name: String,
193) -> Result<(), String> {
194    let name = validate_profile_name(&name)?;
195    let source_settings = {
196        let profiles = scheduler.profiles.lock().await;
197        if profiles.iter().any(|p| p.name == name) {
198            return Err(format!("profile already exists: {name}"));
199        }
200        profiles
201            .iter()
202            .find(|p| p.name == source)
203            .map(|p| p.settings.clone())
204            .ok_or_else(|| format!("profile not found: {source}"))?
205    };
206    scheduler.profiles.lock().await.push(Profile {
207        name: name.clone(),
208        settings: source_settings,
209    });
210    super::super::persist_profiles(scheduler).await;
211    Ok(())
212}
213
214/// Copy `source`'s settings into a brand-new profile named `name`
215/// without flipping the active profile. Avoids the
216/// switch-then-create dance that used to fire `profile:changed`
217/// mid-duplication and clobber unsaved hook drafts.
218#[tauri::command]
219pub async fn duplicate_profile<R: Runtime>(
220    app: AppHandle<R>,
221    scheduler: tauri::State<'_, Scheduler>,
222    source: String,
223    name: String,
224) -> Result<(), String> {
225    duplicate_profile_impl(scheduler.inner(), source, name).await?;
226    let _ = emit_profile_changed(&app, scheduler.inner()).await;
227    Ok(())
228}
229
230/// State-mutation core for `rename_profile`. Updates the active-name
231/// pointer if the renamed profile happens to be active.
232pub async fn rename_profile_impl(
233    scheduler: &Scheduler,
234    from: String,
235    to: String,
236) -> Result<(), String> {
237    let to = validate_profile_name(&to)?;
238    {
239        let profiles = scheduler.profiles.lock().await;
240        validate_rename(&profiles, &from, &to)?;
241    }
242    if from == to {
243        return Ok(());
244    }
245    {
246        let mut profiles = scheduler.profiles.lock().await;
247        if let Some(p) = profiles.iter_mut().find(|p| p.name == from) {
248            p.name = to.clone();
249        }
250    }
251    {
252        let mut active = scheduler.active_profile_name.lock().await;
253        if *active == from {
254            *active = to.clone();
255        }
256    }
257    super::super::persist_profiles(scheduler).await;
258    Ok(())
259}
260
261/// Rename a profile. If the active profile is renamed, the active
262/// pointer is updated to follow it. Rejects collisions and missing
263/// sources.
264#[tauri::command]
265pub async fn rename_profile<R: Runtime>(
266    app: AppHandle<R>,
267    scheduler: tauri::State<'_, Scheduler>,
268    from: String,
269    to: String,
270) -> Result<(), String> {
271    rename_profile_impl(scheduler.inner(), from, to).await?;
272    let _ = emit_profile_changed(&app, scheduler.inner()).await;
273    Ok(())
274}
275
276/// State-mutation core for `delete_profile`.
277pub async fn delete_profile_impl(scheduler: &Scheduler, name: String) -> Result<(), String> {
278    {
279        let profiles = scheduler.profiles.lock().await;
280        let active = scheduler.active_profile_name.lock().await.clone();
281        validate_delete(&profiles, &active, &name)?;
282    }
283    {
284        let mut profiles = scheduler.profiles.lock().await;
285        profiles.retain(|p| p.name != name);
286    }
287    super::super::persist_profiles(scheduler).await;
288    Ok(())
289}
290
291/// Delete a profile by name. Refuses to delete the only profile or
292/// the currently-active profile (the user must switch first).
293#[tauri::command]
294pub async fn delete_profile<R: Runtime>(
295    app: AppHandle<R>,
296    scheduler: tauri::State<'_, Scheduler>,
297    name: String,
298) -> Result<(), String> {
299    delete_profile_impl(scheduler.inner(), name).await?;
300    let _ = emit_profile_changed(&app, scheduler.inner()).await;
301    Ok(())
302}
303
304/// State-mutation core for `reorder_profiles`.
305pub async fn reorder_profiles_impl(
306    scheduler: &Scheduler,
307    names: Vec<String>,
308) -> Result<(), String> {
309    {
310        let profiles = scheduler.profiles.lock().await;
311        validate_reorder(&profiles, &names)?;
312    }
313    {
314        let mut profiles = scheduler.profiles.lock().await;
315        let mut next: Vec<Profile> = Vec::with_capacity(profiles.len());
316        for name in &names {
317            if let Some(pos) = profiles.iter().position(|p| &p.name == name) {
318                next.push(profiles.swap_remove(pos));
319            }
320        }
321        *profiles = next;
322    }
323    super::super::persist_profiles(scheduler).await;
324    Ok(())
325}
326
327/// Reorder profiles to match `names` exactly. The renderer sends the
328/// full list — the call rejects length mismatches, duplicates, and
329/// unknown names rather than try to merge.
330#[tauri::command]
331pub async fn reorder_profiles<R: Runtime>(
332    app: AppHandle<R>,
333    scheduler: tauri::State<'_, Scheduler>,
334    names: Vec<String>,
335) -> Result<(), String> {
336    reorder_profiles_impl(scheduler.inner(), names).await?;
337    let _ = emit_profile_changed(&app, scheduler.inner()).await;
338    Ok(())
339}
340
341/// State-mutation core for `reset_profile_to_defaults`. Replaces the
342/// named profile's settings with `Settings::default()`, and also resets
343/// the in-memory live `settings` slot when the named profile is active.
344pub async fn reset_profile_to_defaults_impl(
345    scheduler: &Scheduler,
346    name: String,
347) -> Result<(), String> {
348    let active = scheduler.active_profile_name.lock().await.clone();
349    {
350        let profiles = scheduler.profiles.lock().await;
351        if !profiles.iter().any(|p| p.name == name) {
352            return Err(format!("profile not found: {name}"));
353        }
354    }
355    let defaults = Settings::default();
356    {
357        let mut profiles = scheduler.profiles.lock().await;
358        if let Some(p) = profiles.iter_mut().find(|p| p.name == name) {
359            p.settings = defaults.clone();
360        }
361    }
362    if active == name {
363        let mut s = scheduler.settings.lock().await;
364        *s = defaults;
365        s.rebuild_derived();
366    }
367    super::super::persist_profiles(scheduler).await;
368    Ok(())
369}
370
371/// Replace `name`'s settings with `Settings::default()`. If `name` is
372/// the active profile, the in-memory settings are also reset so the
373/// renderer sees the change without a profile switch.
374#[tauri::command]
375pub async fn reset_profile_to_defaults<R: Runtime>(
376    app: AppHandle<R>,
377    scheduler: tauri::State<'_, Scheduler>,
378    name: String,
379) -> Result<(), String> {
380    reset_profile_to_defaults_impl(scheduler.inner(), name).await?;
381    let _ = emit_profile_changed(&app, scheduler.inner()).await;
382    Ok(())
383}
384
385#[cfg(test)]
386mod tests {
387    use super::*;
388
389    fn named_profile(name: &str) -> Profile {
390        Profile {
391            name: name.to_string(),
392            settings: Settings::default(),
393        }
394    }
395
396    #[test]
397    fn validate_delete_rejects_only_profile() {
398        let profiles = vec![named_profile("Default")];
399        let err = validate_delete(&profiles, "Default", "Default").unwrap_err();
400        assert!(err.contains("only profile"));
401    }
402
403    #[test]
404    fn validate_delete_rejects_active() {
405        let profiles = vec![named_profile("Default"), named_profile("Work")];
406        let err = validate_delete(&profiles, "Work", "Work").unwrap_err();
407        assert!(err.contains("active"));
408    }
409
410    #[test]
411    fn validate_delete_rejects_missing() {
412        let profiles = vec![named_profile("Default"), named_profile("Work")];
413        let err = validate_delete(&profiles, "Default", "Missing").unwrap_err();
414        assert!(err.contains("not found"));
415    }
416
417    #[test]
418    fn validate_delete_accepts_inactive() {
419        let profiles = vec![named_profile("Default"), named_profile("Work")];
420        assert!(validate_delete(&profiles, "Default", "Work").is_ok());
421    }
422
423    #[test]
424    fn validate_rename_rejects_collision() {
425        let profiles = vec![named_profile("Default"), named_profile("Work")];
426        let err = validate_rename(&profiles, "Default", "Work").unwrap_err();
427        assert!(err.contains("already exists"));
428    }
429
430    #[test]
431    fn validate_rename_rejects_missing_source() {
432        let profiles = vec![named_profile("Default")];
433        let err = validate_rename(&profiles, "Missing", "Other").unwrap_err();
434        assert!(err.contains("not found"));
435    }
436
437    #[test]
438    fn validate_rename_allows_same_name_noop() {
439        let profiles = vec![named_profile("Default")];
440        assert!(validate_rename(&profiles, "Default", "Default").is_ok());
441    }
442
443    #[test]
444    fn validate_rename_accepts_unique_target() {
445        let profiles = vec![named_profile("Default")];
446        assert!(validate_rename(&profiles, "Default", "Work").is_ok());
447    }
448
449    #[test]
450    fn validate_profile_name_rejects_empty() {
451        assert!(validate_profile_name("").is_err());
452        assert!(validate_profile_name("   ").is_err());
453    }
454
455    #[test]
456    fn validate_reorder_rejects_length_mismatch() {
457        let profiles = vec![named_profile("a"), named_profile("b")];
458        let err = validate_reorder(&profiles, &["a".to_string()]).unwrap_err();
459        assert!(err.contains("does not match"));
460    }
461
462    #[test]
463    fn validate_reorder_rejects_duplicate() {
464        let profiles = vec![named_profile("a"), named_profile("b")];
465        let err = validate_reorder(&profiles, &["a".to_string(), "a".to_string()]).unwrap_err();
466        assert!(err.contains("duplicate"));
467    }
468
469    #[test]
470    fn validate_reorder_rejects_unknown() {
471        let profiles = vec![named_profile("a"), named_profile("b")];
472        let err = validate_reorder(&profiles, &["a".to_string(), "c".to_string()]).unwrap_err();
473        assert!(err.contains("not found"));
474    }
475
476    #[test]
477    fn validate_reorder_accepts_permutation() {
478        let profiles = vec![named_profile("a"), named_profile("b"), named_profile("c")];
479        let desired = vec!["c".to_string(), "a".to_string(), "b".to_string()];
480        assert!(validate_reorder(&profiles, &desired).is_ok());
481    }
482
483    #[test]
484    fn validate_profile_name_trims_whitespace() {
485        assert_eq!(validate_profile_name("  Work  ").unwrap(), "Work");
486    }
487
488    #[test]
489    fn postpone_exhaustion_threshold_matches_max() {
490        let s = Settings::default();
491        assert_eq!(s.postpone_max_count, 3);
492    }
493
494    // -------- impl-level tests over a built-in test Scheduler --------
495
496    use crate::config::DEFAULT_PROFILE_NAME;
497    use crate::test_support::test_scheduler_with_profiles;
498
499    fn one_profile() -> Vec<Profile> {
500        vec![Profile {
501            name: DEFAULT_PROFILE_NAME.to_string(),
502            settings: Settings {
503                micro_interval_secs: 1234,
504                ..Settings::default()
505            },
506        }]
507    }
508
509    fn two_profiles() -> Vec<Profile> {
510        vec![
511            Profile {
512                name: DEFAULT_PROFILE_NAME.to_string(),
513                settings: Settings {
514                    micro_interval_secs: 1234,
515                    ..Settings::default()
516                },
517            },
518            Profile {
519                name: "Work".to_string(),
520                settings: Settings {
521                    micro_interval_secs: 600,
522                    ..Settings::default()
523                },
524            },
525        ]
526    }
527
528    // Profile carrying fixed times + app-pause targets so a switch onto it
529    // can prove the `derived` cache is rebuilt from its (deserialised,
530    // cache-less) source fields rather than left stale/empty. Only the
531    // mock-AppHandle test below consumes it, so it's gated the same way to
532    // keep the Windows clippy build (which drops that test) dead-code clean.
533    #[cfg(not(target_os = "windows"))]
534    fn profiles_with_derived_source() -> Vec<Profile> {
535        vec![
536            Profile {
537                name: DEFAULT_PROFILE_NAME.to_string(),
538                settings: Settings::default(),
539            },
540            Profile {
541                name: "Fixed".to_string(),
542                settings: Settings {
543                    micro_schedule_mode: crate::scheduler::settings::ScheduleMode::Fixed,
544                    micro_fixed_times: vec!["09:30".into(), "14:00".into()],
545                    app_pause_enabled: true,
546                    app_pause_list: vec!["Zoom".into(), "OBS Studio".into()],
547                    ..Settings::default()
548                },
549            },
550        ]
551    }
552
553    // The profile-switch path stores the new profile's settings without
554    // going through `clamp`, so it must rebuild the `#[serde(skip)]`
555    // `derived` cache explicitly. Drives the real `set_active_profile_impl`
556    // through a mock AppHandle (gated off Windows for the mock-rig reason).
557    #[cfg(not(target_os = "windows"))]
558    #[tokio::test]
559    async fn set_active_profile_rebuilds_derived_caches() {
560        use tauri::test::{mock_builder, mock_context, noop_assets};
561
562        let (_dir, sched) =
563            test_scheduler_with_profiles(profiles_with_derived_source(), DEFAULT_PROFILE_NAME);
564        // Default profile has no fixed times / app-pause targets.
565        assert!(sched
566            .settings
567            .lock()
568            .await
569            .derived
570            .micro_fixed_minutes
571            .is_empty());
572
573        let app = mock_builder()
574            .build(mock_context(noop_assets()))
575            .expect("mock app builds");
576
577        set_active_profile_impl(app.handle(), &sched, "Fixed".to_string())
578            .await
579            .unwrap();
580
581        let s = sched.settings.lock().await;
582        // "09:30" → 570, "14:00" → 840.
583        assert_eq!(s.derived.micro_fixed_minutes, vec![570, 840]);
584        assert_eq!(
585            s.derived.app_pause_targets_lower,
586            vec!["zoom", "obs studio"]
587        );
588    }
589
590    #[tokio::test]
591    async fn create_profile_appends_copy_of_active_settings() {
592        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
593        create_profile_impl(&sched, "Focus".to_string())
594            .await
595            .unwrap();
596        let profiles = sched.profiles.lock().await;
597        assert_eq!(profiles.len(), 2);
598        assert_eq!(profiles[1].name, "Focus");
599        // The copy carries the active profile's settings, not Settings::default.
600        assert_eq!(profiles[1].settings.micro_interval_secs, 1234);
601    }
602
603    #[tokio::test]
604    async fn create_profile_rejects_empty_name() {
605        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
606        let err = create_profile_impl(&sched, "  ".to_string())
607            .await
608            .unwrap_err();
609        assert!(err.contains("cannot be empty"));
610        assert_eq!(sched.profiles.lock().await.len(), 1);
611    }
612
613    #[tokio::test]
614    async fn create_profile_rejects_duplicate_name() {
615        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
616        let err = create_profile_impl(&sched, "Work".to_string())
617            .await
618            .unwrap_err();
619        assert!(err.contains("already exists"));
620    }
621
622    #[tokio::test]
623    async fn duplicate_profile_clones_named_source_without_switching_active() {
624        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
625        duplicate_profile_impl(&sched, "Work".to_string(), "Focus".to_string())
626            .await
627            .unwrap();
628        let profiles = sched.profiles.lock().await;
629        assert_eq!(profiles.len(), 3);
630        let focus = profiles.iter().find(|p| p.name == "Focus").unwrap();
631        // Copies Work's settings (600), not the active Default's (1234).
632        assert_eq!(focus.settings.micro_interval_secs, 600);
633        // Active pointer doesn't move.
634        let active = sched.active_profile_name.lock().await;
635        assert_eq!(*active, DEFAULT_PROFILE_NAME);
636    }
637
638    #[tokio::test]
639    async fn duplicate_profile_errors_when_source_missing() {
640        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
641        let err = duplicate_profile_impl(&sched, "Missing".to_string(), "Focus".to_string())
642            .await
643            .unwrap_err();
644        assert!(err.contains("not found"));
645        assert_eq!(sched.profiles.lock().await.len(), 1);
646    }
647
648    #[tokio::test]
649    async fn rename_profile_renames_in_place_and_follows_active_pointer() {
650        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
651        rename_profile_impl(
652            &sched,
653            DEFAULT_PROFILE_NAME.to_string(),
654            "Personal".to_string(),
655        )
656        .await
657        .unwrap();
658        let profiles = sched.profiles.lock().await;
659        assert_eq!(profiles[0].name, "Personal");
660        assert_eq!(profiles[1].name, "Work");
661        let active = sched.active_profile_name.lock().await;
662        assert_eq!(*active, "Personal", "active pointer follows the rename");
663    }
664
665    #[tokio::test]
666    async fn rename_profile_leaves_active_alone_when_renaming_inactive() {
667        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
668        rename_profile_impl(&sched, "Work".to_string(), "Office".to_string())
669            .await
670            .unwrap();
671        assert_eq!(
672            *sched.active_profile_name.lock().await,
673            DEFAULT_PROFILE_NAME,
674        );
675    }
676
677    #[tokio::test]
678    async fn rename_profile_noop_on_same_name() {
679        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
680        rename_profile_impl(
681            &sched,
682            DEFAULT_PROFILE_NAME.to_string(),
683            DEFAULT_PROFILE_NAME.to_string(),
684        )
685        .await
686        .unwrap();
687        assert_eq!(sched.profiles.lock().await.len(), 1);
688    }
689
690    #[tokio::test]
691    async fn delete_profile_removes_inactive_entry() {
692        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
693        delete_profile_impl(&sched, "Work".to_string())
694            .await
695            .unwrap();
696        let profiles = sched.profiles.lock().await;
697        assert_eq!(profiles.len(), 1);
698        assert_eq!(profiles[0].name, DEFAULT_PROFILE_NAME);
699    }
700
701    #[tokio::test]
702    async fn delete_profile_rejects_active() {
703        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
704        let err = delete_profile_impl(&sched, DEFAULT_PROFILE_NAME.to_string())
705            .await
706            .unwrap_err();
707        assert!(err.contains("active"));
708        assert_eq!(sched.profiles.lock().await.len(), 2);
709    }
710
711    #[tokio::test]
712    async fn delete_profile_rejects_only_profile() {
713        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
714        let err = delete_profile_impl(&sched, DEFAULT_PROFILE_NAME.to_string())
715            .await
716            .unwrap_err();
717        assert!(err.contains("only profile"));
718    }
719
720    #[tokio::test]
721    async fn reorder_profiles_reorders_in_place() {
722        let three = vec![
723            Profile {
724                name: "a".into(),
725                settings: Settings::default(),
726            },
727            Profile {
728                name: "b".into(),
729                settings: Settings::default(),
730            },
731            Profile {
732                name: "c".into(),
733                settings: Settings::default(),
734            },
735        ];
736        let (_dir, sched) = test_scheduler_with_profiles(three, "a");
737        reorder_profiles_impl(&sched, vec!["c".into(), "a".into(), "b".into()])
738            .await
739            .unwrap();
740        let names: Vec<String> = sched
741            .profiles
742            .lock()
743            .await
744            .iter()
745            .map(|p| p.name.clone())
746            .collect();
747        assert_eq!(names, vec!["c", "a", "b"]);
748    }
749
750    #[tokio::test]
751    async fn reorder_profiles_rejects_unknown_name() {
752        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
753        let err = reorder_profiles_impl(&sched, vec!["Work".into(), "Missing".into()])
754            .await
755            .unwrap_err();
756        assert!(err.contains("not found"));
757    }
758
759    #[tokio::test]
760    async fn reset_profile_to_defaults_resets_inactive_only_on_disk() {
761        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
762        reset_profile_to_defaults_impl(&sched, "Work".to_string())
763            .await
764            .unwrap();
765        let profiles = sched.profiles.lock().await;
766        let work = profiles.iter().find(|p| p.name == "Work").unwrap();
767        assert_eq!(
768            work.settings.micro_interval_secs,
769            Settings::default().micro_interval_secs,
770        );
771        // The live `settings` slot belongs to Default, not Work, so the
772        // reset of an inactive profile must leave it alone.
773        assert_eq!(
774            sched.settings.lock().await.micro_interval_secs,
775            1234,
776            "active profile's live settings stay put when the inactive one resets",
777        );
778    }
779
780    #[tokio::test]
781    async fn reset_profile_to_defaults_also_resets_live_settings_when_active() {
782        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
783        reset_profile_to_defaults_impl(&sched, DEFAULT_PROFILE_NAME.to_string())
784            .await
785            .unwrap();
786        assert_eq!(
787            sched.settings.lock().await.micro_interval_secs,
788            Settings::default().micro_interval_secs,
789        );
790    }
791
792    #[tokio::test]
793    async fn reset_profile_to_defaults_errors_when_missing() {
794        let (_dir, sched) = test_scheduler_with_profiles(one_profile(), DEFAULT_PROFILE_NAME);
795        let err = reset_profile_to_defaults_impl(&sched, "Missing".to_string())
796            .await
797            .unwrap_err();
798        assert!(err.contains("not found"));
799    }
800
801    #[tokio::test]
802    async fn set_active_profile_switches_settings_and_resets_timers() {
803        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
804        // Stash a stale timer state so we can prove reset_timers_keep_sleep ran.
805        {
806            let mut t = sched.timers.lock().await;
807            t.micro_warned = true;
808            t.long_warned = true;
809            t.last_sleep = Some(std::time::Instant::now());
810        }
811        // Need a fake AppHandle for the emit. The helper accepts &AppHandle
812        // but only uses it for app.emit, which is a fire-and-forget call.
813        // Skip the impl's emit by calling the parts we can: switch
814        // settings + reset timers via direct mutation through impl path
815        // exercising every branch except the emit.
816        // The cleanest way: split set_active_profile_impl in two like the
817        // others. For now, observe state after calling the existing impl
818        // with a constructed mock AppHandle — but that requires Tauri.
819        // Instead, drive the same observable state changes:
820        let next = sched
821            .profiles
822            .lock()
823            .await
824            .iter()
825            .find(|p| p.name == "Work")
826            .map(|p| p.settings.clone())
827            .unwrap();
828        *sched.settings.lock().await = next.clone();
829        *sched.active_profile_name.lock().await = "Work".to_string();
830        crate::scheduler::timers::reset_timers_keep_sleep(&mut *sched.timers.lock().await);
831        // After the switch: settings reflect Work, active points at Work,
832        // and the timers' last_sleep is preserved while flags clear.
833        assert_eq!(sched.settings.lock().await.micro_interval_secs, 600);
834        assert_eq!(*sched.active_profile_name.lock().await, "Work");
835        let t = sched.timers.lock().await;
836        assert!(!t.micro_warned);
837        assert!(!t.long_warned);
838        assert!(
839            t.last_sleep.is_some(),
840            "sleep marker preserved across switch"
841        );
842    }
843
844    #[tokio::test]
845    async fn list_profiles_returns_names_in_storage_order() {
846        let (_dir, sched) = test_scheduler_with_profiles(two_profiles(), DEFAULT_PROFILE_NAME);
847        let names: Vec<String> = sched
848            .profiles
849            .lock()
850            .await
851            .iter()
852            .map(|p| p.name.clone())
853            .collect();
854        assert_eq!(names, vec![DEFAULT_PROFILE_NAME.to_string(), "Work".into()]);
855    }
856}
857
858// =====================================================================
859// Integration-test rig: drives the `#[tauri::command]` wrappers (now
860// generic over `R: Runtime`) end-to-end through `mock_app_with_scheduler`.
861// The impl-level tests above cover validation + state mutation; these
862// tests prove the wrappers thread the AppHandle through and emit
863// `profile:changed` so the renderer stays in sync after every mutation.
864// =====================================================================
865#[cfg(all(test, not(target_os = "windows")))]
866mod rig_smoke_tests {
867    use super::*;
868    use crate::config::DEFAULT_PROFILE_NAME;
869    use crate::test_support::{mock_app_with_scheduler, wrap_in_mock_app};
870    use std::sync::atomic::{AtomicUsize, Ordering};
871    use std::sync::Arc;
872    use tauri::{Listener, Manager};
873
874    /// Wire a `profile:changed` listener that captures the payload (the
875    /// active profile name). Returns the captured slot — assert against
876    /// it after invoking the command.
877    fn listen_for_profile_changed(
878        app: &tauri::App<tauri::test::MockRuntime>,
879    ) -> Arc<std::sync::Mutex<Option<String>>> {
880        let captured: Arc<std::sync::Mutex<Option<String>>> = Arc::new(std::sync::Mutex::new(None));
881        let captured_handle = captured.clone();
882        app.listen("profile:changed", move |event| {
883            let name: String =
884                serde_json::from_str(event.payload()).expect("profile:changed payload is a string");
885            *captured_handle.lock().unwrap() = Some(name);
886        });
887        captured
888    }
889
890    fn two_rig_profiles() -> Vec<Profile> {
891        vec![
892            Profile {
893                name: DEFAULT_PROFILE_NAME.to_string(),
894                settings: Settings::default(),
895            },
896            Profile {
897                name: "Work".to_string(),
898                settings: Settings {
899                    micro_interval_secs: 600,
900                    ..Settings::default()
901                },
902            },
903        ]
904    }
905
906    #[tokio::test]
907    async fn create_profile_command_emits_profile_changed() {
908        let (_dir, app, sched) = mock_app_with_scheduler(Settings::default());
909        let captured = listen_for_profile_changed(&app);
910        let state = app.state::<Scheduler>();
911        create_profile(app.handle().clone(), state, "Focus".to_string())
912            .await
913            .expect("create_profile succeeds");
914        assert_eq!(
915            captured.lock().unwrap().as_deref(),
916            Some(DEFAULT_PROFILE_NAME),
917            "emit carries the active profile (unchanged by create)",
918        );
919        assert_eq!(sched.profiles.lock().await.len(), 2);
920    }
921
922    #[tokio::test]
923    async fn create_profile_command_propagates_validation_error() {
924        // Empty name → wrapper surfaces "profile name cannot be empty"
925        // and never emits.
926        let (_dir, app, _sched) = mock_app_with_scheduler(Settings::default());
927        let state = app.state::<Scheduler>();
928        let err = create_profile(app.handle().clone(), state, "  ".to_string())
929            .await
930            .expect_err("empty name rejected");
931        assert!(err.contains("cannot be empty"));
932    }
933
934    #[tokio::test]
935    async fn duplicate_profile_command_emits_profile_changed() {
936        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
937            two_rig_profiles(),
938            DEFAULT_PROFILE_NAME,
939        );
940        let app = wrap_in_mock_app(sched.clone());
941        let captured = listen_for_profile_changed(&app);
942        let state = app.state::<Scheduler>();
943        duplicate_profile(
944            app.handle().clone(),
945            state,
946            "Work".to_string(),
947            "Focus".to_string(),
948        )
949        .await
950        .expect("duplicate_profile succeeds");
951        assert_eq!(
952            captured.lock().unwrap().as_deref(),
953            Some(DEFAULT_PROFILE_NAME)
954        );
955        let names: Vec<String> = sched
956            .profiles
957            .lock()
958            .await
959            .iter()
960            .map(|p| p.name.clone())
961            .collect();
962        assert!(names.contains(&"Focus".to_string()));
963        drop(dir);
964    }
965
966    #[tokio::test]
967    async fn rename_profile_command_emits_profile_changed_and_follows_active() {
968        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
969            two_rig_profiles(),
970            DEFAULT_PROFILE_NAME,
971        );
972        let app = wrap_in_mock_app(sched.clone());
973        let captured = listen_for_profile_changed(&app);
974        let state = app.state::<Scheduler>();
975        rename_profile(
976            app.handle().clone(),
977            state,
978            DEFAULT_PROFILE_NAME.to_string(),
979            "Personal".to_string(),
980        )
981        .await
982        .expect("rename_profile succeeds");
983        // Active pointer follows the rename, so the emit must carry the
984        // new name.
985        assert_eq!(captured.lock().unwrap().as_deref(), Some("Personal"));
986        drop(dir);
987    }
988
989    #[tokio::test]
990    async fn delete_profile_command_emits_profile_changed() {
991        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
992            two_rig_profiles(),
993            DEFAULT_PROFILE_NAME,
994        );
995        let app = wrap_in_mock_app(sched.clone());
996        let captured = listen_for_profile_changed(&app);
997        let state = app.state::<Scheduler>();
998        delete_profile(app.handle().clone(), state, "Work".to_string())
999            .await
1000            .expect("delete_profile succeeds");
1001        assert_eq!(
1002            captured.lock().unwrap().as_deref(),
1003            Some(DEFAULT_PROFILE_NAME)
1004        );
1005        assert_eq!(sched.profiles.lock().await.len(), 1);
1006        drop(dir);
1007    }
1008
1009    #[tokio::test]
1010    async fn reorder_profiles_command_emits_profile_changed() {
1011        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1012            two_rig_profiles(),
1013            DEFAULT_PROFILE_NAME,
1014        );
1015        let app = wrap_in_mock_app(sched.clone());
1016        let captured = listen_for_profile_changed(&app);
1017        let state = app.state::<Scheduler>();
1018        reorder_profiles(
1019            app.handle().clone(),
1020            state,
1021            vec!["Work".to_string(), DEFAULT_PROFILE_NAME.to_string()],
1022        )
1023        .await
1024        .expect("reorder_profiles succeeds");
1025        assert_eq!(
1026            captured.lock().unwrap().as_deref(),
1027            Some(DEFAULT_PROFILE_NAME)
1028        );
1029        let names: Vec<String> = sched
1030            .profiles
1031            .lock()
1032            .await
1033            .iter()
1034            .map(|p| p.name.clone())
1035            .collect();
1036        assert_eq!(
1037            names,
1038            vec!["Work".to_string(), DEFAULT_PROFILE_NAME.to_string()]
1039        );
1040        drop(dir);
1041    }
1042
1043    #[tokio::test]
1044    async fn reset_profile_to_defaults_command_emits_profile_changed() {
1045        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1046            two_rig_profiles(),
1047            DEFAULT_PROFILE_NAME,
1048        );
1049        let app = wrap_in_mock_app(sched.clone());
1050        let captured = listen_for_profile_changed(&app);
1051        let state = app.state::<Scheduler>();
1052        reset_profile_to_defaults(app.handle().clone(), state, "Work".to_string())
1053            .await
1054            .expect("reset succeeds");
1055        assert_eq!(
1056            captured.lock().unwrap().as_deref(),
1057            Some(DEFAULT_PROFILE_NAME)
1058        );
1059        drop(dir);
1060    }
1061
1062    #[tokio::test]
1063    async fn set_active_profile_command_emits_profile_changed_with_new_active() {
1064        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1065            two_rig_profiles(),
1066            DEFAULT_PROFILE_NAME,
1067        );
1068        let app = wrap_in_mock_app(sched.clone());
1069        let captured = listen_for_profile_changed(&app);
1070        let state = app.state::<Scheduler>();
1071        set_active_profile(app.handle().clone(), state, "Work".to_string())
1072            .await
1073            .expect("set_active_profile succeeds");
1074        assert_eq!(
1075            captured.lock().unwrap().as_deref(),
1076            Some("Work"),
1077            "emit must carry the newly-active name",
1078        );
1079        assert_eq!(*sched.active_profile_name.lock().await, "Work");
1080        drop(dir);
1081    }
1082
1083    #[tokio::test]
1084    async fn set_active_profile_command_no_emit_when_already_active() {
1085        // The impl early-returns Ok(()) when the named profile is
1086        // already active and skips the emit. The wrapper inherits that
1087        // shape — a no-op switch must not fire `profile:changed`.
1088        let (dir, sched) = crate::test_support::test_scheduler_with_profiles(
1089            two_rig_profiles(),
1090            DEFAULT_PROFILE_NAME,
1091        );
1092        let app = wrap_in_mock_app(sched.clone());
1093        let fired = Arc::new(AtomicUsize::new(0));
1094        {
1095            let fired = fired.clone();
1096            app.listen("profile:changed", move |_event| {
1097                fired.fetch_add(1, Ordering::SeqCst);
1098            });
1099        }
1100        let state = app.state::<Scheduler>();
1101        set_active_profile(
1102            app.handle().clone(),
1103            state,
1104            DEFAULT_PROFILE_NAME.to_string(),
1105        )
1106        .await
1107        .expect("set_active_profile no-op succeeds");
1108        assert_eq!(
1109            fired.load(Ordering::SeqCst),
1110            0,
1111            "no emit on same-active no-op",
1112        );
1113        drop(dir);
1114    }
1115}