envelope_cli/storage/
categories.rs

1//! Category and CategoryGroup repository for JSON storage
2//!
3//! Manages loading and saving categories to budget.json
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::RwLock;
8
9use crate::error::EnvelopeError;
10use crate::models::{Category, CategoryGroup, CategoryGroupId, CategoryId};
11
12use super::file_io::{read_json, write_json_atomic};
13
14/// Serializable category data structure
15#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
16pub struct CategoryData {
17    pub groups: Vec<CategoryGroup>,
18    pub categories: Vec<Category>,
19}
20
21/// Repository for category and group persistence
22pub struct CategoryRepository {
23    path: PathBuf,
24    groups: RwLock<HashMap<CategoryGroupId, CategoryGroup>>,
25    categories: RwLock<HashMap<CategoryId, Category>>,
26}
27
28impl CategoryRepository {
29    /// Create a new category repository
30    pub fn new(path: PathBuf) -> Self {
31        Self {
32            path,
33            groups: RwLock::new(HashMap::new()),
34            categories: RwLock::new(HashMap::new()),
35        }
36    }
37
38    /// Load categories from disk
39    pub fn load(&self) -> Result<(), EnvelopeError> {
40        let file_data: CategoryData = read_json(&self.path)?;
41
42        let mut groups = self
43            .groups
44            .write()
45            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
46        let mut categories = self
47            .categories
48            .write()
49            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
50
51        groups.clear();
52        categories.clear();
53
54        for group in file_data.groups {
55            groups.insert(group.id, group);
56        }
57
58        for category in file_data.categories {
59            categories.insert(category.id, category);
60        }
61
62        Ok(())
63    }
64
65    /// Save categories to disk
66    pub fn save(&self) -> Result<(), EnvelopeError> {
67        let groups = self
68            .groups
69            .read()
70            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
71        let categories = self
72            .categories
73            .read()
74            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
75
76        let mut group_list: Vec<_> = groups.values().cloned().collect();
77        group_list.sort_by_key(|g| g.sort_order);
78
79        let mut category_list: Vec<_> = categories.values().cloned().collect();
80        category_list.sort_by_key(|c| (c.sort_order, c.name.clone()));
81
82        let file_data = CategoryData {
83            groups: group_list,
84            categories: category_list,
85        };
86
87        write_json_atomic(&self.path, &file_data)
88    }
89
90    // Group operations
91
92    /// Get a group by ID
93    pub fn get_group(&self, id: CategoryGroupId) -> Result<Option<CategoryGroup>, EnvelopeError> {
94        let groups = self
95            .groups
96            .read()
97            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
98
99        Ok(groups.get(&id).cloned())
100    }
101
102    /// Get all groups
103    pub fn get_all_groups(&self) -> Result<Vec<CategoryGroup>, EnvelopeError> {
104        let groups = self
105            .groups
106            .read()
107            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
108
109        let mut list: Vec<_> = groups.values().cloned().collect();
110        list.sort_by_key(|g| g.sort_order);
111        Ok(list)
112    }
113
114    /// Get a group by name
115    pub fn get_group_by_name(&self, name: &str) -> Result<Option<CategoryGroup>, EnvelopeError> {
116        let groups = self
117            .groups
118            .read()
119            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
120
121        let name_lower = name.to_lowercase();
122        Ok(groups
123            .values()
124            .find(|g| g.name.to_lowercase() == name_lower)
125            .cloned())
126    }
127
128    /// Insert or update a group
129    pub fn upsert_group(&self, group: CategoryGroup) -> Result<(), EnvelopeError> {
130        let mut groups = self
131            .groups
132            .write()
133            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
134
135        groups.insert(group.id, group);
136        Ok(())
137    }
138
139    /// Delete a group (and optionally its categories)
140    pub fn delete_group(
141        &self,
142        id: CategoryGroupId,
143        delete_categories: bool,
144    ) -> Result<bool, EnvelopeError> {
145        let mut groups = self
146            .groups
147            .write()
148            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
149
150        if delete_categories {
151            let mut categories = self.categories.write().map_err(|e| {
152                EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e))
153            })?;
154            categories.retain(|_, c| c.group_id != id);
155        }
156
157        Ok(groups.remove(&id).is_some())
158    }
159
160    // Category operations
161
162    /// Get a category by ID
163    pub fn get_category(&self, id: CategoryId) -> Result<Option<Category>, EnvelopeError> {
164        let categories = self
165            .categories
166            .read()
167            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
168
169        Ok(categories.get(&id).cloned())
170    }
171
172    /// Get all categories
173    pub fn get_all_categories(&self) -> Result<Vec<Category>, EnvelopeError> {
174        let categories = self
175            .categories
176            .read()
177            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
178
179        let mut list: Vec<_> = categories.values().cloned().collect();
180        list.sort_by_key(|c| (c.sort_order, c.name.clone()));
181        Ok(list)
182    }
183
184    /// Get categories in a group
185    pub fn get_categories_in_group(
186        &self,
187        group_id: CategoryGroupId,
188    ) -> Result<Vec<Category>, EnvelopeError> {
189        let categories = self
190            .categories
191            .read()
192            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
193
194        let mut list: Vec<_> = categories
195            .values()
196            .filter(|c| c.group_id == group_id)
197            .cloned()
198            .collect();
199        list.sort_by_key(|c| (c.sort_order, c.name.clone()));
200        Ok(list)
201    }
202
203    /// Get a category by name
204    pub fn get_category_by_name(&self, name: &str) -> Result<Option<Category>, EnvelopeError> {
205        let categories = self
206            .categories
207            .read()
208            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
209
210        let name_lower = name.to_lowercase();
211        Ok(categories
212            .values()
213            .find(|c| c.name.to_lowercase() == name_lower)
214            .cloned())
215    }
216
217    /// Insert or update a category
218    pub fn upsert_category(&self, category: Category) -> Result<(), EnvelopeError> {
219        let mut categories = self
220            .categories
221            .write()
222            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
223
224        categories.insert(category.id, category);
225        Ok(())
226    }
227
228    /// Delete a category
229    pub fn delete_category(&self, id: CategoryId) -> Result<bool, EnvelopeError> {
230        let mut categories = self
231            .categories
232            .write()
233            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
234
235        Ok(categories.remove(&id).is_some())
236    }
237
238    /// Count groups
239    pub fn group_count(&self) -> Result<usize, EnvelopeError> {
240        let groups = self
241            .groups
242            .read()
243            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
244        Ok(groups.len())
245    }
246
247    /// Count categories
248    pub fn category_count(&self) -> Result<usize, EnvelopeError> {
249        let categories = self
250            .categories
251            .read()
252            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
253        Ok(categories.len())
254    }
255}
256
257#[cfg(test)]
258mod tests {
259    use super::*;
260    use tempfile::TempDir;
261
262    fn create_test_repo() -> (TempDir, CategoryRepository) {
263        let temp_dir = TempDir::new().unwrap();
264        let path = temp_dir.path().join("budget.json");
265        let repo = CategoryRepository::new(path);
266        (temp_dir, repo)
267    }
268
269    #[test]
270    fn test_empty_load() {
271        let (_temp_dir, repo) = create_test_repo();
272        repo.load().unwrap();
273        assert_eq!(repo.group_count().unwrap(), 0);
274        assert_eq!(repo.category_count().unwrap(), 0);
275    }
276
277    #[test]
278    fn test_group_operations() {
279        let (_temp_dir, repo) = create_test_repo();
280        repo.load().unwrap();
281
282        let group = CategoryGroup::new("Bills");
283        let id = group.id;
284
285        repo.upsert_group(group).unwrap();
286        assert_eq!(repo.group_count().unwrap(), 1);
287
288        let retrieved = repo.get_group(id).unwrap().unwrap();
289        assert_eq!(retrieved.name, "Bills");
290
291        repo.delete_group(id, false).unwrap();
292        assert_eq!(repo.group_count().unwrap(), 0);
293    }
294
295    #[test]
296    fn test_category_operations() {
297        let (_temp_dir, repo) = create_test_repo();
298        repo.load().unwrap();
299
300        let group = CategoryGroup::new("Bills");
301        repo.upsert_group(group.clone()).unwrap();
302
303        let category = Category::new("Rent", group.id);
304        let cat_id = category.id;
305
306        repo.upsert_category(category).unwrap();
307        assert_eq!(repo.category_count().unwrap(), 1);
308
309        let retrieved = repo.get_category(cat_id).unwrap().unwrap();
310        assert_eq!(retrieved.name, "Rent");
311
312        let in_group = repo.get_categories_in_group(group.id).unwrap();
313        assert_eq!(in_group.len(), 1);
314    }
315
316    #[test]
317    fn test_save_and_reload() {
318        let (temp_dir, repo) = create_test_repo();
319        repo.load().unwrap();
320
321        let group = CategoryGroup::new("Bills");
322        let category = Category::new("Rent", group.id);
323        let cat_id = category.id;
324
325        repo.upsert_group(group).unwrap();
326        repo.upsert_category(category).unwrap();
327        repo.save().unwrap();
328
329        // Create new repo and load
330        let path = temp_dir.path().join("budget.json");
331        let repo2 = CategoryRepository::new(path);
332        repo2.load().unwrap();
333
334        assert_eq!(repo2.group_count().unwrap(), 1);
335        assert_eq!(repo2.category_count().unwrap(), 1);
336
337        let retrieved = repo2.get_category(cat_id).unwrap().unwrap();
338        assert_eq!(retrieved.name, "Rent");
339    }
340
341    #[test]
342    fn test_get_by_name() {
343        let (_temp_dir, repo) = create_test_repo();
344        repo.load().unwrap();
345
346        let group = CategoryGroup::new("My Bills");
347        repo.upsert_group(group.clone()).unwrap();
348
349        let category = Category::new("Monthly Rent", group.id);
350        repo.upsert_category(category).unwrap();
351
352        // Case insensitive
353        let found_group = repo.get_group_by_name("my bills").unwrap();
354        assert!(found_group.is_some());
355
356        let found_cat = repo.get_category_by_name("MONTHLY RENT").unwrap();
357        assert!(found_cat.is_some());
358    }
359
360    #[test]
361    fn test_delete_group_with_categories() {
362        let (_temp_dir, repo) = create_test_repo();
363        repo.load().unwrap();
364
365        let group = CategoryGroup::new("Bills");
366        let group_id = group.id;
367        repo.upsert_group(group.clone()).unwrap();
368
369        let cat1 = Category::new("Rent", group.id);
370        let cat2 = Category::new("Utilities", group.id);
371        repo.upsert_category(cat1).unwrap();
372        repo.upsert_category(cat2).unwrap();
373
374        assert_eq!(repo.category_count().unwrap(), 2);
375
376        repo.delete_group(group_id, true).unwrap();
377
378        assert_eq!(repo.group_count().unwrap(), 0);
379        assert_eq!(repo.category_count().unwrap(), 0);
380    }
381}