diff --git a/README.md b/README.md index 219b23a..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 @@ -96,6 +97,37 @@ 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] +period = "month" +group_by = "day" + +[commands.report] +period = "day" +``` ## ✨ Usage @@ -103,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 @@ -116,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: @@ -127,6 +160,8 @@ Options: -q, --quiet... Decrease logging verbosity -h, --help Print help -V, --version Print version + +Made by @coko7 ``` > [!TIP] @@ -141,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). @@ -152,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 + + diff --git a/src/cli.rs b/src/cli.rs deleted file mode 100644 index 05b3f51..0000000 --- a/src/cli.rs +++ /dev/null @@ -1,380 +0,0 @@ -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; - -#[derive(Parser, Debug)] -#[command( - name = "boat", - version, - author = "Made by @coko7 ", - color = ColorChoice::Auto, - about = "Basic Opinionated Activity Tracker", - help_template = "{name} {version}\n\n{about}\n\n{usage-heading}\n{usage}\n\n{all-args}\n\n{author}" -)] -pub struct Cli { - #[command(subcommand)] - pub command: Commands, - - #[command(flatten)] - pub verbose: clap_verbosity_flag::Verbosity, -} - -#[derive(Subcommand, Debug)] -#[command(rename_all = "kebab-case")] -pub enum Commands { - /// Create a new activity - #[command(alias = "n", alias = "create")] - New(CreateActivityArgs), - - /// Start/resume an activity - #[command( - alias = "s", - alias = "st", - alias = "sail", - alias = "continue", - alias = "resume" - )] - Start(SelectActivityArgs), - - /// Cancel the current activity - #[command(alias = "c", alias = "can")] - Cancel, - - /// Pause/stop the current activity - #[command(alias = "p", alias = "stop")] - Pause, - - /// Modify an activity - #[command(alias = "m", alias = "mod")] - Modify(ModifyActivityArgs), - - /// Edit activity logs as text in an external editor - #[command(alias = "e", alias = "ed")] - Edit(EditLogsArgs), - - /// Delete an activity - #[command( - alias = "d", - alias = "del", - alias = "rm", - alias = "rem", - alias = "remove" - )] - Delete(SelectActivityArgs), - - /// Get the current activity - #[command(alias = "g")] - Get(PrintActivityArgs), - - /// List activities - #[command(alias = "l", alias = "ls")] - List(ListActivityArgs), - - // /// Query boat objects - // #[command(alias = "q")] - // Query { - // #[command(subcommand)] - // command: QuerySubcommand, - // }, - - // This is ONLY way I could find to use the 'h' short alias for help. - #[command(alias = "h", hide = true)] - HelpExtension, - // Verify the activity data and report eventual issues - // #[command(alias = "v", alias = "verif")] - // Verify {}, - - // Query the different objects: activities, logs, tags - // #[command(alias = "q")] - // Query {}, - - // Display a report with statistics about activities - // #[command(alias = "r", alias = "rep")] - // Report {}, - // ^^^ or maybe export 'x' ??? -} - -#[derive(Subcommand)] -#[command(rename_all = "kebab-case")] -pub enum QuerySubcommand { - /// Manage logs - #[command(name = "logs", alias = "l", alias = "log")] - Logs(ListActivityArgs), - - /// 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)] -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})") - } - } - } -} - -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(Args, Debug)] -pub struct ListActivityArgs { - /// Restrict to entries starting in the given - #[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" - )] - pub date_range: Option, - - /// Show a per-activity summary instead of listing all logs - #[arg(short = 's', long = "summary", conflicts_with = "no_grouping")] - pub show_summary: bool, - - /// 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, - // /// Only show tags - // #[arg(short = 't', long = "tags-only", conflicts_with = "no_grouping")] - // pub tags_only: bool, -} - -#[derive(Args, Debug)] -pub struct ListArgs { - /// Output in JSON - #[arg(short = 'j', long = "json")] - 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 { - /// Output in JSON - #[arg(short = 'j', long = "json")] - pub use_json_format: bool, -} - -#[derive(Args, Debug)] -pub struct SelectActivityArgs { - /// ID of the activity - pub activity_id: Id, -} - -#[derive(Args, Debug)] -pub struct CreateActivityArgs { - /// Name of the activity - pub name: String, - - /// ID of the parent activity - #[arg(short, long)] - pub description: Option, - - /// List of tags to apply to the activity - #[arg(short, long, value_delimiter = ',', action = ArgAction::Append)] - pub tags: Vec, - - /// Start the new activity automatically - #[arg(short = 's', long = "start")] - pub auto_start: bool, - - /// Output in JSON - #[arg(short = 'j', long = "json")] - pub use_json_format: bool, -} - -#[derive(Args, Debug)] -pub struct ModifyActivityArgs { - /// ID of the activity to edit - pub id: Id, - - #[clap(flatten)] - pub update: UpdateGroup, -} - -#[derive(Args, Debug)] -#[group(required = true)] -pub struct UpdateGroup { - /// New name for the activity - #[arg(short = 'n', long = "name")] - pub name: Option, - - /// New description for the activity - #[arg(short, long)] - pub description: Option, - - /// New list of tags to use for the activity - #[arg(short, long, value_delimiter = ',', action = ArgAction::Append, num_args(0..))] - pub tags: Option>, -} - -#[derive(Args, Debug)] -pub struct EditLogsArgs { - /// Restrict to entries starting in the given - #[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" - )] - pub date_range: Option, - - /// Do not include instruction comments in the editable file - #[arg(short = 'n', long = "no-instructions")] - pub hide_instructions: bool, -} diff --git a/src/cli/args.rs b/src/cli/args.rs new file mode 100644 index 0000000..9da71a6 --- /dev/null +++ b/src/cli/args.rs @@ -0,0 +1,312 @@ +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; + +#[derive(Parser, Debug)] +#[command( + name = "boat", + version, + author = "Made by @coko7 ", + color = ColorChoice::Auto, + about = "Basic Opinionated Activity Tracker", + help_template = "{name} {version}\n\n{about}\n\n{usage-heading}\n{usage}\n\n{all-args}\n\n{author}" +)] +pub struct Cli { + #[command(subcommand)] + pub command: Commands, + + #[command(flatten)] + pub verbose: clap_verbosity_flag::Verbosity, +} + +#[derive(Subcommand, Debug)] +#[command(rename_all = "kebab-case")] +pub enum Commands { + /// Create a new activity + #[command(alias = "n", alias = "create")] + New(CreateActivityArgs), + + /// Start/resume an activity + #[command( + alias = "s", + alias = "st", + alias = "sail", + alias = "continue", + alias = "resume" + )] + Start(StartActivityArgs), + + /// Cancel the current activity + #[command(alias = "c", alias = "can")] + Cancel(CancelActivityArgs), + + /// Pause/stop the current activity + #[command(alias = "p", alias = "stop")] + Pause, + + /// Modify an activity + #[command(alias = "m", alias = "mod")] + Modify(ModifyActivityArgs), + + /// Edit activity logs as text in an external editor + #[command(alias = "e", alias = "ed")] + Edit(EditLogsArgs), + + /// Delete an activity + #[command( + alias = "d", + alias = "del", + alias = "rm", + alias = "rem", + alias = "remove" + )] + Delete(DeleteActivityArgs), + + /// Get the current activity + #[command(alias = "g")] + Get(PrintActivityArgs), + + /// List activity logs + #[command(alias = "l", alias = "ls")] + List(FilterActivitiesArgs), + + /// Show activity summaries + #[command(alias = "r", alias = "rep")] + Report(FilterActivitiesArgs), + + // /// Query boat objects + // #[command(alias = "q")] + // Query { + // #[command(subcommand)] + // command: QuerySubcommand, + // }, + + // This is ONLY way I could find to use the 'h' short alias for help. + #[command(alias = "h", hide = true)] + HelpExtension, + // Verify the activity data and report eventual issues + // #[command(alias = "v", alias = "verif")] + // Verify {}, + + // Query the different objects: activities, logs, tags + // #[command(alias = "q")] + // Query {}, + + // Display a report with statistics about activities + // #[command(alias = "r", alias = "rep")] + // Report {}, + // ^^^ or maybe export 'x' ??? +} + +#[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")] + #[default] + None, +} + +#[derive(Args, Debug)] +pub struct FilterActivitiesArgs { + /// Restrict to entries matching the given + #[arg( + short = 'p', + long = "period", + help = "Period: day|d, week|w, month|m, year|y, , or .." + )] + pub period: Option, + + /// Specify how entries should be grouped + #[arg(short = 'g', long = "group-by")] + pub group_by: Option, + + // /// Specify how entries should be sorted + // #[arg(short = 's', long = "sort-by")] + // pub sort_by: SortInput, + /// Output in JSON format + #[arg(short = 'j', long = "json")] + pub use_json_format: bool, + // /// Only show tags + // #[arg(short = 't', long = "tags-only", conflicts_with = "no_grouping")] + // pub tags_only: bool, +} + +#[derive(Args, Debug, Default)] +#[group(multiple = false)] +pub struct PrintActivityArgs { + /// Output in JSON + #[arg(short = 'j', long = "json")] + 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 + 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 + pub name: String, + + /// ID of the parent activity + #[arg(short, long)] + pub description: Option, + + /// List of tags to apply to the activity + #[arg(short, long, value_delimiter = ',', action = ArgAction::Append)] + pub tags: Vec, + + /// 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, +} + +#[derive(Args, Debug)] +pub struct ModifyActivityArgs { + /// ID of the activity to edit + pub id: Id, + + #[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)] +#[group(required = true)] +pub struct UpdateGroup { + /// New name for the activity + #[arg(short = 'n', long = "name")] + pub name: Option, + + /// New description for the activity + #[arg(short, long)] + pub description: Option, + + /// New list of tags to use for the activity + #[arg(short, long, value_delimiter = ',', action = ArgAction::Append, num_args(0..))] + pub tags: Option>, +} + +#[derive(Args, Debug)] +pub struct EditLogsArgs { + /// Restrict to entries matching the given + #[arg( + short = 'p', + long = "period", + help = "Period: day|d, week|w, month|m, year|y, , or .." + )] + 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 = '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, + + /// 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 new file mode 100644 index 0000000..1547b61 --- /dev/null +++ b/src/cli/mod.rs @@ -0,0 +1,25 @@ +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::StartActivityArgs; +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..c35dd27 --- /dev/null +++ b/src/cli/period.rs @@ -0,0 +1,288 @@ +use chrono::{Datelike, Local, Months, NaiveDate}; +use log::debug; +use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; +use std::{fmt::Display, str::FromStr}; + +use crate::utils::date::DateTimeRenderMode; + +#[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 }, +} + +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)"; +} + +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() { + "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)); + } + "all-time" | "all" => return Ok(PeriodInput::Preset(PresetPeriod::AllTime)), + _ => {} + } + // Match range + if let Some((start, end)) = s.split_once("..") { + 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 = 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 }); + } + // Single date + 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 { + 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 } => { + let start = DateTimeRenderMode::DateOnly.render_naive_date(start); + let end = DateTimeRenderMode::DateOnly.render_naive_date(end); + write!(f, "{start}..{end}") + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::str::FromStr; + use toml; + + #[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); + } + } + + #[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()); + } + + // --- 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/commands/cancel.rs b/src/commands/cancel.rs index d0731b1..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::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(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 c6e8901..0ae7b8c 100644 --- a/src/commands/create.rs +++ b/src/commands/create.rs @@ -6,11 +6,23 @@ 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 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(), @@ -24,7 +36,7 @@ pub fn create(conn: &mut Connection, args: &cli::CreateActivityArgs) -> Result<( 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 bda1a7d..862489c 100644 --- a/src/commands/delete.rs +++ b/src/commands/delete.rs @@ -3,16 +3,36 @@ 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<()> { - 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)); +pub fn delete( + config: &Configuration, + conn: &mut Connection, + args: &cli::DeleteActivityArgs, +) -> Result<()> { + 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 41ce8d1..b3c9d1b 100644 --- a/src/commands/edit.rs +++ b/src/commands/edit.rs @@ -11,16 +11,49 @@ 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::{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 + .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}"); -pub fn edit(conn: &mut rusqlite::Connection, args: &EditLogsArgs) -> Result<()> { - let date_input_opt = args.date_range; - let period_opt = args.period; - let include_instructions = !args.hide_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 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, date_input_opt, period_opt); - let default_content = boat_data.to_csv_str(include_instructions); + let boat_data = BoatData::create_filtered_data(all_acts, period); + 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()); @@ -67,7 +100,7 @@ pub fn edit(conn: &mut rusqlite::Connection, args: &EditLogsArgs) -> Result<()> 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/get.rs b/src/commands/get.rs index 41419e2..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: &mut 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 1e40918..0e697c1 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -1,93 +1,58 @@ 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 yansi::Paint; use crate::{ - cli::{self}, + cli::{self, PeriodInput, args::GroupBy}, + config::Configuration, models::{activity_log::PrintableActivityLog, boat_data::BoatData}, utils::{self, date::DateTimeRenderMode}, }; -pub fn list_activities(conn: &mut Connection, args: &cli::ListActivityArgs) -> Result<()> { +pub fn list_activity_logs( + config: &Configuration, + conn: &Connection, + args: &cli::FilterActivitiesArgs, +) -> Result<()> { + let period = args + .period + .or(config.commands.list.period) + .or(config.period) + .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, 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) -} - -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() - ); - } + let boat_data = BoatData::create_filtered_data(db_acts, period); - Ok(()) -} - -fn list_activity_logs(boat_data: &BoatData, args: &cli::ListActivityArgs) -> Result<()> { 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)?; } @@ -95,7 +60,29 @@ fn list_activity_logs(boat_data: &BoatData, args: &cli::ListActivityArgs) -> Res 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(); @@ -108,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/mod.rs b/src/commands/mod.rs index 55b2982..d769189 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -6,7 +6,7 @@ pub mod get; 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 +14,8 @@ 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::report::show_report; pub use self::start::start; diff --git a/src/commands/modify.rs b/src/commands/modify.rs index f3849db..8614ca2 100644 --- a/src/commands/modify.rs +++ b/src/commands/modify.rs @@ -3,14 +3,34 @@ 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::invaid_activity_id(args.id)); + 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/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/query.rs b/src/commands/query.rs deleted file mode 100644 index 812f571..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 new file mode 100644 index 0000000..f323efd --- /dev/null +++ b/src/commands/report.rs @@ -0,0 +1,88 @@ +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}, + config::Configuration, + models::boat_data::BoatData, + utils, +}; + +pub fn show_report( + config: &Configuration, + conn: &Connection, + args: &cli::FilterActivitiesArgs, +) -> Result<()> { + let period = args + .period + .or(config.commands.report.period) + .or(config.period) + .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 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 { + // 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.use_json_format) +} + +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| 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 f4dceed..30e4229 100644 --- a/src/commands/start.rs +++ b/src/commands/start.rs @@ -1,33 +1,76 @@ 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, utils}; +use crate::{ + cli, + commands::{self, pause_current}, + config::Configuration, + 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)); +pub fn start( + config: &Configuration, + conn: &mut Connection, + args: &cli::StartActivityArgs, +) -> Result<()> { + 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, + no_auto_start: false, + 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(()); } info!("pausing current..."); - pause_current(conn)?; + pause_current(config, conn)?; } 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 17723bb..c0369ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,15 +4,155 @@ use log::{debug, info}; use serde::{Deserialize, Serialize}; use std::{env, fs, path::PathBuf}; +use crate::cli::{PeriodInput, args::GroupBy}; + 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, 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, + + /// 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)] +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 NewCommandConfig { + /// Whether the new activity should start automatically + #[serde(rename = "auto_start")] + pub auto_start: bool, +} + +/// Configuration values for the start command +#[derive(Debug, Default, Serialize, Deserialize)] +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 CancelCommandConfig { + /// Prompts for confirmation before cancelling activity + pub confirm: bool, +} + +/// Configuration values for the pause command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct PauseCommandConfig; + +/// Configuration values for the modify command +#[derive(Debug, Default, Serialize, Deserialize)] +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)] +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 DeleteCommandConfig { + /// Prompts for confirmation before deleting an activity and all its logs + pub confirm: bool, +} + +/// Configuration values for the get command +#[derive(Debug, Default, Serialize, Deserialize)] +pub struct GetCommandConfig; + +/// Configuration values for the list command +#[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")] + #[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, + // /// Format to use for data ('plain', 'json', 'csv') + // pub format: Option, +} + +/// Configuration values for the report command +#[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")] + // #[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, + // /// Format to use for data ('plain', 'json', 'csv') + // pub format: Option, } impl Configuration { @@ -23,7 +163,38 @@ 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, + // format: OutputFormat::Plain, + commands: CommandsConfig { + new: NewCommandConfig { auto_start: false }, + start: StartCommandConfig { quick_start: false }, + cancel: CancelCommandConfig { confirm: true }, + // pause: PauseCommandConfig, + modify: ModifyCommandConfig { confirm: true }, + 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, + }, + }, + }) } pub fn load_from_fs() -> Result { @@ -82,3 +253,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/main.rs b/src/main.rs index fe684e1..456f00d 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(&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::New(args) => commands::create(&config, &mut conn, args), + cli::Commands::Start(args) => commands::start(&config, &mut conn, args), + 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), + 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/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 e715bab..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, + cli::{self}, models::{ activity::{PrintableActivity, SimpleActivity}, activity_log::PrintableActivityLog, @@ -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) }) @@ -82,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 { @@ -95,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); @@ -109,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, @@ -117,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( @@ -162,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 f253abd..61cea65 100644 --- a/src/utils/common.rs +++ b/src/utils/common.rs @@ -1,16 +1,35 @@ 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::{DateInput, Period}, + cli::{PeriodInput, PresetPeriod}, models::{RowPrintable, TablePrintable}, 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 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, @@ -37,56 +56,43 @@ 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 } => matches_date_range(log, start, end), } } -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 => true, } } -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, +) -> bool { + 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(); - - 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 - } - } + log_start >= *range_start && log_end <= *range_end } pub fn get_date_info_msg(today: NaiveDate, compare_to: NaiveDate) -> String { @@ -118,3 +124,180 @@ 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..f3734de 100644 --- a/src/utils/date.rs +++ b/src/utils/date.rs @@ -197,3 +197,149 @@ 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/src/utils/display.rs b/src/utils/display.rs index 830afcc..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 #{} \"{}\"", @@ -51,10 +109,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_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_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")); 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_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/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..f43224a 100644 --- a/tests/utils.rs +++ b/tests/utils.rs @@ -7,10 +7,38 @@ 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.modify] +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)) }