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(&self, id: Uuid, updater: impl FnOnce(&mut Reminder)) -> Result<bool> {
97        let mut reminders = self.load()?;
98
99        if let Some(reminder) = reminders.iter_mut().find(|r| r.id == id) {
100            updater(reminder);
101            self.save(&reminders)?;
102            Ok(true)
103        } else {
104            Ok(false)
105        }
106    }
107
108    pub fn get(&self, id: Uuid) -> Result<Option<Reminder>> {
109        let reminders = self.load()?;
110        Ok(reminders.into_iter().find(|r| r.id == id))
111    }
112
113    /// Find reminder by short ID (prefix match)
114    pub fn find_by_short_id(&self, short_id: &str) -> Result<Option<Reminder>> {
115        let reminders = self.load()?;
116        let matches: Vec<_> = reminders
117            .into_iter()
118            .filter(|r| r.id.to_string().starts_with(short_id))
119            .collect();
120
121        match matches.len() {
122            0 => Ok(None),
123            1 => Ok(Some(matches.into_iter().next().unwrap())),
124            _ => anyhow::bail!(
125                "Ambiguous ID '{}': matches {} reminders. Please use more characters.",
126                short_id,
127                matches.len()
128            ),
129        }
130    }
131
132    /// Delete reminder by short ID
133    pub fn delete_by_short_id(&self, short_id: &str) -> Result<Option<Uuid>> {
134        let mut reminders = self.load()?;
135        let matches: Vec<_> = reminders
136            .iter()
137            .filter(|r| r.id.to_string().starts_with(short_id))
138            .map(|r| r.id)
139            .collect();
140
141        match matches.len() {
142            0 => Ok(None),
143            1 => {
144                let id = matches[0];
145                reminders.retain(|r| r.id != id);
146                self.save(&reminders)?;
147                Ok(Some(id))
148            }
149            _ => anyhow::bail!(
150                "Ambiguous ID '{}': matches {} reminders. Please use more characters.",
151                short_id,
152                matches.len()
153            ),
154        }
155    }
156
157    /// Clean completed reminders
158    pub fn clean_completed(&self) -> Result<usize> {
159        let mut reminders = self.load()?;
160        let initial_len = reminders.len();
161        reminders.retain(|r| !r.completed);
162        let removed = initial_len - reminders.len();
163
164        if removed > 0 {
165            self.save(&reminders)?;
166        }
167
168        Ok(removed)
169    }
170
171    pub fn pid_file_path() -> Result<PathBuf> {
172        let data_dir = dirs::data_local_dir()
173            .context("Failed to get local data directory")?
174            .join("reminder-cli");
175
176        fs::create_dir_all(&data_dir)?;
177        Ok(data_dir.join("daemon.pid"))
178    }
179
180    pub fn log_file_path() -> Result<PathBuf> {
181        let data_dir = dirs::data_local_dir()
182            .context("Failed to get local data directory")?
183            .join("reminder-cli");
184
185        fs::create_dir_all(&data_dir)?;
186        Ok(data_dir.join("daemon.log"))
187    }
188
189    /// Export all reminders to a JSON file
190    pub fn export_to_file(&self, path: &Path) -> Result<usize> {
191        let reminders = self.load()?;
192        let count = reminders.len();
193
194        let content = serde_json::to_string_pretty(&reminders)
195            .context("Failed to serialize reminders for export")?;
196
197        fs::write(path, content).context("Failed to write export file")?;
198
199        Ok(count)
200    }
201
202    /// Import reminders from a JSON file
203    /// Returns (imported_count, skipped_count)
204    pub fn import_from_file(&self, path: &Path, overwrite: bool) -> Result<(usize, usize)> {
205        let content = fs::read_to_string(path).context("Failed to read import file")?;
206
207        let imported: Vec<Reminder> =
208            serde_json::from_str(&content).context("Failed to parse import JSON")?;
209
210        let mut existing = self.load()?;
211        let existing_ids: std::collections::HashSet<Uuid> = existing.iter().map(|r| r.id).collect();
212
213        let mut imported_count = 0;
214        let mut skipped_count = 0;
215
216        for reminder in imported {
217            if existing_ids.contains(&reminder.id) {
218                if overwrite {
219                    existing.retain(|r| r.id != reminder.id);
220                    existing.push(reminder);
221                    imported_count += 1;
222                } else {
223                    skipped_count += 1;
224                }
225            } else {
226                existing.push(reminder);
227                imported_count += 1;
228            }
229        }
230
231        self.save(&existing)?;
232        Ok((imported_count, skipped_count))
233    }
234}