reminder_cli/
storage.rs

1use crate::reminder::Reminder;
2use anyhow::{Context, Result};
3use fs2::FileExt;
4use std::fs;
5use std::fs::{File, OpenOptions};
6use std::io::{Read, Write};
7use std::path::{Path, PathBuf};
8use uuid::Uuid;
9
10pub struct Storage {
11    path: PathBuf,
12}
13
14impl Storage {
15    pub fn new() -> Result<Self> {
16        let data_dir = dirs::data_local_dir()
17            .context("Failed to get local data directory")?
18            .join("reminder-cli");
19
20        fs::create_dir_all(&data_dir)?;
21
22        Ok(Self {
23            path: data_dir.join("reminders.json"),
24        })
25    }
26
27    pub fn load(&self) -> Result<Vec<Reminder>> {
28        if !self.path.exists() {
29            return Ok(Vec::new());
30        }
31
32        let file = File::open(&self.path).context("Failed to open reminders file")?;
33        file.lock_shared().context("Failed to acquire read lock")?;
34
35        let mut content = String::new();
36        let mut reader = &file;
37        reader
38            .read_to_string(&mut content)
39            .context("Failed to read reminders file")?;
40
41        file.unlock().context("Failed to release lock")?;
42
43        if content.trim().is_empty() {
44            return Ok(Vec::new());
45        }
46
47        let reminders: Vec<Reminder> =
48            serde_json::from_str(&content).context("Failed to parse reminders JSON")?;
49
50        Ok(reminders)
51    }
52
53    pub fn save(&self, reminders: &[Reminder]) -> Result<()> {
54        let file = OpenOptions::new()
55            .write(true)
56            .create(true)
57            .truncate(true)
58            .open(&self.path)
59            .context("Failed to open reminders file for writing")?;
60
61        file.lock_exclusive()
62            .context("Failed to acquire write lock")?;
63
64        let content =
65            serde_json::to_string_pretty(reminders).context("Failed to serialize reminders")?;
66
67        let mut writer = &file;
68        writer
69            .write_all(content.as_bytes())
70            .context("Failed to write reminders file")?;
71
72        file.unlock().context("Failed to release lock")?;
73
74        Ok(())
75    }
76
77    pub fn add(&self, reminder: Reminder) -> Result<()> {
78        let mut reminders = self.load()?;
79        reminders.push(reminder);
80        self.save(&reminders)
81    }
82
83    pub fn delete(&self, id: Uuid) -> Result<bool> {
84        let mut reminders = self.load()?;
85        let initial_len = reminders.len();
86        reminders.retain(|r| r.id != id);
87
88        if reminders.len() == initial_len {
89            return Ok(false);
90        }
91
92        self.save(&reminders)?;
93        Ok(true)
94    }
95
96    pub fn update(
97        &self,
98        id: Uuid,
99        updater: impl FnOnce(&mut Reminder) -> Result<()>,
100    ) -> Result<bool> {
101        let mut reminders = self.load()?;
102
103        if let Some(reminder) = reminders.iter_mut().find(|r| r.id == id) {
104            updater(reminder)?;
105            self.save(&reminders)?;
106            Ok(true)
107        } else {
108            Ok(false)
109        }
110    }
111
112    pub fn get(&self, id: Uuid) -> Result<Option<Reminder>> {
113        let reminders = self.load()?;
114        Ok(reminders.into_iter().find(|r| r.id == id))
115    }
116
117    /// Find reminder by short ID (prefix match)
118    pub fn find_by_short_id(&self, short_id: &str) -> Result<Option<Reminder>> {
119        let reminders = self.load()?;
120        let matches: Vec<_> = reminders
121            .into_iter()
122            .filter(|r| r.id.to_string().starts_with(short_id))
123            .collect();
124
125        match matches.len() {
126            0 => Ok(None),
127            1 => Ok(Some(matches.into_iter().next().unwrap())),
128            _ => anyhow::bail!(
129                "Ambiguous ID '{}': matches {} reminders. Please use more characters.",
130                short_id,
131                matches.len()
132            ),
133        }
134    }
135
136    /// Delete reminder by short ID
137    pub fn delete_by_short_id(&self, short_id: &str) -> Result<Option<Uuid>> {
138        let mut reminders = self.load()?;
139        let matches: Vec<_> = reminders
140            .iter()
141            .filter(|r| r.id.to_string().starts_with(short_id))
142            .map(|r| r.id)
143            .collect();
144
145        match matches.len() {
146            0 => Ok(None),
147            1 => {
148                let id = matches[0];
149                reminders.retain(|r| r.id != id);
150                self.save(&reminders)?;
151                Ok(Some(id))
152            }
153            _ => anyhow::bail!(
154                "Ambiguous ID '{}': matches {} reminders. Please use more characters.",
155                short_id,
156                matches.len()
157            ),
158        }
159    }
160
161    /// Clean completed reminders
162    pub fn clean_completed(&self) -> Result<usize> {
163        let mut reminders = self.load()?;
164        let initial_len = reminders.len();
165        reminders.retain(|r| !r.completed);
166        let removed = initial_len - reminders.len();
167
168        if removed > 0 {
169            self.save(&reminders)?;
170        }
171
172        Ok(removed)
173    }
174
175    pub fn pid_file_path() -> Result<PathBuf> {
176        let data_dir = dirs::data_local_dir()
177            .context("Failed to get local data directory")?
178            .join("reminder-cli");
179
180        fs::create_dir_all(&data_dir)?;
181        Ok(data_dir.join("daemon.pid"))
182    }
183
184    pub fn log_file_path() -> Result<PathBuf> {
185        let data_dir = dirs::data_local_dir()
186            .context("Failed to get local data directory")?
187            .join("reminder-cli");
188
189        fs::create_dir_all(&data_dir)?;
190        Ok(data_dir.join("daemon.log"))
191    }
192
193    pub fn heartbeat_file_path() -> Result<PathBuf> {
194        let data_dir = dirs::data_local_dir()
195            .context("Failed to get local data directory")?
196            .join("reminder-cli");
197
198        fs::create_dir_all(&data_dir)?;
199        Ok(data_dir.join("daemon.heartbeat"))
200    }
201
202    /// Filter reminders by tag
203    pub fn filter_by_tag(&self, tag: &str) -> Result<Vec<Reminder>> {
204        let reminders = self.load()?;
205        Ok(reminders
206            .into_iter()
207            .filter(|r| r.tags.contains(tag))
208            .collect())
209    }
210
211    /// Get all unique tags
212    pub fn get_all_tags(&self) -> Result<Vec<String>> {
213        let reminders = self.load()?;
214        let mut tags: Vec<String> = reminders
215            .iter()
216            .flat_map(|r| r.tags.iter().cloned())
217            .collect();
218        tags.sort();
219        tags.dedup();
220        Ok(tags)
221    }
222
223    /// Pause reminder by short ID
224    pub fn pause_by_short_id(&self, short_id: &str) -> Result<Option<Uuid>> {
225        let reminder = self.find_by_short_id(short_id)?;
226        if let Some(r) = reminder {
227            let id = r.id;
228            self.update(id, |rem| {
229                rem.pause();
230                Ok(())
231            })?;
232            Ok(Some(id))
233        } else {
234            Ok(None)
235        }
236    }
237
238    /// Resume reminder by short ID
239    pub fn resume_by_short_id(&self, short_id: &str) -> Result<Option<Uuid>> {
240        let reminder = self.find_by_short_id(short_id)?;
241        if let Some(r) = reminder {
242            let id = r.id;
243            self.update(id, |rem| {
244                rem.resume();
245                Ok(())
246            })?;
247            Ok(Some(id))
248        } else {
249            Ok(None)
250        }
251    }
252
253    /// Export all reminders to a JSON file
254    pub fn export_to_file(&self, path: &Path) -> Result<usize> {
255        let reminders = self.load()?;
256        let count = reminders.len();
257
258        let content = serde_json::to_string_pretty(&reminders)
259            .context("Failed to serialize reminders for export")?;
260
261        fs::write(path, content).context("Failed to write export file")?;
262
263        Ok(count)
264    }
265
266    /// Import reminders from a JSON file
267    /// Returns (imported_count, skipped_count)
268    pub fn import_from_file(&self, path: &Path, overwrite: bool) -> Result<(usize, usize)> {
269        let content = fs::read_to_string(path).context("Failed to read import file")?;
270
271        let imported: Vec<Reminder> =
272            serde_json::from_str(&content).context("Failed to parse import JSON")?;
273
274        let mut existing = self.load()?;
275        let existing_ids: std::collections::HashSet<Uuid> = existing.iter().map(|r| r.id).collect();
276
277        let mut imported_count = 0;
278        let mut skipped_count = 0;
279
280        for reminder in imported {
281            if existing_ids.contains(&reminder.id) {
282                if overwrite {
283                    existing.retain(|r| r.id != reminder.id);
284                    existing.push(reminder);
285                    imported_count += 1;
286                } else {
287                    skipped_count += 1;
288                }
289            } else {
290                existing.push(reminder);
291                imported_count += 1;
292            }
293        }
294
295        self.save(&existing)?;
296        Ok((imported_count, skipped_count))
297    }
298}