envelope_cli/storage/
payees.rs1use 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#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
16struct PayeeData {
17 payees: Vec<Payee>,
18}
19
20pub struct PayeeRepository {
22 path: PathBuf,
23 data: RwLock<HashMap<PayeeId, Payee>>,
24 by_name: RwLock<HashMap<String, PayeeId>>,
26}
27
28impl PayeeRepository {
29 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 let not_found = repo.get_by_name("Test Store").unwrap();
320 assert!(not_found.is_none());
321 }
322}