envelope_cli/config/
settings.rs

1//! User settings for EnvelopeCLI
2//!
3//! Manages user preferences including budget period type, encryption settings,
4//! and backup retention policies.
5
6use serde::{Deserialize, Serialize};
7
8use super::paths::EnvelopePaths;
9use crate::crypto::key_derivation::KeyDerivationParams;
10use crate::error::EnvelopeError;
11
12/// Budget period type preference
13#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
14#[serde(rename_all = "lowercase")]
15pub enum BudgetPeriodType {
16    /// Monthly budgets (default, e.g., "2025-01")
17    #[default]
18    Monthly,
19    /// Weekly budgets (e.g., "2025-W03")
20    Weekly,
21    /// Bi-weekly budgets
22    BiWeekly,
23}
24
25/// Backup retention settings
26#[derive(Debug, Clone, Serialize, Deserialize)]
27pub struct BackupRetention {
28    /// Number of daily backups to keep
29    pub daily_count: u32,
30    /// Number of monthly backups to keep
31    pub monthly_count: u32,
32}
33
34impl Default for BackupRetention {
35    fn default() -> Self {
36        Self {
37            daily_count: 30,
38            monthly_count: 12,
39        }
40    }
41}
42
43/// Encryption settings
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct EncryptionSettings {
46    /// Whether encryption is enabled
47    #[serde(default)]
48    pub enabled: bool,
49
50    /// Key derivation parameters (salt, memory cost, etc.)
51    #[serde(default, skip_serializing_if = "Option::is_none")]
52    pub key_params: Option<KeyDerivationParams>,
53
54    /// Verification hash to check if passphrase is correct
55    /// (This is a hash of "envelope_verify" encrypted with the key)
56    #[serde(default, skip_serializing_if = "Option::is_none")]
57    pub verification_hash: Option<String>,
58}
59
60/// User settings for EnvelopeCLI
61#[derive(Debug, Clone, Serialize, Deserialize)]
62pub struct Settings {
63    /// Schema version for migration support
64    #[serde(default = "default_schema_version")]
65    pub schema_version: u32,
66
67    /// User's preferred budget period type
68    #[serde(default)]
69    pub budget_period_type: BudgetPeriodType,
70
71    /// Whether encryption is enabled (legacy field for backwards compat)
72    #[serde(default)]
73    pub encryption_enabled: bool,
74
75    /// Full encryption settings
76    #[serde(default)]
77    pub encryption: EncryptionSettings,
78
79    /// Backup retention policy
80    #[serde(default)]
81    pub backup_retention: BackupRetention,
82
83    /// Default currency symbol
84    #[serde(default = "default_currency")]
85    pub currency_symbol: String,
86
87    /// Date format preference (strftime format)
88    #[serde(default = "default_date_format")]
89    pub date_format: String,
90
91    /// First day of week (0 = Sunday, 1 = Monday)
92    #[serde(default = "default_first_day_of_week")]
93    pub first_day_of_week: u8,
94
95    /// Whether initial setup has been completed
96    #[serde(default)]
97    pub setup_completed: bool,
98}
99
100fn default_schema_version() -> u32 {
101    1
102}
103
104fn default_currency() -> String {
105    "$".to_string()
106}
107
108fn default_date_format() -> String {
109    "%Y-%m-%d".to_string()
110}
111
112fn default_first_day_of_week() -> u8 {
113    0 // Sunday
114}
115
116impl Default for Settings {
117    fn default() -> Self {
118        Self {
119            schema_version: default_schema_version(),
120            budget_period_type: BudgetPeriodType::default(),
121            encryption_enabled: false,
122            encryption: EncryptionSettings::default(),
123            backup_retention: BackupRetention::default(),
124            currency_symbol: default_currency(),
125            date_format: default_date_format(),
126            first_day_of_week: default_first_day_of_week(),
127            setup_completed: false,
128        }
129    }
130}
131
132impl Settings {
133    /// Check if encryption is enabled (using new encryption field)
134    pub fn is_encryption_enabled(&self) -> bool {
135        self.encryption.enabled || self.encryption_enabled
136    }
137
138    /// Load settings from disk, or create default settings if file doesn't exist
139    pub fn load_or_create(paths: &EnvelopePaths) -> Result<Self, EnvelopeError> {
140        let settings_path = paths.settings_file();
141
142        if settings_path.exists() {
143            let contents = std::fs::read_to_string(&settings_path)
144                .map_err(|e| EnvelopeError::Io(format!("Failed to read settings file: {}", e)))?;
145
146            let settings: Settings = serde_json::from_str(&contents).map_err(|e| {
147                EnvelopeError::Config(format!("Failed to parse settings file: {}", e))
148            })?;
149
150            Ok(settings)
151        } else {
152            // Create default settings
153            let settings = Settings::default();
154            // Don't save yet - let caller decide when to persist
155            Ok(settings)
156        }
157    }
158
159    /// Save settings to disk
160    pub fn save(&self, paths: &EnvelopePaths) -> Result<(), EnvelopeError> {
161        // Ensure the config directory exists
162        paths.ensure_directories()?;
163
164        let settings_path = paths.settings_file();
165        let contents = serde_json::to_string_pretty(self)
166            .map_err(|e| EnvelopeError::Config(format!("Failed to serialize settings: {}", e)))?;
167
168        std::fs::write(&settings_path, contents)
169            .map_err(|e| EnvelopeError::Io(format!("Failed to write settings file: {}", e)))?;
170
171        Ok(())
172    }
173}
174
175#[cfg(test)]
176mod tests {
177    use super::*;
178    use tempfile::TempDir;
179
180    #[test]
181    fn test_default_settings() {
182        let settings = Settings::default();
183        assert_eq!(settings.budget_period_type, BudgetPeriodType::Monthly);
184        assert!(!settings.encryption_enabled);
185        assert_eq!(settings.backup_retention.daily_count, 30);
186        assert_eq!(settings.backup_retention.monthly_count, 12);
187    }
188
189    #[test]
190    fn test_save_and_load() {
191        let temp_dir = TempDir::new().unwrap();
192        let paths = EnvelopePaths::with_base_dir(temp_dir.path().to_path_buf());
193
194        let settings = Settings {
195            budget_period_type: BudgetPeriodType::Weekly,
196            encryption_enabled: true,
197            ..Default::default()
198        };
199
200        settings.save(&paths).unwrap();
201
202        let loaded = Settings::load_or_create(&paths).unwrap();
203        assert_eq!(loaded.budget_period_type, BudgetPeriodType::Weekly);
204        assert!(loaded.encryption_enabled);
205    }
206
207    #[test]
208    fn test_serde_round_trip() {
209        let settings = Settings::default();
210        let json = serde_json::to_string(&settings).unwrap();
211        let deserialized: Settings = serde_json::from_str(&json).unwrap();
212        assert_eq!(settings.budget_period_type, deserialized.budget_period_type);
213    }
214}