envelope_cli/storage/
payees.rs

1//! Payee repository for JSON storage
2//!
3//! Manages loading and saving payees to payees.json
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::RwLock;
8
9use crate::error::EnvelopeError;
10use crate::models::{Payee, PayeeId};
11
12use super::file_io::{read_json, write_json_atomic};
13
14/// Serializable payee data structure
15#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
16struct PayeeData {
17    payees: Vec<Payee>,
18}
19
20/// Repository for payee persistence
21pub struct PayeeRepository {
22    path: PathBuf,
23    data: RwLock<HashMap<PayeeId, Payee>>,
24    /// Index: normalized name -> payee_id
25    by_name: RwLock<HashMap<String, PayeeId>>,
26}
27
28impl PayeeRepository {
29    /// Create a new payee repository
30    pub fn new(path: PathBuf) -> Self {
31        Self {
32            path,
33            data: RwLock::new(HashMap::new()),
34            by_name: RwLock::new(HashMap::new()),
35        }
36    }
37
38    /// Load payees from disk
39    pub fn load(&self) -> Result<(), EnvelopeError> {
40        let file_data: PayeeData = read_json(&self.path)?;
41
42        let mut data = self
43            .data
44            .write()
45            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
46        let mut by_name = self
47            .by_name
48            .write()
49            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
50
51        data.clear();
52        by_name.clear();
53
54        for payee in file_data.payees {
55            let normalized = Payee::normalize_name(&payee.name);
56            by_name.insert(normalized, payee.id);
57            data.insert(payee.id, payee);
58        }
59
60        Ok(())
61    }
62
63    /// Save payees to disk
64    pub fn save(&self) -> Result<(), EnvelopeError> {
65        let data = self
66            .data
67            .read()
68            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
69
70        let mut payees: Vec<_> = data.values().cloned().collect();
71        payees.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
72
73        let file_data = PayeeData { payees };
74        write_json_atomic(&self.path, &file_data)
75    }
76
77    /// Get a payee by ID
78    pub fn get(&self, id: PayeeId) -> Result<Option<Payee>, EnvelopeError> {
79        let data = self
80            .data
81            .read()
82            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
83
84        Ok(data.get(&id).cloned())
85    }
86
87    /// Get all payees
88    pub fn get_all(&self) -> Result<Vec<Payee>, EnvelopeError> {
89        let data = self
90            .data
91            .read()
92            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
93
94        let mut payees: Vec<_> = data.values().cloned().collect();
95        payees.sort_by(|a, b| a.name.to_lowercase().cmp(&b.name.to_lowercase()));
96        Ok(payees)
97    }
98
99    /// Get a payee by exact name (case-insensitive)
100    pub fn get_by_name(&self, name: &str) -> Result<Option<Payee>, EnvelopeError> {
101        let data = self
102            .data
103            .read()
104            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
105        let by_name = self
106            .by_name
107            .read()
108            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
109
110        let normalized = Payee::normalize_name(name);
111        if let Some(&id) = by_name.get(&normalized) {
112            Ok(data.get(&id).cloned())
113        } else {
114            Ok(None)
115        }
116    }
117
118    /// Find payees matching a query (fuzzy search)
119    pub fn search(&self, query: &str, limit: usize) -> Result<Vec<Payee>, EnvelopeError> {
120        let data = self
121            .data
122            .read()
123            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
124
125        let mut scored: Vec<_> = data
126            .values()
127            .map(|p| (p.clone(), p.similarity_score(query)))
128            .filter(|(_, score)| *score > 0.3)
129            .collect();
130
131        scored.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
132        Ok(scored.into_iter().take(limit).map(|(p, _)| p).collect())
133    }
134
135    /// Get or create a payee by name
136    pub fn get_or_create(&self, name: &str) -> Result<Payee, EnvelopeError> {
137        if let Some(payee) = self.get_by_name(name)? {
138            return Ok(payee);
139        }
140
141        let payee = Payee::new(name);
142        self.upsert(payee.clone())?;
143        Ok(payee)
144    }
145
146    /// Insert or update a payee
147    pub fn upsert(&self, payee: Payee) -> Result<(), EnvelopeError> {
148        let mut data = self
149            .data
150            .write()
151            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
152        let mut by_name = self
153            .by_name
154            .write()
155            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
156
157        // Remove old name index if updating
158        if let Some(old) = data.get(&payee.id) {
159            let old_normalized = Payee::normalize_name(&old.name);
160            by_name.remove(&old_normalized);
161        }
162
163        // Add new name index
164        let normalized = Payee::normalize_name(&payee.name);
165        by_name.insert(normalized, payee.id);
166
167        data.insert(payee.id, payee);
168        Ok(())
169    }
170
171    /// Delete a payee
172    pub fn delete(&self, id: PayeeId) -> Result<bool, EnvelopeError> {
173        let mut data = self
174            .data
175            .write()
176            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
177        let mut by_name = self
178            .by_name
179            .write()
180            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
181
182        if let Some(payee) = data.remove(&id) {
183            let normalized = Payee::normalize_name(&payee.name);
184            by_name.remove(&normalized);
185            Ok(true)
186        } else {
187            Ok(false)
188        }
189    }
190
191    /// Count payees
192    pub fn count(&self) -> Result<usize, EnvelopeError> {
193        let data = self
194            .data
195            .read()
196            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
197        Ok(data.len())
198    }
199}
200
201#[cfg(test)]
202mod tests {
203    use super::*;
204    use tempfile::TempDir;
205
206    fn create_test_repo() -> (TempDir, PayeeRepository) {
207        let temp_dir = TempDir::new().unwrap();
208        let path = temp_dir.path().join("payees.json");
209        let repo = PayeeRepository::new(path);
210        (temp_dir, repo)
211    }
212
213    #[test]
214    fn test_empty_load() {
215        let (_temp_dir, repo) = create_test_repo();
216        repo.load().unwrap();
217        assert_eq!(repo.count().unwrap(), 0);
218    }
219
220    #[test]
221    fn test_upsert_and_get() {
222        let (_temp_dir, repo) = create_test_repo();
223        repo.load().unwrap();
224
225        let payee = Payee::new("Test Store");
226        let id = payee.id;
227
228        repo.upsert(payee).unwrap();
229
230        let retrieved = repo.get(id).unwrap().unwrap();
231        assert_eq!(retrieved.name, "Test Store");
232    }
233
234    #[test]
235    fn test_get_by_name() {
236        let (_temp_dir, repo) = create_test_repo();
237        repo.load().unwrap();
238
239        repo.upsert(Payee::new("Grocery Store")).unwrap();
240
241        // Case insensitive
242        let found = repo.get_by_name("grocery store").unwrap();
243        assert!(found.is_some());
244        assert_eq!(found.unwrap().name, "Grocery Store");
245
246        let not_found = repo.get_by_name("other store").unwrap();
247        assert!(not_found.is_none());
248    }
249
250    #[test]
251    fn test_get_or_create() {
252        let (_temp_dir, repo) = create_test_repo();
253        repo.load().unwrap();
254
255        // Should create
256        let p1 = repo.get_or_create("New Store").unwrap();
257        assert_eq!(p1.name, "New Store");
258        assert_eq!(repo.count().unwrap(), 1);
259
260        // Should return existing
261        let p2 = repo.get_or_create("new store").unwrap();
262        assert_eq!(p1.id, p2.id);
263        assert_eq!(repo.count().unwrap(), 1);
264    }
265
266    #[test]
267    fn test_search() {
268        let (_temp_dir, repo) = create_test_repo();
269        repo.load().unwrap();
270
271        repo.upsert(Payee::new("Grocery Store")).unwrap();
272        repo.upsert(Payee::new("Gas Station")).unwrap();
273        repo.upsert(Payee::new("Restaurant")).unwrap();
274
275        let results = repo.search("groc", 10).unwrap();
276        assert!(!results.is_empty());
277        assert_eq!(results[0].name, "Grocery Store");
278
279        let results2 = repo.search("st", 10).unwrap();
280        // Should match "Store" and "Station"
281        assert!(results2.len() >= 2);
282    }
283
284    #[test]
285    fn test_save_and_reload() {
286        let (temp_dir, repo) = create_test_repo();
287        repo.load().unwrap();
288
289        let payee = Payee::new("Test Store");
290        let id = payee.id;
291
292        repo.upsert(payee).unwrap();
293        repo.save().unwrap();
294
295        // Create new repo and load
296        let path = temp_dir.path().join("payees.json");
297        let repo2 = PayeeRepository::new(path);
298        repo2.load().unwrap();
299
300        let retrieved = repo2.get(id).unwrap().unwrap();
301        assert_eq!(retrieved.name, "Test Store");
302    }
303
304    #[test]
305    fn test_delete() {
306        let (_temp_dir, repo) = create_test_repo();
307        repo.load().unwrap();
308
309        let payee = Payee::new("Test Store");
310        let id = payee.id;
311
312        repo.upsert(payee).unwrap();
313        assert_eq!(repo.count().unwrap(), 1);
314
315        repo.delete(id).unwrap();
316        assert_eq!(repo.count().unwrap(), 0);
317
318        // Name index should also be cleared
319        let not_found = repo.get_by_name("Test Store").unwrap();
320        assert!(not_found.is_none());
321    }
322}