envelope_cli/storage/
accounts.rs

1//! Account repository for JSON storage
2//!
3//! Manages loading and saving accounts to accounts.json
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::RwLock;
8
9use crate::error::EnvelopeError;
10use crate::models::{Account, AccountId};
11
12use super::file_io::{read_json, write_json_atomic};
13
14/// Serializable account data structure
15#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
16struct AccountData {
17    accounts: Vec<Account>,
18}
19
20/// Repository for account persistence
21pub struct AccountRepository {
22    path: PathBuf,
23    data: RwLock<HashMap<AccountId, Account>>,
24}
25
26impl AccountRepository {
27    /// Create a new account repository
28    pub fn new(path: PathBuf) -> Self {
29        Self {
30            path,
31            data: RwLock::new(HashMap::new()),
32        }
33    }
34
35    /// Load accounts from disk
36    pub fn load(&self) -> Result<(), EnvelopeError> {
37        let file_data: AccountData = read_json(&self.path)?;
38
39        let mut data = self
40            .data
41            .write()
42            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
43
44        data.clear();
45        for account in file_data.accounts {
46            data.insert(account.id, account);
47        }
48
49        Ok(())
50    }
51
52    /// Save accounts to disk
53    pub fn save(&self) -> Result<(), EnvelopeError> {
54        let data = self
55            .data
56            .read()
57            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
58
59        let file_data = AccountData {
60            accounts: data.values().cloned().collect(),
61        };
62
63        write_json_atomic(&self.path, &file_data)
64    }
65
66    /// Get an account by ID
67    pub fn get(&self, id: AccountId) -> Result<Option<Account>, EnvelopeError> {
68        let data = self
69            .data
70            .read()
71            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
72
73        Ok(data.get(&id).cloned())
74    }
75
76    /// Get all accounts
77    pub fn get_all(&self) -> Result<Vec<Account>, EnvelopeError> {
78        let data = self
79            .data
80            .read()
81            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
82
83        let mut accounts: Vec<_> = data.values().cloned().collect();
84        accounts.sort_by(|a, b| a.sort_order.cmp(&b.sort_order).then(a.name.cmp(&b.name)));
85        Ok(accounts)
86    }
87
88    /// Get all active (non-archived) accounts
89    pub fn get_active(&self) -> Result<Vec<Account>, EnvelopeError> {
90        let all = self.get_all()?;
91        Ok(all.into_iter().filter(|a| !a.archived).collect())
92    }
93
94    /// Get an account by name (case-insensitive)
95    pub fn get_by_name(&self, name: &str) -> Result<Option<Account>, EnvelopeError> {
96        let data = self
97            .data
98            .read()
99            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
100
101        let name_lower = name.to_lowercase();
102        Ok(data
103            .values()
104            .find(|a| a.name.to_lowercase() == name_lower)
105            .cloned())
106    }
107
108    /// Insert or update an account
109    pub fn upsert(&self, account: Account) -> Result<(), EnvelopeError> {
110        let mut data = self
111            .data
112            .write()
113            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
114
115        data.insert(account.id, account);
116        Ok(())
117    }
118
119    /// Delete an account
120    pub fn delete(&self, id: AccountId) -> Result<bool, EnvelopeError> {
121        let mut data = self
122            .data
123            .write()
124            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
125
126        Ok(data.remove(&id).is_some())
127    }
128
129    /// Check if an account exists
130    pub fn exists(&self, id: AccountId) -> Result<bool, EnvelopeError> {
131        let data = self
132            .data
133            .read()
134            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
135
136        Ok(data.contains_key(&id))
137    }
138
139    /// Check if an account name is already taken
140    pub fn name_exists(
141        &self,
142        name: &str,
143        exclude_id: Option<AccountId>,
144    ) -> Result<bool, EnvelopeError> {
145        let data = self
146            .data
147            .read()
148            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
149
150        let name_lower = name.to_lowercase();
151        Ok(data
152            .values()
153            .any(|a| a.name.to_lowercase() == name_lower && Some(a.id) != exclude_id))
154    }
155
156    /// Count accounts
157    pub fn count(&self) -> Result<usize, EnvelopeError> {
158        let data = self
159            .data
160            .read()
161            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
162
163        Ok(data.len())
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use crate::models::AccountType;
171    use tempfile::TempDir;
172
173    fn create_test_repo() -> (TempDir, AccountRepository) {
174        let temp_dir = TempDir::new().unwrap();
175        let path = temp_dir.path().join("accounts.json");
176        let repo = AccountRepository::new(path);
177        (temp_dir, repo)
178    }
179
180    #[test]
181    fn test_empty_load() {
182        let (_temp_dir, repo) = create_test_repo();
183        repo.load().unwrap();
184        assert_eq!(repo.count().unwrap(), 0);
185    }
186
187    #[test]
188    fn test_upsert_and_get() {
189        let (_temp_dir, repo) = create_test_repo();
190        repo.load().unwrap();
191
192        let account = Account::new("Checking", AccountType::Checking);
193        let id = account.id;
194
195        repo.upsert(account.clone()).unwrap();
196
197        let retrieved = repo.get(id).unwrap().unwrap();
198        assert_eq!(retrieved.name, "Checking");
199    }
200
201    #[test]
202    fn test_save_and_reload() {
203        let (temp_dir, repo) = create_test_repo();
204
205        let account = Account::new("Savings", AccountType::Savings);
206        let id = account.id;
207
208        repo.load().unwrap();
209        repo.upsert(account).unwrap();
210        repo.save().unwrap();
211
212        // Create new repo and load
213        let path = temp_dir.path().join("accounts.json");
214        let repo2 = AccountRepository::new(path);
215        repo2.load().unwrap();
216
217        let retrieved = repo2.get(id).unwrap().unwrap();
218        assert_eq!(retrieved.name, "Savings");
219    }
220
221    #[test]
222    fn test_get_by_name() {
223        let (_temp_dir, repo) = create_test_repo();
224        repo.load().unwrap();
225
226        let account = Account::new("My Checking", AccountType::Checking);
227        repo.upsert(account).unwrap();
228
229        // Case insensitive
230        let found = repo.get_by_name("my checking").unwrap();
231        assert!(found.is_some());
232        assert_eq!(found.unwrap().name, "My Checking");
233
234        let not_found = repo.get_by_name("other").unwrap();
235        assert!(not_found.is_none());
236    }
237
238    #[test]
239    fn test_delete() {
240        let (_temp_dir, repo) = create_test_repo();
241        repo.load().unwrap();
242
243        let account = Account::new("Test", AccountType::Checking);
244        let id = account.id;
245
246        repo.upsert(account).unwrap();
247        assert!(repo.exists(id).unwrap());
248
249        repo.delete(id).unwrap();
250        assert!(!repo.exists(id).unwrap());
251    }
252
253    #[test]
254    fn test_get_active_filters_archived() {
255        let (_temp_dir, repo) = create_test_repo();
256        repo.load().unwrap();
257
258        let account1 = Account::new("Active", AccountType::Checking);
259        let mut account2 = Account::new("Archived", AccountType::Savings);
260        account2.archive();
261
262        repo.upsert(account1).unwrap();
263        repo.upsert(account2).unwrap();
264
265        let all = repo.get_all().unwrap();
266        assert_eq!(all.len(), 2);
267
268        let active = repo.get_active().unwrap();
269        assert_eq!(active.len(), 1);
270        assert_eq!(active[0].name, "Active");
271    }
272
273    #[test]
274    fn test_name_exists() {
275        let (_temp_dir, repo) = create_test_repo();
276        repo.load().unwrap();
277
278        let account = Account::new("Test Account", AccountType::Checking);
279        let id = account.id;
280        repo.upsert(account).unwrap();
281
282        // Name exists
283        assert!(repo.name_exists("test account", None).unwrap());
284
285        // Exclude self
286        assert!(!repo.name_exists("test account", Some(id)).unwrap());
287
288        // Different name
289        assert!(!repo.name_exists("other", None).unwrap());
290    }
291}