Skip to main content

entracte_lib/scheduler/commands/
content_pack.rs

1use std::path::Path;
2
3use tauri::{Runtime, WebviewWindow};
4
5use crate::scheduler::content_pack::{
6    export_pack, merge_pack, parse_pack, serialize_pack, validate_pack, ContentPack, MergeSummary,
7};
8use crate::secure_io::{read_capped, write_user_only};
9
10use super::super::Scheduler;
11
12/// Content-pack IPC is restricted to the main settings window, like backup.
13const MAIN_WINDOW_LABEL: &str = "main";
14/// Hard cap on a pack file we'll read+parse. Packs are plain text (hints +
15/// routine steps); 8 MiB is far above any hand-curated bundle while keeping
16/// the read+parse allocation small.
17const MAX_PACK_BYTES: u64 = 8 * 1024 * 1024;
18
19fn ensure_main_window<R: Runtime>(webview: &WebviewWindow<R>) -> Result<(), String> {
20    if webview.label() != MAIN_WINDOW_LABEL {
21        return Err("content-pack commands are restricted to the main window".to_string());
22    }
23    Ok(())
24}
25
26/// Import a content pack from `path`: read (size-capped), parse, validate,
27/// and merge its hints + routines into the active profile additively
28/// (duplicates and id-collisions skipped). Persists and returns a summary of
29/// what was added. Errors are user-facing strings.
30#[tauri::command]
31pub async fn import_content_pack<R: Runtime>(
32    webview: WebviewWindow<R>,
33    scheduler: tauri::State<'_, Scheduler>,
34    path: String,
35) -> Result<MergeSummary, String> {
36    ensure_main_window(&webview)?;
37    let text = read_capped(Path::new(&path), MAX_PACK_BYTES).map_err(|e| match e.kind() {
38        std::io::ErrorKind::InvalidData => {
39            format!(
40                "content-pack file is too large (over {} MiB)",
41                MAX_PACK_BYTES / (1024 * 1024)
42            )
43        }
44        _ => format!("failed to read content-pack file: {e}"),
45    })?;
46    let pack = parse_pack(&text)?;
47    validate_pack(&pack)?;
48    Ok(apply_pack(scheduler.inner(), &pack).await)
49}
50
51/// Merge `pack` into the active profile and persist. Mirrors the
52/// `update_settings` write sequence: merge into a clone, **rebuild the
53/// `#[serde(skip)]` derived caches** (the break fire path reads
54/// `derived.*_hints_resolved`, not the raw pools — without this an imported
55/// idea stays invisible until the next settings change), store, then upsert
56/// the active profile (pushing it if somehow absent, like `update_settings`)
57/// and persist. Split out so it's unit-testable without a `WebviewWindow`.
58async fn apply_pack(scheduler: &Scheduler, pack: &ContentPack) -> MergeSummary {
59    let (merged, summary) = {
60        let mut next = scheduler.settings.lock().await.clone();
61        let summary = merge_pack(pack, &mut next);
62        next.rebuild_derived();
63        (next, summary)
64    };
65    *scheduler.settings.lock().await = merged.clone();
66    {
67        let active = scheduler.active_profile_name.lock().await.clone();
68        let mut profiles = scheduler.profiles.lock().await;
69        if let Some(p) = profiles.iter_mut().find(|p| p.name == active) {
70            p.settings = merged;
71        } else {
72            profiles.push(crate::config::Profile {
73                name: active,
74                settings: merged,
75            });
76        }
77    }
78    super::super::persist_profiles(scheduler).await;
79    summary
80}
81
82/// Export the active profile's hint pools + custom routines as a content pack
83/// written to `path`. `name` labels the pack.
84#[tauri::command]
85pub async fn export_content_pack<R: Runtime>(
86    webview: WebviewWindow<R>,
87    scheduler: tauri::State<'_, Scheduler>,
88    path: String,
89    name: String,
90) -> Result<(), String> {
91    ensure_main_window(&webview)?;
92    let pack = {
93        let s = scheduler.settings.lock().await;
94        export_pack(&name, &s)
95    };
96    let text = serialize_pack(&pack)?;
97    write_user_only(Path::new(&path), text.as_bytes())
98        .map_err(|e| format!("failed to write content-pack file: {e}"))?;
99    Ok(())
100}
101
102#[cfg(test)]
103mod tests {
104    use super::*;
105    use crate::scheduler::content_pack::{PackHints, CONTENT_PACK_VERSION};
106    use crate::scheduler::routines::{Routine, RoutineCategory, RoutineDifficulty, RoutineKind};
107    use crate::scheduler::types::RoutineStep;
108    use crate::scheduler::BreakKind;
109    use crate::test_support::test_scheduler;
110
111    fn sample_pack() -> ContentPack {
112        ContentPack {
113            version: CONTENT_PACK_VERSION,
114            name: "Test".to_string(),
115            hints: PackHints {
116                micro_physical: vec!["An imported micro idea".to_string()],
117                ..PackHints::default()
118            },
119            routines: vec![Routine {
120                id: "imported-rt".to_string(),
121                label: "Imported".to_string(),
122                kind: RoutineKind::Micro,
123                category: RoutineCategory::Eyes,
124                difficulty: RoutineDifficulty::Gentle,
125                steps: vec![RoutineStep {
126                    text: "Look away".to_string(),
127                    seconds: 5,
128                    asset: None,
129                    sound: None,
130                }],
131                pacing: None,
132                max_step_secs: None,
133                breath: None,
134            }],
135        }
136    }
137
138    #[tokio::test]
139    async fn apply_pack_merges_persists_and_rebuilds_the_derived_cache() {
140        let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
141        let summary = apply_pack(&sched, &sample_pack()).await;
142        assert_eq!(summary.hints_added, 1);
143        assert_eq!(summary.routines_added, 1);
144
145        let s = sched.settings.lock().await;
146        // The raw pool got the hint...
147        assert!(s
148            .micro_physical_hints
149            .contains(&"An imported micro idea".to_string()));
150        // ...and crucially the derived cache the fire path reads was rebuilt,
151        // so the imported idea actually shows at break time.
152        assert!(s
153            .effective_hints(BreakKind::Micro)
154            .contains(&"An imported micro idea".to_string()));
155        assert!(s.custom_routines.iter().any(|r| r.id == "imported-rt"));
156    }
157
158    #[tokio::test]
159    async fn apply_pack_writes_into_the_active_profile() {
160        let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
161        apply_pack(&sched, &sample_pack()).await;
162        let active = sched.active_profile_name.lock().await.clone();
163        let profiles = sched.profiles.lock().await;
164        let p = profiles
165            .iter()
166            .find(|p| p.name == active)
167            .expect("active profile present");
168        assert!(p
169            .settings
170            .custom_routines
171            .iter()
172            .any(|r| r.id == "imported-rt"));
173    }
174
175    #[tokio::test]
176    async fn apply_pack_pushes_active_profile_when_absent() {
177        let (_dir, sched) = test_scheduler(crate::scheduler::Settings::default());
178        // Point the active name at a profile that isn't in the list.
179        *sched.active_profile_name.lock().await = "Ghost".to_string();
180        apply_pack(&sched, &sample_pack()).await;
181        let profiles = sched.profiles.lock().await;
182        let ghost = profiles
183            .iter()
184            .find(|p| p.name == "Ghost")
185            .expect("absent active profile was pushed");
186        assert!(ghost
187            .settings
188            .custom_routines
189            .iter()
190            .any(|r| r.id == "imported-rt"));
191    }
192}