From fad2ff5d596058984c4fc9027b9713f97437d471 Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Wed, 15 Apr 2026 14:27:19 +0200 Subject: [PATCH 1/8] refactor(wip): work on report (list -s) --- src/{cli.rs => cli/args.rs} | 194 +++++++----------------------------- src/cli/mod.rs | 24 +++++ src/cli/period.rs | 122 +++++++++++++++++++++++ src/commands/delete.rs | 2 +- src/commands/edit.rs | 5 +- src/commands/get.rs | 2 +- src/commands/list.rs | 57 ++++++----- src/commands/mod.rs | 3 +- src/commands/modify.rs | 2 +- src/commands/query.rs | 6 +- src/commands/report.rs | 0 src/commands/start.rs | 2 +- src/main.rs | 6 +- src/models/boat_data.rs | 7 +- src/utils/common.rs | 77 +++++++------- src/utils/display.rs | 2 +- 16 files changed, 267 insertions(+), 244 deletions(-) rename src/{cli.rs => cli/args.rs} (52%) create mode 100644 src/cli/mod.rs create mode 100644 src/cli/period.rs create mode 100644 src/commands/report.rs diff --git a/src/cli.rs b/src/cli/args.rs similarity index 52% rename from src/cli.rs rename to src/cli/args.rs index 05b3f51..e6db4e5 100644 --- a/src/cli.rs +++ b/src/cli/args.rs @@ -1,15 +1,9 @@ use boat_lib::repository::Id; -use chrono::Datelike; -use chrono::Local; -use chrono::Months; -use chrono::NaiveDate; use clap::ColorChoice; use clap::Parser; use clap::{ArgAction, Args, Subcommand, ValueEnum}; -use std::str::FromStr; -use crate::utils; -use crate::utils::date::DateTimeRenderMode; +use crate::cli::PeriodInput; #[derive(Parser, Debug)] #[command( @@ -75,9 +69,13 @@ pub enum Commands { #[command(alias = "g")] Get(PrintActivityArgs), - /// List activities + /// List activity logs #[command(alias = "l", alias = "ls")] - List(ListActivityArgs), + List(FilterActivitiesArgs), + + /// Show activity summaries + #[command(alias = "r", alias = "report")] + Report(FilterActivitiesArgs), // /// Query boat objects // #[command(alias = "q")] @@ -108,7 +106,7 @@ pub enum Commands { pub enum QuerySubcommand { /// Manage logs #[command(name = "logs", alias = "l", alias = "log")] - Logs(ListActivityArgs), + Logs(FilterActivitiesArgs), /// Manage activities #[command( @@ -125,105 +123,49 @@ pub enum QuerySubcommand { Tags(ListArgs), } -#[derive(Debug, Clone, Copy)] -pub enum DateInput { - Single(NaiveDate), - Range { - start: NaiveDate, - end: NaiveDate, - inclusive: bool, - }, -} - -impl DateInput { - const ERR_MSG: &'static str = - "Provide either a range (YYYY-MM-DD..YYYY-MM-DD) or a single date (YYYY-MM-DD)"; -} - -impl std::fmt::Display for DateInput { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - DateInput::Single(naive_date) => { - let dt = DateTimeRenderMode::DateOnly.render_naive_date(naive_date); - write!(f, "{dt}") - } - DateInput::Range { - start, - end, - inclusive, - } => { - let start = DateTimeRenderMode::DateOnly.render_naive_date(start); - let end = DateTimeRenderMode::DateOnly.render_naive_date(end); - let inclusion_msg = (if *inclusive { "included" } else { "excluded" }).to_string(); - write!(f, "{start} to {end} ({inclusion_msg})") - } - } - } +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub enum GroupBy { + #[value(name = "none", alias = "no")] + #[default] + None, + #[value(name = "day", alias = "d")] + Day, + #[value(name = "week", alias = "wk", alias = "w")] + Week, + #[value(name = "month", alias = "mo", alias = "m")] + Month, + #[value(name = "year", alias = "y")] + Year, } -impl FromStr for DateInput { - type Err = String; - - fn from_str(s: &str) -> Result { - // Match range - if let Some((start, end)) = s.split_once("..") { - let start = utils::date::parse_date(start).map_err(|_| Self::ERR_MSG)?; - let (end, inclusive) = match end.strip_prefix('=') { - Some(substr) => (substr, true), - None => (end, false), - }; - let end = utils::date::parse_date(end).map_err(|_| Self::ERR_MSG)?; - - if start > end { - return Err("DateInput: start cannot be after end when using range".to_string()); - } - - return Ok(DateInput::Range { - start, - end, - inclusive, - }); - } - - // Single date - let date = utils::date::parse_date(s).map_err(|_| Self::ERR_MSG)?; - Ok(DateInput::Single(date)) - } +#[derive(Debug, Clone, Copy, ValueEnum, Default)] +pub enum SortBy { + #[value(name = "none", alias = "no")] + #[default] + None, } #[derive(Args, Debug)] -pub struct ListActivityArgs { - /// Restrict to entries starting in the given +pub struct FilterActivitiesArgs { + /// Restrict to entries matching a given time period #[arg( short = 'p', long = "period", - value_name = "PERIOD", - value_enum, - conflicts_with = "date_range" - )] - pub period: Option, - - /// Restrict to entries matching (YYYY-MM-DD format) - #[arg( - short = 'd', - long = "date", - value_name = "DATE_RANGE", - conflicts_with = "period" + help = "Period: day|d, week|w, month|m, year|y, , or .." )] - pub date_range: Option, + pub period: PeriodInput, - /// Show a per-activity summary instead of listing all logs - #[arg(short = 's', long = "summary", conflicts_with = "no_grouping")] - pub show_summary: bool, + /// Specify how entries should be grouped + #[arg(short = 'g', long = "group-by")] + pub group_by: bool, + // /// Specify how entries should be sorted + // #[arg(short = 's', long = "sort-by")] + // pub sort_by: SortInput, /// Show all activities, even the ones with no log #[arg(short = 'a', long = "all", conflicts_with = "no_grouping")] pub show_all: bool, - /// Do not group activity logs by date - #[arg(short = 'n', long = "no-grouping", conflicts_with_all = ["show_summary", "show_all"])] - pub no_grouping: bool, - /// Output in JSON #[arg(short = 'j', long = "json")] pub use_json_format: bool, @@ -239,59 +181,6 @@ pub struct ListArgs { pub use_json_format: bool, } -#[derive(ValueEnum, Clone, Copy, Debug, Default)] -pub enum Period { - #[value(name = "today", alias = "td", alias = "tod")] - Today, - - #[value(name = "yesterday", alias = "yd", alias = "ytd")] - Yesterday, - - #[value(name = "this-week", alias = "tw", alias = "twk", alias = "wk")] - #[default] - ThisWeek, - - #[value( - name = "last-week", - alias = "lw", - alias = "lwk", - alias = "yesterweek", - alias = "yw", - alias = "ywk" - )] - LastWeek, - - #[value(name = "this-month", alias = "tm", alias = "tmo", alias = "mo")] - ThisMonth, - - #[value( - name = "last-month", - alias = "lm", - alias = "lmo", - alias = "yestermonth", - alias = "ym", - alias = "ymo" - )] - LastMonth, -} - -impl std::fmt::Display for Period { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let now = Local::now(); - let last_month = now - Months::new(1); - - let period = match self { - Period::Today => "Today".to_string(), - Period::Yesterday => "Yesterday".to_string(), - Period::ThisWeek => "This week".to_string(), - Period::LastWeek => "Last week".to_string(), - Period::ThisMonth => format!("{} {}", now.format("%B"), now.year()), - Period::LastMonth => format!("{} {}", last_month.format("%B"), last_month.year()), - }; - write!(f, "{period}") - } -} - #[derive(Args, Debug, Default)] #[group(multiple = false)] pub struct PrintActivityArgs { @@ -363,16 +252,7 @@ pub struct EditLogsArgs { value_enum, conflicts_with = "date_range" )] - pub period: Option, - - /// Restrict to entries matching (YYYY-MM-DD format) - #[arg( - short = 'd', - long = "date", - value_name = "DATE_RANGE", - conflicts_with = "period" - )] - pub date_range: Option, + pub period: PeriodInput, /// Do not include instruction comments in the editable file #[arg(short = 'n', long = "no-instructions")] diff --git a/src/cli/mod.rs b/src/cli/mod.rs new file mode 100644 index 0000000..e8dd970 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,24 @@ +pub mod args; +pub mod period; + +pub use self::args::Cli; +pub use self::args::Commands; +pub use self::args::CreateActivityArgs; +pub use self::args::EditLogsArgs; +pub use self::args::FilterActivitiesArgs; +pub use self::args::ListArgs; +pub use self::args::ModifyActivityArgs; +pub use self::args::PrintActivityArgs; +pub use self::args::SelectActivityArgs; +pub use self::period::PeriodInput; +pub use self::period::PresetPeriod; + +// pub use self::cancel::cancel_current; +// pub use self::create::create; +// pub use self::delete::delete; +// pub use self::edit::edit; +// pub use self::get::get_current; +// pub use self::list::list_activities; +// pub use self::modify::modify; +// pub use self::pause::pause_current; +// pub use self::start::start; diff --git a/src/cli/period.rs b/src/cli/period.rs new file mode 100644 index 0000000..bba0682 --- /dev/null +++ b/src/cli/period.rs @@ -0,0 +1,122 @@ +use chrono::{Datelike, Local, Months, NaiveDate}; +use std::str::FromStr; + +use crate::utils::{self, date::DateTimeRenderMode}; + +#[derive(Debug, Clone, Copy)] +pub enum PeriodInput { + Preset(PresetPeriod), + Single(NaiveDate), + Range { + start: NaiveDate, + end: NaiveDate, + inclusive: bool, + }, +} + +impl PeriodInput { + const ERR_MSG: &'static str = + "Provide either a range (YYYY-MM-DD..YYYY-MM-DD) or a single date (YYYY-MM-DD)"; +} + +impl FromStr for PeriodInput { + type Err = String; + + fn from_str(s: &str) -> Result { + let s = s.to_lowercase(); + // Handle presets + match s.as_str() { + "today" | "td" | "tod" => return Ok(PeriodInput::Preset(PresetPeriod::Today)), + "yesterday" | "yd" | "ytd" => return Ok(PeriodInput::Preset(PresetPeriod::Yesterday)), + "this-week" | "tw" | "twk" | "wk" => { + return Ok(PeriodInput::Preset(PresetPeriod::ThisWeek)); + } + "last-week" | "lw" | "lwk" | "yesterweek" | "yw" | "ywk" => { + return Ok(PeriodInput::Preset(PresetPeriod::LastWeek)); + } + "this-month" | "tm" | "tmo" | "mo" => { + return Ok(PeriodInput::Preset(PresetPeriod::ThisMonth)); + } + "last-month" | "lm" | "lmo" | "yestermonth" | "ym" | "ymo" => { + return Ok(PeriodInput::Preset(PresetPeriod::LastMonth)); + } + _ => {} + } + + // Match range + if let Some((start, end)) = s.split_once("..") { + let start = utils::date::parse_date(start).map_err(|_| Self::ERR_MSG)?; + let (end, inclusive) = match end.strip_prefix('=') { + Some(substr) => (substr, true), + None => (end, false), + }; + let end = utils::date::parse_date(end).map_err(|_| Self::ERR_MSG)?; + + if start > end { + return Err("DateInput: start cannot be after end when using range".to_string()); + } + + return Ok(PeriodInput::Range { + start, + end, + inclusive, + }); + } + + // Single date + let date = utils::date::parse_date(&s).map_err(|_| Self::ERR_MSG)?; + Ok(PeriodInput::Single(date)) + } +} + +impl std::fmt::Display for PeriodInput { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + PeriodInput::Preset(preset_period) => write!(f, "{}", preset_period), + PeriodInput::Single(naive_date) => { + let dt = DateTimeRenderMode::DateOnly.render_naive_date(naive_date); + write!(f, "{dt}") + } + PeriodInput::Range { + start, + end, + inclusive, + } => { + let start = DateTimeRenderMode::DateOnly.render_naive_date(start); + let end = DateTimeRenderMode::DateOnly.render_naive_date(end); + let inclusion_msg = (if *inclusive { "included" } else { "excluded" }).to_string(); + write!(f, "{start} to {end} ({inclusion_msg})") + } + } + } +} + +#[derive(Clone, Copy, Debug, Default)] +pub enum PresetPeriod { + Today, + Yesterday, + ThisWeek, + LastWeek, + ThisMonth, + LastMonth, + #[default] + AllTime, +} + +impl std::fmt::Display for PresetPeriod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let now = Local::now(); + let last_month = now - Months::new(1); + + let period = match self { + PresetPeriod::Today => "Today".to_string(), + PresetPeriod::Yesterday => "Yesterday".to_string(), + PresetPeriod::ThisWeek => "This week".to_string(), + PresetPeriod::LastWeek => "Last week".to_string(), + PresetPeriod::ThisMonth => format!("{} {}", now.format("%B"), now.year()), + PresetPeriod::LastMonth => format!("{} {}", last_month.format("%B"), last_month.year()), + PresetPeriod::AllTime => "All time".to_string(), + }; + write!(f, "{period}") + } +} diff --git a/src/commands/delete.rs b/src/commands/delete.rs index bda1a7d..32c3f27 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -8,7 +8,7 @@ use crate::{cli, utils}; pub fn delete(conn: &mut Connection, args: &cli::SelectActivityArgs) -> Result<()> { let Ok(to_delete) = activities::get_by_id(conn, args.activity_id) else { info!("cannot delete because ID is invalid: {}", args.activity_id); - bail!(utils::display::invaid_activity_id(args.activity_id)); + bail!(utils::display::invalid_activity_id(args.activity_id)); }; info!("deleting activity: {to_delete:?}"); diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 41ce8d1..e5aa08f 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -14,12 +14,11 @@ use yansi::Paint; use crate::{cli::EditLogsArgs, models::boat_data::BoatData}; pub fn edit(conn: &mut rusqlite::Connection, args: &EditLogsArgs) -> Result<()> { - let date_input_opt = args.date_range; - let period_opt = args.period; + let period = args.period; let include_instructions = !args.hide_instructions; let all_acts = activities::get_all(conn)?; - let boat_data = BoatData::create_filtered_data(all_acts, date_input_opt, period_opt); + let boat_data = BoatData::create_filtered_data(all_acts, period); let default_content = boat_data.to_csv_str(include_instructions); let edit_file_path = create_tmp_edit_file(&default_content)?; diff --git a/src/commands/get.rs b/src/commands/get.rs index 41419e2..944bb60 100644 --- a/src/commands/get.rs +++ b/src/commands/get.rs @@ -9,7 +9,7 @@ use crate::{ utils, }; -pub fn get_current(conn: &mut Connection, args: &cli::PrintActivityArgs) -> Result<()> { +pub fn get_current(conn: &Connection, args: &cli::PrintActivityArgs) -> Result<()> { match activities::get_current_ongoing(conn)? { Some(current) => { info!("got current activity: {current:?}"); diff --git a/src/commands/list.rs b/src/commands/list.rs index 1e40918..3b76cdf 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -12,30 +12,25 @@ use crate::{ utils::{self, date::DateTimeRenderMode}, }; -pub fn list_activities(conn: &mut Connection, args: &cli::ListActivityArgs) -> Result<()> { - info!("getting all activities"); - let db_acts: Vec<_> = activities::get_all(conn)?; - let boat_data = BoatData::create_filtered_data(db_acts, args.date_range, args.period); - - if args.show_summary { - info!("showing summary"); - if !args.use_json_format { - info!("using JSON format for summary"); - let date_msg = match args.date_range { - Some(dt_range) => Some(dt_range.to_string()), - None => args.period.map(|p| p.to_string()), - }; - - if let Some(date_msg) = date_msg { - info!("using custom date msg for summary"); - println!("{} {}\n", "Summary:".underline(), date_msg.green()); - } - } - - return list_activity_summaries(&boat_data, args.show_all, args.use_json_format); - } - - list_activity_logs(&boat_data, args) +pub fn show_report() -> Result<()> { + todo!() + // if args.show_summary { + // info!("showing summary"); + // if !args.use_json_format { + // info!("using JSON format for summary"); + // let date_msg = match args.date_range { + // Some(dt_range) => Some(dt_range.to_string()), + // None => args.period.map(|p| p.to_string()), + // }; + // + // if let Some(date_msg) = date_msg { + // info!("using custom date msg for summary"); + // println!("{} {}\n", "Summary:".underline(), date_msg.green()); + // } + // } + // + // return list_activity_summaries(&boat_data, args.show_all, args.use_json_format); + // } } fn list_activity_summaries(boat_data: &BoatData, show_all: bool, use_json: bool) -> Result<()> { @@ -60,14 +55,18 @@ fn list_activity_summaries(boat_data: &BoatData, show_all: bool, use_json: bool) Ok(()) } -fn list_activity_logs(boat_data: &BoatData, args: &cli::ListActivityArgs) -> Result<()> { +pub fn list_activity_logs(conn: &Connection, args: &cli::FilterActivitiesArgs) -> Result<()> { + info!("getting all activities"); + let db_acts: Vec<_> = activities::get_all(conn)?; + let boat_data = BoatData::create_filtered_data(db_acts, args.period); + info!("listing individual activity logs"); let prt_logs = boat_data.get_printable_logs(); - if args.no_grouping { - info!("activity logs will not be grouped by date"); - return utils::common::list_printable_items(&prt_logs, args.use_json_format); - } + // if args.no_grouping { + // info!("activity logs will not be grouped by date"); + // return utils::common::list_printable_items(&prt_logs, args.use_json_format); + // } let act_logs_by_date = group_by_date(&prt_logs); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 55b2982..b946c82 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -7,6 +7,7 @@ pub mod list; pub mod modify; pub mod pause; pub mod query; +pub mod report; pub mod start; pub use self::cancel::cancel_current; @@ -14,7 +15,7 @@ pub use self::create::create; pub use self::delete::delete; pub use self::edit::edit; pub use self::get::get_current; -pub use self::list::list_activities; +pub use self::list::list_activity_logs; pub use self::modify::modify; pub use self::pause::pause_current; pub use self::start::start; diff --git a/src/commands/modify.rs b/src/commands/modify.rs index f3849db..a2001cd 100644 --- a/src/commands/modify.rs +++ b/src/commands/modify.rs @@ -8,7 +8,7 @@ use crate::{cli, utils}; pub fn modify(conn: &mut Connection, args: &cli::ModifyActivityArgs) -> Result<()> { let Ok(old) = activities::get_by_id(conn, args.id) else { info!("cannot modify because ID is invalid: {}", args.id); - bail!(utils::display::invaid_activity_id(args.id)); + bail!(utils::display::invalid_activity_id(args.id)); }; info!("about to modify activity: {old:?}"); diff --git a/src/commands/query.rs b/src/commands/query.rs index 812f571..6dfb76c 100644 --- a/src/commands/query.rs +++ b/src/commands/query.rs @@ -6,9 +6,9 @@ use rusqlite::Connection; use crate::{cli, models::tag::PrintableTag, utils}; -pub fn query_subcommand(conn: &mut Connection, command: &cli::QuerySubcommand) -> Result<()> { - todo!() -} +// pub fn query_subcommand(conn: &mut Connection, command: &cli::QuerySubcommand) -> Result<()> { +// todo!() +// } fn list_tags(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> { let mut all_tags: Vec<_> = tags::get_all(conn)? diff --git a/src/commands/report.rs b/src/commands/report.rs new file mode 100644 index 0000000..e69de29 diff --git a/src/commands/start.rs b/src/commands/start.rs index f4dceed..d48a710 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -10,7 +10,7 @@ use crate::{cli, commands::pause_current, utils}; pub fn start(conn: &mut Connection, args: &cli::SelectActivityArgs) -> Result<()> { let Ok(to_start) = activities::get_by_id(conn, args.activity_id) else { info!("cannot start because ID is invalid: {}", args.activity_id); - bail!(utils::display::invaid_activity_id(args.activity_id)); + bail!(utils::display::invalid_activity_id(args.activity_id)); }; if let Some(current) = activities::get_current_ongoing(conn)? { diff --git a/src/main.rs b/src/main.rs index fe684e1..d7f30ca 100644 --- a/src/main.rs +++ b/src/main.rs @@ -51,9 +51,9 @@ fn process_args(args: Cli) -> Result<()> { cli::Commands::Modify(args) => commands::modify(&mut conn, args), cli::Commands::Edit(args) => commands::edit(&mut conn, args), cli::Commands::Delete(args) => commands::delete(&mut conn, args), - cli::Commands::Get(args) => commands::get_current(&mut conn, args), - // cli::Commands::Query { command } => commands::query::query_subcommand(&mut conn, command), - cli::Commands::List(args) => commands::list_activities(&mut conn, args), + cli::Commands::Get(args) => commands::get_current(&conn, args), + cli::Commands::List(args) => commands::list_activity_logs(&conn, args), + cli::Commands::Report(list_activity_args) => todo!(), cli::Commands::HelpExtension => print_help(), } } diff --git a/src/models/boat_data.rs b/src/models/boat_data.rs index e715bab..2ad47a5 100644 --- a/src/models/boat_data.rs +++ b/src/models/boat_data.rs @@ -22,8 +22,7 @@ pub struct BoatData { impl BoatData { pub fn create_filtered_data( db_activities: Vec, - date_input_opt: Option, - period_opt: Option, + period_input: cli::PeriodInput, ) -> Self { info!("creating filtered boat data"); let activities = db_activities @@ -38,9 +37,7 @@ impl BoatData { let filtered_logs = db_act .logs .into_iter() - .filter(|log| { - utils::common::matches_date_filter(log, date_input_opt, period_opt) - }) + .filter(|log| utils::common::matches_period_filter(log, &period_input)) .collect(); (db_act.id, filtered_logs) }) diff --git a/src/utils/common.rs b/src/utils/common.rs index f253abd..b5fddb5 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -6,7 +6,7 @@ use serde::Serialize; use std::collections::HashSet; use crate::{ - cli::{DateInput, Period}, + cli::{PeriodInput, PresetPeriod}, models::{RowPrintable, TablePrintable}, utils, }; @@ -37,56 +37,57 @@ pub fn tags_str(tags: &HashSet) -> String { tags.join(",") } -pub fn matches_date_filter( - log: &DatabaseLog, - date_input_opt: Option, - period_opt: Option, -) -> bool { - if let Some(date_range) = date_input_opt { - matches_date_range(log, &date_range) - } else if let Some(period) = period_opt { - matches_period(log, &period) - } else { - debug!("no period / date filter provided, retaining activity log"); - true +pub fn matches_period_filter(log: &DatabaseLog, period_input: &PeriodInput) -> bool { + match period_input { + PeriodInput::Preset(preset_period) => matches_period(log, preset_period), + PeriodInput::Single(date) => matches_date(log, date), + PeriodInput::Range { + start, + end, + inclusive, + } => matches_date_range(log, start, end, *inclusive), } } -pub fn matches_period(log: &DatabaseLog, period: &Period) -> bool { +pub fn matches_period(log: &DatabaseLog, period: &PresetPeriod) -> bool { debug!("checking if {log:?} matches the given period: {period:?}"); match period { - Period::Today => utils::date::is_today(log.starts_at), - Period::Yesterday => utils::date::is_yesterday(log.starts_at), - Period::ThisWeek => utils::date::is_this_week(log.starts_at), - Period::LastWeek => utils::date::is_last_week(log.starts_at), - Period::ThisMonth => utils::date::is_this_month(log.starts_at), - Period::LastMonth => utils::date::is_last_month(log.starts_at), + PresetPeriod::Today => utils::date::is_today(log.starts_at), + PresetPeriod::Yesterday => utils::date::is_yesterday(log.starts_at), + PresetPeriod::ThisWeek => utils::date::is_this_week(log.starts_at), + PresetPeriod::LastWeek => utils::date::is_last_week(log.starts_at), + PresetPeriod::ThisMonth => utils::date::is_this_month(log.starts_at), + PresetPeriod::LastMonth => utils::date::is_last_month(log.starts_at), + PresetPeriod::AllTime => todo!(), } } -pub fn matches_date_range(log: &DatabaseLog, date_range: &DateInput) -> bool { - debug!("checking if {log:?} matches the given date_range: {date_range:?}"); +pub fn matches_date(log: &DatabaseLog, date: &NaiveDate) -> bool { + log.starts_at.date_naive() == *date + && log.ends_at.unwrap_or(Local::now().into()).date_naive() == *date +} + +pub fn matches_date_range( + log: &DatabaseLog, + range_start: &NaiveDate, + range_end: &NaiveDate, + inclusive: bool, +) -> bool { + debug!( + "checking if {log:?} matches the given date_range: {range_start:?}, {range_end:?}, {inclusive}" + ); let log_start = log.starts_at.date_naive(); let log_end = log.ends_at.unwrap_or(Local::now().into()).date_naive(); - match date_range { - DateInput::Single(naive_date) => log_start == *naive_date && log_end == *naive_date, - DateInput::Range { - start, - end, - inclusive, - } => { - let log_ends_before_range_end = if *inclusive { - log_end <= *end - } else { - log_end < *end - }; - - log_start >= *start && log_ends_before_range_end - } - } + let log_ends_before_range_end = if inclusive { + log_end <= *range_end + } else { + log_end < *range_end + }; + + log_start >= *range_start && log_ends_before_range_end } pub fn get_date_info_msg(today: NaiveDate, compare_to: NaiveDate) -> String { diff --git a/src/utils/display.rs b/src/utils/display.rs index 830afcc..9cb81b2 100644 --- a/src/utils/display.rs +++ b/src/utils/display.rs @@ -51,7 +51,7 @@ pub fn started_activity_msg(activity: &DatabaseActivity, start_dt: DateTime String { +pub fn invalid_activity_id(id: Id) -> String { format!("#{id} does not exist").red().to_string() } From 9dd755d805d1be4be679e5670f8cb753c8fdbadd Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Thu, 16 Apr 2026 11:32:12 +0200 Subject: [PATCH 2/8] refactor(wip): work on config refactor with more customizations support --- src/cli/args.rs | 7 +++-- src/cli/period.rs | 39 ++++++++++++++++++----- src/commands/cancel.rs | 4 +-- src/commands/create.rs | 7 ++++- src/commands/delete.rs | 8 +++-- src/commands/edit.rs | 8 +++-- src/commands/get.rs | 7 ++++- src/commands/list.rs | 59 +++++++---------------------------- src/commands/mod.rs | 1 + src/commands/modify.rs | 8 +++-- src/commands/pause.rs | 4 +-- src/commands/report.rs | 71 ++++++++++++++++++++++++++++++++++++++++++ src/commands/start.rs | 10 ++++-- src/config.rs | 62 +++++++++++++++++++++++++++++++++++- src/main.rs | 22 ++++++------- src/utils/common.rs | 2 +- 16 files changed, 234 insertions(+), 85 deletions(-) diff --git a/src/cli/args.rs b/src/cli/args.rs index e6db4e5..313ac09 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -4,6 +4,7 @@ use clap::Parser; use clap::{ArgAction, Args, Subcommand, ValueEnum}; use crate::cli::PeriodInput; +use crate::cli::PresetPeriod; #[derive(Parser, Debug)] #[command( @@ -74,7 +75,7 @@ pub enum Commands { List(FilterActivitiesArgs), /// Show activity summaries - #[command(alias = "r", alias = "report")] + #[command(alias = "r", alias = "rep")] Report(FilterActivitiesArgs), // /// Query boat objects @@ -153,7 +154,7 @@ pub struct FilterActivitiesArgs { long = "period", help = "Period: day|d, week|w, month|m, year|y, , or .." )] - pub period: PeriodInput, + pub period: Option, /// Specify how entries should be grouped #[arg(short = 'g', long = "group-by")] @@ -163,7 +164,7 @@ pub struct FilterActivitiesArgs { // #[arg(short = 's', long = "sort-by")] // pub sort_by: SortInput, /// Show all activities, even the ones with no log - #[arg(short = 'a', long = "all", conflicts_with = "no_grouping")] + #[arg(short = 'a', long = "all")] pub show_all: bool, /// Output in JSON diff --git a/src/cli/period.rs b/src/cli/period.rs index bba0682..909c9b3 100644 --- a/src/cli/period.rs +++ b/src/cli/period.rs @@ -1,9 +1,11 @@ use chrono::{Datelike, Local, Months, NaiveDate}; -use std::str::FromStr; +use log::debug; +use serde::{Deserialize, Serialize}; +use std::{fmt::Display, str::FromStr}; use crate::utils::{self, date::DateTimeRenderMode}; -#[derive(Debug, Clone, Copy)] +#[derive(Debug, Clone, Copy, Deserialize, Serialize)] pub enum PeriodInput { Preset(PresetPeriod), Single(NaiveDate), @@ -14,6 +16,12 @@ pub enum PeriodInput { }, } +impl Default for PeriodInput { + fn default() -> Self { + Self::Preset(PresetPeriod::AllTime) + } +} + impl PeriodInput { const ERR_MSG: &'static str = "Provide either a range (YYYY-MM-DD..YYYY-MM-DD) or a single date (YYYY-MM-DD)"; @@ -23,6 +31,7 @@ impl FromStr for PeriodInput { type Err = String; fn from_str(s: &str) -> Result { + debug!("period input from: {s}"); let s = s.to_lowercase(); // Handle presets match s.as_str() { @@ -40,6 +49,7 @@ impl FromStr for PeriodInput { "last-month" | "lm" | "lmo" | "yestermonth" | "ym" | "ymo" => { return Ok(PeriodInput::Preset(PresetPeriod::LastMonth)); } + "all-time" | "all" => return Ok(PeriodInput::Preset(PresetPeriod::AllTime)), _ => {} } @@ -91,7 +101,7 @@ impl std::fmt::Display for PeriodInput { } } -#[derive(Clone, Copy, Debug, Default)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] pub enum PresetPeriod { Today, Yesterday, @@ -103,12 +113,28 @@ pub enum PresetPeriod { AllTime, } -impl std::fmt::Display for PresetPeriod { +impl Display for PresetPeriod { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = (match self { + PresetPeriod::Today => "today", + PresetPeriod::Yesterday => "yesterday", + PresetPeriod::ThisWeek => "this-week", + PresetPeriod::LastWeek => "last-week", + PresetPeriod::ThisMonth => "this-month", + PresetPeriod::LastMonth => "last-month", + PresetPeriod::AllTime => "all-time", + }) + .to_string(); + write!(f, "{str}") + } +} + +impl PresetPeriod { + pub fn display_pretty(&self) -> String { let now = Local::now(); let last_month = now - Months::new(1); - let period = match self { + match self { PresetPeriod::Today => "Today".to_string(), PresetPeriod::Yesterday => "Yesterday".to_string(), PresetPeriod::ThisWeek => "This week".to_string(), @@ -116,7 +142,6 @@ impl std::fmt::Display for PresetPeriod { PresetPeriod::ThisMonth => format!("{} {}", now.format("%B"), now.year()), PresetPeriod::LastMonth => format!("{} {}", last_month.format("%B"), last_month.year()), PresetPeriod::AllTime => "All time".to_string(), - }; - write!(f, "{period}") + } } } diff --git a/src/commands/cancel.rs b/src/commands/cancel.rs index d0731b1..37d2b1e 100644 --- a/src/commands/cancel.rs +++ b/src/commands/cancel.rs @@ -3,9 +3,9 @@ use boat_lib::repository::activities_repository as activities; use log::info; use rusqlite::Connection; -use crate::utils; +use crate::{config::Configuration, utils}; -pub fn cancel_current(conn: &mut Connection) -> Result<()> { +pub fn cancel_current(config: &Configuration, conn: &mut Connection) -> Result<()> { match activities::get_current_ongoing(conn)? { Some(current) => { info!("cancelling current activity: {current:?}"); diff --git a/src/commands/create.rs b/src/commands/create.rs index c6e8901..674efe3 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -6,11 +6,16 @@ use rusqlite::Connection; use crate::{ cli, + config::Configuration, models::activity::{PrintableActivity, SimpleActivity}, utils, }; -pub fn create(conn: &mut Connection, args: &cli::CreateActivityArgs) -> Result<()> { +pub fn create( + config: &Configuration, + conn: &mut Connection, + args: &cli::CreateActivityArgs, +) -> Result<()> { let new = NewActivity { name: args.name.clone(), description: args.description.clone(), diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 32c3f27..971be8a 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -3,9 +3,13 @@ use boat_lib::repository::activities_repository as activities; use log::info; use rusqlite::Connection; -use crate::{cli, utils}; +use crate::{cli, config::Configuration, utils}; -pub fn delete(conn: &mut Connection, args: &cli::SelectActivityArgs) -> Result<()> { +pub fn delete( + config: &Configuration, + conn: &mut Connection, + args: &cli::SelectActivityArgs, +) -> Result<()> { let Ok(to_delete) = activities::get_by_id(conn, args.activity_id) else { info!("cannot delete because ID is invalid: {}", args.activity_id); bail!(utils::display::invalid_activity_id(args.activity_id)); diff --git a/src/commands/edit.rs b/src/commands/edit.rs index e5aa08f..356a37a 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -11,9 +11,13 @@ use rusqlite::Connection; use std::{env, fs, path::PathBuf, process::Command}; use yansi::Paint; -use crate::{cli::EditLogsArgs, models::boat_data::BoatData}; +use crate::{cli::EditLogsArgs, config::Configuration, models::boat_data::BoatData}; -pub fn edit(conn: &mut rusqlite::Connection, args: &EditLogsArgs) -> Result<()> { +pub fn edit( + config: &Configuration, + conn: &mut rusqlite::Connection, + args: &EditLogsArgs, +) -> Result<()> { let period = args.period; let include_instructions = !args.hide_instructions; diff --git a/src/commands/get.rs b/src/commands/get.rs index 944bb60..011a56a 100644 --- a/src/commands/get.rs +++ b/src/commands/get.rs @@ -5,11 +5,16 @@ use rusqlite::Connection; use crate::{ cli, + config::Configuration, models::{activity::SimpleActivity, activity_log::PrintableActivityLog}, utils, }; -pub fn get_current(conn: &Connection, args: &cli::PrintActivityArgs) -> Result<()> { +pub fn get_current( + config: &Configuration, + conn: &Connection, + args: &cli::PrintActivityArgs, +) -> Result<()> { match activities::get_current_ongoing(conn)? { Some(current) => { info!("got current activity: {current:?}"); diff --git a/src/commands/list.rs b/src/commands/list.rs index 3b76cdf..f805860 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -4,61 +4,26 @@ use chrono::{Local, NaiveDate}; use log::info; use rusqlite::Connection; use std::collections::BTreeMap; -use yansi::Paint; use crate::{ - cli::{self}, + cli::{self, PeriodInput}, + config::Configuration, models::{activity_log::PrintableActivityLog, boat_data::BoatData}, utils::{self, date::DateTimeRenderMode}, }; -pub fn show_report() -> Result<()> { - todo!() - // if args.show_summary { - // info!("showing summary"); - // if !args.use_json_format { - // info!("using JSON format for summary"); - // let date_msg = match args.date_range { - // Some(dt_range) => Some(dt_range.to_string()), - // None => args.period.map(|p| p.to_string()), - // }; - // - // if let Some(date_msg) = date_msg { - // info!("using custom date msg for summary"); - // println!("{} {}\n", "Summary:".underline(), date_msg.green()); - // } - // } - // - // return list_activity_summaries(&boat_data, args.show_all, args.use_json_format); - // } -} - -fn list_activity_summaries(boat_data: &BoatData, show_all: bool, use_json: bool) -> Result<()> { - info!("listing activity summaries (show_all: {show_all})"); - let prt_acts = boat_data - .get_printable_activities() - .into_iter() - .filter(|act| show_all || act.duration > 0) - .collect(); - - utils::common::list_printable_items(&prt_acts, use_json)?; - - if !use_json && !prt_acts.is_empty() { - let total_sec: i64 = prt_acts.iter().map(|pa| pa.duration).sum(); - println!( - "{} {}", - "Total:".underline(), - utils::date::pretty_format_duration(total_sec, false).green() - ); - } - - Ok(()) -} - -pub fn list_activity_logs(conn: &Connection, args: &cli::FilterActivitiesArgs) -> Result<()> { +pub fn list_activity_logs( + config: &Configuration, + conn: &Connection, + args: &cli::FilterActivitiesArgs, +) -> Result<()> { info!("getting all activities"); + let period = args + .period + .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)); + let db_acts: Vec<_> = activities::get_all(conn)?; - let boat_data = BoatData::create_filtered_data(db_acts, args.period); + let boat_data = BoatData::create_filtered_data(db_acts, period); info!("listing individual activity logs"); let prt_logs = boat_data.get_printable_logs(); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index b946c82..67fb931 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -18,4 +18,5 @@ pub use self::get::get_current; pub use self::list::list_activity_logs; pub use self::modify::modify; pub use self::pause::pause_current; +pub use self::report::show_report; pub use self::start::start; diff --git a/src/commands/modify.rs b/src/commands/modify.rs index a2001cd..830ccd5 100644 --- a/src/commands/modify.rs +++ b/src/commands/modify.rs @@ -3,9 +3,13 @@ use boat_lib::repository::activities_repository as activities; use log::info; use rusqlite::Connection; -use crate::{cli, utils}; +use crate::{cli, config::Configuration, utils}; -pub fn modify(conn: &mut Connection, args: &cli::ModifyActivityArgs) -> Result<()> { +pub fn modify( + config: &Configuration, + conn: &mut Connection, + args: &cli::ModifyActivityArgs, +) -> Result<()> { let Ok(old) = activities::get_by_id(conn, args.id) else { info!("cannot modify because ID is invalid: {}", args.id); bail!(utils::display::invalid_activity_id(args.id)); diff --git a/src/commands/pause.rs b/src/commands/pause.rs index d9059d0..1e418f2 100644 --- a/src/commands/pause.rs +++ b/src/commands/pause.rs @@ -4,9 +4,9 @@ use chrono::Local; use log::info; use rusqlite::Connection; -use crate::utils; +use crate::{config::Configuration, utils}; -pub fn pause_current(conn: &mut Connection) -> Result<()> { +pub fn pause_current(config: &Configuration, conn: &mut Connection) -> Result<()> { match activities::get_current_ongoing(conn)? { Some(current) => { info!("ongoing activity: {current:?}"); diff --git a/src/commands/report.rs b/src/commands/report.rs index e69de29..c822985 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -0,0 +1,71 @@ +use anyhow::Result; +use boat_lib::repository::activities_repository as activities; +use log::info; +use rusqlite::Connection; +use yansi::Paint; + +use crate::{ + cli::{self, PeriodInput}, + config::Configuration, + models::boat_data::BoatData, + utils, +}; + +pub fn show_report( + config: &Configuration, + conn: &Connection, + args: &cli::FilterActivitiesArgs, +) -> Result<()> { + info!("getting all activities"); + let period = args.period.unwrap_or( + config + .period + .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)), + ); + info!("using period: {period}"); + + let db_acts: Vec<_> = activities::get_all(conn)?; + let boat_data = BoatData::create_filtered_data(db_acts, period); + + info!("listing individual activity logs"); + + info!("showing summary"); + if !args.use_json_format { + // let date_msg = match args.period { + // Some(dt_range) => Some(dt_range.to_string()), + // None => args.period.map(|p| p.to_string()), + // cli::PeriodInput::Preset(preset_period) => todo!(), + // cli::PeriodInput::Single(naive_date) => todo!(), + // cli::PeriodInput::Range(range) => range.to_string(), + // }; + + // if let Some(date_msg) = date_msg { + // info!("using custom date msg for summary"); + // println!("{} {}\n", "Summary:".underline(), date_msg.green()); + // } + } + + list_activity_summaries(&boat_data, args.show_all, args.use_json_format) +} + +fn list_activity_summaries(boat_data: &BoatData, show_all: bool, use_json: bool) -> Result<()> { + info!("listing activity summaries (show_all: {show_all})"); + let prt_acts = boat_data + .get_printable_activities() + .into_iter() + .filter(|act| show_all || act.duration > 0) + .collect(); + + utils::common::list_printable_items(&prt_acts, use_json)?; + + if !use_json && !prt_acts.is_empty() { + let total_sec: i64 = prt_acts.iter().map(|pa| pa.duration).sum(); + println!( + "{} {}", + "Total:".underline(), + utils::date::pretty_format_duration(total_sec, false).green() + ); + } + + Ok(()) +} diff --git a/src/commands/start.rs b/src/commands/start.rs index d48a710..0084a67 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -5,9 +5,13 @@ use log::info; use rusqlite::Connection; use yansi::Paint; -use crate::{cli, commands::pause_current, utils}; +use crate::{cli, commands::pause_current, config::Configuration, utils}; -pub fn start(conn: &mut Connection, args: &cli::SelectActivityArgs) -> Result<()> { +pub fn start( + config: &Configuration, + conn: &mut Connection, + args: &cli::SelectActivityArgs, +) -> Result<()> { let Ok(to_start) = activities::get_by_id(conn, args.activity_id) else { info!("cannot start because ID is invalid: {}", args.activity_id); bail!(utils::display::invalid_activity_id(args.activity_id)); @@ -23,7 +27,7 @@ pub fn start(conn: &mut Connection, args: &cli::SelectActivityArgs) -> Result<() } info!("pausing current..."); - pause_current(conn)?; + pause_current(config, conn)?; } info!("about to start: {to_start:?}"); diff --git a/src/config.rs b/src/config.rs index 17723bb..f5f87ef 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,15 +4,71 @@ use log::{debug, info}; use serde::{Deserialize, Serialize}; use std::{env, fs, path::PathBuf}; +use crate::cli::PeriodInput; + pub const APP_NAME: &str = "boat"; pub const CONFIG_VAR: &str = "BOAT_CONFIG"; pub const DEFAULT_CONFIG_PATH: &str = "config.toml"; pub const DEFAULT_DB_FILE: &str = "boat.db"; +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct NewCommandConfig { + #[serde(rename = "auto-start")] + pub auto_start: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CancelCommandConfig { + pub confirm: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct EditCommandConfig { + pub period: Option, + #[serde(rename = "hide-instructions")] + pub hide_instructions: Option, + #[serde(rename = "hide-activity-definitions")] + pub hide_activity_definitions: Option, + pub editor: Option, + pub confirm: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ListCommandConfig { + pub period: Option, + pub sort: Option, + #[serde(rename = "group-by")] + pub group_by: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ReportCommandConfig { + pub period: Option, + pub sort: Option, + #[serde(rename = "group-by")] + pub group_by: Option, + pub format: Option, +} + +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct CommandsConfig { + pub new: Option, + pub cancel: Option, + pub list: Option, + pub report: Option, + pub edit: Option, +} + #[derive(Debug, Serialize, Deserialize)] pub struct Configuration { #[serde(rename = "database_path")] pub database_path: PathBuf, + + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + + #[serde(skip_serializing_if = "Option::is_none")] + pub commands: Option, } impl Configuration { @@ -23,7 +79,11 @@ impl Configuration { .context("config file should have a parent directory")?; let database_path = config_dir.join(DEFAULT_DB_FILE); - Ok(Self { database_path }) + Ok(Self { + database_path, + period: None, + commands: None, + }) } pub fn load_from_fs() -> Result { diff --git a/src/main.rs b/src/main.rs index d7f30ca..bfbed1b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -41,19 +41,19 @@ fn process_args(args: Cli) -> Result<()> { info!("loading config"); let config = Configuration::load_from_fs()?; info!("init db connection"); - let mut conn = boat_lib::utils::init_database(config.database_path)?; + let mut conn = boat_lib::utils::init_database(&config.database_path)?; match &args.command { - cli::Commands::New(args) => commands::create(&mut conn, args), - cli::Commands::Start(args) => commands::start(&mut conn, args), - cli::Commands::Cancel => commands::cancel_current(&mut conn), - cli::Commands::Pause => commands::pause_current(&mut conn), - cli::Commands::Modify(args) => commands::modify(&mut conn, args), - cli::Commands::Edit(args) => commands::edit(&mut conn, args), - cli::Commands::Delete(args) => commands::delete(&mut conn, args), - cli::Commands::Get(args) => commands::get_current(&conn, args), - cli::Commands::List(args) => commands::list_activity_logs(&conn, args), - cli::Commands::Report(list_activity_args) => todo!(), + cli::Commands::New(args) => commands::create(&config, &mut conn, args), + cli::Commands::Start(args) => commands::start(&config, &mut conn, args), + cli::Commands::Cancel => commands::cancel_current(&config, &mut conn), + cli::Commands::Pause => commands::pause_current(&config, &mut conn), + cli::Commands::Modify(args) => commands::modify(&config, &mut conn, args), + cli::Commands::Edit(args) => commands::edit(&config, &mut conn, args), + cli::Commands::Delete(args) => commands::delete(&config, &mut conn, args), + cli::Commands::Get(args) => commands::get_current(&config, &conn, args), + cli::Commands::List(args) => commands::list_activity_logs(&config, &conn, args), + cli::Commands::Report(args) => commands::show_report(&config, &conn, args), cli::Commands::HelpExtension => print_help(), } } diff --git a/src/utils/common.rs b/src/utils/common.rs index b5fddb5..72e4759 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -59,7 +59,7 @@ pub fn matches_period(log: &DatabaseLog, period: &PresetPeriod) -> bool { PresetPeriod::LastWeek => utils::date::is_last_week(log.starts_at), PresetPeriod::ThisMonth => utils::date::is_this_month(log.starts_at), PresetPeriod::LastMonth => utils::date::is_last_month(log.starts_at), - PresetPeriod::AllTime => todo!(), + PresetPeriod::AllTime => true, } } From def62365502f0910ea8bafc4e73ddadaea2d9031 Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Tue, 21 Apr 2026 08:51:44 +0200 Subject: [PATCH 3/8] wip(config): improve config support --- src/cli/args.rs | 68 ++++++--------- src/cli/mod.rs | 2 +- src/cli/period.rs | 177 ++++++++++++++++++++++++--------------- src/commands/delete.rs | 2 +- src/commands/edit.rs | 31 ++++++- src/commands/list.rs | 3 + src/commands/mod.rs | 1 - src/commands/modify.rs | 2 +- src/commands/query.rs | 21 ----- src/commands/report.rs | 18 ++-- src/commands/start.rs | 56 +++++++++++-- src/config.rs | 178 +++++++++++++++++++++++++++++++--------- src/models/boat_data.rs | 34 +++++--- src/utils/common.rs | 28 +++---- src/utils/display.rs | 17 +++- tests/cli_list.rs | 16 +--- 16 files changed, 416 insertions(+), 238 deletions(-) delete mode 100644 src/commands/query.rs diff --git a/src/cli/args.rs b/src/cli/args.rs index 313ac09..e6b206d 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -4,7 +4,6 @@ use clap::Parser; use clap::{ArgAction, Args, Subcommand, ValueEnum}; use crate::cli::PeriodInput; -use crate::cli::PresetPeriod; #[derive(Parser, Debug)] #[command( @@ -38,7 +37,7 @@ pub enum Commands { alias = "continue", alias = "resume" )] - Start(SelectActivityArgs), + Start(StartActivityArgs), /// Cancel the current activity #[command(alias = "c", alias = "can")] @@ -102,28 +101,6 @@ pub enum Commands { // ^^^ or maybe export 'x' ??? } -#[derive(Subcommand)] -#[command(rename_all = "kebab-case")] -pub enum QuerySubcommand { - /// Manage logs - #[command(name = "logs", alias = "l", alias = "log")] - Logs(FilterActivitiesArgs), - - /// Manage activities - #[command( - name = "acts", - alias = "act", - alias = "a", - alias = "activity", - alias = "activities" - )] - Activities(ListArgs), - - /// Manage tags - #[command(name = "tags", alias = "t", alias = "tag")] - Tags(ListArgs), -} - #[derive(Debug, Clone, Copy, ValueEnum, Default)] pub enum GroupBy { #[value(name = "none", alias = "no")] @@ -148,7 +125,7 @@ pub enum SortBy { #[derive(Args, Debug)] pub struct FilterActivitiesArgs { - /// Restrict to entries matching a given time period + /// Restrict to entries matching the given #[arg( short = 'p', long = "period", @@ -163,11 +140,7 @@ pub struct FilterActivitiesArgs { // /// Specify how entries should be sorted // #[arg(short = 's', long = "sort-by")] // pub sort_by: SortInput, - /// Show all activities, even the ones with no log - #[arg(short = 'a', long = "all")] - pub show_all: bool, - - /// Output in JSON + /// Output in JSON format #[arg(short = 'j', long = "json")] pub use_json_format: bool, // /// Only show tags @@ -175,13 +148,6 @@ pub struct FilterActivitiesArgs { // pub tags_only: bool, } -#[derive(Args, Debug)] -pub struct ListArgs { - /// Output in JSON - #[arg(short = 'j', long = "json")] - pub use_json_format: bool, -} - #[derive(Args, Debug, Default)] #[group(multiple = false)] pub struct PrintActivityArgs { @@ -190,6 +156,12 @@ pub struct PrintActivityArgs { pub use_json_format: bool, } +#[derive(Args, Debug)] +pub struct StartActivityArgs { + /// ID of an existing activity or name for a new activity + pub activity_handle: String, +} + #[derive(Args, Debug)] pub struct SelectActivityArgs { /// ID of the activity @@ -245,17 +217,27 @@ pub struct UpdateGroup { #[derive(Args, Debug)] pub struct EditLogsArgs { - /// Restrict to entries starting in the given + /// Restrict to entries matching the given #[arg( short = 'p', long = "period", - value_name = "PERIOD", - value_enum, - conflicts_with = "date_range" + help = "Period: day|d, week|w, month|m, year|y, , or .." )] - pub period: PeriodInput, + pub period: Option, + + /// Include instruction comments in the editable file + #[arg(short = 'i', long = "with-instructions", alias = "with-instr", action = ArgAction::SetTrue, conflicts_with = "hide_instructions")] + pub show_instructions: bool, /// Do not include instruction comments in the editable file - #[arg(short = 'n', long = "no-instructions")] + #[arg(short = 'I', long = "no-instructions", alias = "no-instr", action = ArgAction::SetTrue, conflicts_with = "show_instructions")] pub hide_instructions: bool, + + /// Include activity definitions comments in the editable file + #[arg(short = 'd', long = "with-activity-definitions", alias = "with-act-defs", alias = "with-defs", action = ArgAction::SetTrue, conflicts_with = "hide_activity_definitions")] + pub show_activity_definitions: bool, + + /// Do not include activity definitions comments in the editable file + #[arg(short = 'D', long = "no-activity-definitions", alias = "no-act-defs", alias = "no-defs", action = ArgAction::SetTrue, conflicts_with = "show_activity_definitions")] + pub hide_activity_definitions: bool, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index e8dd970..2f594de 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -6,10 +6,10 @@ pub use self::args::Commands; pub use self::args::CreateActivityArgs; pub use self::args::EditLogsArgs; pub use self::args::FilterActivitiesArgs; -pub use self::args::ListArgs; pub use self::args::ModifyActivityArgs; pub use self::args::PrintActivityArgs; pub use self::args::SelectActivityArgs; +pub use self::args::StartActivityArgs; pub use self::period::PeriodInput; pub use self::period::PresetPeriod; diff --git a/src/cli/period.rs b/src/cli/period.rs index 909c9b3..128e06a 100644 --- a/src/cli/period.rs +++ b/src/cli/period.rs @@ -1,19 +1,59 @@ use chrono::{Datelike, Local, Months, NaiveDate}; use log::debug; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::{fmt::Display, str::FromStr}; -use crate::utils::{self, date::DateTimeRenderMode}; +use crate::utils::date::DateTimeRenderMode; -#[derive(Debug, Clone, Copy, Deserialize, Serialize)] +#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] +pub enum PresetPeriod { + Today, + Yesterday, + ThisWeek, + LastWeek, + ThisMonth, + LastMonth, + #[default] + AllTime, +} + +impl Display for PresetPeriod { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = (match self { + PresetPeriod::Today => "today", + PresetPeriod::Yesterday => "yesterday", + PresetPeriod::ThisWeek => "this-week", + PresetPeriod::LastWeek => "last-week", + PresetPeriod::ThisMonth => "this-month", + PresetPeriod::LastMonth => "last-month", + PresetPeriod::AllTime => "all-time", + }) + .to_string(); + write!(f, "{str}") + } +} + +impl PresetPeriod { + pub fn display_pretty(&self) -> String { + let now = Local::now(); + let last_month = now - Months::new(1); + match self { + PresetPeriod::Today => "Today".to_string(), + PresetPeriod::Yesterday => "Yesterday".to_string(), + PresetPeriod::ThisWeek => "This week".to_string(), + PresetPeriod::LastWeek => "Last week".to_string(), + PresetPeriod::ThisMonth => format!("{} {}", now.format("%B"), now.year()), + PresetPeriod::LastMonth => format!("{} {}", last_month.format("%B"), last_month.year()), + PresetPeriod::AllTime => "All time".to_string(), + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] pub enum PeriodInput { Preset(PresetPeriod), Single(NaiveDate), - Range { - start: NaiveDate, - end: NaiveDate, - inclusive: bool, - }, + Range { start: NaiveDate, end: NaiveDate }, } impl Default for PeriodInput { @@ -29,7 +69,6 @@ impl PeriodInput { impl FromStr for PeriodInput { type Err = String; - fn from_str(s: &str) -> Result { debug!("period input from: {s}"); let s = s.to_lowercase(); @@ -52,33 +91,44 @@ impl FromStr for PeriodInput { "all-time" | "all" => return Ok(PeriodInput::Preset(PresetPeriod::AllTime)), _ => {} } - // Match range if let Some((start, end)) = s.split_once("..") { - let start = utils::date::parse_date(start).map_err(|_| Self::ERR_MSG)?; + let start = crate::utils::date::parse_date(start).map_err(|_| Self::ERR_MSG)?; let (end, inclusive) = match end.strip_prefix('=') { Some(substr) => (substr, true), None => (end, false), }; - let end = utils::date::parse_date(end).map_err(|_| Self::ERR_MSG)?; - + let end = crate::utils::date::parse_date(end).map_err(|_| Self::ERR_MSG)?; if start > end { return Err("DateInput: start cannot be after end when using range".to_string()); } - - return Ok(PeriodInput::Range { - start, - end, - inclusive, - }); + return Ok(PeriodInput::Range { start, end }); } - // Single date - let date = utils::date::parse_date(&s).map_err(|_| Self::ERR_MSG)?; + let date = crate::utils::date::parse_date(&s).map_err(|_| Self::ERR_MSG)?; Ok(PeriodInput::Single(date)) } } +impl Serialize for PeriodInput { + fn serialize(&self, serializer: S) -> Result + where + S: Serializer, + { + serializer.serialize_str(&self.to_string()) + } +} + +impl<'de> Deserialize<'de> for PeriodInput { + fn deserialize(deserializer: D) -> Result + where + D: Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + PeriodInput::from_str(&s).map_err(de::Error::custom) + } +} + impl std::fmt::Display for PeriodInput { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -87,61 +137,56 @@ impl std::fmt::Display for PeriodInput { let dt = DateTimeRenderMode::DateOnly.render_naive_date(naive_date); write!(f, "{dt}") } - PeriodInput::Range { - start, - end, - inclusive, - } => { + PeriodInput::Range { start, end } => { let start = DateTimeRenderMode::DateOnly.render_naive_date(start); let end = DateTimeRenderMode::DateOnly.render_naive_date(end); - let inclusion_msg = (if *inclusive { "included" } else { "excluded" }).to_string(); - write!(f, "{start} to {end} ({inclusion_msg})") + write!(f, "{start}..{end}") } } } } -#[derive(Clone, Copy, Debug, Default, Deserialize, Serialize)] -pub enum PresetPeriod { - Today, - Yesterday, - ThisWeek, - LastWeek, - ThisMonth, - LastMonth, - #[default] - AllTime, -} +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + use toml; -impl Display for PresetPeriod { - fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - let str = (match self { - PresetPeriod::Today => "today", - PresetPeriod::Yesterday => "yesterday", - PresetPeriod::ThisWeek => "this-week", - PresetPeriod::LastWeek => "last-week", - PresetPeriod::ThisMonth => "this-month", - PresetPeriod::LastMonth => "last-month", - PresetPeriod::AllTime => "all-time", - }) - .to_string(); - write!(f, "{str}") + #[derive(Serialize, Deserialize, Debug, PartialEq)] + struct Wrapper { + val: PeriodInput, + } + #[test] + fn periodinput_toml_serializes_and_deserializes_as_string_forms() { + let cases = [ + ("today", PeriodInput::from_str("today").unwrap()), + ("last-week", PeriodInput::from_str("last-week").unwrap()), + ("2023-01-02", PeriodInput::from_str("2023-01-02").unwrap()), + ( + "2023-01-02..2023-01-06", + PeriodInput::from_str("2023-01-02..2023-01-06").unwrap(), + ), + ( + "2023-01-02..=2023-01-07", + PeriodInput::from_str("2023-01-02..=2023-01-07").unwrap(), + ), + ]; + for (s, val) in cases { + let wrap = Wrapper { val }; + let t = toml::to_string(&wrap).unwrap(); + assert_eq!(t.trim(), format!("val = \"{}\"", s)); + let round: Wrapper = toml::from_str(&format!("val = \"{}\"", s)).unwrap(); + assert_eq!(format!("{}", round.val), s); + } } -} - -impl PresetPeriod { - pub fn display_pretty(&self) -> String { - let now = Local::now(); - let last_month = now - Months::new(1); - match self { - PresetPeriod::Today => "Today".to_string(), - PresetPeriod::Yesterday => "Yesterday".to_string(), - PresetPeriod::ThisWeek => "This week".to_string(), - PresetPeriod::LastWeek => "Last week".to_string(), - PresetPeriod::ThisMonth => format!("{} {}", now.format("%B"), now.year()), - PresetPeriod::LastMonth => format!("{} {}", last_month.format("%B"), last_month.year()), - PresetPeriod::AllTime => "All time".to_string(), + #[test] + fn periodinput_toml_deserialize_invalid() { + #[derive(Deserialize, Debug)] + struct Wrapper { + val: PeriodInput, } + let err = toml::from_str::("val = \"not-a-real-period\""); + assert!(err.is_err()); } } diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 971be8a..12a0fc7 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -12,7 +12,7 @@ pub fn delete( ) -> Result<()> { let Ok(to_delete) = activities::get_by_id(conn, args.activity_id) else { info!("cannot delete because ID is invalid: {}", args.activity_id); - bail!(utils::display::invalid_activity_id(args.activity_id)); + bail!(utils::display::activity_id_does_not_exist(args.activity_id)); }; info!("deleting activity: {to_delete:?}"); diff --git a/src/commands/edit.rs b/src/commands/edit.rs index 356a37a..e338066 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -11,19 +11,42 @@ use rusqlite::Connection; use std::{env, fs, path::PathBuf, process::Command}; use yansi::Paint; -use crate::{cli::EditLogsArgs, config::Configuration, models::boat_data::BoatData}; +use crate::{ + cli::{self, EditLogsArgs, PeriodInput}, + config::Configuration, + models::boat_data::BoatData, + utils, +}; pub fn edit( config: &Configuration, conn: &mut rusqlite::Connection, args: &EditLogsArgs, ) -> Result<()> { - let period = args.period; - let include_instructions = !args.hide_instructions; + let period = args + .period + .or(config.commands.edit.period) + .or(config.period) + .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)); + info!("using period: {period}"); + + let include_instructions = utils::common::resolve_tri_state( + args.show_instructions, + args.hide_instructions, + config.commands.edit.show_instructions, + ); + info!("include instructions? {include_instructions}"); + + let include_activity_definitions = utils::common::resolve_tri_state( + args.show_activity_definitions, + args.hide_activity_definitions, + config.commands.edit.show_activity_definitions, + ); + info!("include activity definitions? {include_activity_definitions}"); let all_acts = activities::get_all(conn)?; let boat_data = BoatData::create_filtered_data(all_acts, period); - let default_content = boat_data.to_csv_str(include_instructions); + let default_content = boat_data.to_csv_str(include_instructions, include_activity_definitions); let edit_file_path = create_tmp_edit_file(&default_content)?; let editor = env::var("EDITOR").unwrap_or_else(|_| "vim".to_string()); diff --git a/src/commands/list.rs b/src/commands/list.rs index f805860..610daed 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -20,7 +20,10 @@ pub fn list_activity_logs( info!("getting all activities"); let period = args .period + .or(config.commands.list.period) + .or(config.period) .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)); + info!("using period: {period}"); let db_acts: Vec<_> = activities::get_all(conn)?; let boat_data = BoatData::create_filtered_data(db_acts, period); diff --git a/src/commands/mod.rs b/src/commands/mod.rs index 67fb931..d769189 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,7 +6,6 @@ pub mod get; pub mod list; pub mod modify; pub mod pause; -pub mod query; pub mod report; pub mod start; diff --git a/src/commands/modify.rs b/src/commands/modify.rs index 830ccd5..baf5fd1 100644 --- a/src/commands/modify.rs +++ b/src/commands/modify.rs @@ -12,7 +12,7 @@ pub fn modify( ) -> Result<()> { let Ok(old) = activities::get_by_id(conn, args.id) else { info!("cannot modify because ID is invalid: {}", args.id); - bail!(utils::display::invalid_activity_id(args.id)); + bail!(utils::display::activity_id_does_not_exist(args.id)); }; info!("about to modify activity: {old:?}"); diff --git a/src/commands/query.rs b/src/commands/query.rs deleted file mode 100644 index 6dfb76c..0000000 --- a/src/commands/query.rs +++ /dev/null @@ -1,21 +0,0 @@ -use std::cmp::Reverse; - -use anyhow::Result; -use boat_lib::repository::tags_repository as tags; -use rusqlite::Connection; - -use crate::{cli, models::tag::PrintableTag, utils}; - -// pub fn query_subcommand(conn: &mut Connection, command: &cli::QuerySubcommand) -> Result<()> { -// todo!() -// } - -fn list_tags(conn: &mut Connection, args: &cli::ListArgs) -> Result<()> { - let mut all_tags: Vec<_> = tags::get_all(conn)? - .iter() - .map(PrintableTag::from_tag) - .collect(); - all_tags.sort_by_key(|t| Reverse(t.id)); - - utils::common::list_printable_items(&all_tags, args.use_json_format) -} diff --git a/src/commands/report.rs b/src/commands/report.rs index c822985..12417f2 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -17,11 +17,11 @@ pub fn show_report( args: &cli::FilterActivitiesArgs, ) -> Result<()> { info!("getting all activities"); - let period = args.period.unwrap_or( - config - .period - .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)), - ); + let period = args + .period + .or(config.commands.report.period) + .or(config.period) + .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)); info!("using period: {period}"); let db_acts: Vec<_> = activities::get_all(conn)?; @@ -45,15 +45,15 @@ pub fn show_report( // } } - list_activity_summaries(&boat_data, args.show_all, args.use_json_format) + list_activity_summaries(&boat_data, args.use_json_format) } -fn list_activity_summaries(boat_data: &BoatData, show_all: bool, use_json: bool) -> Result<()> { - info!("listing activity summaries (show_all: {show_all})"); +fn list_activity_summaries(boat_data: &BoatData, use_json: bool) -> Result<()> { + info!("listing activity summaries"); let prt_acts = boat_data .get_printable_activities() .into_iter() - .filter(|act| show_all || act.duration > 0) + .filter(|act| act.duration > 0) .collect(); utils::common::list_printable_items(&prt_acts, use_json)?; diff --git a/src/commands/start.rs b/src/commands/start.rs index 0084a67..1124f6e 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -1,26 +1,64 @@ use anyhow::{Result, bail}; -use boat_lib::repository::activities_repository as activities; +use boat_lib::repository::{Id, activities_repository as activities}; use chrono::Local; -use log::info; +use log::{info, warn}; use rusqlite::Connection; use yansi::Paint; -use crate::{cli, commands::pause_current, config::Configuration, utils}; +use crate::{ + cli, + commands::{self, pause_current}, + config::Configuration, + utils, +}; pub fn start( config: &Configuration, conn: &mut Connection, - args: &cli::SelectActivityArgs, + args: &cli::StartActivityArgs, ) -> Result<()> { - let Ok(to_start) = activities::get_by_id(conn, args.activity_id) else { - info!("cannot start because ID is invalid: {}", args.activity_id); - bail!(utils::display::invalid_activity_id(args.activity_id)); + let activity_id = args.activity_handle.parse::(); + + if config.commands.start.quick_start { + info!("quick start enabled, allow to create a named activity and start it on the fly"); + + if activity_id.is_err() { + return commands::create::create( + config, + conn, + &cli::CreateActivityArgs { + name: args.activity_handle.clone(), + description: None, + tags: vec![], + auto_start: true, + use_json_format: false, + }, + ); + } + } + + let activity_id = match activity_id { + Ok(id) => id, + Err(_) => { + warn!( + "cannot start because activity handle is not an integer '{}' and quick start is disabled", + args.activity_handle + ); + bail!(utils::display::invalid_activity_id_format( + &args.activity_handle + )); + } + }; + + let Ok(to_start) = activities::get_by_id(conn, activity_id) else { + info!("cannot start because ID is invalid: {}", activity_id); + bail!(utils::display::activity_id_does_not_exist(activity_id)); }; if let Some(current) = activities::get_current_ongoing(conn)? { info!("ongoing activity: {current:?}"); - if current.id == args.activity_id { + if current.id == activity_id { info!("not starting because same activity already ongoing"); println!("{}", "activity already ongoing...".italic()); return Ok(()); @@ -31,7 +69,7 @@ pub fn start( } info!("about to start: {to_start:?}"); - activities::start(conn, args.activity_id)?; + activities::start(conn, activity_id)?; println!( "{}", utils::display::started_activity_msg(&to_start, Local::now()) diff --git a/src/config.rs b/src/config.rs index f5f87ef..ad31a5f 100644 --- a/src/config.rs +++ b/src/config.rs @@ -11,64 +11,139 @@ pub const CONFIG_VAR: &str = "BOAT_CONFIG"; pub const DEFAULT_CONFIG_PATH: &str = "config.toml"; pub const DEFAULT_DB_FILE: &str = "boat.db"; +#[derive(Debug, Serialize, Deserialize, Default)] +pub enum OutputFormat { + #[serde(rename = "plain")] + #[default] + Plain, + #[serde(rename = "json")] + Json, + #[serde(rename = "csv")] + Csv, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct Configuration { + /// Path to the SQLite database file + #[serde(rename = "database_path")] + pub database_path: PathBuf, + + /// Default period to use for activities + #[serde(rename = "period")] + #[serde(skip_serializing_if = "Option::is_none")] + pub period: Option, + + /// Default output format to use ('plain', 'json', 'csv') + #[serde(rename = "format")] + pub format: OutputFormat, + + /// Configuration values for the various commands + #[serde(rename = "commands")] + pub commands: CommandsConfig, +} + #[derive(Debug, Default, Serialize, Deserialize)] -pub struct NewCommandConfig { - #[serde(rename = "auto-start")] - pub auto_start: Option, +pub struct CommandsConfig { + pub new: NewCommandConfig, + pub start: StartCommandConfig, + pub cancel: CancelCommandConfig, + // pub pause: PauseCommandConfig, + // pub modify: ModifyCommandConfig, + pub edit: EditCommandConfig, + pub delete: DeleteCommandConfig, + // pub get: GetCommandConfig, + pub list: ListCommandConfig, + pub report: ReportCommandConfig, } +/// Configuration values for the new command #[derive(Debug, Default, Serialize, Deserialize)] -pub struct CancelCommandConfig { - pub confirm: Option, +pub struct NewCommandConfig { + /// Start the new activity automatically + #[serde(rename = "auto_start")] + pub auto_start: bool, } +/// Configuration values for the start command #[derive(Debug, Default, Serialize, Deserialize)] -pub struct EditCommandConfig { - pub period: Option, - #[serde(rename = "hide-instructions")] - pub hide_instructions: Option, - #[serde(rename = "hide-activity-definitions")] - pub hide_activity_definitions: Option, - pub editor: Option, - pub confirm: Option, +pub struct StartCommandConfig { + /// Allow to create and start a new activity by specifying its name instead of an activity number + #[serde(rename = "quick_start")] + pub quick_start: bool, } +/// Configuration values for the cancel command #[derive(Debug, Default, Serialize, Deserialize)] -pub struct ListCommandConfig { - pub period: Option, - pub sort: Option, - #[serde(rename = "group-by")] - pub group_by: Option, +pub struct CancelCommandConfig { + /// Prompts for confirmation before cancelling activity + pub confirm: bool, } +/// Configuration values for the pause command #[derive(Debug, Default, Serialize, Deserialize)] -pub struct ReportCommandConfig { - pub period: Option, - pub sort: Option, - #[serde(rename = "group-by")] - pub group_by: Option, - pub format: Option, +pub struct PauseCommandConfig; + +/// Configuration values for the modify command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ModifyCommandConfig; + +/// Configuration values for the edit command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct EditCommandConfig { + /// Restrict to entries matching the given + pub period: Option, + + /// Do not include instruction comments in the editable file + #[serde(rename = "show_instructions")] + pub show_instructions: bool, + + /// Do not include activity definitions in the editable file + #[serde(rename = "show_activity_definitions")] + pub show_activity_definitions: bool, + + /// Prompts for confirmation before applying changes + pub confirm: bool, } +/// Configuration values for the delete command #[derive(Debug, Default, Serialize, Deserialize)] -pub struct CommandsConfig { - pub new: Option, - pub cancel: Option, - pub list: Option, - pub report: Option, - pub edit: Option, +pub struct DeleteCommandConfig { + /// Prompts for confirmation before deleting an activity and all its logs + pub confirm: bool, } -#[derive(Debug, Serialize, Deserialize)] -pub struct Configuration { - #[serde(rename = "database_path")] - pub database_path: PathBuf, +/// Configuration values for the get command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct GetCommandConfig; - #[serde(skip_serializing_if = "Option::is_none")] +/// Configuration values for the list command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ListCommandConfig { + /// Restrict to entries matching the given pub period: Option, + // /// Specify how entries should be grouped + // #[serde(rename = "group_by")] + // pub group_by: Option, + // /// Specify how entries should be sorted + // #[serde(rename = "sort_by")] + // pub sort_by: Option, + // /// Format to use for data ('plain', 'json', 'csv') + // pub format: Option, +} - #[serde(skip_serializing_if = "Option::is_none")] - pub commands: Option, +/// Configuration values for the report command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct ReportCommandConfig { + /// Restrict to entries matching the given + pub period: Option, + // /// Specify how entries should be grouped + // #[serde(rename = "group_by")] + // pub group_by: Option, + // /// Specify how entries should be sorted + // #[serde(rename = "sort_by")] + // pub sort_by: Option, + // /// Format to use for data ('plain', 'json', 'csv') + // pub format: Option, } impl Configuration { @@ -82,7 +157,34 @@ impl Configuration { Ok(Self { database_path, period: None, - commands: None, + format: OutputFormat::Plain, + commands: CommandsConfig { + new: NewCommandConfig { auto_start: false }, + start: StartCommandConfig { quick_start: false }, + cancel: CancelCommandConfig { confirm: true }, + // pause: PauseCommandConfig, + // modify: ModifyCommandConfig, + edit: EditCommandConfig { + period: None, + show_instructions: true, + show_activity_definitions: true, + confirm: true, + }, + delete: DeleteCommandConfig { confirm: true }, + // get: GetCommandConfig, + list: ListCommandConfig { + period: None, + // group_by: None, + // sort_by: None, + // format: None, + }, + report: ReportCommandConfig { + period: None, + // group_by: None, + // sort_by: None, + // format: None, + }, + }, }) } diff --git a/src/models/boat_data.rs b/src/models/boat_data.rs index 2ad47a5..0f2ac39 100644 --- a/src/models/boat_data.rs +++ b/src/models/boat_data.rs @@ -79,7 +79,11 @@ impl BoatData { prt_logs } - pub fn to_csv_str(&self, include_instructions: bool) -> String { + pub fn to_csv_str( + &self, + include_instructions: bool, + include_activity_definitions: bool, + ) -> String { let mut csv_data = String::new(); if include_instructions { @@ -92,9 +96,11 @@ impl BoatData { csv_data.push_str("# You may only edit activity logs here.\n#\n"); } - csv_data.push_str("# Activity definitions:\n"); - csv_data.push_str("# | ID | Name | Description | Tags |\n"); - csv_data.push_str("# | -- | ---- | ----------- | ---- |\n"); + if include_activity_definitions { + csv_data.push_str("# Activity definitions:\n"); + csv_data.push_str("# | ID | Name | Description | Tags |\n"); + csv_data.push_str("# | -- | ---- | ----------- | ---- |\n"); + } let mut activities = self.activities.values().collect::>(); activities.sort_by_key(|act| act.id); @@ -106,6 +112,16 @@ impl BoatData { continue; } + for log in related_logs { + let local_starts_at = log.starts_at.with_timezone(&chrono::Local); + let local_ends_at = log.ends_at.map(|dt| dt.with_timezone(&chrono::Local)); + act_logs.push((act.id, log.id, local_starts_at, local_ends_at)); + } + + if !include_activity_definitions { + continue; + } + let act_csv = format!( "# | {} | {} | {} | {} |\n", act.id, @@ -114,15 +130,11 @@ impl BoatData { utils::common::tags_str(&act.tags) ); csv_data.push_str(&act_csv); - - for log in related_logs { - let local_starts_at = log.starts_at.with_timezone(&chrono::Local); - let local_ends_at = log.ends_at.map(|dt| dt.with_timezone(&chrono::Local)); - act_logs.push((act.id, log.id, local_starts_at, local_ends_at)); - } } - csv_data.push_str("\n\n"); + if include_activity_definitions { + csv_data.push_str("\n\n"); + } if include_instructions { csv_data.push_str( diff --git a/src/utils/common.rs b/src/utils/common.rs index 72e4759..3b62683 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -11,6 +11,14 @@ use crate::{ utils, }; +pub fn resolve_tri_state(a: bool, b: bool, c: bool) -> bool { + match (a, b) { + (true, false) => true, + (false, true) => false, + _ => c, // neither specified → fallback + } +} + pub fn list_printable_items( items: &Vec, show_as_json: bool, @@ -41,11 +49,7 @@ pub fn matches_period_filter(log: &DatabaseLog, period_input: &PeriodInput) -> b match period_input { PeriodInput::Preset(preset_period) => matches_period(log, preset_period), PeriodInput::Single(date) => matches_date(log, date), - PeriodInput::Range { - start, - end, - inclusive, - } => matches_date_range(log, start, end, *inclusive), + PeriodInput::Range { start, end } => matches_date_range(log, start, end), } } @@ -72,22 +76,12 @@ pub fn matches_date_range( log: &DatabaseLog, range_start: &NaiveDate, range_end: &NaiveDate, - inclusive: bool, ) -> bool { - debug!( - "checking if {log:?} matches the given date_range: {range_start:?}, {range_end:?}, {inclusive}" - ); + debug!("checking if {log:?} matches the given date_range: {range_start:?}, {range_end:?}"); let log_start = log.starts_at.date_naive(); let log_end = log.ends_at.unwrap_or(Local::now().into()).date_naive(); - - let log_ends_before_range_end = if inclusive { - log_end <= *range_end - } else { - log_end < *range_end - }; - - log_start >= *range_start && log_ends_before_range_end + log_start >= *range_start && log_end <= *range_end } pub fn get_date_info_msg(today: NaiveDate, compare_to: NaiveDate) -> String { diff --git a/src/utils/display.rs b/src/utils/display.rs index 9cb81b2..03c0acc 100644 --- a/src/utils/display.rs +++ b/src/utils/display.rs @@ -51,10 +51,25 @@ pub fn started_activity_msg(activity: &DatabaseActivity, start_dt: DateTime String { +pub fn invalid_activity_name(activity_name: &str) -> String { + format!( + "the activity name cannot only contain digits: \"{}\"", + activity_name + ) + .red() + .to_string() +} + +pub fn activity_id_does_not_exist(id: Id) -> String { format!("#{id} does not exist").red().to_string() } +pub fn invalid_activity_id_format(id_str: &str) -> String { + format!("invalid activity ID format: \"{}\"", id_str) + .red() + .to_string() +} + pub fn deleted_activity_msg(activity: &DatabaseActivity) -> String { format!("{} #{} \"{}\"", "deleted".red(), activity.id, activity.name) } diff --git a/tests/cli_list.rs b/tests/cli_list.rs index c7da5ef..f2c772f 100644 --- a/tests/cli_list.rs +++ b/tests/cli_list.rs @@ -5,25 +5,11 @@ use crate::utils::{cli_args_for_temp, run_boat}; mod utils; -#[test] -fn list_mutually_exclusive_args_fails() -> Result<()> { - let (_tmp, config_path) = cli_args_for_temp()?; - - run_boat( - ["list", "--period", "today", "--date", "2024-05-01"], - config_path, - ) - .failure() - .stderr(predicates::str::contains("cannot be used with")); - - Ok(()) -} - #[test] fn list_with_invalid_date_input_fails() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; - run_boat(["list", "--date", "not-a-date"], config_path) + run_boat(["list", "--period", "not-a-date"], config_path) .failure() .stderr(predicates::str::contains("Provide either a range")); From 7c1d8f6c62c47e75a325de3570c3a4f715565ec1 Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Thu, 23 Apr 2026 20:52:23 +0200 Subject: [PATCH 4/8] feat: worked on many features - rework auto_start + add confirm flags args for some commands - implement group_by for list command - add support for confirm on cancel and delete - improve configuration --- README.md | 29 ++++++++++++ src/cli/args.rs | 81 ++++++++++++++++++++++++++++++--- src/cli/mod.rs | 3 +- src/commands/cancel.rs | 25 ++++++++++- src/commands/create.rs | 9 +++- src/commands/delete.rs | 26 ++++++++--- src/commands/edit.rs | 9 +++- src/commands/list.rs | 99 +++++++++++++++++++++++++++++++++-------- src/commands/report.rs | 25 +++++++++-- src/commands/start.rs | 1 + src/config.rs | 30 ++++++++----- src/main.rs | 2 +- src/models/boat_data.rs | 2 +- src/utils/common.rs | 11 +++++ src/utils/display.rs | 70 ++++++++++++++++++++++++++--- 15 files changed, 364 insertions(+), 58 deletions(-) diff --git a/README.md b/README.md index 219b23a..ec67ef3 100644 --- a/README.md +++ b/README.md @@ -96,6 +96,35 @@ database_path = "/home//.config/boat/boat.db" ``` You can override the default configuration file path by setting the `BOAT_CONFIG` environment variable. +Here is the full default configuration: + +```toml +database_path = "/home//.config/boat/boat.db" +period = "all" +format = "plain" + +[commands.new] +auto_start = false + +[commands.start] +quick_start = true + +[commands.cancel] +confirm = true + +[commands.edit] +show_instructions = true +show_activity_definitions = true +confirm = true + +[commands.delete] +confirm = true + +[commands.list] +group_by = "day" + +[commands.report] +``` ## ✨ Usage diff --git a/src/cli/args.rs b/src/cli/args.rs index e6b206d..9da71a6 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -2,6 +2,8 @@ use boat_lib::repository::Id; use clap::ColorChoice; use clap::Parser; use clap::{ArgAction, Args, Subcommand, ValueEnum}; +use serde::Deserialize; +use serde::Serialize; use crate::cli::PeriodInput; @@ -41,7 +43,7 @@ pub enum Commands { /// Cancel the current activity #[command(alias = "c", alias = "can")] - Cancel, + Cancel(CancelActivityArgs), /// Pause/stop the current activity #[command(alias = "p", alias = "stop")] @@ -63,7 +65,7 @@ pub enum Commands { alias = "rem", alias = "remove" )] - Delete(SelectActivityArgs), + Delete(DeleteActivityArgs), /// Get the current activity #[command(alias = "g")] @@ -101,21 +103,43 @@ pub enum Commands { // ^^^ or maybe export 'x' ??? } -#[derive(Debug, Clone, Copy, ValueEnum, Default)] +#[derive(Debug, Serialize, Deserialize, Clone, Copy, ValueEnum, Default, PartialEq, Eq)] pub enum GroupBy { #[value(name = "none", alias = "no")] #[default] + #[serde(rename = "none")] None, + #[value(name = "day", alias = "d")] + #[serde(rename = "day")] Day, + #[value(name = "week", alias = "wk", alias = "w")] + #[serde(rename = "week")] Week, + #[value(name = "month", alias = "mo", alias = "m")] + #[serde(rename = "month")] Month, + #[value(name = "year", alias = "y")] + #[serde(rename = "year")] Year, } +impl std::fmt::Display for GroupBy { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let str = match self { + GroupBy::None => "none", + GroupBy::Day => "day", + GroupBy::Week => "week", + GroupBy::Month => "month", + GroupBy::Year => "year", + }; + write!(f, "{str}") + } +} + #[derive(Debug, Clone, Copy, ValueEnum, Default)] pub enum SortBy { #[value(name = "none", alias = "no")] @@ -135,7 +159,7 @@ pub struct FilterActivitiesArgs { /// Specify how entries should be grouped #[arg(short = 'g', long = "group-by")] - pub group_by: bool, + pub group_by: Option, // /// Specify how entries should be sorted // #[arg(short = 's', long = "sort-by")] @@ -168,6 +192,31 @@ pub struct SelectActivityArgs { pub activity_id: Id, } +#[derive(Args, Debug)] +pub struct DeleteActivityArgs { + /// ID of the activity to delete + pub id: Id, + + /// Asks for confirmation before deleting the activity + #[arg(short = 'c', long = "confirm", action = ArgAction::SetTrue, conflicts_with = "no_confirm")] + pub confirm: bool, + + /// Skip the confirmation when deleting the activity + #[arg(short = 'C', long = "no-confirm", action = ArgAction::SetTrue, conflicts_with = "confirm")] + pub no_confirm: bool, +} + +#[derive(Args, Debug)] +pub struct CancelActivityArgs { + /// Asks for confirmation before cancelling the current activity + #[arg(short = 'c', long = "confirm", action = ArgAction::SetTrue, conflicts_with = "no_confirm")] + pub confirm: bool, + + /// Skip the confirmation when cancelling the current activity + #[arg(short = 'C', long = "no-confirm", action = ArgAction::SetTrue, conflicts_with = "confirm")] + pub no_confirm: bool, +} + #[derive(Args, Debug)] pub struct CreateActivityArgs { /// Name of the activity @@ -181,10 +230,14 @@ pub struct CreateActivityArgs { #[arg(short, long, value_delimiter = ',', action = ArgAction::Append)] pub tags: Vec, - /// Start the new activity automatically - #[arg(short = 's', long = "start")] + /// Start the new activity automatically after creation + #[arg(short = 's', long = "start-now", action = ArgAction::SetTrue, conflicts_with = "no_auto_start")] pub auto_start: bool, + /// Prevent the new activity from starting automatically + #[arg(short = 'S', long = "no-start-now", action = ArgAction::SetTrue, conflicts_with = "auto_start")] + pub no_auto_start: bool, + /// Output in JSON #[arg(short = 'j', long = "json")] pub use_json_format: bool, @@ -197,6 +250,14 @@ pub struct ModifyActivityArgs { #[clap(flatten)] pub update: UpdateGroup, + + /// Asks for confirmation before applying changes + #[arg(short = 'c', long = "confirm", action = ArgAction::SetTrue, conflicts_with = "no_confirm")] + pub confirm: bool, + + /// Skip the confirmation before applying changes + #[arg(short = 'C', long = "no-confirm", action = ArgAction::SetTrue, conflicts_with = "confirm")] + pub no_confirm: bool, } #[derive(Args, Debug)] @@ -240,4 +301,12 @@ pub struct EditLogsArgs { /// Do not include activity definitions comments in the editable file #[arg(short = 'D', long = "no-activity-definitions", alias = "no-act-defs", alias = "no-defs", action = ArgAction::SetTrue, conflicts_with = "show_activity_definitions")] pub hide_activity_definitions: bool, + + /// Asks for confirmation before applying changes + #[arg(short = 'c', long = "confirm", action = ArgAction::SetTrue, conflicts_with = "no_confirm")] + pub confirm: bool, + + /// Skip the confirmation before applying changes + #[arg(short = 'C', long = "no-confirm", action = ArgAction::SetTrue, conflicts_with = "confirm")] + pub no_confirm: bool, } diff --git a/src/cli/mod.rs b/src/cli/mod.rs index 2f594de..1547b61 100644 --- a/src/cli/mod.rs +++ b/src/cli/mod.rs @@ -1,14 +1,15 @@ pub mod args; pub mod period; +pub use self::args::CancelActivityArgs; pub use self::args::Cli; pub use self::args::Commands; pub use self::args::CreateActivityArgs; +pub use self::args::DeleteActivityArgs; pub use self::args::EditLogsArgs; pub use self::args::FilterActivitiesArgs; pub use self::args::ModifyActivityArgs; pub use self::args::PrintActivityArgs; -pub use self::args::SelectActivityArgs; pub use self::args::StartActivityArgs; pub use self::period::PeriodInput; pub use self::period::PresetPeriod; diff --git a/src/commands/cancel.rs b/src/commands/cancel.rs index 37d2b1e..45569d1 100644 --- a/src/commands/cancel.rs +++ b/src/commands/cancel.rs @@ -3,11 +3,32 @@ use boat_lib::repository::activities_repository as activities; use log::info; use rusqlite::Connection; -use crate::{config::Configuration, utils}; +use crate::{cli, config::Configuration, utils}; + +pub fn cancel_current( + config: &Configuration, + conn: &mut Connection, + args: &cli::CancelActivityArgs, +) -> Result<()> { + let ask_for_confirmation = utils::common::resolve_tri_state( + args.confirm, + args.no_confirm, + config.commands.cancel.confirm, + ); + info!("ask user for confirmation? {ask_for_confirmation}"); -pub fn cancel_current(config: &Configuration, conn: &mut Connection) -> Result<()> { match activities::get_current_ongoing(conn)? { Some(current) => { + let prompt_msg = format!( + "are you sure you want to cancel activity #{} \"{}\"?", + current.id, current.name + ); + if ask_for_confirmation && !utils::common::prompt_for_confirmation(&prompt_msg, false)? + { + println!("user aborted the operation, activity was not cancelled"); + return Ok(()); + } + info!("cancelling current activity: {current:?}"); activities::cancel_current(conn)?; println!("{}", utils::display::cancelled_activity_msg(¤t)); diff --git a/src/commands/create.rs b/src/commands/create.rs index 674efe3..0ae7b8c 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -16,6 +16,13 @@ pub fn create( conn: &mut Connection, args: &cli::CreateActivityArgs, ) -> Result<()> { + let start_auto = utils::common::resolve_tri_state( + args.auto_start, + args.no_auto_start, + config.commands.new.auto_start, + ); + + info!("start the new activity automatically? {start_auto}"); let new = NewActivity { name: args.name.clone(), description: args.description.clone(), @@ -29,7 +36,7 @@ pub fn create( println!("{}", utils::display::created_activity_msg(&created)); } - if args.auto_start { + if start_auto { info!("activity auto_start is enabled"); activities::start(conn, created.id)?; if !args.use_json_format { diff --git a/src/commands/delete.rs b/src/commands/delete.rs index 12a0fc7..862489c 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -8,15 +8,31 @@ use crate::{cli, config::Configuration, utils}; pub fn delete( config: &Configuration, conn: &mut Connection, - args: &cli::SelectActivityArgs, + args: &cli::DeleteActivityArgs, ) -> Result<()> { - let Ok(to_delete) = activities::get_by_id(conn, args.activity_id) else { - info!("cannot delete because ID is invalid: {}", args.activity_id); - bail!(utils::display::activity_id_does_not_exist(args.activity_id)); + let ask_for_confirmation = utils::common::resolve_tri_state( + args.confirm, + args.no_confirm, + config.commands.delete.confirm, + ); + info!("ask user for confirmation? {ask_for_confirmation}"); + + let Ok(to_delete) = activities::get_by_id(conn, args.id) else { + info!("cannot delete because ID is invalid: {}", args.id); + bail!(utils::display::activity_id_does_not_exist(args.id)); }; + let prompt_msg = format!( + "are you sure you want to delete activity #{} \"{}\"?", + to_delete.id, to_delete.name + ); + if ask_for_confirmation && !utils::common::prompt_for_confirmation(&prompt_msg, false)? { + println!("user aborted the operation, activity was not deleted"); + return Ok(()); + } + info!("deleting activity: {to_delete:?}"); - activities::delete(conn, args.activity_id)?; + activities::delete(conn, args.id)?; println!("{}", utils::display::deleted_activity_msg(&to_delete)); Ok(()) diff --git a/src/commands/edit.rs b/src/commands/edit.rs index e338066..b3c9d1b 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -44,6 +44,13 @@ pub fn edit( ); info!("include activity definitions? {include_activity_definitions}"); + let ask_for_confirmation = utils::common::resolve_tri_state( + args.confirm, + args.no_confirm, + config.commands.edit.confirm, + ); + info!("ask user for confirmation? {ask_for_confirmation}"); + let all_acts = activities::get_all(conn)?; let boat_data = BoatData::create_filtered_data(all_acts, period); let default_content = boat_data.to_csv_str(include_instructions, include_activity_definitions); @@ -93,7 +100,7 @@ pub fn edit( return Ok(()); } - if !confirm_changes(&to_update)? { + if ask_for_confirmation && !confirm_changes(&to_update)? { println!("user aborted the operation, no changes will be made"); return Ok(()); } diff --git a/src/commands/list.rs b/src/commands/list.rs index 610daed..0e697c1 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,12 +1,12 @@ use anyhow::Result; use boat_lib::repository::activities_repository as activities; -use chrono::{Local, NaiveDate}; +use chrono::{Datelike, Local}; use log::info; use rusqlite::Connection; use std::collections::BTreeMap; use crate::{ - cli::{self, PeriodInput}, + cli::{self, PeriodInput, args::GroupBy}, config::Configuration, models::{activity_log::PrintableActivityLog, boat_data::BoatData}, utils::{self, date::DateTimeRenderMode}, @@ -17,7 +17,6 @@ pub fn list_activity_logs( conn: &Connection, args: &cli::FilterActivitiesArgs, ) -> Result<()> { - info!("getting all activities"); let period = args .period .or(config.commands.list.period) @@ -25,36 +24,35 @@ pub fn list_activity_logs( .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)); info!("using period: {period}"); + let group_by_value = args + .group_by + .or(config.commands.list.group_by) + .unwrap_or(GroupBy::None); + info!("grouping by: {group_by_value}"); + + info!("getting all activities"); let db_acts: Vec<_> = activities::get_all(conn)?; let boat_data = BoatData::create_filtered_data(db_acts, period); info!("listing individual activity logs"); let prt_logs = boat_data.get_printable_logs(); - // if args.no_grouping { - // info!("activity logs will not be grouped by date"); - // return utils::common::list_printable_items(&prt_logs, args.use_json_format); - // } - - let act_logs_by_date = group_by_date(&prt_logs); - + let grouped_logs = group_by(&prt_logs, group_by_value); if args.use_json_format { - let json = serde_json::to_string(&act_logs_by_date)?; + let json = serde_json::to_string(&grouped_logs)?; println!("{json}"); return Ok(()); } - if act_logs_by_date.is_empty() { + if grouped_logs.is_empty() { println!("no available data"); return Ok(()); } info!("displaying activity logs grouped by date"); - for (date, act_logs) in act_logs_by_date.iter() { - let dt = NaiveDate::parse_from_str(date, "%Y-%m-%d")?; - let diff_msg = utils::common::get_date_info_msg(Local::now().date_naive(), dt); - let ribbon = utils::display::format_ascii_ribbon(date, Some(&diff_msg)); - + for (group, act_logs) in grouped_logs.iter() { + let (text, tooltip) = utils::display::get_group_by_display_values(group_by_value, group)?; + let ribbon = utils::display::format_ascii_ribbon(&text, tooltip.as_deref()); println!("{ribbon}"); utils::common::list_printable_items(act_logs, false)?; } @@ -62,7 +60,29 @@ pub fn list_activity_logs( Ok(()) } -fn group_by_date( +trait ActivityLog { + fn starts_at(&self) -> Option>; + fn ends_at(&self) -> Option>; +} + +fn group_by( + activity_logs: &[PrintableActivityLog], + group_by: GroupBy, +) -> BTreeMap> { + match group_by { + GroupBy::None => { + let mut map = BTreeMap::new(); + map.insert("all".to_string(), activity_logs.to_vec()); + map + } + GroupBy::Day => group_by_day(activity_logs), + GroupBy::Week => group_by_week(activity_logs), + GroupBy::Month => group_by_month(activity_logs), + GroupBy::Year => group_by_year(activity_logs), + } +} + +fn group_by_day( activity_logs: &[PrintableActivityLog], ) -> BTreeMap> { let mut groups: BTreeMap<_, Vec<_>> = BTreeMap::new(); @@ -75,3 +95,46 @@ fn group_by_date( groups } + +fn group_by_week( + activity_logs: &[PrintableActivityLog], +) -> BTreeMap> { + let mut groups: BTreeMap<_, Vec<_>> = BTreeMap::new(); + + for act_log in activity_logs { + let latest_dt = act_log.log.ends_at.unwrap_or(Local::now()); + let iso_week = latest_dt.iso_week(); + let key = format!("{}-W{}", iso_week.year(), iso_week.week()); + groups.entry(key).or_default().push(act_log.clone()); + } + + groups +} + +fn group_by_month( + activity_logs: &[PrintableActivityLog], +) -> BTreeMap> { + let mut groups: BTreeMap<_, Vec<_>> = BTreeMap::new(); + + for act_log in activity_logs { + let latest_dt = act_log.log.ends_at.unwrap_or(Local::now()); + let key = format!("{}-{:02}", latest_dt.year(), latest_dt.month()); + groups.entry(key).or_default().push(act_log.clone()); + } + + groups +} + +fn group_by_year( + activity_logs: &[PrintableActivityLog], +) -> BTreeMap> { + let mut groups: BTreeMap<_, Vec<_>> = BTreeMap::new(); + + for act_log in activity_logs { + let latest_dt = act_log.log.ends_at.unwrap_or(Local::now()); + let key = format!("{}", latest_dt.year()); + groups.entry(key).or_default().push(act_log.clone()); + } + + groups +} diff --git a/src/commands/report.rs b/src/commands/report.rs index 12417f2..c1559d0 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -1,11 +1,11 @@ -use anyhow::Result; +use anyhow::{Result, bail}; use boat_lib::repository::activities_repository as activities; use log::info; use rusqlite::Connection; use yansi::Paint; use crate::{ - cli::{self, PeriodInput}, + cli::{self, PeriodInput, args::GroupBy}, config::Configuration, models::boat_data::BoatData, utils, @@ -16,7 +16,6 @@ pub fn show_report( conn: &Connection, args: &cli::FilterActivitiesArgs, ) -> Result<()> { - info!("getting all activities"); let period = args .period .or(config.commands.report.period) @@ -24,10 +23,28 @@ pub fn show_report( .unwrap_or(PeriodInput::Preset(cli::PresetPeriod::AllTime)); info!("using period: {period}"); + if args.group_by.is_some() { + bail!("grouping is not supported for report command yet"); + } + + // let group_by_value = args + // .group_by + // .or(config.commands.list.group_by) + // .unwrap_or(GroupBy::None); + // info!("grouping by: {group_by_value}"); + + info!("getting all activities"); let db_acts: Vec<_> = activities::get_all(conn)?; let boat_data = BoatData::create_filtered_data(db_acts, period); - info!("listing individual activity logs"); + info!("listing activity summaries"); + let prt_acts = boat_data + .get_printable_activities() + .into_iter() + .filter(|act| act.duration > 0) + .collect::>(); + + // let grouped_acts = utils::common::group_by(&prt_acts, group_by_value); info!("showing summary"); if !args.use_json_format { diff --git a/src/commands/start.rs b/src/commands/start.rs index 1124f6e..30e4229 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -31,6 +31,7 @@ pub fn start( description: None, tags: vec![], auto_start: true, + no_auto_start: false, use_json_format: false, }, ); diff --git a/src/config.rs b/src/config.rs index ad31a5f..5fed8d9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,7 +4,7 @@ use log::{debug, info}; use serde::{Deserialize, Serialize}; use std::{env, fs, path::PathBuf}; -use crate::cli::PeriodInput; +use crate::cli::{PeriodInput, args::GroupBy}; pub const APP_NAME: &str = "boat"; pub const CONFIG_VAR: &str = "BOAT_CONFIG"; @@ -33,13 +33,12 @@ pub struct Configuration { #[serde(skip_serializing_if = "Option::is_none")] pub period: Option, - /// Default output format to use ('plain', 'json', 'csv') - #[serde(rename = "format")] - pub format: OutputFormat, - /// Configuration values for the various commands #[serde(rename = "commands")] pub commands: CommandsConfig, + // /// Default output format to use ('plain', 'json', 'csv') + // #[serde(rename = "format")] + // pub format: OutputFormat, } #[derive(Debug, Default, Serialize, Deserialize)] @@ -59,7 +58,7 @@ pub struct CommandsConfig { /// Configuration values for the new command #[derive(Debug, Default, Serialize, Deserialize)] pub struct NewCommandConfig { - /// Start the new activity automatically + /// Whether the new activity should start automatically #[serde(rename = "auto_start")] pub auto_start: bool, } @@ -120,10 +119,14 @@ pub struct GetCommandConfig; #[derive(Debug, Default, Serialize, Deserialize)] pub struct ListCommandConfig { /// Restrict to entries matching the given + #[serde(rename = "period")] + #[serde(skip_serializing_if = "Option::is_none")] pub period: Option, - // /// Specify how entries should be grouped - // #[serde(rename = "group_by")] - // pub group_by: Option, + + /// Specify how entries should be grouped + #[serde(rename = "group_by")] + #[serde(skip_serializing_if = "Option::is_none")] + pub group_by: Option, // /// Specify how entries should be sorted // #[serde(rename = "sort_by")] // pub sort_by: Option, @@ -135,10 +138,13 @@ pub struct ListCommandConfig { #[derive(Debug, Default, Serialize, Deserialize)] pub struct ReportCommandConfig { /// Restrict to entries matching the given + #[serde(rename = "period")] + #[serde(skip_serializing_if = "Option::is_none")] pub period: Option, // /// Specify how entries should be grouped // #[serde(rename = "group_by")] - // pub group_by: Option, + // #[serde(skip_serializing_if = "Option::is_none")] + // pub group_by: Option, // /// Specify how entries should be sorted // #[serde(rename = "sort_by")] // pub sort_by: Option, @@ -157,7 +163,7 @@ impl Configuration { Ok(Self { database_path, period: None, - format: OutputFormat::Plain, + // format: OutputFormat::Plain, commands: CommandsConfig { new: NewCommandConfig { auto_start: false }, start: StartCommandConfig { quick_start: false }, @@ -174,7 +180,7 @@ impl Configuration { // get: GetCommandConfig, list: ListCommandConfig { period: None, - // group_by: None, + group_by: None, // sort_by: None, // format: None, }, diff --git a/src/main.rs b/src/main.rs index bfbed1b..456f00d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -46,7 +46,7 @@ fn process_args(args: Cli) -> Result<()> { match &args.command { cli::Commands::New(args) => commands::create(&config, &mut conn, args), cli::Commands::Start(args) => commands::start(&config, &mut conn, args), - cli::Commands::Cancel => commands::cancel_current(&config, &mut conn), + cli::Commands::Cancel(args) => commands::cancel_current(&config, &mut conn, args), cli::Commands::Pause => commands::pause_current(&config, &mut conn), cli::Commands::Modify(args) => commands::modify(&config, &mut conn, args), cli::Commands::Edit(args) => commands::edit(&config, &mut conn, args), diff --git a/src/models/boat_data.rs b/src/models/boat_data.rs index 0f2ac39..016bd0d 100644 --- a/src/models/boat_data.rs +++ b/src/models/boat_data.rs @@ -6,7 +6,7 @@ use log::info; use std::{cmp::Reverse, collections::HashMap}; use crate::{ - cli, + cli::{self, args::GroupBy}, models::{ activity::{PrintableActivity, SimpleActivity}, activity_log::PrintableActivityLog, diff --git a/src/utils/common.rs b/src/utils/common.rs index 3b62683..bececda 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -1,9 +1,11 @@ use anyhow::Result; use boat_lib::models::log::Log as DatabaseLog; use chrono::{Local, NaiveDate}; +use dialoguer::Confirm; use log::debug; use serde::Serialize; use std::collections::HashSet; +use yansi::Paint; use crate::{ cli::{PeriodInput, PresetPeriod}, @@ -19,6 +21,15 @@ pub fn resolve_tri_state(a: bool, b: bool, c: bool) -> bool { } } +pub fn prompt_for_confirmation(msg: &str, default_value: bool) -> Result { + let proceed = Confirm::new() + .with_prompt(Paint::yellow(&msg).to_string()) + .default(default_value) + .interact()?; + + Ok(proceed) +} + pub fn list_printable_items( items: &Vec, show_as_json: bool, diff --git a/src/utils/display.rs b/src/utils/display.rs index 03c0acc..79713f9 100644 --- a/src/utils/display.rs +++ b/src/utils/display.rs @@ -1,24 +1,82 @@ use anyhow::{Context, Result}; use boat_lib::{models::activity::Activity as DatabaseActivity, repository::Id}; -use chrono::{DateTime, Local}; +use chrono::{DateTime, Local, NaiveDate}; use yansi::Paint; -use crate::utils::{self, date::DateTimeRenderMode}; +use crate::{ + cli::args::GroupBy, + utils::{self, date::DateTimeRenderMode}, +}; pub fn format_ascii_ribbon(text: &str, tooltip_text: Option<&str>) -> String { let top_bot = format!(" *{}*\n", "-".repeat(text.len() + 2)); + + let arrow_part = match tooltip_text { + Some(tt) => format!("|=========( {tt} )====================>"), + None => "|==================================>".to_string(), + }; + format!( - "{}{} {} {} {} {}\n{}", + "{}{} {} {}\n{}", top_bot.blue(), "====|".blue(), text.cyan().bold(), - "|=========(".blue(), - tooltip_text.unwrap_or_default().italic(), - ")====================>".blue(), + arrow_part.blue(), top_bot.blue(), ) } +pub fn get_group_by_display_values( + group_by: GroupBy, + key: &str, +) -> Result<(String, Option)> { + let tuple = match group_by { + GroupBy::None => ("ALL".to_string(), None), + GroupBy::Day => { + let diff_msg = utils::common::get_date_info_msg( + Local::now().date_naive(), + NaiveDate::parse_from_str(key, "%Y-%m-%d")?, + ); + + // format!("Day {}", DateTimeRenderMode::DateOnly.render_date_time_str(key)), Some(diff_msg)) + (key.to_string(), Some(diff_msg)) + } + GroupBy::Week => { + let week_num = key.split("-W").nth(1).unwrap_or(key); + let first_day_of_week = NaiveDate::parse_from_str( + &format!("{}-W{}-1", key.split("-W").next().unwrap_or(key), week_num), + "%Y-W%W-%u", + )? + .format("%b %d, %Y") + .to_string(); + let last_day_of_week = NaiveDate::parse_from_str( + &format!("{}-W{}-7", key.split("-W").next().unwrap_or(key), week_num), + "%Y-W%W-%u", + ); + + ( + format!("Week {week_num}"), + Some(format!( + "{} - {}", + first_day_of_week, + last_day_of_week.unwrap().format("%b %d, %Y") + )), + ) + } + GroupBy::Month => { + let first_day_of_month = format!("{}-01", key); + ( + NaiveDate::parse_from_str(&first_day_of_month, "%Y-%m-%d")? + .format("%B %Y") + .to_string(), + None, + ) + } + GroupBy::Year => (key.to_string(), None), + }; + Ok(tuple) +} + pub fn created_activity_msg(activity: &DatabaseActivity) -> String { format!( "{} new #{} \"{}\"", From 4ce1693ab6b995a194c1138d982833256c3fc63e Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:54:23 +0200 Subject: [PATCH 5/8] test: have Claude improve test coverage + update old broken tests after config update --- src/cli/period.rs | 100 ++++++++++++++++++++- src/config.rs | 46 ++++++++++ src/models/activity.rs | 70 +++++++++++++++ src/models/boat_data.rs | 189 ++++++++++++++++++++++++++++++++++++++++ src/utils/common.rs | 159 +++++++++++++++++++++++++++++++++ src/utils/date.rs | 140 +++++++++++++++++++++++++++++ tests/cli_cancel.rs | 4 +- tests/cli_delete.rs | 6 +- tests/cli_get.rs | 4 +- tests/cli_modify.rs | 49 ++++++++--- tests/cli_start.rs | 2 +- tests/utils.rs | 33 ++++++- 12 files changed, 774 insertions(+), 28 deletions(-) diff --git a/src/cli/period.rs b/src/cli/period.rs index 128e06a..c35dd27 100644 --- a/src/cli/period.rs +++ b/src/cli/period.rs @@ -156,6 +156,7 @@ mod tests { struct Wrapper { val: PeriodInput, } + #[test] fn periodinput_toml_serializes_and_deserializes_as_string_forms() { let cases = [ @@ -167,8 +168,8 @@ mod tests { PeriodInput::from_str("2023-01-02..2023-01-06").unwrap(), ), ( - "2023-01-02..=2023-01-07", - PeriodInput::from_str("2023-01-02..=2023-01-07").unwrap(), + "2023-01-02..2023-01-07", + PeriodInput::from_str("2023-01-02..2023-01-07").unwrap(), ), ]; for (s, val) in cases { @@ -189,4 +190,99 @@ mod tests { let err = toml::from_str::("val = \"not-a-real-period\""); assert!(err.is_err()); } + + // --- PeriodInput::from_str aliases --- + + #[test] + fn periodinput_from_str_preset_aliases() { + let cases = [ + ("td", PresetPeriod::Today), + ("tod", PresetPeriod::Today), + ("yd", PresetPeriod::Yesterday), + ("ytd", PresetPeriod::Yesterday), + ("tw", PresetPeriod::ThisWeek), + ("twk", PresetPeriod::ThisWeek), + ("wk", PresetPeriod::ThisWeek), + ("lw", PresetPeriod::LastWeek), + ("ywk", PresetPeriod::LastWeek), + ("tm", PresetPeriod::ThisMonth), + ("mo", PresetPeriod::ThisMonth), + ("lm", PresetPeriod::LastMonth), + ("ymo", PresetPeriod::LastMonth), + ("all", PresetPeriod::AllTime), + ]; + for (input, expected) in cases { + assert_eq!( + PeriodInput::from_str(input).unwrap(), + PeriodInput::Preset(expected), + "failed for alias: {input}" + ); + } + } + + #[test] + fn periodinput_from_str_range_start_after_end_fails() { + let result = PeriodInput::from_str("2024-06-15..2024-06-01"); + assert!(result.is_err()); + } + + #[test] + fn periodinput_from_str_invalid_string_fails() { + assert!(PeriodInput::from_str("not-a-date").is_err()); + } + + #[test] + fn periodinput_from_str_invalid_date_fails() { + assert!(PeriodInput::from_str("2024-13-01").is_err()); + } + + // --- PeriodInput Display --- + + #[test] + fn periodinput_display_preset() { + assert_eq!( + format!("{}", PeriodInput::Preset(PresetPeriod::Today)), + "today" + ); + assert_eq!( + format!("{}", PeriodInput::Preset(PresetPeriod::LastWeek)), + "last-week" + ); + assert_eq!( + format!("{}", PeriodInput::Preset(PresetPeriod::AllTime)), + "all-time" + ); + } + + #[test] + fn periodinput_display_single_date() { + let input = PeriodInput::Single(NaiveDate::from_ymd_opt(2024, 4, 15).unwrap()); + assert_eq!(format!("{input}"), "2024-04-15"); + } + + #[test] + fn periodinput_display_range() { + let input = PeriodInput::Range { + start: NaiveDate::from_ymd_opt(2024, 4, 10).unwrap(), + end: NaiveDate::from_ymd_opt(2024, 4, 15).unwrap(), + }; + assert_eq!(format!("{input}"), "2024-04-10..2024-04-15"); + } + + // --- PresetPeriod::display_pretty --- + + #[test] + fn preset_period_display_pretty_static_variants() { + assert_eq!(PresetPeriod::Today.display_pretty(), "Today"); + assert_eq!(PresetPeriod::Yesterday.display_pretty(), "Yesterday"); + assert_eq!(PresetPeriod::ThisWeek.display_pretty(), "This week"); + assert_eq!(PresetPeriod::LastWeek.display_pretty(), "Last week"); + assert_eq!(PresetPeriod::AllTime.display_pretty(), "All time"); + } + + #[test] + fn preset_period_display_pretty_dynamic_variants_are_nonempty() { + assert!(!PresetPeriod::ThisMonth.display_pretty().is_empty()); + assert!(!PresetPeriod::LastMonth.display_pretty().is_empty()); + } } diff --git a/src/config.rs b/src/config.rs index 5fed8d9..d7ff1f9 100644 --- a/src/config.rs +++ b/src/config.rs @@ -250,3 +250,49 @@ pub fn initialize_config() -> Result<()> { fs::write(config_path, toml)?; Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn get_config_file_path_uses_env_var() { + let expected = std::path::PathBuf::from("/tmp/boat_test_config.toml"); + // SAFETY: single-threaded test; env var is restored immediately after + unsafe { std::env::set_var(CONFIG_VAR, &expected) }; + let result = get_config_file_path().unwrap(); + unsafe { std::env::remove_var(CONFIG_VAR) }; + assert_eq!(result, expected); + } + + #[test] + fn get_config_file_path_fallback_contains_app_name() { + // SAFETY: single-threaded test; removing a var we don't depend on elsewhere + unsafe { std::env::remove_var(CONFIG_VAR) }; + let path = get_config_file_path().unwrap(); + assert!( + path.to_string_lossy().contains(APP_NAME), + "default config path should contain '{APP_NAME}'" + ); + } + + #[test] + fn configuration_create_default_has_expected_defaults() { + unsafe { std::env::remove_var(CONFIG_VAR) }; + let config = Configuration::create_default().unwrap(); + assert!(!config.commands.new.auto_start); + assert!(config.commands.cancel.confirm); + assert!(config.commands.delete.confirm); + assert!(config.commands.edit.confirm); + } + + #[test] + fn configuration_to_toml_str_round_trips() { + unsafe { std::env::remove_var(CONFIG_VAR) }; + let config = Configuration::create_default().unwrap(); + let toml_str = config.to_toml_str().unwrap(); + assert!(toml_str.contains("database_path")); + let restored: Configuration = toml::from_str(&toml_str).unwrap(); + assert_eq!(restored.database_path, config.database_path); + } +} diff --git a/src/models/activity.rs b/src/models/activity.rs index 164cb92..9b46f36 100644 --- a/src/models/activity.rs +++ b/src/models/activity.rs @@ -83,6 +83,36 @@ impl RowPrintable for PrintableActivity { #[cfg(test)] mod tests { use super::*; + use boat_lib::models::log::Log as DatabaseLog; + use chrono::{TimeZone, Utc}; + + fn simple_act(id: Id) -> SimpleActivity { + SimpleActivity { + id, + name: "coding".to_string(), + description: None, + tags: HashSet::new(), + } + } + + fn closed_log(id: Id, duration_secs: i64) -> DatabaseLog { + let start = Utc.with_ymd_and_hms(2024, 4, 15, 10, 0, 0).unwrap(); + DatabaseLog { + id, + activity_id: 1, + starts_at: start, + ends_at: Some(start + chrono::Duration::seconds(duration_secs)), + } + } + + fn open_log(id: Id) -> DatabaseLog { + DatabaseLog { + id, + activity_id: 1, + starts_at: Utc.with_ymd_and_hms(2024, 4, 15, 10, 0, 0).unwrap(), + ends_at: None, + } + } #[test] fn tags_str_renders_comma_separated() { @@ -104,4 +134,44 @@ mod tests { assert!(tags_str.contains("bar")); assert!(tags_str.find(',').is_some()); } + + #[test] + fn from_activity_and_logs_empty_logs_zero_duration_not_ongoing() { + let act = simple_act(1); + let pa = PrintableActivity::from_activity_and_logs(&act, &[]); + assert_eq!(pa.duration, 0); + assert!(!pa.ongoing); + } + + #[test] + fn from_activity_and_logs_single_closed_log() { + let act = simple_act(1); + let pa = PrintableActivity::from_activity_and_logs(&act, &[closed_log(1, 3600)]); + assert_eq!(pa.duration, 3600); + assert!(!pa.ongoing); + } + + #[test] + fn from_activity_and_logs_open_ended_log_is_ongoing() { + let act = simple_act(1); + let pa = PrintableActivity::from_activity_and_logs(&act, &[open_log(1)]); + assert!(pa.ongoing); + } + + #[test] + fn from_activity_and_logs_multiple_logs_sums_durations() { + let act = simple_act(1); + let logs = [closed_log(1, 1800), closed_log(2, 900)]; + let pa = PrintableActivity::from_activity_and_logs(&act, &logs); + assert_eq!(pa.duration, 2700); + assert!(!pa.ongoing); + } + + #[test] + fn from_activity_and_logs_mixed_closed_and_open_is_ongoing() { + let act = simple_act(1); + let logs = [closed_log(1, 1800), open_log(2)]; + let pa = PrintableActivity::from_activity_and_logs(&act, &logs); + assert!(pa.ongoing); + } } diff --git a/src/models/boat_data.rs b/src/models/boat_data.rs index 016bd0d..eac9dc8 100644 --- a/src/models/boat_data.rs +++ b/src/models/boat_data.rs @@ -171,3 +171,192 @@ impl BoatData { csv_data } } + +#[cfg(test)] +mod tests { + use super::*; + use boat_lib::models::log::Log as DatabaseLog; + use chrono::{TimeZone, Utc}; + use std::collections::HashMap; + + fn make_activity(id: Id, name: &str) -> SimpleActivity { + SimpleActivity { + id, + name: name.to_string(), + description: None, + tags: std::collections::HashSet::new(), + } + } + + fn make_log(id: Id, activity_id: Id, start_h: u32, end_h: Option) -> DatabaseLog { + let base = Utc.with_ymd_and_hms(2024, 4, 15, start_h, 0, 0).unwrap(); + DatabaseLog { + id, + activity_id, + starts_at: base, + ends_at: end_h.map(|h| Utc.with_ymd_and_hms(2024, 4, 15, h, 0, 0).unwrap()), + } + } + + fn boat_data(acts: Vec<(Id, &str)>, logs: Vec<(Id, Vec)>) -> BoatData { + let activities: HashMap = acts + .into_iter() + .map(|(id, name)| (id, make_activity(id, name))) + .collect(); + let logs: HashMap> = logs.into_iter().collect(); + BoatData { activities, logs } + } + + // --- get_printable_activities --- + + #[test] + fn get_printable_activities_sorted_by_duration_desc() { + let data = boat_data( + vec![(1, "short"), (2, "long")], + vec![ + (1, vec![make_log(10, 1, 10, Some(11))]), // 1h + (2, vec![make_log(20, 2, 8, Some(12))]), // 4h + ], + ); + let acts = data.get_printable_activities(); + assert_eq!(acts.len(), 2); + assert!(acts[0].duration > acts[1].duration); + assert_eq!(acts[0].name, "long"); + } + + #[test] + fn get_printable_activities_activity_with_no_logs_has_zero_duration() { + let data = boat_data(vec![(1, "idle")], vec![(1, vec![])]); + let acts = data.get_printable_activities(); + assert_eq!(acts[0].duration, 0); + assert!(!acts[0].ongoing); + } + + // --- get_printable_logs --- + + #[test] + fn get_printable_logs_sorted_by_start_asc() { + let data = boat_data( + vec![(1, "work")], + vec![( + 1, + vec![ + make_log(2, 1, 14, Some(15)), // 14:00 + make_log(1, 1, 9, Some(10)), // 09:00 — earlier + ], + )], + ); + let logs = data.get_printable_logs(); + assert_eq!(logs.len(), 2); + assert!(logs[0].log.starts_at < logs[1].log.starts_at); + } + + // --- to_csv_str --- + + fn data_line_count(csv: &str) -> usize { + csv.lines() + .filter(|l| !l.is_empty() && !l.trim_start().starts_with('#')) + .count() + } + + #[test] + fn to_csv_str_contains_log_line() { + let data = boat_data( + vec![(1, "coding")], + vec![(1, vec![make_log(1, 1, 10, Some(11))])], + ); + let csv = data.to_csv_str(false, false); + assert_eq!(data_line_count(&csv), 1); + let data_line = csv + .lines() + .find(|l| !l.trim_start().starts_with('#')) + .unwrap(); + // format: act_id,log_id,starts_at,ends_at + let fields: Vec<&str> = data_line.split(',').collect(); + assert_eq!( + fields.len(), + 4, + "log line should have 4 comma-separated fields" + ); + assert_eq!(fields[0], "1"); + assert_eq!(fields[1], "1"); + } + + #[test] + fn to_csv_str_open_ended_log_has_empty_ends_at() { + let data = boat_data( + vec![(1, "coding")], + vec![(1, vec![make_log(1, 1, 10, None)])], + ); + let csv = data.to_csv_str(false, false); + let data_line = csv + .lines() + .find(|l| !l.trim_start().starts_with('#')) + .unwrap(); + // last field before newline should be empty + assert!( + data_line.ends_with(','), + "ends_at should be empty for open-ended log" + ); + } + + #[test] + fn to_csv_str_includes_instructions_when_requested() { + let data = boat_data( + vec![(1, "t")], + vec![(1, vec![make_log(1, 1, 10, Some(11))])], + ); + let with = data.to_csv_str(true, false); + let without = data.to_csv_str(false, false); + assert!(with.contains("# This is a CSV export")); + assert!(!without.contains("# This is a CSV export")); + } + + #[test] + fn to_csv_str_includes_activity_definitions_when_requested() { + let data = boat_data( + vec![(1, "t")], + vec![(1, vec![make_log(1, 1, 10, Some(11))])], + ); + let with = data.to_csv_str(false, true); + let without = data.to_csv_str(false, false); + assert!(with.contains("# Activity definitions:")); + assert!(!without.contains("# Activity definitions:")); + } + + #[test] + fn to_csv_str_logs_sorted_chronologically() { + let data = boat_data( + vec![(1, "work")], + vec![( + 1, + vec![ + make_log(2, 1, 14, Some(15)), // later + make_log(1, 1, 9, Some(10)), // earlier + ], + )], + ); + let csv = data.to_csv_str(false, false); + let data_lines: Vec<&str> = csv + .lines() + .filter(|l| !l.trim_start().starts_with('#')) + .collect(); + assert_eq!(data_lines.len(), 2); + // first line should be log id=1 (9:00), second should be log id=2 (14:00) + assert!( + data_lines[0].starts_with("1,1,"), + "earlier log should appear first" + ); + assert!( + data_lines[1].starts_with("1,2,"), + "later log should appear second" + ); + } + + #[test] + fn to_csv_str_activity_with_no_logs_is_omitted() { + let data = boat_data(vec![(1, "idle")], vec![(1, vec![])]); + let csv = data.to_csv_str(false, false); + assert_eq!(data_line_count(&csv), 0); + } +} diff --git a/src/utils/common.rs b/src/utils/common.rs index bececda..eb8cde8 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -124,3 +124,162 @@ pub fn get_date_info_msg(today: NaiveDate, compare_to: NaiveDate) -> String { format!("{diff_months} months ago") } + +#[cfg(test)] +mod tests { + use super::*; + use boat_lib::models::log::Log as DatabaseLog; + use chrono::{NaiveDate, TimeZone, Utc}; + + fn make_log(starts: (i32, u32, u32), ends: Option<(i32, u32, u32)>) -> DatabaseLog { + DatabaseLog { + id: 1, + activity_id: 1, + starts_at: Utc.with_ymd_and_hms(starts.0, starts.1, starts.2, 10, 0, 0).unwrap(), + ends_at: ends.map(|(y, m, d)| Utc.with_ymd_and_hms(y, m, d, 11, 0, 0).unwrap()), + } + } + + fn date(s: &str) -> NaiveDate { + NaiveDate::parse_from_str(s, "%Y-%m-%d").unwrap() + } + + // --- resolve_tri_state --- + + #[test] + fn resolve_tri_state_a_overrides() { + assert!(resolve_tri_state(true, false, false)); + assert!(resolve_tri_state(true, false, true)); + } + + #[test] + fn resolve_tri_state_b_overrides() { + assert!(!resolve_tri_state(false, true, true)); + assert!(!resolve_tri_state(false, true, false)); + } + + #[test] + fn resolve_tri_state_falls_back_to_c() { + assert!(resolve_tri_state(false, false, true)); + assert!(!resolve_tri_state(false, false, false)); + } + + #[test] + fn resolve_tri_state_both_ab_set_uses_c() { + assert!(resolve_tri_state(true, true, true)); + assert!(!resolve_tri_state(true, true, false)); + } + + // --- tags_str --- + + #[test] + fn tags_str_empty() { + assert_eq!(tags_str(&std::collections::HashSet::new()), ""); + } + + #[test] + fn tags_str_single() { + let mut tags = std::collections::HashSet::new(); + tags.insert("rust".to_string()); + assert_eq!(tags_str(&tags), "rust"); + } + + #[test] + fn tags_str_sorted_alphabetically() { + let mut tags = std::collections::HashSet::new(); + tags.insert("zzz".to_string()); + tags.insert("aaa".to_string()); + tags.insert("mmm".to_string()); + assert_eq!(tags_str(&tags), "aaa,mmm,zzz"); + } + + // --- matches_date --- + + #[test] + fn matches_date_log_on_same_day() { + let log = make_log((2024, 4, 15), Some((2024, 4, 15))); + assert!(matches_date(&log, &date("2024-04-15"))); + } + + #[test] + fn matches_date_log_on_different_day() { + let log = make_log((2024, 4, 15), Some((2024, 4, 15))); + assert!(!matches_date(&log, &date("2024-04-14"))); + } + + #[test] + fn matches_date_log_spanning_two_days_does_not_match() { + let log = make_log((2024, 4, 14), Some((2024, 4, 15))); + assert!(!matches_date(&log, &date("2024-04-14"))); + assert!(!matches_date(&log, &date("2024-04-15"))); + } + + // --- matches_date_range --- + + #[test] + fn matches_date_range_log_within_bounds() { + let log = make_log((2024, 4, 12), Some((2024, 4, 12))); + assert!(matches_date_range(&log, &date("2024-04-10"), &date("2024-04-15"))); + } + + #[test] + fn matches_date_range_log_at_exact_bounds() { + let log = make_log((2024, 4, 10), Some((2024, 4, 10))); + assert!(matches_date_range(&log, &date("2024-04-10"), &date("2024-04-10"))); + } + + #[test] + fn matches_date_range_start_before_range() { + let log = make_log((2024, 4, 9), Some((2024, 4, 12))); + assert!(!matches_date_range(&log, &date("2024-04-10"), &date("2024-04-15"))); + } + + #[test] + fn matches_date_range_end_after_range() { + let log = make_log((2024, 4, 12), Some((2024, 4, 16))); + assert!(!matches_date_range(&log, &date("2024-04-10"), &date("2024-04-15"))); + } + + // --- get_date_info_msg --- + + #[test] + fn get_date_info_msg_today() { + let today = date("2024-06-15"); + assert_eq!(get_date_info_msg(today, today), "Today"); + } + + #[test] + fn get_date_info_msg_yesterday() { + let today = date("2024-06-15"); + assert_eq!(get_date_info_msg(today, date("2024-06-14")), "Yesterday"); + } + + #[test] + fn get_date_info_msg_days_ago() { + let today = date("2024-06-15"); + assert_eq!(get_date_info_msg(today, date("2024-06-12")), "3 days ago"); + // boundary: 7 is still "days ago" (the <= 7 check comes before the weeks check) + assert_eq!(get_date_info_msg(today, date("2024-06-08")), "7 days ago"); + } + + #[test] + fn get_date_info_msg_weeks_ago() { + let today = date("2024-06-15"); + assert_eq!(get_date_info_msg(today, date("2024-06-07")), "1 week ago"); + assert_eq!(get_date_info_msg(today, date("2024-06-01")), "2 weeks ago"); + } + + #[test] + fn get_date_info_msg_last_month() { + let today = date("2024-06-15"); + // 35 days ago: diff_weeks = 5, diff_months = 1 → "Last month" + assert_eq!(get_date_info_msg(today, date("2024-05-11")), "Last month"); + } + + #[test] + fn get_date_info_msg_months_ago() { + let today = date("2024-06-15"); + // 75 days ago: diff_weeks = 10, diff_months = 2 + assert_eq!(get_date_info_msg(today, date("2024-04-01")), "2 months ago"); + } +} diff --git a/src/utils/date.rs b/src/utils/date.rs index 6f3073f..c8caad6 100644 --- a/src/utils/date.rs +++ b/src/utils/date.rs @@ -197,3 +197,143 @@ mod parse_date_tests { assert!(e.contains("invalid date")); } } + +#[cfg(test)] +mod date_check_tests { + use super::*; + use chrono::{Local, Months}; + + // --- pretty_format_duration (long format) --- + + #[test] + fn pretty_format_duration_long_format_seconds() { + assert_eq!(pretty_format_duration(0, true), "0 seconds"); + assert_eq!(pretty_format_duration(45, true), "45 seconds"); + } + + #[test] + fn pretty_format_duration_long_format_minutes() { + assert_eq!(pretty_format_duration(60, true), "1 minutes"); + assert_eq!(pretty_format_duration(90, true), "1 minutes"); + } + + #[test] + fn pretty_format_duration_long_format_hours_and_minutes() { + assert_eq!(pretty_format_duration(3661, true), "1 hours 1 minutes"); + } + + // --- DateTimeRenderMode --- + + #[test] + fn render_naive_date_date_only() { + let d = NaiveDate::from_ymd_opt(2024, 4, 15).unwrap(); + assert_eq!(DateTimeRenderMode::DateOnly.render_naive_date(&d), "2024-04-15"); + } + + #[test] + fn render_date_time_date_only() { + use chrono::{TimeZone, Utc}; + let dt = Utc.with_ymd_and_hms(2024, 4, 15, 13, 30, 0).unwrap(); + assert_eq!(DateTimeRenderMode::DateOnly.render_date_time(dt), "2024-04-15"); + } + + #[test] + fn render_date_time_time_only() { + use chrono::{TimeZone, Utc}; + let dt = Utc.with_ymd_and_hms(2024, 4, 15, 13, 30, 0).unwrap(); + assert_eq!(DateTimeRenderMode::TimeOnly.render_date_time(dt), "13:30"); + } + + #[test] + fn render_date_time_date_and_time() { + use chrono::{TimeZone, Utc}; + let dt = Utc.with_ymd_and_hms(2024, 4, 15, 13, 30, 0).unwrap(); + assert_eq!( + DateTimeRenderMode::DateAndTime.render_date_time(dt), + "2024-04-15 13:30" + ); + } + + // --- is_today / is_yesterday --- + + #[test] + fn is_today_with_now() { + assert!(is_today(Local::now())); + } + + #[test] + fn is_today_with_yesterday() { + assert!(!is_today(Local::now() - Duration::days(1))); + } + + #[test] + fn is_yesterday_with_one_day_ago() { + assert!(is_yesterday(Local::now() - Duration::days(1))); + } + + #[test] + fn is_yesterday_with_today() { + assert!(!is_yesterday(Local::now())); + } + + #[test] + fn is_yesterday_with_two_days_ago() { + assert!(!is_yesterday(Local::now() - Duration::days(2))); + } + + // --- is_this_week / is_last_week --- + + #[test] + fn is_this_week_with_now() { + assert!(is_this_week(Local::now())); + } + + #[test] + fn is_this_week_with_seven_days_ago() { + // 7 days ago is always the previous ISO week + assert!(!is_this_week(Local::now() - Duration::days(7))); + } + + #[test] + fn is_last_week_with_seven_days_ago() { + // Subtracting exactly 7 days always lands in the previous ISO week + assert!(is_last_week(Local::now() - Duration::days(7))); + } + + #[test] + fn is_last_week_with_now() { + assert!(!is_last_week(Local::now())); + } + + #[test] + fn is_last_week_with_fourteen_days_ago() { + assert!(!is_last_week(Local::now() - Duration::days(14))); + } + + // --- is_this_month / is_last_month --- + + #[test] + fn is_this_month_with_now() { + assert!(is_this_month(Local::now())); + } + + #[test] + fn is_this_month_with_forty_days_ago() { + assert!(!is_this_month(Local::now() - Duration::days(40))); + } + + #[test] + fn is_last_month_with_last_month() { + assert!(is_last_month(Local::now() - Months::new(1))); + } + + #[test] + fn is_last_month_with_now() { + assert!(!is_last_month(Local::now())); + } + + #[test] + fn is_last_month_with_two_months_ago() { + assert!(!is_last_month(Local::now() - Months::new(2))); + } +} diff --git a/tests/cli_cancel.rs b/tests/cli_cancel.rs index e967fde..afda77b 100644 --- a/tests/cli_cancel.rs +++ b/tests/cli_cancel.rs @@ -10,10 +10,10 @@ fn cancel_running_activity_succeeds() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Create & start activity - run_boat(["new", "to-cancel", "--start"], &config_path).success(); + run_boat(["new", "to-cancel", "--start-now"], &config_path).success(); // Cancel - run_boat(["cancel"], &config_path) + run_boat(["cancel", "--no-confirm"], &config_path) .success() .stdout(predicates::str::contains("cancelled")); diff --git a/tests/cli_delete.rs b/tests/cli_delete.rs index dbc5688..e4991ce 100644 --- a/tests/cli_delete.rs +++ b/tests/cli_delete.rs @@ -14,15 +14,15 @@ fn delete_existing_succeeds() -> Result<()> { run_boat(["new", "to-delete"], &config_path).success(); run_boat(["start", "1"], &config_path).success(); run_boat(["pause"], &config_path).success(); - run_boat(["list", "--all"], &config_path) + run_boat(["list"], &config_path) .success() .stdout(predicates::str::contains("to-delete")); // Delete - run_boat(["delete", "1"], &config_path).success(); + run_boat(["delete", "1", "--no-confirm"], &config_path).success(); // Should not appear in the list anymore - run_boat(["list", "--all", "--json"], &config_path) + run_boat(["list", "--json"], &config_path) .success() .stdout(predicates::str::contains("to-delete").not()); diff --git a/tests/cli_get.rs b/tests/cli_get.rs index 2b52b1f..1600c83 100644 --- a/tests/cli_get.rs +++ b/tests/cli_get.rs @@ -10,7 +10,7 @@ fn get_running_activity_plain_succeeds() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Create, start activity and get - run_boat(["new", "get-act-plain", "--start"], &config_path).success(); + run_boat(["new", "get-act-plain", "--start-now"], &config_path).success(); run_boat(["start", "1"], &config_path).success(); run_boat(["get"], &config_path).stdout(predicates::str::contains("get-act-plain")); @@ -22,7 +22,7 @@ fn get_running_activity_json_succeeds() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Create, start activity and get as JSON - run_boat(["new", "get-act-json", "--start"], &config_path).success(); + run_boat(["new", "get-act-json", "--start-now"], &config_path).success(); run_boat(["get", "--json"], &config_path) .success() .stdout(predicates::str::contains("get-act-json")); diff --git a/tests/cli_modify.rs b/tests/cli_modify.rs index 920a881..bca27fa 100644 --- a/tests/cli_modify.rs +++ b/tests/cli_modify.rs @@ -11,13 +11,17 @@ fn modify_name_succeeds() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Create activity and start - run_boat(["new", "OldName", "--start"], &config_path).success(); + run_boat(["new", "OldName", "--start-now"], &config_path).success(); // Modify name - run_boat(["modify", "1", "--name", "NewName"], &config_path).success(); + run_boat( + ["modify", "1", "--name", "NewName", "--no-confirm"], + &config_path, + ) + .success(); // Confirm change in `list` - run_boat(["list", "--all", "--json"], &config_path) + run_boat(["list", "--json"], &config_path) .success() .stdout(predicates::str::contains("NewName")); @@ -29,16 +33,22 @@ fn modify_description_succeeds() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Create activity - run_boat(["new", "TestAct", "--start"], &config_path).success(); + run_boat(["new", "TestAct", "--start-now"], &config_path).success(); // Modify description run_boat( - ["modify", "1", "--description", "an activity for tests"], + [ + "modify", + "1", + "--description", + "an activity for tests", + "--no-confirm", + ], &config_path, ); // Confirm change in `list` - run_boat(["list", "--all", "--json"], &config_path) + run_boat(["list", "--json"], &config_path) .success() .stdout(predicates::str::contains("an activity for tests")); @@ -50,16 +60,24 @@ fn modify_tags_succeeds() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Create activity - run_boat(["new", "Taggy", "--start"], &config_path).success(); + run_boat(["new", "Taggy", "--start-now"], &config_path).success(); // Modify tags run_boat( - ["modify", "1", "--tags", "testing", "write-tests", "foo"], + [ + "modify", + "1", + "--tags", + "testing", + "write-tests", + "foo", + "--no-confirm", + ], &config_path, ); // Confirm change in `list` - run_boat(["list", "--all", "--json"], &config_path) + run_boat(["list", "--json"], &config_path) .success() .stdout(predicates::str::contains("testing").and(predicates::str::contains("write-tests"))); @@ -71,9 +89,12 @@ fn modify_nonexistent_id_fails() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // No activities at all - run_boat(["modify", "1", "--name", "Nobody"], &config_path) - .failure() - .stderr(predicates::str::contains("does not exist")); + run_boat( + ["modify", "1", "--name", "Nobody", "--no-confirm"], + &config_path, + ) + .failure() + .stderr(predicates::str::contains("does not exist")); Ok(()) } @@ -86,7 +107,7 @@ fn modify_no_field_fails() -> Result<()> { run_boat(["new", "Hello"], &config_path).success(); // Attempt modify with no field to change - run_boat(["modify", "1"], &config_path) + run_boat(["modify", "1", "--no-confirm"], &config_path) .failure() .stderr(predicates::str::contains( "the following required arguments were not provided", @@ -100,7 +121,7 @@ fn modify_missing_id_fails() -> Result<()> { let (_tmp, config_path) = cli_args_for_temp()?; // Try to modify without specifying an ID - run_boat(["modify", "--name", "Nope"], &config_path) + run_boat(["modify", "--name", "Nope", "--no-confirm"], &config_path) .failure() .stderr(predicates::str::contains("ID")); diff --git a/tests/cli_start.rs b/tests/cli_start.rs index a916693..973171b 100644 --- a/tests/cli_start.rs +++ b/tests/cli_start.rs @@ -23,7 +23,7 @@ fn start_with_no_id_fails() -> Result<()> { run_boat(["start"], config_path) .failure() - .stderr(predicates::str::contains("ID")); + .stderr(predicates::str::contains("ACTIVITY_HANDLE")); Ok(()) } diff --git a/tests/utils.rs b/tests/utils.rs index 89f5bf2..c5186fb 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -7,10 +7,35 @@ pub fn cli_args_for_temp() -> Result<(TempDir, std::path::PathBuf)> { let db_path = tmp.path().join("boat.db"); let config_path = tmp.path().join("boat_config.toml"); - std::fs::write( - &config_path, - format!("database_path = {:?}", db_path.display()), - )?; + let config_content = format!("database_path = {:?}\n", db_path.display()) + + r#" +period = "all" +format = "plain" + +[commands.new] +auto_start = false + +[commands.start] +quick_start = true + +[commands.cancel] +confirm = true + +[commands.edit] +show_instructions = true +show_activity_definitions = true +confirm = true + +[commands.delete] +confirm = true + +[commands.list] +group_by = "day" + +[commands.report] +"#; + + std::fs::write(&config_path, config_content.as_bytes())?; Ok((tmp, config_path)) } From becae7085fb75172fb131cd59e640f07f5d4ac3e Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Thu, 23 Apr 2026 22:07:19 +0200 Subject: [PATCH 6/8] chore: run cargo fmt to fix code styling --- src/utils/common.rs | 28 +++++++++++++++++++++++----- src/utils/date.rs | 10 ++++++++-- 2 files changed, 31 insertions(+), 7 deletions(-) diff --git a/src/utils/common.rs b/src/utils/common.rs index eb8cde8..61cea65 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -135,7 +135,9 @@ mod tests { DatabaseLog { id: 1, activity_id: 1, - starts_at: Utc.with_ymd_and_hms(starts.0, starts.1, starts.2, 10, 0, 0).unwrap(), + starts_at: Utc + .with_ymd_and_hms(starts.0, starts.1, starts.2, 10, 0, 0) + .unwrap(), ends_at: ends.map(|(y, m, d)| Utc.with_ymd_and_hms(y, m, d, 11, 0, 0).unwrap()), } } @@ -219,25 +221,41 @@ mod tests { #[test] fn matches_date_range_log_within_bounds() { let log = make_log((2024, 4, 12), Some((2024, 4, 12))); - assert!(matches_date_range(&log, &date("2024-04-10"), &date("2024-04-15"))); + assert!(matches_date_range( + &log, + &date("2024-04-10"), + &date("2024-04-15") + )); } #[test] fn matches_date_range_log_at_exact_bounds() { let log = make_log((2024, 4, 10), Some((2024, 4, 10))); - assert!(matches_date_range(&log, &date("2024-04-10"), &date("2024-04-10"))); + assert!(matches_date_range( + &log, + &date("2024-04-10"), + &date("2024-04-10") + )); } #[test] fn matches_date_range_start_before_range() { let log = make_log((2024, 4, 9), Some((2024, 4, 12))); - assert!(!matches_date_range(&log, &date("2024-04-10"), &date("2024-04-15"))); + assert!(!matches_date_range( + &log, + &date("2024-04-10"), + &date("2024-04-15") + )); } #[test] fn matches_date_range_end_after_range() { let log = make_log((2024, 4, 12), Some((2024, 4, 16))); - assert!(!matches_date_range(&log, &date("2024-04-10"), &date("2024-04-15"))); + assert!(!matches_date_range( + &log, + &date("2024-04-10"), + &date("2024-04-15") + )); } // --- get_date_info_msg --- diff --git a/src/utils/date.rs b/src/utils/date.rs index c8caad6..f3734de 100644 --- a/src/utils/date.rs +++ b/src/utils/date.rs @@ -227,14 +227,20 @@ mod date_check_tests { #[test] fn render_naive_date_date_only() { let d = NaiveDate::from_ymd_opt(2024, 4, 15).unwrap(); - assert_eq!(DateTimeRenderMode::DateOnly.render_naive_date(&d), "2024-04-15"); + assert_eq!( + DateTimeRenderMode::DateOnly.render_naive_date(&d), + "2024-04-15" + ); } #[test] fn render_date_time_date_only() { use chrono::{TimeZone, Utc}; let dt = Utc.with_ymd_and_hms(2024, 4, 15, 13, 30, 0).unwrap(); - assert_eq!(DateTimeRenderMode::DateOnly.render_date_time(dt), "2024-04-15"); + assert_eq!( + DateTimeRenderMode::DateOnly.render_date_time(dt), + "2024-04-15" + ); } #[test] From 300d0996a750f648fde78c9568f6dbebc246e923 Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Fri, 24 Apr 2026 08:26:13 +0200 Subject: [PATCH 7/8] fix: confirm was not evaluated in modify command + fix cli_smoke.rs which was not using the temp config file --- src/commands/modify.rs | 16 +++++++++++++++ src/commands/report.rs | 2 +- src/config.rs | 9 ++++++--- src/models/boat_data.rs | 2 +- tests/cli_smoke.rs | 45 ++++++++++++++++++++++++----------------- tests/utils.rs | 3 +++ 6 files changed, 53 insertions(+), 24 deletions(-) diff --git a/src/commands/modify.rs b/src/commands/modify.rs index baf5fd1..8614ca2 100644 --- a/src/commands/modify.rs +++ b/src/commands/modify.rs @@ -15,6 +15,22 @@ pub fn modify( bail!(utils::display::activity_id_does_not_exist(args.id)); }; + let ask_for_confirmation = utils::common::resolve_tri_state( + args.confirm, + args.no_confirm, + config.commands.modify.confirm, + ); + info!("ask user for confirmation? {ask_for_confirmation}"); + + let prompt_msg = format!( + "are you sure you want to modify activity #{} \"{}\"?", + args.id, old.name + ); + if ask_for_confirmation && !utils::common::prompt_for_confirmation(&prompt_msg, false)? { + println!("user aborted the operation, activity was not modified"); + return Ok(()); + } + info!("about to modify activity: {old:?}"); activities::update( conn, diff --git a/src/commands/report.rs b/src/commands/report.rs index c1559d0..f323efd 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -5,7 +5,7 @@ use rusqlite::Connection; use yansi::Paint; use crate::{ - cli::{self, PeriodInput, args::GroupBy}, + cli::{self, PeriodInput}, config::Configuration, models::boat_data::BoatData, utils, diff --git a/src/config.rs b/src/config.rs index d7ff1f9..c0369ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -47,7 +47,7 @@ pub struct CommandsConfig { pub start: StartCommandConfig, pub cancel: CancelCommandConfig, // pub pause: PauseCommandConfig, - // pub modify: ModifyCommandConfig, + pub modify: ModifyCommandConfig, pub edit: EditCommandConfig, pub delete: DeleteCommandConfig, // pub get: GetCommandConfig, @@ -84,7 +84,10 @@ pub struct PauseCommandConfig; /// Configuration values for the modify command #[derive(Debug, Default, Serialize, Deserialize)] -pub struct ModifyCommandConfig; +pub struct ModifyCommandConfig { + /// Prompts for confirmation before applying modifications to an activity + pub confirm: bool, +} /// Configuration values for the edit command #[derive(Debug, Default, Serialize, Deserialize)] @@ -169,7 +172,7 @@ impl Configuration { start: StartCommandConfig { quick_start: false }, cancel: CancelCommandConfig { confirm: true }, // pause: PauseCommandConfig, - // modify: ModifyCommandConfig, + modify: ModifyCommandConfig { confirm: true }, edit: EditCommandConfig { period: None, show_instructions: true, diff --git a/src/models/boat_data.rs b/src/models/boat_data.rs index eac9dc8..e9dbb83 100644 --- a/src/models/boat_data.rs +++ b/src/models/boat_data.rs @@ -6,7 +6,7 @@ use log::info; use std::{cmp::Reverse, collections::HashMap}; use crate::{ - cli::{self, args::GroupBy}, + cli::{self}, models::{ activity::{PrintableActivity, SimpleActivity}, activity_log::PrintableActivityLog, diff --git a/tests/cli_smoke.rs b/tests/cli_smoke.rs index 05f9c9c..db38ee5 100644 --- a/tests/cli_smoke.rs +++ b/tests/cli_smoke.rs @@ -1,39 +1,46 @@ //! Basic smoke tests: help, version, invalid command -use assert_cmd::Command; +use anyhow::Result; use predicates::prelude::*; +use crate::utils::{cli_args_for_temp, run_boat}; + +mod utils; + #[test] -fn test_help_arg() { - let mut cmd = Command::cargo_bin("boat").unwrap(); - cmd.arg("--help"); - cmd.assert() +fn test_help_arg() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + run_boat(["--help"], config_path) .success() .stdout(predicates::str::contains("Usage").or(predicates::str::contains("USAGE"))); + Ok(()) } #[test] -fn test_help_subcommand_short_alias() { - let mut cmd = Command::cargo_bin("boat").unwrap(); - cmd.arg("h"); - cmd.assert() +fn test_help_subcommand_short_alias() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + run_boat(["h"], config_path) .success() .stdout(predicates::str::contains("Usage").or(predicates::str::contains("USAGE"))); + Ok(()) } #[test] -fn test_version_arg() { - let mut cmd = Command::cargo_bin("boat").unwrap(); - cmd.arg("--version"); - cmd.assert() +fn test_version_arg() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + run_boat(["--version"], config_path) .success() .stdout(predicates::str::contains("boat")); + Ok(()) } #[test] -fn test_unknown_subcommand_fails() { - let mut cmd = Command::cargo_bin("boat").unwrap(); - cmd.arg("definitely-not-a-command"); - cmd.assert().failure().stderr( - predicates::str::contains("error").or(predicates::str::contains("not a valid subcommand")), - ); +fn test_unknown_subcommand_fails() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + run_boat(["definitely-not-a-command"], config_path) + .failure() + .stderr( + predicates::str::contains("error") + .or(predicates::str::contains("not a valid subcommand")), + ); + Ok(()) } diff --git a/tests/utils.rs b/tests/utils.rs index c5186fb..f43224a 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -21,6 +21,9 @@ quick_start = true [commands.cancel] confirm = true +[commands.modify] +confirm = true + [commands.edit] show_instructions = true show_activity_definitions = true From e4c5d5d69e6c81589de7d5783a92939a7ff0120d Mon Sep 17 00:00:00 2001 From: Coko <91132775+Coko7@users.noreply.github.com> Date: Fri, 24 Apr 2026 09:08:05 +0200 Subject: [PATCH 8/8] docs: update README --- README.md | 32 +++++++++++++++++++++++++++++--- 1 file changed, 29 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index ec67ef3..b8dac26 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,7 @@ It relies on [`boat-lib`](https://github.com/coko7/boat-lib) for core functions. - [Install with a bundled version of SQLite](#install-with-a-bundled-version-of-sqlite) - [⚙️ Configuration](#%EF%B8%8F-configuration) - [✨ Usage](#-usage) +- [🧠 (mostly) Brain made](#-mostly-brain-made) ## 🚀 Demo @@ -121,9 +122,11 @@ confirm = true confirm = true [commands.list] +period = "month" group_by = "day" [commands.report] +period = "day" ``` ## ✨ Usage @@ -132,7 +135,7 @@ If you have ever used [`bartib`](https://github.com/nikolassv/bartib), then `boa Try `boat help` for a quick list of commands: ```help -boat 0.6.0 +boat 0.8.0 Basic Opinionated Activity Tracker @@ -145,10 +148,11 @@ Commands: cancel Cancel the current activity pause Pause/stop the current activity modify Modify an activity - edit Edit activity logs manually + edit Edit activity logs as text in an external editor delete Delete an activity get Get the current activity - list List activities + list List activity logs + report Show activity summaries help Print this message or the help of the given subcommand(s) Options: @@ -156,6 +160,8 @@ Options: -q, --quiet... Decrease logging verbosity -h, --help Print help -V, --version Print version + +Made by @coko7 ``` > [!TIP] @@ -170,6 +176,7 @@ Options: > - delete: `d`, `del`, `delete`, `rm`, `rem`, `remove` > - get: `g`, `get` > - list: `l`, `ls`, `list` +> - report: `r`, `rep`, `report` > - help: `h`, `help`, `-h`, `--help` > > Prefer using the full length command names in scripts as they are more explicit and unlikely to be changed (unlike shorter aliases). @@ -181,3 +188,22 @@ Like `stop` would have been a better command than `pause` but since it shares th Maybe I will drop this in the future, let's see. _I have included some fallback in case you type `stop`/`remove` instead of `pause`/`delete` 👀_ + +## 🧠 (mostly) Brain made + +**This project was NOT vibe-coded BUT AI is still involved in some parts of it.** + +I think generating big portions of code using AI can be justified in some contexts, and I am not opposed to it if done well. But, I care about this project too much to vibe code it :) + +Still, **AI is used** in this project and here's where: + +- **Generating tests:** Because it's something I always skip so I would rather have some AI generated tests than none at all. +- **Micro-improvements:** I have used AI as an advisor to improve some bits of code here and there. Big refactors or new features are done by my hand though. _(This is why this tool breaks so often I guess 💀)_ + + + + + + brainmade + +