envelope_cli/storage/
mod.rs

1//! Storage layer for EnvelopeCLI
2//!
3//! Provides JSON file storage with atomic writes, file locking, and
4//! automatic directory creation. Includes audit logging for all
5//! create, update, and delete operations.
6
7pub mod accounts;
8pub mod budget;
9pub mod categories;
10pub mod file_io;
11pub mod income;
12pub mod init;
13pub mod payees;
14pub mod targets;
15pub mod transactions;
16
17pub use accounts::AccountRepository;
18pub use budget::BudgetRepository;
19pub use categories::CategoryRepository;
20pub use file_io::{read_json, write_json_atomic};
21pub use income::IncomeRepository;
22pub use init::initialize_storage;
23pub use payees::PayeeRepository;
24pub use targets::TargetRepository;
25pub use transactions::TransactionRepository;
26
27use std::path::{Path, PathBuf};
28
29use crate::audit::{AuditEntry, AuditLogger, EntityType};
30use crate::backup::{BackupManager, RestoreManager, RestoreResult};
31use crate::config::paths::EnvelopePaths;
32use crate::config::settings::BackupRetention;
33use crate::error::{EnvelopeError, EnvelopeResult};
34
35/// Main storage coordinator that provides access to all repositories
36/// and handles audit logging for all operations.
37pub struct Storage {
38    paths: EnvelopePaths,
39    pub accounts: AccountRepository,
40    pub transactions: TransactionRepository,
41    pub categories: CategoryRepository,
42    pub budget: BudgetRepository,
43    pub payees: PayeeRepository,
44    pub targets: TargetRepository,
45    pub income: IncomeRepository,
46    audit: AuditLogger,
47}
48
49impl Storage {
50    /// Create a new Storage instance
51    pub fn new(paths: EnvelopePaths) -> Result<Self, EnvelopeError> {
52        // Ensure directories exist
53        paths.ensure_directories()?;
54
55        let audit = AuditLogger::new(paths.audit_log());
56
57        Ok(Self {
58            accounts: AccountRepository::new(paths.accounts_file()),
59            transactions: TransactionRepository::new(paths.transactions_file()),
60            categories: CategoryRepository::new(paths.budget_file()),
61            budget: BudgetRepository::new(paths.allocations_file()),
62            payees: PayeeRepository::new(paths.payees_file()),
63            targets: TargetRepository::new(paths.targets_file()),
64            income: IncomeRepository::new(paths.income_file()),
65            audit,
66            paths,
67        })
68    }
69
70    /// Get the paths configuration
71    pub fn paths(&self) -> &EnvelopePaths {
72        &self.paths
73    }
74
75    /// Get a reference to the audit logger
76    pub fn audit(&self) -> &AuditLogger {
77        &self.audit
78    }
79
80    /// Log an audit entry
81    pub fn log_audit(&self, entry: &AuditEntry) -> EnvelopeResult<()> {
82        self.audit.log(entry)
83    }
84
85    /// Log a create operation
86    pub fn log_create<T: serde::Serialize>(
87        &self,
88        entity_type: EntityType,
89        entity_id: impl Into<String>,
90        entity_name: Option<String>,
91        entity: &T,
92    ) -> EnvelopeResult<()> {
93        let entry = AuditEntry::create(entity_type, entity_id, entity_name, entity);
94        self.audit.log(&entry)
95    }
96
97    /// Log an update operation
98    pub fn log_update<T: serde::Serialize>(
99        &self,
100        entity_type: EntityType,
101        entity_id: impl Into<String>,
102        entity_name: Option<String>,
103        before: &T,
104        after: &T,
105        diff_summary: Option<String>,
106    ) -> EnvelopeResult<()> {
107        let entry = AuditEntry::update(
108            entity_type,
109            entity_id,
110            entity_name,
111            before,
112            after,
113            diff_summary,
114        );
115        self.audit.log(&entry)
116    }
117
118    /// Log a delete operation
119    pub fn log_delete<T: serde::Serialize>(
120        &self,
121        entity_type: EntityType,
122        entity_id: impl Into<String>,
123        entity_name: Option<String>,
124        entity: &T,
125    ) -> EnvelopeResult<()> {
126        let entry = AuditEntry::delete(entity_type, entity_id, entity_name, entity);
127        self.audit.log(&entry)
128    }
129
130    /// Read recent audit entries
131    pub fn read_audit_log(&self, count: usize) -> EnvelopeResult<Vec<AuditEntry>> {
132        self.audit.read_recent(count)
133    }
134
135    /// Load all data from disk
136    pub fn load_all(&mut self) -> Result<(), EnvelopeError> {
137        self.accounts.load()?;
138        self.transactions.load()?;
139        self.categories.load()?;
140        self.budget.load()?;
141        self.payees.load()?;
142        self.targets.load()?;
143        self.income.load()?;
144        Ok(())
145    }
146
147    /// Save all data to disk
148    pub fn save_all(&self) -> Result<(), EnvelopeError> {
149        self.accounts.save()?;
150        self.transactions.save()?;
151        self.categories.save()?;
152        self.budget.save()?;
153        self.payees.save()?;
154        self.targets.save()?;
155        self.income.save()?;
156        Ok(())
157    }
158
159    /// Check if storage has been initialized (has any data)
160    pub fn is_initialized(&self) -> bool {
161        self.paths.settings_file().exists()
162    }
163
164    /// Create a backup of all data
165    ///
166    /// Creates a backup using the default retention policy.
167    /// Returns the path to the created backup file.
168    pub fn create_backup(&self) -> EnvelopeResult<PathBuf> {
169        let retention = BackupRetention::default();
170        let manager = BackupManager::new(self.paths.clone(), retention);
171        manager.create_backup()
172    }
173
174    /// Create a backup with a custom retention policy
175    pub fn create_backup_with_retention(
176        &self,
177        retention: BackupRetention,
178    ) -> EnvelopeResult<(PathBuf, Vec<PathBuf>)> {
179        let manager = BackupManager::new(self.paths.clone(), retention);
180        manager.create_backup_with_retention()
181    }
182
183    /// Restore data from a backup file
184    ///
185    /// WARNING: This will overwrite all current data.
186    /// It's recommended to create a backup before restoring.
187    pub fn restore_from_backup(&mut self, backup_path: &Path) -> EnvelopeResult<RestoreResult> {
188        let restore_manager = RestoreManager::new(self.paths.clone());
189        let result = restore_manager.restore_from_file(backup_path)?;
190
191        // Reload all repositories after restore
192        self.load_all()?;
193
194        Ok(result)
195    }
196
197    /// Get the backup manager for advanced backup operations
198    pub fn backup_manager(&self, retention: BackupRetention) -> BackupManager {
199        BackupManager::new(self.paths.clone(), retention)
200    }
201
202    /// Create a backup before a destructive operation if needed
203    ///
204    /// This creates a backup only if:
205    /// - No backup exists yet, OR
206    /// - The most recent backup is older than 60 seconds
207    ///
208    /// This prevents creating too many backups when multiple destructive
209    /// operations happen in quick succession.
210    ///
211    /// Returns Ok(Some(path)) if a backup was created, Ok(None) if skipped.
212    pub fn backup_before_destructive(&self) -> EnvelopeResult<Option<PathBuf>> {
213        let retention = BackupRetention::default();
214        let manager = BackupManager::new(self.paths.clone(), retention);
215
216        // Check if we need to create a backup
217        if let Some(latest) = manager.get_latest_backup()? {
218            let age = chrono::Utc::now().signed_duration_since(latest.created_at);
219
220            // Skip if last backup was less than 60 seconds ago
221            if age.num_seconds() < 60 {
222                return Ok(None);
223            }
224        }
225
226        // Create backup
227        let path = manager.create_backup()?;
228        Ok(Some(path))
229    }
230}
231
232#[cfg(test)]
233mod tests {
234    use super::*;
235    use tempfile::TempDir;
236
237    #[test]
238    fn test_storage_creation() {
239        let temp_dir = TempDir::new().unwrap();
240        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
241        let storage = Storage::new(paths).unwrap();
242
243        assert!(temp_dir.path().join("data").exists());
244        assert!(temp_dir.path().join("backups").exists());
245        assert!(!storage.is_initialized());
246    }
247}