cashflow 0.1.1

A terminal-based expense tracker built with Ratatui
use chrono::{Datelike, NaiveDate};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Category {
    Food,
    Transport,
    Rent,
    Utilities,
    Entertainment,
    Shopping,
    Health,
    Education,
    Subscriptions,
    Other(String),
}

impl Category {
    pub const _VARIANTS: &'static [Category] = &[
        Category::Food,
        Category::Transport,
        Category::Rent,
        Category::Utilities,
        Category::Entertainment,
        Category::Shopping,
        Category::Health,
        Category::Education,
        Category::Subscriptions,
    ];

    pub fn all_display_names() -> Vec<&'static str> {
        vec![
            "Food",
            "Transport",
            "Rent",
            "Utilities",
            "Entertainment",
            "Shopping",
            "Health",
            "Education",
            "Subscriptions",
            "Other",
        ]
    }

    pub fn from_index(index: usize, custom: Option<String>) -> Self {
        match index {
            0 => Category::Food,
            1 => Category::Transport,
            2 => Category::Rent,
            3 => Category::Utilities,
            4 => Category::Entertainment,
            5 => Category::Shopping,
            6 => Category::Health,
            7 => Category::Education,
            8 => Category::Subscriptions,
            _ => Category::Other(custom.unwrap_or_default()),
        }
    }

    pub fn to_index(&self) -> usize {
        match self {
            Category::Food => 0,
            Category::Transport => 1,
            Category::Rent => 2,
            Category::Utilities => 3,
            Category::Entertainment => 4,
            Category::Shopping => 5,
            Category::Health => 6,
            Category::Education => 7,
            Category::Subscriptions => 8,
            Category::Other(_) => 9,
        }
    }
}

impl fmt::Display for Category {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Category::Food => write!(f, "Food"),
            Category::Transport => write!(f, "Transport"),
            Category::Rent => write!(f, "Rent"),
            Category::Utilities => write!(f, "Utilities"),
            Category::Entertainment => write!(f, "Entertainment"),
            Category::Shopping => write!(f, "Shopping"),
            Category::Health => write!(f, "Health"),
            Category::Education => write!(f, "Education"),
            Category::Subscriptions => write!(f, "Subscriptions"),
            Category::Other(s) if s.is_empty() => write!(f, "Other"),
            Category::Other(s) => write!(f, "Other({})", s),
        }
    }
}

impl Category {
    pub fn from_str_value(s: &str) -> Self {
        match s {
            "Food" => Category::Food,
            "Transport" => Category::Transport,
            "Rent" => Category::Rent,
            "Utilities" => Category::Utilities,
            "Entertainment" => Category::Entertainment,
            "Shopping" => Category::Shopping,
            "Health" => Category::Health,
            "Education" => Category::Education,
            "Subscriptions" => Category::Subscriptions,
            other => {
                let inner = other
                    .strip_prefix("Other(")
                    .and_then(|s| s.strip_suffix(')'))
                    .unwrap_or(if other == "Other" { "" } else { other });
                Category::Other(inner.to_string())
            }
        }
    }
}

impl Serialize for Category {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for Category {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        Ok(Category::from_str_value(&s))
    }
}

#[derive(Debug, Clone, Copy, PartialEq)]
pub enum Recurrence {
    Daily,
    Weekly,
    Monthly,
    Yearly,
}

impl Recurrence {
    pub const _VARIANTS: &'static [Recurrence] = &[
        Recurrence::Daily,
        Recurrence::Weekly,
        Recurrence::Monthly,
        Recurrence::Yearly,
    ];

    pub fn all_display_names() -> Vec<&'static str> {
        vec!["Daily", "Weekly", "Monthly", "Yearly"]
    }

    pub fn from_index(index: usize) -> Self {
        match index {
            0 => Recurrence::Daily,
            1 => Recurrence::Weekly,
            2 => Recurrence::Monthly,
            _ => Recurrence::Yearly,
        }
    }

    pub fn to_index(&self) -> usize {
        match self {
            Recurrence::Daily => 0,
            Recurrence::Weekly => 1,
            Recurrence::Monthly => 2,
            Recurrence::Yearly => 3,
        }
    }

    pub fn next_date(&self, from: NaiveDate) -> NaiveDate {
        match self {
            Recurrence::Daily => from + chrono::Duration::days(1),
            Recurrence::Weekly => from + chrono::Duration::weeks(1),
            Recurrence::Monthly => {
                let month = from.month();
                let year = from.year();
                if month == 12 {
                    NaiveDate::from_ymd_opt(year + 1, 1, from.day().min(28))
                        .unwrap_or(from + chrono::Duration::days(30))
                } else {
                    NaiveDate::from_ymd_opt(year, month + 1, from.day().min(28))
                        .unwrap_or(from + chrono::Duration::days(30))
                }
            }
            Recurrence::Yearly => {
                NaiveDate::from_ymd_opt(from.year() + 1, from.month(), from.day().min(28))
                    .unwrap_or(from + chrono::Duration::days(365))
            }
        }
    }
}

impl fmt::Display for Recurrence {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Recurrence::Daily => write!(f, "Daily"),
            Recurrence::Weekly => write!(f, "Weekly"),
            Recurrence::Monthly => write!(f, "Monthly"),
            Recurrence::Yearly => write!(f, "Yearly"),
        }
    }
}

impl Recurrence {
    pub fn from_str_value(s: &str) -> Option<Self> {
        match s {
            "Daily" => Some(Recurrence::Daily),
            "Weekly" => Some(Recurrence::Weekly),
            "Monthly" => Some(Recurrence::Monthly),
            "Yearly" => Some(Recurrence::Yearly),
            _ => None,
        }
    }
}

impl Serialize for Recurrence {
    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
        serializer.serialize_str(&self.to_string())
    }
}

impl<'de> Deserialize<'de> for Recurrence {
    fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
        let s = String::deserialize(deserializer)?;
        Recurrence::from_str_value(&s)
            .ok_or_else(|| serde::de::Error::custom(format!("unknown recurrence: {}", s)))
    }
}

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Expense {
    pub id: u64,
    pub amount: f64,
    pub category: Category,
    pub description: String,
    pub date: NaiveDate,
    pub is_recurring: bool,
    pub recurrence: Option<Recurrence>,
}

impl Expense {
    pub fn new(
        id: u64,
        amount: f64,
        category: Category,
        description: String,
        date: NaiveDate,
        is_recurring: bool,
        recurrence: Option<Recurrence>,
    ) -> Self {
        Self {
            id,
            amount,
            category,
            description,
            date,
            is_recurring,
            recurrence,
        }
    }
}