expense_tracker/
expense.rs

1use std::error::Error;
2use std::fs::OpenOptions;
3use std::path::{Path, PathBuf};
4use std::{io::Write, vec};
5
6use chrono::{DateTime, Utc};
7
8use crate::config::{FILE_NAME, TIME_FORMAT};
9use crate::file_parser::read_file_content;
10
11pub struct Expense {
12    amount: f64,
13    category: String,
14    tags: Vec<String>,
15    datetime: DateTime<Utc>,
16    description: Option<String>,
17}
18
19#[derive(Default)]
20pub struct ExpenseList {
21    expense_list: Vec<Expense>,
22}
23
24impl ExpenseList {
25    pub fn new() -> Self {
26        ExpenseList {
27            expense_list: vec![],
28        }
29    }
30
31    pub fn load_expenses_from_psv(
32        &mut self,
33        file_path: Option<PathBuf>,
34    ) -> Result<(), Box<dyn Error>> {
35        let content: String = read_file_content(file_path)?;
36        for line in content.trim().split('\n') {
37            let fields: Vec<&str> = line.trim().split('|').collect();
38            self.expense_list.push(Expense {
39                amount: fields[2].trim().parse::<f64>()?,
40                description: Some(fields[3][1..fields[3].len() - 1].to_owned()),
41                category: fields[1].to_owned(),
42                tags: fields[4][1..fields[4].len() - 1]
43                    .trim()
44                    .split(',')
45                    .map(|s| s.to_string())
46                    .collect(),
47                datetime: DateTime::parse_from_str(fields[0], TIME_FORMAT)?.to_utc(),
48            });
49        }
50
51        Ok(())
52    }
53}
54
55impl Expense {
56    pub fn new(
57        amount: f64,
58        description: Option<String>,
59        category: String,
60        tags: Option<Vec<String>>,
61    ) -> Expense {
62        Expense {
63            amount,
64            description,
65            category,
66            tags: tags.unwrap_or_default(),
67            datetime: Utc::now(),
68        }
69    }
70
71    pub fn write_expense_to_psv(&self, file_path: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
72        let record = self.to_psv_record();
73        let content = record.as_bytes();
74
75        let path = file_path.unwrap_or(Path::new(FILE_NAME).to_path_buf());
76
77        let mut file = OpenOptions::new().append(true).create(true).open(path)?;
78
79        file.write_all(content)?;
80
81        Ok(())
82    }
83
84    pub fn to_psv_record(&self) -> String {
85        format!(
86            "{}|{}|{}|\"{}\"|\"{}\"\n",
87            self.datetime.format(TIME_FORMAT),
88            self.category.as_str().to_lowercase(),
89            self.amount,
90            self.description.as_ref().unwrap_or(&"".to_string()),
91            self.tags.join(",").to_lowercase()
92        )
93    }
94
95    pub fn display_expenses(expense_list: Vec<&Expense>) {
96        println!(
97            "{:<30}{:<20}{:<10}{:<50}{:<10}",
98            "Time", "Category", "Amount", "Description", "Tags"
99        );
100
101        for expense in expense_list.iter() {
102            println!(
103                "{:<30}{:<20}{:<10}{:<50}{:?}",
104                expense.datetime.format(TIME_FORMAT).to_string(),
105                expense.category,
106                expense.amount,
107                expense
108                    .description
109                    .as_ref()
110                    .unwrap_or(&"".to_string())
111                    .as_str(),
112                expense.tags
113            );
114        }
115    }
116
117    pub fn list_expenses(file_path: Option<PathBuf>) -> Result<(), Box<dyn Error>> {
118        let expense_list = Self::get_expense_list_from_psv(file_path)?;
119        let x = Vec::from_iter(expense_list.expense_list.iter());
120
121        Self::display_expenses(x);
122        Ok(())
123    }
124
125    pub fn expense_total(file_path: Option<PathBuf>) -> Result<f64, Box<dyn Error>> {
126        let expense_list = Self::get_expense_list_from_psv(file_path)?;
127
128        let mut total: f64 = 0.0;
129
130        for expense in expense_list.expense_list {
131            total += expense.amount;
132        }
133
134        Ok(total)
135    }
136
137    fn get_expense_list_from_psv(
138        file_path: Option<PathBuf>,
139    ) -> Result<ExpenseList, Box<dyn Error>> {
140        let mut expense_list = ExpenseList::new();
141
142        expense_list.load_expenses_from_psv(file_path)?;
143
144        Ok(expense_list)
145    }
146
147    pub fn filter_expenses(
148        category: Option<String>,
149        tags: Option<Vec<String>>,
150        amount: Option<f64>,
151        file_path: Option<PathBuf>,
152    ) -> Result<(), Box<dyn Error>> {
153        let expense_list = Self::get_expense_list_from_psv(file_path)?;
154
155        let filtered_list = expense_list.expense_list.iter().filter(|exp: &&Expense| {
156            let amount_flag = if let Some(amount) = amount {
157                amount == exp.amount
158            } else {
159                true
160            };
161
162            let category_flag = if let Some(category) = &category {
163                category.as_str() == exp.category.as_str()
164            } else {
165                true
166            };
167
168            let tags_flag = if let Some(tags) = &tags {
169                let mut flag = false;
170                if exp.tags.is_empty() {
171                    flag = false
172                } else {
173                    for tag in tags {
174                        flag = exp.tags.contains(tag);
175                    }
176                }
177                flag
178            } else {
179                true
180            };
181
182            amount_flag && category_flag && tags_flag
183        });
184        let x = filtered_list.collect::<Vec<_>>();
185
186        Self::display_expenses(x);
187        Ok(())
188    }
189}
190
191#[cfg(test)]
192mod tests {
193
194    use rstest::{fixture, rstest};
195
196    use super::*;
197
198    const MOCK_EXPENSE_PATH: &str = "tests/resources/mock_expenses.psv";
199
200    #[fixture]
201    fn mock_expenses_path() -> PathBuf {
202        PathBuf::from(MOCK_EXPENSE_PATH)
203    }
204
205    #[fixture]
206    fn mock_expenses_list(mock_expenses_path: PathBuf) -> ExpenseList {
207        let mut mock_expenses_list = ExpenseList::new();
208        mock_expenses_list
209            .load_expenses_from_psv(Some(mock_expenses_path))
210            .unwrap_or_else(|err| {
211                eprintln!("Failed to parse amount : {}", err);
212            });
213
214        mock_expenses_list
215    }
216
217    // #[rstest]
218    // fn test_filter_expense(mock_expenses_list: ExpenseList) {}
219
220    #[rstest]
221    fn test_expense_total(mock_expenses_path: PathBuf) -> Result<(), Box<dyn Error>> {
222        let total = Expense::expense_total(Some(mock_expenses_path))?;
223        assert_eq!(total, 1044.0);
224        Ok(())
225    }
226}