budget_tracker/
expense.rs

1//! Defines all [Expense] struct related objects.
2
3use chrono::{Local, NaiveDate};
4use log::{error, trace};
5use std::io::{self, BufRead, BufReader, Write};
6use std::{env, process::Command};
7use std::{fs, path::PathBuf};
8
9pub fn capitalize(string: String) -> String {
10    if string.is_empty() {
11        return String::new();
12    }
13
14    let mut chars = string.chars();
15    let first_char = chars.next().unwrap().to_uppercase().to_string();
16    let rest: String = chars.collect();
17
18    first_char + &rest
19}
20
21/// The [Expense] struct; helps reading/writing data in a structured manner. It reflects the schema of the database.
22#[derive(Debug, Clone)]
23pub struct Expense {
24    pub date: String,
25    pub description: String,
26    pub expense_type: String,
27    pub amount: f64,
28}
29
30impl Expense {
31    pub fn new(date: String, description: String, expense_type: String, amount: f64) -> Self {
32        Self {
33            date,
34            description,
35            expense_type: capitalize(expense_type),
36            amount,
37        }
38    }
39
40    /**
41    Function to add and expense to the database.
42
43    Takes input from `stdin` for date, description, expense type and amount.
44    Support YYYY-MM-DD and YYYY/MM/DD date format as input.
45    For amount no denoination is expected as of now.
46    */
47    pub fn add_expense() -> Result<(), Box<dyn std::error::Error>> {
48        trace!("Adding expense ...");
49        let date = Self::input_date()?;
50        let description = Self::input("Enter description:")?;
51        let expense_type = capitalize(Self::input(
52            "Enter expense type (Food, Travel, Fun, Medical, Personal or Other): ",
53        )?);
54        let amount = Self::input_amount()?;
55        let expense = Self::new(date, description, expense_type, amount);
56
57        Self::append_to_csv("expenses.csv", &expense)?;
58        println!("Added your data to the db!");
59        trace!("Added expense: {:?}", expense);
60
61        Ok(())
62    }
63
64    /// Takes in a [String] input, after printing a prompt
65    fn input(prompt: &str) -> Result<String, Box<dyn std::error::Error>> {
66        let mut input = String::new();
67        print!("{}", prompt);
68        io::stdout().flush()?;
69        io::stdin().read_line(&mut input)?;
70        Ok(input.trim().to_string())
71    }
72
73    /// Takes in an input of a Date format, currently defined as YYYY-MM-DD or YYYY/MM/DD
74    fn input_date() -> Result<String, Box<dyn std::error::Error>> {
75        loop {
76            let input = Self::input(
77                "Enter date (YYYY-MM-DD or YYYY/MM/DD, leave empty for today's date): ",
78            )?;
79            if input.is_empty() {
80                return Ok(Local::now().format("%Y-%m-%d").to_string());
81            } else if let Ok(date) = NaiveDate::parse_from_str(&input, "%Y-%m-%d") {
82                return Ok(date.to_string());
83            } else if let Ok(date) = NaiveDate::parse_from_str(&input, "%Y/%m/%d") {
84                return Ok(date.to_string());
85            } else {
86                println!("Invalid date format. Please enter the date in YYYY-MM-DD or YYYY/MM/DD format.");
87            }
88        }
89    }
90
91    /// Takes input of type [f64]
92    fn input_amount() -> Result<f64, Box<dyn std::error::Error>> {
93        loop {
94            let input = Self::input("Enter amount: ")?;
95            match input.trim().parse() {
96                Ok(amount) => return Ok(amount),
97                Err(_) => println!("Invalid amount. Please enter a valid number."),
98            }
99        }
100    }
101
102    /// Allows editing the database by specifying an EDITOR environment variable. By default its nano.
103    pub fn edit_expenses(file_name: &str) -> Result<(), Box<dyn std::error::Error>> {
104        trace!("Editing the expenses file ...");
105        let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
106        trace!("Choosing '{}' as the editor", editor);
107        Command::new(editor)
108            .arg(Expense::get_database_file_path(file_name)?)
109            .status()?;
110
111        Ok(())
112    }
113
114    /// Allows adding data to the end of the database
115    pub fn append_to_csv(
116        file_name: &str,
117        expense: &Expense,
118    ) -> Result<(), Box<dyn std::error::Error>> {
119        trace!("Appending to db ... ");
120        let file_path = Expense::get_database_file_path(file_name)?;
121        let mut file = fs::OpenOptions::new().append(true).open(file_path)?;
122        let data = format!(
123            "{},{},{},{}\n",
124            expense.date, expense.description, expense.expense_type, expense.amount
125        );
126        file.write_all(data.as_bytes())?;
127
128        Ok(())
129    }
130
131    /// Read the database if its present from ~/.local/share/budget-tracker/expenses.csv;
132    /// if not present it returns an error.
133    pub fn read_csv(file_name: &str) -> Result<Vec<Expense>, Box<dyn std::error::Error>> {
134        trace!("Reading the db ... ");
135        let file_path = Expense::get_database_file_path(file_name)?;
136        let file = fs::File::open(file_path)?;
137
138        let reader = BufReader::new(file);
139        let mut expenses = Vec::new();
140
141        for (index, line) in reader.lines().enumerate() {
142            let line = line?;
143            if index == 0 {
144                continue; // Skip header
145            }
146            let fields: Vec<&str> = line.split(',').collect();
147            if fields.len() == 4 {
148                let expense_type: String = fields[2].parse()?;
149                let expense = Expense::new(
150                    fields[0].to_string(),
151                    fields[1].to_string(),
152                    expense_type,
153                    fields[3].parse::<f64>()?,
154                );
155                expenses.push(expense);
156            }
157        }
158        Ok(expenses)
159    }
160
161    /// Creates the database. Usually called when running the program for the first time.
162    pub fn create_expenses_csv() -> Result<(), Box<dyn std::error::Error>> {
163        trace!("Creating the db ... ");
164        let budget_tracker_dir = Expense::get_database_file_path("")?;
165        if let Err(err) = fs::create_dir_all(&budget_tracker_dir) {
166            error!(
167                "Error creating directory {}: {}",
168                budget_tracker_dir.display(),
169                err
170            );
171            return Err(err.into());
172        }
173
174        let expenses_file = budget_tracker_dir.join("expenses.csv");
175        if let Err(err) = fs::File::create(&expenses_file) {
176            error!("Error creating file {}: {}", expenses_file.display(), err);
177            return Err(err.into());
178        }
179        Ok(())
180    }
181
182    fn get_database_file_path(file_name: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
183        let home_dir = dirs::home_dir().ok_or("Unable to determine user's home directory")?;
184        Ok(home_dir
185            .join(".local")
186            .join("share")
187            .join("budget-tracker")
188            .join(file_name))
189    }
190}