1use serde::{Deserialize, Serialize};
21
22use super::routines::{starter_routines, Routine};
23use super::settings::Settings;
24use super::types::RoutineStep;
25
26pub const CONTENT_PACK_VERSION: u32 = 1;
29
30const 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#[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#[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#[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
67pub struct MergeSummary {
68 pub hints_added: usize,
69 pub routines_added: usize,
70}
71
72pub 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
78pub 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
90pub 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 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
192fn 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#[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 #[serde(default)]
232 pub asset_files: Vec<String>,
233}
234
235pub 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 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
288pub 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
307fn sanitize_imported_step(step: &mut RoutineStep) {
316 let RoutineStep {
317 text: _,
318 seconds: _,
319 asset,
320 sound,
321 } = step;
322 *asset = None;
325 *sound = None;
326}
327
328pub 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
355pub 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 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 #[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 let mut r = sample_routine("breathe");
534 r.steps = vec![]; r.breath = Some(breath(4, 4));
536 assert!(validate_pack(&pack_with(vec![r], PackHints::default())).is_ok());
537
538 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 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"), sample_routine("micro-eye-reset"), ],
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 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 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 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 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 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 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 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}