envelope_cli/storage/
income.rs

1//! Income expectations repository
2//!
3//! Handles persistence of income expectations to JSON files.
4
5use std::collections::HashMap;
6use std::path::PathBuf;
7use std::sync::RwLock;
8
9use crate::error::EnvelopeError;
10use crate::models::{BudgetPeriod, IncomeExpectation, IncomeId};
11
12use super::file_io::{read_json, write_json_atomic};
13
14#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
15struct IncomeData {
16    #[serde(default)]
17    expectations: Vec<IncomeExpectation>,
18}
19
20/// Repository for income expectations
21pub struct IncomeRepository {
22    path: PathBuf,
23    expectations: RwLock<HashMap<BudgetPeriod, IncomeExpectation>>,
24}
25
26impl IncomeRepository {
27    /// Create a new repository
28    pub fn new(path: PathBuf) -> Self {
29        Self {
30            path,
31            expectations: RwLock::new(HashMap::new()),
32        }
33    }
34
35    /// Load expectations from disk
36    pub fn load(&self) -> Result<(), EnvelopeError> {
37        let file_data: IncomeData = read_json(&self.path)?;
38
39        let mut expectations = self
40            .expectations
41            .write()
42            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
43
44        expectations.clear();
45        for expectation in file_data.expectations {
46            expectations.insert(expectation.period.clone(), expectation);
47        }
48
49        Ok(())
50    }
51
52    /// Save expectations to disk
53    pub fn save(&self) -> Result<(), EnvelopeError> {
54        let expectations = self
55            .expectations
56            .read()
57            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
58
59        let mut list: Vec<_> = expectations.values().cloned().collect();
60        list.sort_by(|a, b| a.created_at.cmp(&b.created_at));
61
62        let file_data = IncomeData { expectations: list };
63
64        write_json_atomic(&self.path, &file_data)
65    }
66
67    /// Get income expectation for a period
68    pub fn get_for_period(&self, period: &BudgetPeriod) -> Option<IncomeExpectation> {
69        let expectations = self.expectations.read().ok()?;
70        expectations.get(period).cloned()
71    }
72
73    /// Get income expectation by ID
74    pub fn get(&self, id: IncomeId) -> Result<Option<IncomeExpectation>, EnvelopeError> {
75        let expectations = self
76            .expectations
77            .read()
78            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
79
80        Ok(expectations.values().find(|e| e.id == id).cloned())
81    }
82
83    /// Upsert an income expectation (insert or update)
84    pub fn upsert(&self, expectation: IncomeExpectation) -> Result<(), EnvelopeError> {
85        let mut expectations = self
86            .expectations
87            .write()
88            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire write lock: {}", e)))?;
89
90        expectations.insert(expectation.period.clone(), expectation);
91        Ok(())
92    }
93
94    /// Delete income expectation for a period
95    pub fn delete_for_period(&self, period: &BudgetPeriod) -> Option<IncomeExpectation> {
96        let mut expectations = self.expectations.write().ok()?;
97        expectations.remove(period)
98    }
99
100    /// Get all income expectations
101    pub fn get_all(&self) -> Result<Vec<IncomeExpectation>, EnvelopeError> {
102        let expectations = self
103            .expectations
104            .read()
105            .map_err(|e| EnvelopeError::Storage(format!("Failed to acquire read lock: {}", e)))?;
106
107        let mut list: Vec<_> = expectations.values().cloned().collect();
108        list.sort_by(|a, b| a.period.cmp(&b.period));
109        Ok(list)
110    }
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use crate::models::Money;
117    use tempfile::TempDir;
118
119    #[test]
120    fn test_upsert_and_get() {
121        let temp_dir = TempDir::new().unwrap();
122        let path = temp_dir.path().join("income.json");
123        let repo = IncomeRepository::new(path);
124
125        let period = BudgetPeriod::monthly(2025, 1);
126        let expectation = IncomeExpectation::new(period.clone(), Money::from_cents(500000));
127
128        repo.upsert(expectation).unwrap();
129
130        let retrieved = repo.get_for_period(&period).unwrap();
131        assert_eq!(retrieved.expected_amount.cents(), 500000);
132    }
133
134    #[test]
135    fn test_save_and_load() {
136        let temp_dir = TempDir::new().unwrap();
137        let path = temp_dir.path().join("income.json");
138
139        // Save
140        {
141            let repo = IncomeRepository::new(path.clone());
142            let period = BudgetPeriod::monthly(2025, 1);
143            let expectation = IncomeExpectation::new(period, Money::from_cents(500000));
144            repo.upsert(expectation).unwrap();
145            repo.save().unwrap();
146        }
147
148        // Load
149        {
150            let repo = IncomeRepository::new(path);
151            repo.load().unwrap();
152            let period = BudgetPeriod::monthly(2025, 1);
153            let retrieved = repo.get_for_period(&period).unwrap();
154            assert_eq!(retrieved.expected_amount.cents(), 500000);
155        }
156    }
157
158    #[test]
159    fn test_delete() {
160        let temp_dir = TempDir::new().unwrap();
161        let path = temp_dir.path().join("income.json");
162        let repo = IncomeRepository::new(path);
163
164        let period = BudgetPeriod::monthly(2025, 1);
165        let expectation = IncomeExpectation::new(period.clone(), Money::from_cents(500000));
166
167        repo.upsert(expectation).unwrap();
168        assert!(repo.get_for_period(&period).is_some());
169
170        repo.delete_for_period(&period);
171        assert!(repo.get_for_period(&period).is_none());
172    }
173
174    #[test]
175    fn test_get_all() {
176        let temp_dir = TempDir::new().unwrap();
177        let path = temp_dir.path().join("income.json");
178        let repo = IncomeRepository::new(path);
179
180        let period1 = BudgetPeriod::monthly(2025, 1);
181        let period2 = BudgetPeriod::monthly(2025, 2);
182
183        repo.upsert(IncomeExpectation::new(period1, Money::from_cents(500000)))
184            .unwrap();
185        repo.upsert(IncomeExpectation::new(period2, Money::from_cents(550000)))
186            .unwrap();
187
188        let all = repo.get_all().unwrap();
189        assert_eq!(all.len(), 2);
190    }
191}