Skip to main content

entracte_lib/scheduler/
content_pack.rs

1//! Local content packs (#155): a versioned, user-controlled bundle of break
2//! ideas (hint pools) and guided routines that the user imports/exports as a
3//! plain JSON file. No cloud, no remote registry — everything is a local file
4//! the user explicitly picks.
5//!
6//! Import is **additive and non-clobbering**: pack hints are appended to the
7//! existing pools (exact duplicates skipped) and pack routines are added to
8//! `custom_routines` (ids colliding with a bundled starter or an existing
9//! custom routine are skipped). Export captures the current pools +
10//! `custom_routines`, so export→import round-trips losslessly.
11//!
12//! The schema is versioned and intentionally extensible: sound and
13//! theme/overlay bundling are deferred to a future version bump rather than
14//! crammed in here.
15//!
16//! `parse_pack` / `validate_pack` / `merge_pack` / `export_pack` are pure and
17//! fully unit-tested; the file I/O and IPC wrapping live in
18//! `commands::content_pack`.
19
20use serde::{Deserialize, Serialize};
21
22use super::routines::{starter_routines, Routine};
23use super::settings::Settings;
24use super::types::RoutineStep;
25
26/// Schema version this build reads and writes. Bumped only on a
27/// breaking-change to the bundle shape.
28pub const CONTENT_PACK_VERSION: u32 = 1;
29
30/// Defensive caps so a malformed or hostile bundle can't bloat settings or
31/// stall the UI. Generous relative to any hand-curated pack.
32const MAX_ROUTINES: usize = 500;
33const MAX_STEPS_PER_ROUTINE: usize = 100;
34const MAX_HINTS_PER_POOL: usize = 5_000;
35const MAX_STRING_LEN: usize = 1_000;
36const MAX_STEP_SECONDS: u64 = 3_600;
37
38/// Hint pools a pack can carry. Each maps to the matching `Settings` pool;
39/// all optional so a pack can ship only what it wants.
40#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
41pub struct PackHints {
42    #[serde(default)]
43    pub micro_physical: Vec<String>,
44    #[serde(default)]
45    pub micro_psychological: Vec<String>,
46    #[serde(default)]
47    pub long_solo: Vec<String>,
48    #[serde(default)]
49    pub long_social: Vec<String>,
50    #[serde(default)]
51    pub sleep: Vec<String>,
52}
53
54/// A content pack: versioned, named, with optional hints and routines.
55#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
56pub struct ContentPack {
57    pub version: u32,
58    pub name: String,
59    #[serde(default)]
60    pub hints: PackHints,
61    #[serde(default)]
62    pub routines: Vec<Routine>,
63}
64
65/// What an import added, surfaced to the UI so the user sees the effect.
66#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
67pub struct MergeSummary {
68    pub hints_added: usize,
69    pub routines_added: usize,
70}
71
72/// Parse a content pack from JSON, mapping serde errors to a user-facing
73/// string. Does not validate beyond shape — call [`validate_pack`] next.
74pub fn parse_pack(json: &str) -> Result<ContentPack, String> {
75    serde_json::from_str(json).map_err(|e| format!("not a valid content pack: {e}"))
76}
77
78/// Serialise a pack to pretty JSON.
79pub fn serialize_pack(pack: &ContentPack) -> Result<String, String> {
80    serde_json::to_string_pretty(pack).map_err(|e| format!("failed to serialise pack: {e}"))
81}
82
83fn check_string(value: &str, what: &str) -> Result<(), String> {
84    if value.chars().count() > MAX_STRING_LEN {
85        return Err(format!("{what} exceeds {MAX_STRING_LEN} characters"));
86    }
87    Ok(())
88}
89
90/// Validate a parsed pack: supported version, non-empty name, size caps, and
91/// well-formed routines (non-empty id/label, 1..=N steps with non-empty text
92/// and a sane duration, no duplicate ids within the pack). Returns a clear,
93/// user-facing error on the first problem.
94pub fn validate_pack(pack: &ContentPack) -> Result<(), String> {
95    if pack.version != CONTENT_PACK_VERSION {
96        return Err(format!(
97            "unsupported content-pack version {} (this build reads version {CONTENT_PACK_VERSION})",
98            pack.version
99        ));
100    }
101    if pack.name.trim().is_empty() {
102        return Err("content pack is missing a name".to_string());
103    }
104    check_string(&pack.name, "pack name")?;
105
106    for (pool, label) in [
107        (&pack.hints.micro_physical, "micro physical"),
108        (&pack.hints.micro_psychological, "micro psychological"),
109        (&pack.hints.long_solo, "long solo"),
110        (&pack.hints.long_social, "long social"),
111        (&pack.hints.sleep, "sleep"),
112    ] {
113        if pool.len() > MAX_HINTS_PER_POOL {
114            return Err(format!("{label} hints exceed {MAX_HINTS_PER_POOL} entries"));
115        }
116        for hint in pool {
117            check_string(hint, &format!("a {label} hint"))?;
118        }
119    }
120
121    if pack.routines.len() > MAX_ROUTINES {
122        return Err(format!("pack has more than {MAX_ROUTINES} routines"));
123    }
124    let mut seen_ids = std::collections::HashSet::new();
125    for r in &pack.routines {
126        if r.id.trim().is_empty() {
127            return Err("a routine is missing an id".to_string());
128        }
129        check_string(&r.id, "a routine id")?;
130        if !seen_ids.insert(r.id.as_str()) {
131            return Err(format!("duplicate routine id '{}' in pack", r.id));
132        }
133        if r.label.trim().is_empty() {
134            return Err(format!("routine '{}' is missing a label", r.id));
135        }
136        check_string(&r.label, "a routine label")?;
137        if r.steps.is_empty() && r.breath.is_none() {
138            return Err(format!("routine '{}' has no steps or breath", r.id));
139        }
140        if r.steps.len() > MAX_STEPS_PER_ROUTINE {
141            return Err(format!(
142                "routine '{}' has more than {MAX_STEPS_PER_ROUTINE} steps",
143                r.id
144            ));
145        }
146        for st in &r.steps {
147            if st.text.trim().is_empty() {
148                return Err(format!("routine '{}' has an empty step", r.id));
149            }
150            check_string(&st.text, "a routine step")?;
151            if st.seconds == 0 || st.seconds > MAX_STEP_SECONDS {
152                return Err(format!(
153                    "routine '{}' has a step outside 1..={MAX_STEP_SECONDS}s",
154                    r.id
155                ));
156            }
157        }
158        if let Some(max) = r.max_step_secs {
159            if max == 0 || max > MAX_STEP_SECONDS {
160                return Err(format!(
161                    "routine '{}' max_step_secs must be 1..={MAX_STEP_SECONDS}",
162                    r.id
163                ));
164            }
165        }
166        if let Some(b) = &r.breath {
167            // A cycle needs a real in- and out-breath; holds are optional.
168            if b.inhale == 0 || b.exhale == 0 {
169                return Err(format!(
170                    "routine '{}' breath needs a non-zero inhale and exhale",
171                    r.id
172                ));
173            }
174            for phase in [b.inhale, b.hold, b.exhale, b.hold_out] {
175                if phase > MAX_STEP_SECONDS {
176                    return Err(format!(
177                        "routine '{}' breath phase exceeds {MAX_STEP_SECONDS}s",
178                        r.id
179                    ));
180                }
181            }
182            if let Some(c) = b.cycles {
183                if c == 0 {
184                    return Err(format!("routine '{}' breath cycles must be >= 1", r.id));
185                }
186            }
187        }
188    }
189    Ok(())
190}
191
192/// Append `additions` to `pool`, skipping blanks and exact duplicates
193/// (against both the existing pool and earlier additions). Returns the
194/// strings actually added (so an uninstall can remove exactly those).
195/// Order-preserving and non-clobbering.
196fn merge_pool(pool: &mut Vec<String>, additions: &[String]) -> Vec<String> {
197    let mut added = Vec::new();
198    for hint in additions {
199        let trimmed = hint.trim();
200        if trimmed.is_empty() {
201            continue;
202        }
203        if pool.iter().any(|existing| existing == hint) {
204            continue;
205        }
206        pool.push(hint.clone());
207        added.push(hint.clone());
208    }
209    added
210}
211
212/// The concrete content a merge added, so an uninstall can remove exactly
213/// those entries (the merge-and-track model). Mirrors [`PackHints`] plus the
214/// ids of routines that were appended to `custom_routines`.
215#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
216pub struct AddedContent {
217    #[serde(default)]
218    pub micro_physical: Vec<String>,
219    #[serde(default)]
220    pub micro_psychological: Vec<String>,
221    #[serde(default)]
222    pub long_solo: Vec<String>,
223    #[serde(default)]
224    pub long_social: Vec<String>,
225    #[serde(default)]
226    pub sleep: Vec<String>,
227    #[serde(default)]
228    pub routine_ids: Vec<String>,
229    /// Sidecar filenames of image assets written for this plugin, so uninstall
230    /// can delete exactly them. Empty for packs/plugins with no images.
231    #[serde(default)]
232    pub asset_files: Vec<String>,
233}
234
235/// Merge a validated pack into `settings`, recording exactly what was added.
236/// Append hints to each pool and routines to `custom_routines`, skipping ids
237/// that collide with a bundled starter or an already-present custom routine.
238/// Non-destructive — nothing is removed or overwritten. Returns the summary
239/// counts plus the concrete [`AddedContent`] for a later [`remove_content`].
240pub fn merge_pack_tracked(
241    pack: &ContentPack,
242    settings: &mut Settings,
243) -> (MergeSummary, AddedContent) {
244    let added = AddedContent {
245        micro_physical: merge_pool(
246            &mut settings.micro_physical_hints,
247            &pack.hints.micro_physical,
248        ),
249        micro_psychological: merge_pool(
250            &mut settings.micro_psychological_hints,
251            &pack.hints.micro_psychological,
252        ),
253        long_solo: merge_pool(&mut settings.long_hints, &pack.hints.long_solo),
254        long_social: merge_pool(&mut settings.long_social_hints, &pack.hints.long_social),
255        sleep: merge_pool(&mut settings.sleep_hints, &pack.hints.sleep),
256        routine_ids: {
257            let mut taken: std::collections::HashSet<String> = starter_routines()
258                .into_iter()
259                .map(|r| r.id)
260                .chain(settings.custom_routines.iter().map(|r| r.id.clone()))
261                .collect();
262            let mut ids = Vec::new();
263            for r in &pack.routines {
264                if taken.contains(&r.id) {
265                    continue;
266                }
267                taken.insert(r.id.clone());
268                settings.custom_routines.push(r.clone());
269                ids.push(r.id.clone());
270            }
271            ids
272        },
273        // Set by the plugin-install path after extracting sidecars; the
274        // portable content-pack import path carries no images.
275        asset_files: Vec::new(),
276    };
277    let summary = MergeSummary {
278        hints_added: added.micro_physical.len()
279            + added.micro_psychological.len()
280            + added.long_solo.len()
281            + added.long_social.len()
282            + added.sleep.len(),
283        routines_added: added.routine_ids.len(),
284    };
285    (summary, added)
286}
287
288/// Merge a validated pack into `settings`, returning only the summary counts.
289/// Thin wrapper over [`merge_pack_tracked`] for the content-pack import path,
290/// which has no uninstall and so doesn't need the [`AddedContent`] record.
291///
292/// Strips any per-step `asset` first: a portable pack is unsigned and carries
293/// no image bytes, so an `asset` value could only be an arbitrary local path a
294/// malicious pack smuggled in — only the signed-plugin installer is allowed to
295/// populate `asset`, and it does so with a backend-controlled sidecar path.
296/// Mirror of the stripping [`export_pack`] does on the way out.
297pub fn merge_pack(pack: &ContentPack, settings: &mut Settings) -> MergeSummary {
298    let mut pack = pack.clone();
299    for r in &mut pack.routines {
300        for st in &mut r.steps {
301            sanitize_imported_step(st);
302        }
303    }
304    merge_pack_tracked(&pack, settings).0
305}
306
307/// Clear any field of a step arriving from an UNSIGNED imported pack that only
308/// the signed-plugin installer is allowed to populate.
309///
310/// The exhaustive destructure (no `..`) is the gate, not decoration: adding a
311/// field to [`RoutineStep`] will fail to compile here until someone explicitly
312/// decides whether an untrusted imported pack may carry it. That turns "did we
313/// remember to harden the import path too?" from a review checklist into a
314/// build error.
315fn sanitize_imported_step(step: &mut RoutineStep) {
316    let RoutineStep {
317        text: _,
318        seconds: _,
319        asset,
320        sound,
321    } = step;
322    // Images and sound cues ship only in a signed plugin (with backend-
323    // controlled sidecar paths); an imported pack must never inject one.
324    *asset = None;
325    *sound = None;
326}
327
328/// Remove exactly the content recorded in `added` from `settings` (the
329/// uninstall half of merge-and-track): drop the recorded hint strings from
330/// each pool and the recorded routine ids from `custom_routines`. Tolerant
331/// of intervening user edits — anything already gone is simply skipped.
332/// Returns `(hints_removed, routines_removed)`.
333pub fn remove_content(settings: &mut Settings, added: &AddedContent) -> (usize, usize) {
334    fn drop_all(pool: &mut Vec<String>, remove: &[String]) -> usize {
335        let before = pool.len();
336        pool.retain(|h| !remove.contains(h));
337        before - pool.len()
338    }
339    let hints_removed = drop_all(&mut settings.micro_physical_hints, &added.micro_physical)
340        + drop_all(
341            &mut settings.micro_psychological_hints,
342            &added.micro_psychological,
343        )
344        + drop_all(&mut settings.long_hints, &added.long_solo)
345        + drop_all(&mut settings.long_social_hints, &added.long_social)
346        + drop_all(&mut settings.sleep_hints, &added.sleep);
347    let before = settings.custom_routines.len();
348    settings
349        .custom_routines
350        .retain(|r| !added.routine_ids.contains(&r.id));
351    let routines_removed = before - settings.custom_routines.len();
352    (hints_removed, routines_removed)
353}
354
355/// Build a content pack capturing the user's current pools + custom routines,
356/// so it can be written to a file and imported elsewhere.
357pub fn export_pack(name: &str, settings: &Settings) -> ContentPack {
358    ContentPack {
359        version: CONTENT_PACK_VERSION,
360        name: name.to_string(),
361        hints: PackHints {
362            micro_physical: settings.micro_physical_hints.clone(),
363            micro_psychological: settings.micro_psychological_hints.clone(),
364            long_solo: settings.long_hints.clone(),
365            long_social: settings.long_social_hints.clone(),
366            sleep: settings.sleep_hints.clone(),
367        },
368        routines: settings
369            .custom_routines
370            .iter()
371            .map(|r| {
372                // Drop per-step image references: in settings these are
373                // absolute paths to a plugin's installed sidecars, which would
374                // be dead links on another machine. Portable packs carry no
375                // images (those ship in a signed plugin manifest instead).
376                let mut r = r.clone();
377                for st in &mut r.steps {
378                    st.asset = None;
379                }
380                r
381            })
382            .collect(),
383    }
384}
385
386#[cfg(test)]
387mod tests {
388    use super::*;
389    use crate::scheduler::routines::{RoutineCategory, RoutineDifficulty, RoutineKind};
390    use crate::scheduler::types::{BreathPattern, RoutineStep};
391
392    fn breath(inhale: u64, exhale: u64) -> BreathPattern {
393        BreathPattern {
394            inhale,
395            hold: 0,
396            exhale,
397            hold_out: 0,
398            cycles: None,
399            then: None,
400            sounds: None,
401        }
402    }
403
404    fn sample_routine(id: &str) -> Routine {
405        Routine {
406            id: id.to_string(),
407            label: "Sample".to_string(),
408            kind: RoutineKind::Micro,
409            category: RoutineCategory::Eyes,
410            difficulty: RoutineDifficulty::Gentle,
411            steps: vec![RoutineStep {
412                text: "Look away".to_string(),
413                seconds: 5,
414                asset: None,
415                sound: None,
416            }],
417            pacing: None,
418            max_step_secs: None,
419            breath: None,
420        }
421    }
422
423    fn pack_with(routines: Vec<Routine>, hints: PackHints) -> ContentPack {
424        ContentPack {
425            version: CONTENT_PACK_VERSION,
426            name: "Test pack".to_string(),
427            hints,
428            routines,
429        }
430    }
431
432    #[test]
433    fn parse_rejects_garbage() {
434        assert!(parse_pack("{ not json").is_err());
435    }
436
437    // Settings load tolerates unknown routine category/difficulty values
438    // (#212), but content packs must stay strict — a pack is curated data, so
439    // an unrecognised enum is a malformed file, not something to silently
440    // coerce. Routines parse through the derived `Deserialize`, so these are
441    // rejected at parse time with a clear message.
442    #[test]
443    fn parse_rejects_routine_with_unknown_category() {
444        let json = r#"{
445            "version": 1,
446            "name": "Bad pack",
447            "routines": [{
448                "id": "r1", "label": "R", "kind": "micro",
449                "category": "telepathy", "difficulty": "gentle",
450                "steps": [{"text": "x", "seconds": 5}]
451            }]
452        }"#;
453        assert!(parse_pack(json).is_err());
454    }
455
456    #[test]
457    fn parse_rejects_routine_with_unknown_difficulty() {
458        let json = r#"{
459            "version": 1,
460            "name": "Bad pack",
461            "routines": [{
462                "id": "r1", "label": "R", "kind": "micro",
463                "category": "eyes", "difficulty": "extreme",
464                "steps": [{"text": "x", "seconds": 5}]
465            }]
466        }"#;
467        assert!(parse_pack(json).is_err());
468    }
469
470    #[test]
471    fn validate_rejects_wrong_version() {
472        let mut p = pack_with(vec![], PackHints::default());
473        p.version = 999;
474        let err = validate_pack(&p).unwrap_err();
475        assert!(err.contains("unsupported content-pack version 999"));
476    }
477
478    #[test]
479    fn validate_rejects_empty_name() {
480        let mut p = pack_with(vec![], PackHints::default());
481        p.name = "   ".to_string();
482        assert!(validate_pack(&p).unwrap_err().contains("name"));
483    }
484
485    #[test]
486    fn validate_rejects_duplicate_routine_ids() {
487        let p = pack_with(
488            vec![sample_routine("dup"), sample_routine("dup")],
489            PackHints::default(),
490        );
491        assert!(validate_pack(&p)
492            .unwrap_err()
493            .contains("duplicate routine id"));
494    }
495
496    #[test]
497    fn validate_rejects_malformed_routine() {
498        let mut bad = sample_routine("x");
499        bad.steps = vec![];
500        assert!(validate_pack(&pack_with(vec![bad], PackHints::default()))
501            .unwrap_err()
502            .contains("no steps"));
503
504        let mut zero = sample_routine("y");
505        zero.steps[0].seconds = 0;
506        assert!(validate_pack(&pack_with(vec![zero], PackHints::default()))
507            .unwrap_err()
508            .contains("outside"));
509    }
510
511    #[test]
512    fn validate_rejects_invalid_max_step_secs() {
513        let mut zero = sample_routine("a");
514        zero.max_step_secs = Some(0);
515        assert!(validate_pack(&pack_with(vec![zero], PackHints::default()))
516            .unwrap_err()
517            .contains("max_step_secs"));
518
519        let mut over = sample_routine("b");
520        over.max_step_secs = Some(MAX_STEP_SECONDS + 1);
521        assert!(validate_pack(&pack_with(vec![over], PackHints::default()))
522            .unwrap_err()
523            .contains("max_step_secs"));
524
525        let mut valid = sample_routine("c");
526        valid.max_step_secs = Some(60);
527        assert!(validate_pack(&pack_with(vec![valid], PackHints::default())).is_ok());
528    }
529
530    #[test]
531    fn validate_accepts_a_breathing_routine_without_steps() {
532        // The common breath (no cycle cap) ...
533        let mut r = sample_routine("breathe");
534        r.steps = vec![]; // a breath routine carries no step text
535        r.breath = Some(breath(4, 4));
536        assert!(validate_pack(&pack_with(vec![r], PackHints::default())).is_ok());
537
538        // ... and one with a non-zero cycle cap.
539        let mut capped = sample_routine("breathe-capped");
540        capped.steps = vec![];
541        let mut b = breath(4, 4);
542        b.cycles = Some(6);
543        capped.breath = Some(b);
544        assert!(validate_pack(&pack_with(vec![capped], PackHints::default())).is_ok());
545    }
546
547    #[test]
548    fn validate_rejects_breath_with_zero_inhale_or_exhale() {
549        let mut r = sample_routine("a");
550        r.breath = Some(breath(0, 4));
551        assert!(validate_pack(&pack_with(vec![r], PackHints::default()))
552            .unwrap_err()
553            .contains("non-zero inhale and exhale"));
554    }
555
556    #[test]
557    fn validate_rejects_an_overlong_breath_phase() {
558        let mut r = sample_routine("a");
559        r.breath = Some(breath(4, MAX_STEP_SECONDS + 1));
560        assert!(validate_pack(&pack_with(vec![r], PackHints::default()))
561            .unwrap_err()
562            .contains("breath phase exceeds"));
563    }
564
565    #[test]
566    fn validate_rejects_zero_breath_cycles() {
567        let mut r = sample_routine("a");
568        let mut b = breath(4, 4);
569        b.cycles = Some(0);
570        r.breath = Some(b);
571        assert!(validate_pack(&pack_with(vec![r], PackHints::default()))
572            .unwrap_err()
573            .contains("cycles must be >= 1"));
574    }
575
576    #[test]
577    fn validate_accepts_a_well_formed_pack() {
578        let hints = PackHints {
579            micro_physical: vec!["Stretch".to_string()],
580            ..PackHints::default()
581        };
582        assert!(validate_pack(&pack_with(vec![sample_routine("ok")], hints)).is_ok());
583    }
584
585    #[test]
586    fn merge_appends_hints_without_duplicating() {
587        let mut s = Settings::default();
588        let existing = s.micro_physical_hints.clone();
589        let dup = existing.first().cloned().unwrap_or_default();
590        let pack = pack_with(
591            vec![],
592            PackHints {
593                // One brand-new hint, one already present, one blank.
594                micro_physical: vec!["A totally new idea".to_string(), dup, "  ".to_string()],
595                ..PackHints::default()
596            },
597        );
598        let summary = merge_pack(&pack, &mut s);
599        assert_eq!(summary.hints_added, 1);
600        assert_eq!(s.micro_physical_hints.len(), existing.len() + 1);
601        assert!(s
602            .micro_physical_hints
603            .contains(&"A totally new idea".to_string()));
604    }
605
606    #[test]
607    #[allow(clippy::field_reassign_with_default)]
608    fn merge_adds_routines_but_skips_starter_and_existing_ids() {
609        let mut s = Settings::default();
610        s.custom_routines = vec![sample_routine("already-mine")];
611        let pack = pack_with(
612            vec![
613                sample_routine("brand-new"),
614                sample_routine("already-mine"), // existing custom → skip
615                sample_routine("micro-eye-reset"), // starter id → skip
616            ],
617            PackHints::default(),
618        );
619        let summary = merge_pack(&pack, &mut s);
620        assert_eq!(summary.routines_added, 1);
621        assert_eq!(s.custom_routines.len(), 2);
622        assert!(s.custom_routines.iter().any(|r| r.id == "brand-new"));
623    }
624
625    #[test]
626    fn merge_pack_strips_per_step_image_paths_from_an_imported_pack() {
627        // An unsigned imported pack must never inject an asset path the overlay
628        // would load — only the signed-plugin installer populates `asset`.
629        let mut rt = sample_routine("rt-img");
630        rt.steps[0].asset = Some("/etc/passwd-ish/evil.png".to_string());
631        rt.steps[0].sound = Some("/etc/evil.ogg".to_string());
632        let pack = ContentPack {
633            version: CONTENT_PACK_VERSION,
634            name: "Imported".to_string(),
635            hints: PackHints::default(),
636            routines: vec![rt],
637        };
638        let mut s = Settings::default();
639        merge_pack(&pack, &mut s);
640        let merged = s.custom_routines.iter().find(|r| r.id == "rt-img").unwrap();
641        assert_eq!(merged.steps[0].asset, None);
642        assert_eq!(merged.steps[0].sound, None);
643    }
644
645    #[test]
646    fn export_strips_per_step_image_paths() {
647        // A custom routine whose step carries an installed plugin's absolute
648        // asset path must export with that path dropped — portable packs carry
649        // no images, and the path would be a dead link elsewhere.
650        let mut src = Settings::default();
651        let mut rt = sample_routine("rt-img");
652        rt.steps[0].asset = Some("/home/u/.config/entracte/plugin-modules/x.png".to_string());
653        src.custom_routines = vec![rt];
654
655        let pack = export_pack("No images", &src);
656        assert_eq!(pack.routines[0].steps[0].asset, None);
657    }
658
659    #[test]
660    fn export_then_import_round_trips_losslessly() {
661        // Source settings with extra hints + a custom routine.
662        let mut src = Settings::default();
663        src.micro_physical_hints.push("Custom physical".to_string());
664        src.sleep_hints.push("Custom sleep".to_string());
665        src.custom_routines = vec![sample_routine("rt-1")];
666
667        let json = serialize_pack(&export_pack("Round trip", &src)).unwrap();
668        let parsed = parse_pack(&json).unwrap();
669        validate_pack(&parsed).unwrap();
670
671        // Merge into a fresh settings with the pools emptied, so the result is
672        // an exact reconstruction (order preserved, no pre-existing dups).
673        let mut dst = Settings {
674            micro_physical_hints: Vec::new(),
675            micro_psychological_hints: Vec::new(),
676            long_hints: Vec::new(),
677            long_social_hints: Vec::new(),
678            sleep_hints: Vec::new(),
679            custom_routines: Vec::new(),
680            ..Settings::default()
681        };
682        merge_pack(&parsed, &mut dst);
683
684        assert_eq!(dst.micro_physical_hints, src.micro_physical_hints);
685        assert_eq!(dst.micro_psychological_hints, src.micro_psychological_hints);
686        assert_eq!(dst.long_hints, src.long_hints);
687        assert_eq!(dst.long_social_hints, src.long_social_hints);
688        assert_eq!(dst.sleep_hints, src.sleep_hints);
689        assert_eq!(dst.custom_routines, src.custom_routines);
690    }
691
692    #[test]
693    fn merge_pack_tracked_records_exact_additions() {
694        let mut s = Settings {
695            micro_physical_hints: Vec::new(),
696            sleep_hints: Vec::new(),
697            custom_routines: Vec::new(),
698            ..Settings::default()
699        };
700        let pack = pack_with(
701            vec![sample_routine("rt-a")],
702            PackHints {
703                micro_physical: vec!["Stand up".to_string()],
704                sleep: vec!["Dim the lights".to_string()],
705                ..PackHints::default()
706            },
707        );
708        let (summary, added) = merge_pack_tracked(&pack, &mut s);
709        assert_eq!(summary.hints_added, 2);
710        assert_eq!(summary.routines_added, 1);
711        assert_eq!(added.micro_physical, vec!["Stand up".to_string()]);
712        assert_eq!(added.sleep, vec!["Dim the lights".to_string()]);
713        assert_eq!(added.routine_ids, vec!["rt-a".to_string()]);
714    }
715
716    #[test]
717    fn remove_content_undoes_a_tracked_merge() {
718        let mut s = Settings::default();
719        let before_physical = s.micro_physical_hints.clone();
720        let before_routines = s.custom_routines.len();
721        let pack = pack_with(
722            vec![sample_routine("rt-x")],
723            PackHints {
724                micro_physical: vec!["A brand-new idea".to_string()],
725                ..PackHints::default()
726            },
727        );
728        let (_, added) = merge_pack_tracked(&pack, &mut s);
729        assert!(s
730            .micro_physical_hints
731            .contains(&"A brand-new idea".to_string()));
732
733        let (hints_removed, routines_removed) = remove_content(&mut s, &added);
734        assert_eq!(hints_removed, 1);
735        assert_eq!(routines_removed, 1);
736        // Back to exactly the pre-merge state — no collateral removal.
737        assert_eq!(s.micro_physical_hints, before_physical);
738        assert_eq!(s.custom_routines.len(), before_routines);
739    }
740
741    #[test]
742    fn remove_content_tolerates_entries_the_user_already_deleted() {
743        let mut s = Settings::default();
744        let added = AddedContent {
745            micro_physical: vec!["never present".to_string()],
746            routine_ids: vec!["ghost".to_string()],
747            ..AddedContent::default()
748        };
749        let (hints_removed, routines_removed) = remove_content(&mut s, &added);
750        assert_eq!(hints_removed, 0);
751        assert_eq!(routines_removed, 0);
752    }
753
754    #[test]
755    fn merge_into_default_settings_adds_only_the_deltas() {
756        // The realistic path: a pack exported from a profile (defaults + a few
757        // extras) imported into another profile that already has the defaults.
758        // Only the deltas should transfer; the shared defaults dedup away.
759        let mut src = Settings::default();
760        src.micro_physical_hints.push("Delta idea".to_string());
761        src.custom_routines = vec![sample_routine("delta-rt")];
762
763        let pack = export_pack("Deltas", &src);
764
765        let mut dst = Settings::default();
766        let before = dst.micro_physical_hints.len();
767        let summary = merge_pack(&pack, &mut dst);
768
769        // Exactly one new hint + one new routine crossed over.
770        assert_eq!(summary.hints_added, 1);
771        assert_eq!(summary.routines_added, 1);
772        assert_eq!(dst.micro_physical_hints.len(), before + 1);
773        assert!(dst.micro_physical_hints.contains(&"Delta idea".to_string()));
774        assert_eq!(dst.custom_routines.len(), 1);
775    }
776}