use chrono::{Local, NaiveDate};
use clap::Parser;
use crossterm::{
event::{self, Event, KeyCode},
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
ExecutableCommand,
};
use ratatui::backend::CrosstermBackend;
use ratatui::layout::{Constraint, Direction, Layout};
use ratatui::{prelude::*, widgets::*};
use std::{collections::HashMap, env, process::Command};
use std::fs;
use std::io::{self, BufRead, BufReader, Write};
#[derive(Parser, Debug)]
#[command(version, about, long_about = None)]
struct Args {
#[arg(short, long)]
add: bool,
#[arg(short, long)]
edit: bool,
}
#[derive(Debug)]
struct Expense {
date: String,
description: String,
expense_type: String,
amount: f64,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
if args.add {
add_expense()?;
}
if args.edit {
edit_expenses()?;
}
enable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(EnterAlternateScreen)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
let expenses = match read_csv("expenses.csv") {
Ok(expenses) => expenses,
Err(err) => {
eprintln!("Error reading CSV: {}", err);
match create_expenses_csv() {
Ok(_) => Vec::new(),
Err(err) => {
eprintln!("Error creating CSV: {}", err);
return Err(err);
}
}
}
};
let mut should_quit = false;
while !should_quit {
terminal.draw(|f| ui(f, &expenses))?;
should_quit = handle_events()?;
}
disable_raw_mode()?;
let mut stdout = io::stdout();
stdout.execute(LeaveAlternateScreen)?;
Ok(())
}
fn add_expense() -> Result<(), Box<dyn std::error::Error>> {
let mut input = String::new();
let date = loop {
print!("Enter date (YYYY-MM-DD or YYYY/MM/DD, leave empty for today's date): ");
io::stdout().flush()?;
io::stdin().read_line(&mut input)?;
input = input.trim().to_string();
if input.is_empty() {
break Local::now().format("%Y-%m-%d").to_string();
} else if let Ok(date) = NaiveDate::parse_from_str(&input, "%Y-%m-%d") {
break date.to_string();
} else if let Ok(date) = NaiveDate::parse_from_str(&input, "%Y/%m/%d") {
break date.to_string();
} else {
println!(
"Invalid date format. Please enter the date in YYYY-MM-DD or YYYY/MM/DD format."
);
input.clear();
}
};
input.clear();
print!("Enter description:");
io::stdout().flush()?;
io::stdin().read_line(&mut input)?;
let description = input.trim().to_string();
input.clear();
print!("Enter expense type:");
io::stdout().flush()?;
io::stdin().read_line(&mut input)?;
let expense_type = input.trim().to_string();
input.clear();
print!("Enter amount:");
io::stdout().flush()?;
io::stdin().read_line(&mut input)?;
let amount: f64 = input.trim().parse()?;
let expense = Expense {
date,
description,
expense_type,
amount,
};
append_to_csv("expenses.csv", &expense)?;
println!("Added your data to the db!");
Ok(())
}
fn edit_expenses() -> Result<(), Box<dyn std::error::Error>> {
let editor = env::var("EDITOR").unwrap_or_else(|_| "nano".to_string());
let home_dir = dirs::home_dir().ok_or("Unable to determine user's home directory")?;
let file_path = home_dir.join(".local").join("share").join("budget-tracker").join("expenses.csv");
Command::new(editor)
.arg(file_path)
.status()?;
Ok(())
}
fn append_to_csv(file_name: &str, expense: &Expense) -> Result<(), Box<dyn std::error::Error>> {
let home_dir = match dirs::home_dir() {
Some(path) => path,
None => {
eprintln!("Unable to determine user's home directory");
return Err("Unable to determine user's home directory".into());
}
};
let file_path = home_dir
.join(".local")
.join("share")
.join("budget-tracker")
.join(file_name);
let mut file = fs::OpenOptions::new().append(true).open(file_path)?;
let data = format!(
"{},{},{},{}\n",
expense.date, expense.description, expense.expense_type, expense.amount
);
file.write_all(data.as_bytes())?;
Ok(())
}
fn read_csv(file_name: &str) -> Result<Vec<Expense>, Box<dyn std::error::Error>> {
let home_dir = match dirs::home_dir() {
Some(path) => path,
None => {
eprintln!("Unable to determine user's home directory");
return Err("Unable to determine user's home directory".into());
}
};
let file_path = home_dir
.join(".local")
.join("share")
.join("budget-tracker")
.join(file_name);
let file = fs::File::open(file_path)?;
let reader = BufReader::new(file);
let mut expenses = Vec::new();
for (index, line) in reader.lines().enumerate() {
let line = line?;
if index == 0 {
continue; }
let fields: Vec<&str> = line.split(',').collect();
if fields.len() == 4 {
let expense = Expense {
date: fields[0].to_string(),
description: fields[1].to_string(),
expense_type: fields[2].to_string(),
amount: fields[3].parse::<f64>()?,
};
expenses.push(expense);
}
}
Ok(expenses)
}
fn create_expenses_csv() -> Result<(), Box<dyn std::error::Error>> {
let home_dir = match dirs::home_dir() {
Some(path) => path,
None => {
eprintln!("Unable to determine user's home directory");
return Err("Unable to determine user's home directory".into());
}
};
let budget_tracker_dir = home_dir.join(".local").join("share").join("budget-tracker");
if let Err(err) = fs::create_dir_all(&budget_tracker_dir) {
eprintln!(
"Error creating directory {}: {}",
budget_tracker_dir.display(),
err
);
return Err(err.into());
}
let expenses_file = budget_tracker_dir.join("expenses.csv");
if let Err(err) = fs::File::create(&expenses_file) {
eprintln!("Error creating file {}: {}", expenses_file.display(), err);
return Err(err.into());
}
Ok(())
}
fn handle_events() -> io::Result<bool> {
if event::poll(std::time::Duration::from_millis(50))? {
if let Event::Key(key) = event::read()? {
if key.kind == event::KeyEventKind::Press && key.code == KeyCode::Char('q') {
return Ok(true);
}
}
}
Ok(false)
}
fn ui(frame: &mut Frame, expenses: &[Expense]) {
let chunks = Layout::default()
.direction(Direction::Horizontal)
.margin(2)
.constraints([Constraint::Percentage(70), Constraint::Percentage(30)].as_ref())
.split(frame.size());
let charts_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.split(chunks[1]);
let positive_chunk = charts_chunks[0];
let negative_chunk = charts_chunks[1];
let rows = expenses
.iter()
.map(|expense| {
Row::new(vec![
expense.date.clone(),
expense.description.clone(),
expense.expense_type.clone(),
expense.amount.to_string(),
])
})
.collect::<Vec<Row>>();
let widths = [
Constraint::Length(15),
Constraint::Length(60),
Constraint::Length(10),
Constraint::Length(15),
];
let expense_table = Table::new(rows, widths)
.block(Block::default().title("All Expenses").borders(Borders::ALL))
.column_spacing(1)
.style(Style::default().blue())
.header(
Row::new(vec!["Date", "Description", "Expense Type", "Amount"])
.style(Style::default().bold())
.bottom_margin(1),
)
.block(Block::default().title("Expenses Table"))
.highlight_style(Style::new().reversed())
.highlight_symbol(">>");
frame.render_widget(expense_table, chunks[0]);
let mut aggregated_expenses: HashMap<String, f64> = HashMap::new();
for expense in expenses {
let entry = aggregated_expenses
.entry(expense.expense_type.clone())
.or_insert(0.0);
*entry += expense.amount;
}
let positive_expenses_data: Vec<(String, f64)> = aggregated_expenses
.clone()
.into_iter()
.filter(|(_, amount)| *amount >= 0.0)
.collect();
let negative_expenses_data: Vec<(String, f64)> = aggregated_expenses
.clone()
.into_iter()
.filter(|(_, amount)| *amount < 0.0)
.map(|(date, amount)| (date, -amount))
.collect();
for (mut expense_data, chunk, title, color) in [
(
positive_expenses_data.clone(),
positive_chunk,
"Expenses",
Style::default().cyan(),
),
(
negative_expenses_data,
negative_chunk,
"Profits",
Style::default().red(),
),
] {
expense_data.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
let mut labels = expense_data
.iter()
.map(|(date, _)| Span::raw(date.clone()))
.collect::<Vec<Span>>();
labels.insert(0, "".into());
labels.push("".into());
let max_expense_amount = expense_data
.iter()
.map(|(_, amount)| *amount)
.fold(f64::NEG_INFINITY, f64::max);
let type_data: Vec<(&str, u64)> = expense_data
.iter()
.map(|(date, amount)| (date.as_str(), *amount as u64))
.collect();
let type_barchart = BarChart::default()
.block(Block::default().title(title).borders(Borders::ALL))
.bar_width(10)
.bar_gap(1)
.group_gap(3)
.bar_style(color)
.value_style(Style::default().white().bold())
.label_style(Style::default().white())
.data(&type_data)
.max(max_expense_amount.ceil() as u64);
frame.render_widget(type_barchart, chunk); }
}