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