expense_tracker/
expense.rs1use 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]
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}