Skip to main content

entracte_lib/
chores_store.rs

1use 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
11/// Hard caps on a hand-edited or corrupt store, so it can never flood the
12/// break overlay or blow the read budget. Mirrors the defensive count/length
13/// limits the content-pack path applies to plugin-supplied idea lists.
14pub const MAX_CHORE_ITEMS: usize = 50;
15pub const MAX_CHORE_LEN: usize = 200;
16
17/// The day's "chore post-it" as persisted to `chores.json`.
18///
19/// `date` is the local day (YYYY-MM-DD) the list was entered; a stale date
20/// means the list belongs to a previous day and is dropped on load (see
21/// [`crate::scheduler::chores::ChoresState::from_snapshot`]). `rotation`
22/// advances each time a chore is surfaced so consecutive long breaks suggest
23/// different tasks rather than always the first one.
24#[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    /// Local day (YYYY-MM-DD) the morning chore prompt last fired, so a
31    /// restart mid-morning doesn't re-prompt. Empty until the first prompt;
32    /// a value `!= date` means we haven't prompted today yet. Defaults empty
33    /// on older stores that predate the field.
34    pub prompted_date: String,
35    /// True once the user has ever saved a non-empty chore list. Unlike
36    /// `items` (which reset each morning), this persists across days, so the
37    /// morning prompt only nudges people who actually use chores — a
38    /// permanently-empty list no longer pops Preferences every work-day
39    /// (#251-adjacent annoyance). Defaults false on older stores; a store that
40    /// already has items is migrated to true on load.
41    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
63/// Trim, drop blank lines, and cap a user-entered chore list to the store's
64/// limits. Pure so the `set_chores` command and any future import path share
65/// one definition of "a valid list": each item is trimmed and truncated to
66/// [`MAX_CHORE_LEN`] characters, blanks are removed, and at most
67/// [`MAX_CHORE_ITEMS`] survive.
68pub 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        // Stores written before the morning-prompt feature have no
124        // `prompted_date`; serde(default) must fill it in as empty.
125        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        // Pointing `load` at a directory makes `read_capped` fail with a
148        // non-NotFound error, exercising the read-error branch (distinct from
149        // the missing-file and parse-error branches above).
150        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}