From d0652144888082bddf1c6533f49f691770f42085 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:14:26 +0600 Subject: [PATCH 01/12] Db call for updating tag --- app/src/conn.rs | 18 ++++++++++++++++++ db/src/models/activity_tx_tags.rs | 6 ------ db/src/models/tags.rs | 13 +++++++++++++ 3 files changed, 31 insertions(+), 6 deletions(-) diff --git a/app/src/conn.rs b/app/src/conn.rs index 95053d21..3881486b 100644 --- a/app/src/conn.rs +++ b/app/src/conn.rs @@ -361,6 +361,19 @@ impl DbConn { Ok(()) } + pub fn rename_tag(&mut self, old_name: &str, new_name: &str) -> Result<()> { + let new_tag = self.conn.transaction::(|conn| { + let mut db_conn = MutDbConn::new(conn, &self.cache); + + Ok(Tag::update_name(old_name, new_name, &mut db_conn)?) + })?; + + let tag = self.cache.tags.get_mut(&new_tag.id).unwrap(); + tag.name = new_name.to_string(); + + Ok(()) + } + pub fn set_new_tx_method_positions(&mut self, new_format: &[String]) -> Result<()> { let mut new_method_positions = Vec::new(); @@ -397,6 +410,11 @@ impl DbConn { self.cache.get_methods() } + #[must_use] + pub fn get_tags_sorted(&self) -> Vec<&Tag> { + self.cache.get_tags_sorted() + } + #[must_use] pub fn get_tx_methods_cumulative(&self) -> Vec { let mut methods: Vec = self diff --git a/db/src/models/activity_tx_tags.rs b/db/src/models/activity_tx_tags.rs index 6cf8c974..e1a740e4 100644 --- a/db/src/models/activity_tx_tags.rs +++ b/db/src/models/activity_tx_tags.rs @@ -34,10 +34,4 @@ impl ActivityTxTag { .values(txs) .execute(db_conn.conn()) } - - pub fn delete_by_tx_id(tx_id_value: i32, db_conn: &mut impl ConnCache) -> Result { - use crate::schema::tx_tags::dsl::{tx_id, tx_tags}; - - diesel::delete(tx_tags.filter(tx_id.eq(tx_id_value))).execute(db_conn.conn()) - } } diff --git a/db/src/models/tags.rs b/db/src/models/tags.rs index 333a8653..9efe2b60 100644 --- a/db/src/models/tags.rs +++ b/db/src/models/tags.rs @@ -47,4 +47,17 @@ impl Tag { tags.filter(name.eq(n)).first(db_conn.conn()).optional() } + + pub fn update_name( + old_name: &str, + new_name: &str, + db_conn: &mut impl ConnCache, + ) -> Result { + use crate::schema::tags::dsl::{name, tags}; + + diesel::update(tags.filter(name.eq(old_name))) + .set(name.eq(new_name)) + .returning(Self::as_returning()) + .get_result(db_conn.conn()) + } } From acfc00ceefcec1a16dd21ef7fc3723997ccb14d2 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:14:57 +0600 Subject: [PATCH 02/12] Add popup for renaming tag --- tui/src/key_checker/key_handler.rs | 14 +++- tui/src/pages/popups/choice.rs | 20 ++++- tui/src/pages/popups/input.rs | 14 +++- tui/src/pages/popups/models.rs | 114 +++++++++++++++++++++++------ 4 files changed, 134 insertions(+), 28 deletions(-) diff --git a/tui/src/key_checker/key_handler.rs b/tui/src/key_checker/key_handler.rs index cb42a6db..4ffb1adc 100644 --- a/tui/src/key_checker/key_handler.rs +++ b/tui/src/key_checker/key_handler.rs @@ -851,8 +851,11 @@ impl<'a> InputKeyHandler<'a> { ConfigChoices::RenameTxMethod => { *self.popup_status = PopupType::new_choice_methods(self.conn, self.theme)?; } + ConfigChoices::RenameTag => { + *self.popup_status = PopupType::new_choice_tags(self.conn, self.theme)?; + } ConfigChoices::AddNewTxMethod => { - *self.popup_status = PopupType::new_input(None); + *self.popup_status = PopupType::new_input(None, false); } } } @@ -861,7 +864,14 @@ impl<'a> InputKeyHandler<'a> { return Err(anyhow!("Popup choice should not have been None")); }; - *self.popup_status = PopupType::new_input(Some(choice)); + *self.popup_status = PopupType::new_input(Some(choice), false); + } + ChoicePopupState::Tags => { + let Some(choice) = self.popup_status.get_choice_tag() else { + return Err(anyhow!("Popup choice should not have been None")); + }; + + *self.popup_status = PopupType::new_input(Some(choice), true); } } diff --git a/tui/src/pages/popups/choice.rs b/tui/src/pages/popups/choice.rs index 519d7c9c..fc43b3e5 100644 --- a/tui/src/pages/popups/choice.rs +++ b/tui/src/pages/popups/choice.rs @@ -34,12 +34,12 @@ impl ChoicePopup { title = "Configuration"; message = "Select an option to configure"; - y_value = 12; + y_value = 13; constraints = vec![ Constraint::Length(4), Constraint::Min(1), - Constraint::Length(7), + Constraint::Length(8), ]; } ChoicePopupState::ConfigForced => { @@ -65,6 +65,22 @@ impl ChoicePopup { y_value = 20; } + constraints = vec![ + Constraint::Length(4), + Constraint::Length(1), + Constraint::Length((self.table.items.len() + 2) as u16), + ]; + } + ChoicePopupState::Tags => { + title = "Rename Tag"; + message = "Select a tag to rename"; + + y_value = 5 + self.table.items.len() as u16 + 2; + + if y_value > 20 { + y_value = 20; + } + constraints = vec![ Constraint::Length(4), Constraint::Length(1), diff --git a/tui/src/pages/popups/input.rs b/tui/src/pages/popups/input.rs index 2aea412c..aa707a7c 100644 --- a/tui/src/pages/popups/input.rs +++ b/tui/src/pages/popups/input.rs @@ -14,10 +14,18 @@ impl InputPopup { let x_value = 50; let y_value = 7; - let title = if self.modifying_method.is_none() { + let title = if self.modifying_method.is_none() && self.modifying_tag.is_none() { "New Method" + } else if self.modifying_tag.is_some() { + "Rename Tag" } else { - "Rename to" + "Rename Method" + }; + + let block_name = if self.modifying_tag.is_some() { + "Tag name" + } else { + "Method name" }; let title = Span::styled(title, Style::default().add_modifier(Modifier::BOLD)); @@ -46,7 +54,7 @@ impl InputPopup { let input_section = Paragraph::new(input_text) .style(Style::default().bg(theme.background()).fg(theme.text())) - .block(styled_block("Method name", theme)) + .block(styled_block(block_name, theme)) .alignment(Alignment::Left); let status_section = Paragraph::new(status_text) diff --git a/tui/src/pages/popups/models.rs b/tui/src/pages/popups/models.rs index 7a01e3c3..ace3a587 100644 --- a/tui/src/pages/popups/models.rs +++ b/tui/src/pages/popups/models.rs @@ -66,6 +66,7 @@ pub struct InputPopup { pub cursor_position: usize, pub status: String, pub modifying_method: Option, + pub modifying_tag: Option, } pub struct ChoiceDetails { @@ -91,6 +92,7 @@ pub enum ChoicePopupState { Delete, Config, TxMethods, + Tags, ConfigForced, } @@ -116,6 +118,8 @@ pub enum ConfigChoices { NewLocation, #[strum(to_string = "Set backup paths for app data")] BackupPaths, + #[strum(to_string = "Rename a Tag")] + RenameTag, } impl InfoPopup { @@ -536,12 +540,57 @@ impl PopupType { } } - pub fn new_input(modifying: Option) -> Self { + pub fn new_choice_tags(conn: &mut DbConn, theme: &Theme) -> Result { + let tags = conn.get_tags_sorted(); + + if tags.is_empty() { + return Err(anyhow!( + "There needs to be at least 1 tag existing for this option" + )); + } + + let choices = tags + .iter() + .map(|c| ChoiceDetails { + text: c.name.clone(), + color: theme.positive(), + }) + .collect(); + + let table = tags.iter().map(|m| vec![m.name.clone()]).collect(); + let mut table_data = TableData::new(table); + table_data.state.select(Some(0)); + + Ok(PopupType::Choice(ChoicePopup { + table: table_data, + choices, + showing: ChoicePopupState::Tags, + })) + } + + pub fn get_choice_tag(&self) -> Option { + match self { + PopupType::Choice(choice) => { + let selected = choice.table.state.selected().unwrap(); + Some(choice.choices[selected].text.clone()) + } + _ => None, + } + } + + pub fn new_input(modifying: Option, is_tag: bool) -> Self { + let (modifying_method, modifying_tag) = if is_tag { + (None, modifying) + } else { + (modifying, None) + }; + PopupType::Input(InputPopup { text: String::new(), cursor_position: 0, status: String::from("All good"), - modifying_method: modifying, + modifying_method, + modifying_tag, }) } @@ -549,36 +598,59 @@ impl PopupType { if let PopupType::Input(input) = self { add_char_to(char, &mut input.cursor_position, &mut input.text); - let tx_methods = conn.get_tx_methods_sorted(); + if input.modifying_tag.is_some() { + let tags = conn.get_tags_sorted(); - if tx_methods.iter().any(|m| m.name == input.text) { - input.status = String::from("Cannot use existing method name"); - } else if RESTRICTED.iter().any(|m| m == &input.text) { - input.status = String::from("Cannot use this text as method name"); + if tags.iter().any(|t| t.name == input.text) { + input.status = String::from("Cannot use existing tag name"); + } else if input.text.is_empty() { + input.status = String::from("Tag name cannot be empty"); + } else { + input.status = String::from("All good"); + } } else { - input.status = String::from("All good"); + let tx_methods = conn.get_tx_methods_sorted(); + + if tx_methods.iter().any(|m| m.name == input.text) { + input.status = String::from("Cannot use existing method name"); + } else if RESTRICTED.iter().any(|m| m == &input.text) { + input.status = String::from("Cannot use this text as method name"); + } else { + input.status = String::from("All good"); + } } } } pub fn accept_input(&mut self, conn: &mut DbConn) -> Result { if let PopupType::Input(input) = self { - let tx_methods = conn.get_tx_methods_sorted(); - - if tx_methods.iter().any(|m| m.name == input.text) { - input.status = String::from("Cannot use existing method name"); - return Ok(false); - } + if let Some(modifying) = &input.modifying_tag { + let tags = conn.get_tags_sorted(); - if RESTRICTED.iter().any(|m| m == &input.text) { - input.status = String::from("Cannot use this text as method name"); - return Ok(false); - } + if tags.iter().any(|t| t.name == input.text) { + input.status = String::from("Cannot use existing tag name"); + return Ok(false); + } - if let Some(modifying) = &input.modifying_method { - conn.rename_tx_method(modifying, &input.text)?; + conn.rename_tag(modifying, &input.text)?; } else { - conn.add_new_methods(std::slice::from_ref(&input.text))?; + let tx_methods = conn.get_tx_methods_sorted(); + + if tx_methods.iter().any(|m| m.name == input.text) { + input.status = String::from("Cannot use existing method name"); + return Ok(false); + } + + if RESTRICTED.iter().any(|m| m == &input.text) { + input.status = String::from("Cannot use this text as method name"); + return Ok(false); + } + + if let Some(modifying) = &input.modifying_method { + conn.rename_tx_method(modifying, &input.text)?; + } else { + conn.add_new_methods(std::slice::from_ref(&input.text))?; + } } Ok(true) From 71edc80f06784b090aadd033a19ed89c83f9e27c Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:15:02 +0600 Subject: [PATCH 03/12] Rename tag test --- app/tests/tag_rename.rs | 86 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 86 insertions(+) create mode 100644 app/tests/tag_rename.rs diff --git a/app/tests/tag_rename.rs b/app/tests/tag_rename.rs new file mode 100644 index 00000000..688003b7 --- /dev/null +++ b/app/tests/tag_rename.rs @@ -0,0 +1,86 @@ +use std::fs; + +use crate::common::add_tx; +use crate::common::create_test_db; + +mod common; + +#[test] +fn rename_tag_updates_name() { + let file_name = "test_tag_rename.sqlite"; + let mut db_conn = create_test_db(file_name); + + let tx = add_tx( + &mut db_conn, + "2025-06-15", + "Weekly shopping", + "Cash", + "", + "50.00", + "Expense", + "Groceries", + ); + + let tag_id = { + let tags = db_conn.get_tags_sorted(); + let groc_tag = tags.iter().find(|t| t.name == "Groceries").unwrap(); + assert_eq!(groc_tag.name, "Groceries"); + groc_tag.id + }; + + db_conn.rename_tag("Groceries", "Food").unwrap(); + + let tags = db_conn.get_tags_sorted(); + let renamed = tags.iter().find(|t| t.id == tag_id).unwrap(); + assert_eq!(renamed.name, "Food"); + + let old_exists = tags.iter().any(|t| t.name == "Groceries"); + assert!(!old_exists); + + drop(tags); + + let full_tx = db_conn.fetch_tx_with_id(tx.id).unwrap(); + let tx_tag_names: Vec<&str> = full_tx.tags.iter().map(|t| t.name.as_str()).collect(); + assert!(tx_tag_names.contains(&"Food")); + assert!(!tx_tag_names.contains(&"Groceries")); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} + +#[test] +fn rename_tag_to_existing_fails() { + let file_name = "test_tag_rename_dup.sqlite"; + let mut db_conn = create_test_db(file_name); + + add_tx( + &mut db_conn, + "2025-06-15", + "Groceries", + "Cash", + "", + "50.00", + "Expense", + "Groceries", + ); + add_tx( + &mut db_conn, + "2025-06-16", + "Food expense", + "Cash", + "", + "30.00", + "Expense", + "Food", + ); + + let result = db_conn.rename_tag("Groceries", "Food"); + assert!(result.is_err()); + + let tags = db_conn.get_tags_sorted(); + let still_groceries = tags.iter().any(|t| t.name == "Groceries"); + assert!(still_groceries); + + drop(db_conn); + fs::remove_file(file_name).unwrap(); +} From ac0b58480a1c3a7f583318746e06db9d9f156a65 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:20:12 +0600 Subject: [PATCH 04/12] remove signs of all tx cache --- app/src/conn.rs | 15 +++------------ app/src/views/summary_view.rs | 29 ++--------------------------- db/src/lib.rs | 17 +---------------- tui/src/key_checker/key_handler.rs | 4 ---- 4 files changed, 6 insertions(+), 59 deletions(-) diff --git a/app/src/conn.rs b/app/src/conn.rs index 3881486b..0b720951 100644 --- a/app/src/conn.rs +++ b/app/src/conn.rs @@ -78,7 +78,6 @@ impl DbConn { cache: Cache { tags: HashMap::new(), tx_methods: HashMap::new(), - txs: None, details: HashSet::new(), }, }; @@ -98,7 +97,6 @@ impl DbConn { cache: Cache { tags: HashMap::new(), tx_methods: HashMap::new(), - txs: None, details: HashSet::new(), }, } @@ -257,9 +255,8 @@ impl DbConn { year: &'a str, nature: FetchNature, ) -> Result { - let (summary, txs) = self - .conn - .transaction::<(SummaryView, Option>>), Error, _>(|conn| { + self.conn + .transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); let year_num = year.parse::().unwrap(); @@ -268,13 +265,7 @@ impl DbConn { let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); get_summary(date, nature, &mut db_conn) - })?; - - if let Some(txs) = txs { - self.cache.set_txs(txs); - } - - Ok(summary) + }) } pub fn get_chart_view_with_str<'a>( diff --git a/app/src/views/summary_view.rs b/app/src/views/summary_view.rs index ffc41e20..564b6de2 100644 --- a/app/src/views/summary_view.rs +++ b/app/src/views/summary_view.rs @@ -50,39 +50,14 @@ impl FullSummary { } } -type CacheTxs = HashMap>; - pub(crate) fn get_summary( date: NaiveDate, nature: FetchNature, conn: &mut impl ConnCache, -) -> Result<(SummaryView, Option)> { +) -> Result { let txs = FullTx::get_txs(date, nature, conn)?; - let mut create_map = false; - if let FetchNature::All = nature { - create_map = true; - } - - if create_map { - let mut map = HashMap::with_capacity(txs.len()); - - for tx in &txs { - let unique_value = month_year_to_unique(date.month() as i32, date.year()); - - map.entry(unique_value) - .or_insert_with(Vec::new) - .push(tx.clone()); - } - - let summary_view = SummaryView { txs, nature }; - - return Ok((summary_view, Some(map))); - } - - let summary_view = SummaryView { txs, nature }; - - Ok((summary_view, None)) + Ok(SummaryView { txs, nature }) } impl SummaryView { diff --git a/db/src/lib.rs b/db/src/lib.rs index 2b5122e1..35003592 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -6,7 +6,7 @@ use diesel::prelude::*; use diesel_migrations::{EmbeddedMigrations, MigrationHarness, embed_migrations}; use std::collections::{HashMap, HashSet}; -use crate::models::{FullTx, Tag, TxMethod}; +use crate::models::{Tag, TxMethod}; // pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../db/src/migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/migrations"); @@ -20,7 +20,6 @@ pub trait ConnCache { pub struct Cache { pub tags: HashMap, pub tx_methods: HashMap, - pub txs: Option>>, pub details: HashSet, } @@ -70,20 +69,6 @@ impl Cache { self.details.insert(details); } - // TODO: Start using cache - pub fn set_txs(&mut self, txs: HashMap>) { - self.txs = Some(txs); - } - - #[must_use] - pub fn get_txs(&self, id: i32) -> Option<&Vec> { - if let Some(txs) = &self.txs { - return txs.get(&id); - } - - None - } - #[must_use] pub fn get_methods(&self) -> Vec<&TxMethod> { let mut methods = self.tx_methods.values().collect::>(); diff --git a/tui/src/key_checker/key_handler.rs b/tui/src/key_checker/key_handler.rs index 4ffb1adc..fa7d03d7 100644 --- a/tui/src/key_checker/key_handler.rs +++ b/tui/src/key_checker/key_handler.rs @@ -358,7 +358,6 @@ impl<'a> InputKeyHandler<'a> { match status { Ok(()) => { - // TODO: Update cache? self.go_home_reset(); // We just added a new TX, select the month tab again + reload the data of balance and table widgets to get updated data *self.home_tab = HomeTab::Months; @@ -401,9 +400,6 @@ impl<'a> InputKeyHandler<'a> { let target_tx = self.home_txs.get_tx(index); self.conn.delete_tx(target_tx)?; - // INFO: maybe can reduce fetches by directly deleted from TX list? - // TODO: Update cache? - // Transaction deleted so reload the data again self.reload_home_table(false)?; self.reload_chart_data()?; From 0c4be2939e809f897ddd36fc8001e05945bbad72 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:56:20 +0600 Subject: [PATCH 05/12] Exclude unknown tag from the list --- tui/src/pages/popups/models.rs | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tui/src/pages/popups/models.rs b/tui/src/pages/popups/models.rs index ace3a587..0faa9df3 100644 --- a/tui/src/pages/popups/models.rs +++ b/tui/src/pages/popups/models.rs @@ -541,7 +541,11 @@ impl PopupType { } pub fn new_choice_tags(conn: &mut DbConn, theme: &Theme) -> Result { - let tags = conn.get_tags_sorted(); + let tags: Vec<_> = conn + .get_tags_sorted() + .into_iter() + .filter(|t| t.name != "Unknown") + .collect(); if tags.is_empty() { return Err(anyhow!( From 5c61c35cb7d19091cadb4b31f80a6d74dd346edb Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:56:27 +0600 Subject: [PATCH 06/12] Clear assert for an error --- db/src/models/balances.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/db/src/models/balances.rs b/db/src/models/balances.rs index 47dc0f8a..dcb39092 100644 --- a/db/src/models/balances.rs +++ b/db/src/models/balances.rs @@ -265,10 +265,11 @@ impl Balance { .select(Balance::as_select()) .load(db_conn.conn())?; - assert!( - (balance_list.len() == db_conn.cache().tx_methods.len()), - "Final balances are not set for all transaction methods" - ); + if balance_list.len() != db_conn.cache().tx_methods.len() { + return Err(Error::QueryBuilderError(Box::new(std::io::Error::other( + "Final balances are not set for all transaction methods", + )))); + } let balance_map = balance_list.into_iter().map(|b| (b.method_id, b)).collect(); From eaad3cffdc249836f24de60acf249509d03fe7a6 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:56:39 +0600 Subject: [PATCH 07/12] Remove unnecessary commented out var --- db/src/lib.rs | 1 - 1 file changed, 1 deletion(-) diff --git a/db/src/lib.rs b/db/src/lib.rs index 35003592..1cdd0829 100644 --- a/db/src/lib.rs +++ b/db/src/lib.rs @@ -8,7 +8,6 @@ use std::collections::{HashMap, HashSet}; use crate::models::{Tag, TxMethod}; -// pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("../db/src/migrations"); pub const MIGRATIONS: EmbeddedMigrations = embed_migrations!("src/migrations"); pub trait ConnCache { From 252789fecdbef9433a8c9617ec2a1535307eea45 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:57:14 +0600 Subject: [PATCH 08/12] Add helper functions --- app/src/utils.rs | 83 +++++++++++++++++++++++++++++------------------- 1 file changed, 50 insertions(+), 33 deletions(-) diff --git a/app/src/utils.rs b/app/src/utils.rs index bc20f105..bc8a7a1d 100644 --- a/app/src/utils.rs +++ b/app/src/utils.rs @@ -1,22 +1,40 @@ -use anyhow::Result; +use anyhow::{Result, anyhow}; +use chrono::NaiveDate; use rex_db::models::AmountNature; use rex_shared::models::{Cent, Dollar}; -pub fn month_name_to_num(name: &str) -> u32 { +pub fn split_tags(input: &str) -> Vec { + input + .split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(|s| s.to_string()) + .collect() +} + +pub fn parse_month_year(month: &str, year: &str) -> Result { + let year_num = year.parse::()?; + let month_num = month_name_to_num(month)?; + + NaiveDate::from_ymd_opt(year_num, month_num, 1) + .ok_or_else(|| anyhow!("Invalid date: {year}-{month}-01")) +} + +pub fn month_name_to_num(name: &str) -> Result { match name { - "January" => 1, - "February" => 2, - "March" => 3, - "April" => 4, - "May" => 5, - "June" => 6, - "July" => 7, - "August" => 8, - "September" => 9, - "October" => 10, - "November" => 11, - "December" => 12, - _ => panic!("Invalid month name {name}"), + "January" => Ok(1), + "February" => Ok(2), + "March" => Ok(3), + "April" => Ok(4), + "May" => Ok(5), + "June" => Ok(6), + "July" => Ok(7), + "August" => Ok(8), + "September" => Ok(9), + "October" => Ok(10), + "November" => Ok(11), + "December" => Ok(12), + _ => Err(anyhow!("Invalid month name {name}")), } } @@ -84,24 +102,23 @@ mod tests { #[test] fn test_month_name_to_num_all_months() { - assert_eq!(month_name_to_num("January"), 1); - assert_eq!(month_name_to_num("February"), 2); - assert_eq!(month_name_to_num("March"), 3); - assert_eq!(month_name_to_num("April"), 4); - assert_eq!(month_name_to_num("May"), 5); - assert_eq!(month_name_to_num("June"), 6); - assert_eq!(month_name_to_num("July"), 7); - assert_eq!(month_name_to_num("August"), 8); - assert_eq!(month_name_to_num("September"), 9); - assert_eq!(month_name_to_num("October"), 10); - assert_eq!(month_name_to_num("November"), 11); - assert_eq!(month_name_to_num("December"), 12); - } - - #[test] - #[should_panic(expected = "Invalid month name")] - fn test_month_name_to_num_invalid_panics() { - month_name_to_num("NotAMonth"); + assert_eq!(month_name_to_num("January").unwrap(), 1); + assert_eq!(month_name_to_num("February").unwrap(), 2); + assert_eq!(month_name_to_num("March").unwrap(), 3); + assert_eq!(month_name_to_num("April").unwrap(), 4); + assert_eq!(month_name_to_num("May").unwrap(), 5); + assert_eq!(month_name_to_num("June").unwrap(), 6); + assert_eq!(month_name_to_num("July").unwrap(), 7); + assert_eq!(month_name_to_num("August").unwrap(), 8); + assert_eq!(month_name_to_num("September").unwrap(), 9); + assert_eq!(month_name_to_num("October").unwrap(), 10); + assert_eq!(month_name_to_num("November").unwrap(), 11); + assert_eq!(month_name_to_num("December").unwrap(), 12); + } + + #[test] + fn test_month_name_to_num_invalid_errors() { + assert!(month_name_to_num("NotAMonth").is_err()); } #[test] From d00d49bd7a9bb6d33da02cb244774adc429b40a4 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:57:49 +0600 Subject: [PATCH 09/12] Don't accept non-existing tag when searching --- app/src/modifier/shared.rs | 17 +++++++++-------- 1 file changed, 9 insertions(+), 8 deletions(-) diff --git a/app/src/modifier/shared.rs b/app/src/modifier/shared.rs index b328f7b2..661f3199 100644 --- a/app/src/modifier/shared.rs +++ b/app/src/modifier/shared.rs @@ -217,14 +217,15 @@ pub fn parse_search_fields<'a>( let tags = if tags.is_empty() { None } else { - let tags = tags.split(',').map(str::trim).collect::>(); - let tags = tags - .iter() - .map(|t| db_conn.cache().get_tag_id(t)) - .filter_map(Result::ok) - .collect::>(); - - Some(tags) + let tag_names = tags.split(',').map(str::trim).filter(|s| !s.is_empty()); + + let mut tag_ids = Vec::new(); + + for t in tag_names { + tag_ids.push(db_conn.cache().get_tag_id(t)?); + } + + Some(tag_ids) }; let search_tx = NewSearch::new( From de4ba163ef2e2fc528cef8b836590c528103fc8c Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:58:08 +0600 Subject: [PATCH 10/12] Use helper functions --- app/src/conn.rs | 31 +++++++++---------------------- app/src/migration.rs | 14 +++----------- app/src/modifier/new_activity.rs | 29 +++++++---------------------- app/src/modifier/new_tx.rs | 14 +++----------- 4 files changed, 22 insertions(+), 66 deletions(-) diff --git a/app/src/conn.rs b/app/src/conn.rs index 0b720951..8860b6b6 100644 --- a/app/src/conn.rs +++ b/app/src/conn.rs @@ -11,7 +11,7 @@ use crate::modifier::{ activity_swap_position, add_new_tx, add_new_tx_methods, delete_tx, }; use crate::ui_helper::{Autofiller, Stepper, Verifier}; -use crate::utils::month_name_to_num; +use crate::utils::parse_month_year; use crate::views::{ ActivityView, ChartView, SearchView, SummaryView, TxViewGroup, get_activity_view, get_chart_view, get_search_txs, get_summary, get_txs, @@ -210,10 +210,7 @@ impl DbConn { let result = self.conn.transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; get_txs(date, nature, &mut db_conn) })?; @@ -255,17 +252,13 @@ impl DbConn { year: &'a str, nature: FetchNature, ) -> Result { - self.conn - .transaction::(|conn| { - let mut db_conn = MutDbConn::new(conn, &self.cache); - - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); + self.conn.transaction::(|conn| { + let mut db_conn = MutDbConn::new(conn, &self.cache); - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; - get_summary(date, nature, &mut db_conn) - }) + get_summary(date, nature, &mut db_conn) + }) } pub fn get_chart_view_with_str<'a>( @@ -277,10 +270,7 @@ impl DbConn { let result = self.conn.transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; let tx_view = get_txs(date, nature, &mut db_conn)?; @@ -300,10 +290,7 @@ impl DbConn { let result = self.conn.transaction::(|conn| { let mut db_conn = MutDbConn::new(conn, &self.cache); - let year_num = year.parse::().unwrap(); - let month_num = month_name_to_num(month); - - let date = NaiveDate::from_ymd_opt(year_num, month_num, 1).unwrap(); + let date = parse_month_year(month, year)?; let activity_view = get_activity_view(date, &mut db_conn)?; diff --git a/app/src/migration.rs b/app/src/migration.rs index d3e14881..226e6b35 100644 --- a/app/src/migration.rs +++ b/app/src/migration.rs @@ -14,6 +14,7 @@ use std::io::Write; use crate::conn::{DbConn, MutDbConn}; use crate::modifier::add_new_tx_methods; use crate::utils::parse_amount_nature_cent; +use crate::utils::split_tags; #[derive(QueryableByName)] struct ColumnInfo { @@ -283,19 +284,10 @@ fn migrate_tx( Some(db_conn.cache().get_method_id(to_method).unwrap()) }; - let mut tag_list = Vec::new(); + let mut tag_list = split_tags(tags); - if tags.is_empty() { + if tag_list.is_empty() { tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - tag_list.push(trimmed_tag.to_string()); - } - } } let new_tx = NewTx::new( diff --git a/app/src/modifier/new_activity.rs b/app/src/modifier/new_activity.rs index c8232189..3e903c12 100644 --- a/app/src/modifier/new_activity.rs +++ b/app/src/modifier/new_activity.rs @@ -4,25 +4,18 @@ use rex_db::models::{ ActivityNature, ActivityTxTag, FullTx, NewActivity, NewActivityTx, NewSearch, NewTx, Tag, }; +use crate::utils::split_tags; + pub(crate) fn activity_new_tx(tx: &NewTx, tags: &str, conn: &mut impl ConnCache) -> Result<()> { let activity_type = ActivityNature::AddTx; let new_activity = NewActivity::new(activity_type).insert(conn)?; let added_tx = NewActivityTx::new_from_new_tx(tx, new_activity.id).insert(conn)?; - let mut tag_list = Vec::new(); + let mut tag_list = split_tags(tags); - if tags.is_empty() { + if tag_list.is_empty() { tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - tag_list.push(trimmed_tag.to_string()); - } - } } let mut tx_tags = Vec::new(); @@ -80,24 +73,16 @@ pub(crate) fn activity_edit_tx( NewActivityTx::new_from_full_tx(old_tx, false, new_activity.id).insert(conn)?; let mut old_tag_list = Vec::new(); - let mut new_tag_list = Vec::new(); for tag in &old_tx.tags { let tag = ActivityTxTag::new(old_tx_activity.id, tag.id); old_tag_list.push(tag); } - if tags.is_empty() { + let mut new_tag_list = split_tags(tags); + + if new_tag_list.is_empty() { new_tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - new_tag_list.push(trimmed_tag.to_string()); - } - } } let mut new_tags = Vec::new(); diff --git a/app/src/modifier/new_tx.rs b/app/src/modifier/new_tx.rs index 7b44b391..b618a056 100644 --- a/app/src/modifier/new_tx.rs +++ b/app/src/modifier/new_tx.rs @@ -3,6 +3,7 @@ use rex_db::ConnCache; use rex_db::models::{Balance, NewTag, NewTx, Tag, Tx, TxTag, TxType}; use crate::modifier::tidy_balances; +use crate::utils::split_tags; pub(crate) fn add_new_tx( tx: NewTx, @@ -15,19 +16,10 @@ pub(crate) fn add_new_tx( let to_method = tx.to_method; let amount = tx.amount; let tx_type = tx.tx_type; - let mut tag_list = Vec::new(); + let mut tag_list = split_tags(tags); - if tags.is_empty() { + if tag_list.is_empty() { tag_list.push("Unknown".to_string()); - } else { - let split_tags = tags.split(',').collect::>(); - - for tag in split_tags { - let trimmed_tag = tag.trim(); - if !trimmed_tag.is_empty() { - tag_list.push(trimmed_tag.to_string()); - } - } } let mut current_balance = Balance::get_balance_map(date.date(), db_conn)?; From 568ef6bd6750d74690d358ca3d49caad7e428c76 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Wed, 1 Jul 2026 22:58:10 +0600 Subject: [PATCH 11/12] Update test --- app/tests/parse.rs | 13 +++---------- 1 file changed, 3 insertions(+), 10 deletions(-) diff --git a/app/tests/parse.rs b/app/tests/parse.rs index 94766b69..ac3f4ff8 100644 --- a/app/tests/parse.rs +++ b/app/tests/parse.rs @@ -81,19 +81,12 @@ fn parse_search_fields_amount_nature_variants() { } #[test] -fn parse_search_fields_nonexistent_tags_filtered() { +fn parse_search_fields_nonexistent_tags_errors() { let file_name = "test_parse_search_tags.sqlite"; let db_conn = create_test_db(file_name); - // Non-existent tags are silently skipped by parse_search_fields - let search = - parse_search_fields("", "", "", "", "", "", "FakeTag, AnotherFake", &db_conn).unwrap(); - // tags get filtered to empty because no tag exists in cache (except "Unknown") - assert!( - search.tags.is_none() || search.tags.as_ref().unwrap().is_empty(), - "Non-existent tags should be filtered out, got: {:?}", - search.tags - ); + let result = parse_search_fields("", "", "", "", "", "", "FakeTag", &db_conn); + assert!(result.is_err(), "Non-existent tags should return an error"); drop(db_conn); fs::remove_file(file_name).unwrap(); From b6e5af86e144c1aa4db8a9808e9d55f62aa48ff1 Mon Sep 17 00:00:00 2001 From: Rusty Pickle Date: Thu, 2 Jul 2026 23:35:57 +0600 Subject: [PATCH 12/12] Prevent accepting empty input --- tui/src/pages/popups/models.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/tui/src/pages/popups/models.rs b/tui/src/pages/popups/models.rs index 0faa9df3..8a60f489 100644 --- a/tui/src/pages/popups/models.rs +++ b/tui/src/pages/popups/models.rs @@ -619,6 +619,8 @@ impl PopupType { input.status = String::from("Cannot use existing method name"); } else if RESTRICTED.iter().any(|m| m == &input.text) { input.status = String::from("Cannot use this text as method name"); + } else if input.text.is_empty() { + input.status = String::from("Method name cannot be empty"); } else { input.status = String::from("All good"); } @@ -636,6 +638,11 @@ impl PopupType { return Ok(false); } + if input.text.is_empty() { + input.status = String::from("Tag name cannot be empty"); + return Ok(false); + } + conn.rename_tag(modifying, &input.text)?; } else { let tx_methods = conn.get_tx_methods_sorted(); @@ -650,6 +657,11 @@ impl PopupType { return Ok(false); } + if input.text.is_empty() { + input.status = String::from("Method name cannot be empty"); + return Ok(false); + } + if let Some(modifying) = &input.modifying_method { conn.rename_tx_method(modifying, &input.text)?; } else {