entracte_lib/
chores_store.rs1use std::io;
2use std::path::Path;
3
4use log::error;
5use serde::{Deserialize, Serialize};
6
7use crate::secure_io::{read_capped, write_user_only};
8
9const MAX_CHORES_BYTES: u64 = 16 * 1024;
10
11pub const MAX_CHORE_ITEMS: usize = 50;
15pub const MAX_CHORE_LEN: usize = 200;
16
17#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
25#[serde(default)]
26pub struct ChoresSnapshot {
27 pub date: String,
28 pub items: Vec<String>,
29 pub rotation: u64,
30 pub prompted_date: String,
35 pub ever_used_chores: bool,
42}
43
44pub fn load(path: &Path) -> ChoresSnapshot {
45 match read_capped(path, MAX_CHORES_BYTES) {
46 Ok(text) => serde_json::from_str(&text).unwrap_or_else(|e| {
47 error!("chores_store: failed to parse {}: {e}", path.display());
48 ChoresSnapshot::default()
49 }),
50 Err(e) if e.kind() == io::ErrorKind::NotFound => ChoresSnapshot::default(),
51 Err(e) => {
52 error!("chores_store: failed to read {}: {e}", path.display());
53 ChoresSnapshot::default()
54 }
55 }
56}
57
58pub fn save(path: &Path, snapshot: &ChoresSnapshot) -> io::Result<()> {
59 let body = serde_json::to_string_pretty(snapshot).map_err(io::Error::other)?;
60 write_user_only(path, body.as_bytes())
61}
62
63pub fn sanitize_items(items: Vec<String>) -> Vec<String> {
69 items
70 .into_iter()
71 .map(|s| s.trim().to_string())
72 .filter(|s| !s.is_empty())
73 .map(|s| {
74 if s.chars().count() > MAX_CHORE_LEN {
75 s.chars().take(MAX_CHORE_LEN).collect()
76 } else {
77 s
78 }
79 })
80 .take(MAX_CHORE_ITEMS)
81 .collect()
82}
83
84#[cfg(test)]
85mod tests {
86 use super::*;
87 use crate::test_support::{temp_dir, TempDir};
88
89 fn temp_chores_file() -> (TempDir, std::path::PathBuf) {
90 let dir = temp_dir();
91 let path = dir.path().join("chores.json");
92 (dir, path)
93 }
94
95 #[test]
96 fn load_missing_returns_default() {
97 let (_dir, path) = temp_chores_file();
98 let s = load(&path);
99 assert!(s.date.is_empty());
100 assert!(s.items.is_empty());
101 assert_eq!(s.rotation, 0);
102 }
103
104 #[test]
105 fn save_and_load_round_trip() {
106 let (_dir, path) = temp_chores_file();
107 let snap = ChoresSnapshot {
108 date: "2026-06-11".to_string(),
109 items: vec![
110 "Water the plants".to_string(),
111 "Empty the dishwasher".to_string(),
112 ],
113 rotation: 3,
114 prompted_date: "2026-06-11".to_string(),
115 ever_used_chores: true,
116 };
117 save(&path, &snap).unwrap();
118 assert_eq!(load(&path), snap);
119 }
120
121 #[test]
122 fn load_tolerates_store_without_prompted_date() {
123 let (_dir, path) = temp_chores_file();
126 std::fs::write(
127 &path,
128 r#"{"date":"2026-06-11","items":["Water the plants"],"rotation":1}"#,
129 )
130 .unwrap();
131 let loaded = load(&path);
132 assert_eq!(loaded.items, vec!["Water the plants".to_string()]);
133 assert_eq!(loaded.prompted_date, "");
134 }
135
136 #[test]
137 fn load_corrupt_returns_default() {
138 let (_dir, path) = temp_chores_file();
139 std::fs::write(&path, "{not valid json").unwrap();
140 let loaded = load(&path);
141 assert!(loaded.date.is_empty());
142 assert!(loaded.items.is_empty());
143 }
144
145 #[test]
146 fn load_unreadable_path_returns_default() {
147 let dir = temp_dir();
151 let loaded = load(dir.path());
152 assert!(loaded.date.is_empty());
153 assert!(loaded.items.is_empty());
154 }
155
156 #[test]
157 fn save_creates_parent_dirs() {
158 let dir = temp_dir();
159 let path = dir.path().join("a").join("b").join("chores.json");
160 save(&path, &ChoresSnapshot::default()).unwrap();
161 assert!(path.exists());
162 }
163
164 #[test]
165 fn sanitize_trims_and_drops_blanks() {
166 let cleaned = sanitize_items(vec![
167 " Water the plants ".to_string(),
168 " ".to_string(),
169 "".to_string(),
170 "Take out recycling".to_string(),
171 ]);
172 assert_eq!(
173 cleaned,
174 vec![
175 "Water the plants".to_string(),
176 "Take out recycling".to_string(),
177 ]
178 );
179 }
180
181 #[test]
182 fn sanitize_caps_item_length() {
183 let long = "a".repeat(MAX_CHORE_LEN + 50);
184 let cleaned = sanitize_items(vec![long]);
185 assert_eq!(cleaned.len(), 1);
186 assert_eq!(cleaned[0].chars().count(), MAX_CHORE_LEN);
187 }
188
189 #[test]
190 fn sanitize_caps_item_count() {
191 let many: Vec<String> = (0..MAX_CHORE_ITEMS + 10)
192 .map(|i| format!("chore {i}"))
193 .collect();
194 let cleaned = sanitize_items(many);
195 assert_eq!(cleaned.len(), MAX_CHORE_ITEMS);
196 assert_eq!(cleaned[0], "chore 0");
197 }
198}