diff --git a/README.md b/README.md index b8dac26..60b26ab 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) +- [🔮 Alternatives to boat](-alternatives-to-boat) - [🧠 (mostly) Brain made](#-mostly-brain-made) ## 🚀 Demo @@ -189,6 +190,448 @@ 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` 👀_ +### New + +You can use the `new` command to create a new activity, with an optional **description** and **tags** (comma-separated list). If you want to start the activity immediately, you can include the `-s`/`--start-now` flag: + +```console +$ boat new 'take down prod' -s +created new #51 "take down prod" +started #51 "take down prod" at 20:42 +``` + +If you want to process the output in scripts, you can use the `-j`/`--json` flag: + +```console +$ boat new 'fetch gruvbox wallpaper' \ + -d 'fetching the latest gruvbox wallpapers online' \ + -t leisure,ricing -j +{ + "id": 52, + "name": "fetch gruvbox wallpaper", + "description": "fetching the latest gruvbox wallpapers online", + "duration": 0, + "ongoing": false, + "tags": [ + "leisure", + "ricing" + ] +} +``` + +Full list of options: + +```help +Create a new activity + +Usage: boat new [OPTIONS] + +Arguments: + Name of the activity + +Options: + -d, --description ID of the parent activity + -t, --tags List of tags to apply to the activity + -s, --start-now Start the new activity automatically after creation + -S, --no-start-now Prevent the new activity from starting automatically + -j, --json Output in JSON + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Start + +The `start` command may be used to start tracking time for an existing activity. +It expects an **activity handle** which may be either: + +- the ID of an existing activity (e.g. `51`) +- the name of a new activity to create and start immediately (e.g. `take down prod`) + +Since boat uses whole integers as activity IDs, if you provide an activity handle that cannot be parsed as an integer, boat will assume that you want to create a new activity with the provided name and start it immediately. + +**Examples:** + +With an existing activity ID: + +```console +$ boat start 52 +paused #51 "take down prod" at 20:55 +started #52 "push to master" at 20:55 +``` + +With a new activity name: + +```console +$ boat start 'work on presenterm slides' +created new #55 "work on presenterm slides" +started #55 "work on presenterm slides" at 20:58 +``` + +> [!TIP] +> Make sure you have `quick_start` enabled for the `start` command in your config file if you want to be able to start new activities with this command. Otherwise, you will just get an error. + +Full list of options: + +```help +Start/resume an activity + +Usage: boat start [OPTIONS] + +Arguments: + ID of an existing activity or name for a new activity + +Options: + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Cancel + +If you started an activity by mistake, you can use the `cancel` command to quickly revert it. +You can control the confirmation behavior with the `-c`/`--confirm` and `-C`/`--no-confirm` flags or the `confirm` option in the config file. + +Full list of options: + +```help +Cancel the current activity + +Usage: boat cancel [OPTIONS] + +Options: + -c, --confirm Asks for confirmation before cancelling the current activity + -C, --no-confirm Skip the confirmation when cancelling the current activity + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Pause + +To stop tracking time for the current activity, you can use the `pause` command: + +```console +$ boat pause +paused #55 "work on presenterm slides" at 21:02 +``` + +Full list of options: + +```help +Pause/stop the current activity + +Usage: boat pause [OPTIONS] + +Options: + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Modify + +You can modify the **name**, **description**, and **tags** of an existing activity with the `modify` command: + +```console +$ boat new tests +created new #56 "tests" + +$ boat modify +boat mod 56 -n 'write tests' -d 'write some smoke tests' -t test +are you sure you want to modify activity #56 "tests"? yes +modified #56 "write tests" +``` + +Full list of options: + +```help +Modify an activity + +Usage: boat modify [OPTIONS] <--name |--description |--tags [...]> + +Arguments: + ID of the activity to edit + +Options: + -n, --name New name for the activity + -d, --description New description for the activity + -t, --tags [...] New list of tags to use for the activity + -c, --confirm Asks for confirmation before applying changes + -C, --no-confirm Skip the confirmation before applying changes + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Edit + +If you want to make adjustments to the activity logs (start/end tracking times), you can make use of the `edit` command. Calling the `edit` command will open the list of logs in CSV format in your default `$EDITOR`. You can tweak the `start`/`end` times for multiple logs in there. +After saving the file and quitting the file, you will be able to preview the changes to be applied and either proceed with the update or discard it. + +You may provide a period to edit only a subset of the logs with the `-p`/`--period` flag. If no period is provided, it defaults to whatever is in your config file. + +For example, let's say you want to explicitly edit the logs for today, you can do: + +```console +boat edit -p today +``` + +This will open up a file with your list of activities: + +```csv +# This is a CSV export of your activities and logs. +# Lines starting with '#' are comments and should not be modified. +# Activity definitions are included for reference but are not meant to be edited. +# You may only edit activity logs here. +# +# Activity definitions: +# | ID | Name | Description | Tags | +# | -- | ---- | ----------- | ---- | +# | 51 | take down prod | | | +# | 52 | push to master | | | +# | 55 | work on presenterm slides | | | + + +# Below are your activity logs. You can edit the start and end times here. +# If you want to mark the latest activity as ongoing, simply remove the end time (leave it blank) for that log. +# Please keep the activity_id and log_id unchanged to avoid breaking the data. +# +# Logs (activity_id,log_id,starts_at,ends_at): +# ===== EDIT DATA BELOW ===== +51,119,2026-04-24 20:42,2026-04-24 20:55 +52,120,2026-04-24 20:55,2026-04-24 20:58 +55,121,2026-04-24 20:58,2026-04-24 21:02 +``` + +The file includes comments to help you understand how you can edit the logs in there. +If you start being familiar with the format, you can disable the instructions and activity definitions in the file with the `-I`/`--no-instructions` and `-D`/`--no-activity-definitions` flags. +This behavior can also be configured in the config file with the `show_instructions` and `show_activity_definitions` options under the `edit` command. + +After saving and quitting the file, you will get a preview of the changes to be applied: + +```console +$ boat edit -p today +Detected changes: +Log ID 121: starts_at: 2026-04-24 18:58:05 UTC (no change), ends_at: Some(2026-04-24T19:02:58Z) -> 2026-04-24T19:03:00Z +You are about to update 1 log entries. Do you want to proceed? yes +successfully updated 1 log entries +``` + +Full list of options: + +```help +Edit activity logs as text in an external editor + +Usage: boat edit [OPTIONS] + +Options: + -p, --period Period: day|d, week|w, month|m, year|y, , or .. + -i, --with-instructions Include instruction comments in the editable file + -I, --no-instructions Do not include instruction comments in the editable file + -d, --with-activity-definitions Include activity definitions comments in the editable file + -D, --no-activity-definitions Do not include activity definitions comments in the editable file + -c, --confirm Asks for confirmation before applying changes + -C, --no-confirm Skip the confirmation before applying changes + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Delete + +You may delete an activity with the `delete` command. This will **permanently delete the activity and all its logs**, so be careful with it. + +Let's say you started an activity by mistake: + +```console +boat new 'go back to using Windows' +``` + +I don't know what brought you to do this point, but it's okay, you can fix it. +All you need to do is to delete the activity and all its related logs will disappear just like that: + +``` +$ boat delete 57 +are you sure you want to delete activity #57 "go back to using Windows"? yes +deleted #57 "go back to using Windows" +``` + +😌 _Phewww_, now nobody will ever know about this dark chapter of your life. + +Full list of options: + +```help +Delete an activity + +Usage: boat delete [OPTIONS] + +Arguments: + ID of the activity to delete + +Options: + -c, --confirm Asks for confirmation before deleting the activity + -C, --no-confirm Skip the confirmation when deleting the activity + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Get + +Acquiring information about the current activity is really simple. All you need to do is to use the `get` command: + +```console +$ boat get +current: #55 "work on presenterm slides": 21:24 -> Now (10 seconds) +``` + +This command also supports JSON output with the `-j`/`--json` flag: + +```console +$ boat g -j +{ + "log": { + "starts_at": "2026-04-24T21:24:18+02:00", + "ends_at": null + }, + "activity": { + "id": 55, + "name": "work on presenterm slides", + "description": null, + "tags": [] + } +} +``` + +Full list of options: + +```help +Get the current activity + +Usage: boat get [OPTIONS] + +Options: + -j, --json Output in JSON + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### List + +The `list` command can be used to get an overview of all the activities you have been tracking. +It comes with a lot of options to filter and group the results. You can also output the list in JSON format for further processing in scripts. + +The period argument is very extensive and allows you to filter logs using preset periods (e.g. `today`, `yesterday`, `last-week`, `this-month`, `last-month`, etc.), exact dates (e.g. `2026-04-24`) or date ranges (e.g. `2026-04-01..2026-04-24`). + +Here is a complete overview of all the available values: + +```help +- Period presets: + - today|tod|td + - yesterday|ytd|yd + - this-week|tw|twk|wk + - last-week|lw|lwk|yesterweek|yw|ywk + - this-month|tm|tmo|mo + - last-month|lm|lmo|yestermonth|ym|ymo + - all-time|all +- Exact date: YYYY-MM-DD +- Range: YYYY-MM-DD..YYYY-MM-DD +``` + +Full list of options: + +```console +List activity logs + +Usage: boat list [OPTIONS] + +Options: + -p, --period Restrict matches to a given period: today, yesterday... (--period help to see all options) + -g, --group-by Specify how entries should be grouped [possible values: none, day, week, month, year] + -t, --filter-by-tags Filter out entries that do not have all of the specified tags + -j, --json Output in JSON format + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Report + +The `report` command can be used to get a summary of the total time spent on your activities. +It uses the same filtering options as the `list` command but **does not support grouping yet.** + +Full list of options: + +```help +Show activity summaries + +Usage: boat report [OPTIONS] + +Options: + -p, --period Restrict matches to a given period: today, yesterday... (--period help to see all options) + -g, --group-by Specify how entries should be grouped [possible values: none, day, week, month, year] + -t, --filter-by-tags Filter out entries that do not have all of the specified tags + -j, --json Output in JSON format + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +### Init + +If, at anytime, you need to get a copy of the default configuration file, you can make use of the `init` command and the full TOML representation will be printed in your terminal for you to copy-paste: + +```console +$ boat init +database_path = "/home//.config/boat/boat.db" + +[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] + +[commands.report] +``` + +Full list of options: + +```help +Generate a default boat config and output to stdout + +Usage: boat init [OPTIONS] + +Options: + -v, --verbose... Increase logging verbosity + -q, --quiet... Decrease logging verbosity + -h, --help Print help +``` + +## 🔮 Alternatives to boat + +Hey. I made `boat` to solve my own very specific problems but I don't expect it to be a perfect fit for everyone. If you are looking for similar tools, I got you: + +- [`bartib`](https://github.com/nikolassv/bartib) +- [`zeit`](https://github.com/mrusme/zeit) + ## 🧠 (mostly) Brain made **This project was NOT vibe-coded BUT AI is still involved in some parts of it.** diff --git a/src/cli/args.rs b/src/cli/args.rs index 9da71a6..6bc3481 100644 --- a/src/cli/args.rs +++ b/src/cli/args.rs @@ -79,6 +79,10 @@ pub enum Commands { #[command(alias = "r", alias = "rep")] Report(FilterActivitiesArgs), + /// Generate a default boat config and output to stdout + #[command(alias = "i")] + Init, + // /// Query boat objects // #[command(alias = "q")] // Query { @@ -149,12 +153,8 @@ pub enum SortBy { #[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 .." - )] + /// Restrict matches to a given period: today, yesterday... (--period help to see all options) + #[arg(short = 'p', long = "period")] pub period: Option, /// Specify how entries should be grouped @@ -164,12 +164,13 @@ pub struct FilterActivitiesArgs { // /// Specify how entries should be sorted // #[arg(short = 's', long = "sort-by")] // pub sort_by: SortInput, + /// Filter out entries that do not have all of the specified tags + #[arg(short = 't', long = "filter-by-tags", value_name = "TAGS", value_delimiter = ',', action = ArgAction::Append)] + pub filter_by_tags: Option>, + /// 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)] diff --git a/src/cli/period.rs b/src/cli/period.rs index c35dd27..c59c9cb 100644 --- a/src/cli/period.rs +++ b/src/cli/period.rs @@ -2,8 +2,9 @@ use chrono::{Datelike, Local, Months, NaiveDate}; use log::debug; use serde::{Deserialize, Deserializer, Serialize, Serializer, de}; use std::{fmt::Display, str::FromStr}; +use yansi::Paint; -use crate::utils::date::DateTimeRenderMode; +use crate::utils::{self, date::DateTimeRenderMode}; #[derive(Clone, Copy, Debug, Default, Deserialize, Serialize, PartialEq)] pub enum PresetPeriod { @@ -65,6 +66,33 @@ impl Default for PeriodInput { impl PeriodInput { const ERR_MSG: &'static str = "Provide either a range (YYYY-MM-DD..YYYY-MM-DD) or a single date (YYYY-MM-DD)"; + + fn print_help_message() -> String { + let help_msg = format!( + " +{} +{} + +{} {} + +{} {}", + "* Period presets:".underline(), + " + - today|tod|td + - yesterday|ytd|yd + - this-week|tw|twk|wk + - last-week|lw|lwk|yesterweek|yw|ywk + - this-month|tm|tmo|mo + - last-month|lm|lmo|yestermonth|ym|ymo + - all-time|all" + .green(), + "* Exact date:".underline(), + "YYYY-MM-DD".green(), + "* Range:".underline(), + "YYYY-MM-DD..YYYY-MM-DD".green(), + ); + help_msg.to_string() + } } impl FromStr for PeriodInput { @@ -72,7 +100,9 @@ impl FromStr for PeriodInput { fn from_str(s: &str) -> Result { debug!("period input from: {s}"); let s = s.to_lowercase(); + // Handle presets + debug!("checking if period input is a preset"); match s.as_str() { "today" | "td" | "tod" => return Ok(PeriodInput::Preset(PresetPeriod::Today)), "yesterday" | "yd" | "ytd" => return Ok(PeriodInput::Preset(PresetPeriod::Yesterday)), @@ -91,21 +121,21 @@ impl FromStr for PeriodInput { "all-time" | "all" => return Ok(PeriodInput::Preset(PresetPeriod::AllTime)), _ => {} } + // Match range + debug!("checking if period input is a date 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)?; + let start = utils::date::parse_date(start).map_err(|_| Self::print_help_message())?; + let end = utils::date::parse_date(end).map_err(|_| Self::print_help_message())?; 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)?; + debug!("checking if period input is a single date"); + let date = crate::utils::date::parse_date(&s).map_err(|_| Self::print_help_message())?; Ok(PeriodInput::Single(date)) } } diff --git a/src/commands/init.rs b/src/commands/init.rs new file mode 100644 index 0000000..9ec51b0 --- /dev/null +++ b/src/commands/init.rs @@ -0,0 +1,10 @@ +use anyhow::Result; + +use crate::config::Configuration; + +pub fn init() -> Result<()> { + let config = Configuration::create_default()?; + let toml = toml::to_string(&config)?; + println!("{toml}"); + Ok(()) +} diff --git a/src/commands/list.rs b/src/commands/list.rs index 0e697c1..f2f9c77 100644 --- a/src/commands/list.rs +++ b/src/commands/list.rs @@ -34,9 +34,22 @@ pub fn list_activity_logs( let db_acts: Vec<_> = activities::get_all(conn)?; let boat_data = BoatData::create_filtered_data(db_acts, period); - info!("listing individual activity logs"); - let prt_logs = boat_data.get_printable_logs(); - + info!("filtering logs by tags"); + let prt_logs = boat_data + .get_printable_logs() + .into_iter() + .filter(|log| { + if let Some(filter_tags) = &args.filter_by_tags { + filter_tags + .iter() + .all(|tag| log.activity.tags.contains(tag)) + } else { + true + } + }) + .collect::>(); + + info!("grouping logs based on group_by value"); let grouped_logs = group_by(&prt_logs, group_by_value); if args.use_json_format { let json = serde_json::to_string(&grouped_logs)?; diff --git a/src/commands/mod.rs b/src/commands/mod.rs index d769189..18499a2 100644 --- a/src/commands/mod.rs +++ b/src/commands/mod.rs @@ -3,6 +3,7 @@ pub mod create; pub mod delete; pub mod edit; pub mod get; +pub mod init; pub mod list; pub mod modify; pub mod pause; @@ -14,6 +15,7 @@ pub use self::create::create; pub use self::delete::delete; pub use self::edit::edit; pub use self::get::get_current; +pub use self::init::init; pub use self::list::list_activity_logs; pub use self::modify::modify; pub use self::pause::pause_current; diff --git a/src/commands/report.rs b/src/commands/report.rs index f323efd..d6700f4 100644 --- a/src/commands/report.rs +++ b/src/commands/report.rs @@ -62,17 +62,29 @@ pub fn show_report( // } } - list_activity_summaries(&boat_data, args.use_json_format) + list_activity_summaries(&boat_data, args.use_json_format, &args.filter_by_tags) } -fn list_activity_summaries(boat_data: &BoatData, use_json: bool) -> Result<()> { - info!("listing activity summaries"); +fn list_activity_summaries( + boat_data: &BoatData, + use_json: bool, + filter_by_tags: &Option>, +) -> Result<()> { + info!("filtering logs by tags"); let prt_acts = boat_data .get_printable_activities() .into_iter() .filter(|act| act.duration > 0) + .filter(|act| { + if let Some(filter_tags) = filter_by_tags { + filter_tags.iter().all(|tag| act.tags.contains(tag)) + } else { + true + } + }) .collect(); + info!("listing activity summaries"); utils::common::list_printable_items(&prt_acts, use_json)?; if !use_json && !prt_acts.is_empty() { diff --git a/src/config.rs b/src/config.rs index c0369ac..feabb58 100644 --- a/src/config.rs +++ b/src/config.rs @@ -169,7 +169,7 @@ impl Configuration { // format: OutputFormat::Plain, commands: CommandsConfig { new: NewCommandConfig { auto_start: false }, - start: StartCommandConfig { quick_start: false }, + start: StartCommandConfig { quick_start: true }, cancel: CancelCommandConfig { confirm: true }, // pause: PauseCommandConfig, modify: ModifyCommandConfig { confirm: true }, @@ -284,6 +284,7 @@ mod tests { unsafe { std::env::remove_var(CONFIG_VAR) }; let config = Configuration::create_default().unwrap(); assert!(!config.commands.new.auto_start); + assert!(config.commands.start.quick_start); assert!(config.commands.cancel.confirm); assert!(config.commands.delete.confirm); assert!(config.commands.edit.confirm); diff --git a/src/main.rs b/src/main.rs index 456f00d..c59cd9f 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,7 @@ use anyhow::Result; use clap::{CommandFactory, Parser}; use log::{LevelFilter, info}; -use std::process::ExitCode; +use std::{path::Path, process::ExitCode}; use yansi::Paint; use crate::{cli::Cli, config::Configuration}; @@ -31,6 +31,11 @@ fn main() -> ExitCode { } fn process_args(args: Cli) -> Result<()> { + if let cli::Commands::Init = args.command { + commands::init()?; + return Ok(()); + }; + info!("getting config file"); let config_file = config::get_config_file_path()?; if !config_file.exists() { @@ -38,8 +43,11 @@ fn process_args(args: Cli) -> Result<()> { info!("config file created"); } - info!("loading config"); - let config = Configuration::load_from_fs()?; + info!("trying to load config"); + let config = Configuration::load_from_fs().inspect_err(|_| { + print_broken_config_error_message(&config_file); + })?; + info!("init db connection"); let mut conn = boat_lib::utils::init_database(&config.database_path)?; @@ -55,9 +63,32 @@ fn process_args(args: Cli) -> Result<()> { 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(), + cli::Commands::Init => Ok(()), } } +fn print_broken_config_error_message(config_file_path: &Path) { + eprintln!( + "{} It looks like your configuration file is not compatible with the latest version of boat.", + "Woops!".red() + ); + eprintln!( + "Try {} to get an example of the default working configuration.", + code_format("`boat init`") + ); + + eprintln!( + "You can then update your config at: {}", + code_format(&config_file_path.display().to_string()) + ); +} + +fn code_format(text: &str) -> yansi::Painted<&str> { + Paint::green(text) + .bg(yansi::Color::Black) + .fg(yansi::Color::Green) +} + fn print_help() -> Result<()> { Cli::command().print_help()?; Ok(()) diff --git a/tests/cli_init.rs b/tests/cli_init.rs new file mode 100644 index 0000000..ece051a --- /dev/null +++ b/tests/cli_init.rs @@ -0,0 +1,30 @@ +//! Integration tests for the `init` CLI command. +use anyhow::Result; + +use crate::utils::{cli_args_for_temp, run_boat}; + +mod utils; + +#[test] +fn init_outputs_valid_toml_with_database_path() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + run_boat(["init"], config_path) + .success() + .stdout(predicates::str::contains("database_path")); + + Ok(()) +} + +#[test] +fn init_works_without_existing_config_file() { + // `init` must run before config loading, so it should succeed even when the + // config file doesn't exist yet. + assert_cmd::Command::cargo_bin("boat") + .unwrap() + .env("BOAT_CONFIG", "/tmp/boat_init_test_nonexistent.toml") + .args(["init"]) + .assert() + .success() + .stdout(predicates::str::contains("database_path")); +} diff --git a/tests/cli_list.rs b/tests/cli_list.rs index f2c772f..b2095ee 100644 --- a/tests/cli_list.rs +++ b/tests/cli_list.rs @@ -1,5 +1,6 @@ //! Integration tests for the `list` CLI command. use anyhow::Result; +use predicates::prelude::*; use crate::utils::{cli_args_for_temp, run_boat}; @@ -11,7 +12,66 @@ fn list_with_invalid_date_input_fails() -> Result<()> { run_boat(["list", "--period", "not-a-date"], config_path) .failure() - .stderr(predicates::str::contains("Provide either a range")); + .stderr(predicates::str::contains("Period presets")); + + Ok(()) +} + +#[test] +fn list_filter_by_tag_shows_only_matching_activity() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + run_boat(["new", "RustWork", "--tags", "rust"], &config_path).success(); + run_boat(["new", "PythonWork", "--tags", "python"], &config_path).success(); + run_boat(["start", "1"], &config_path).success(); + run_boat(["pause"], &config_path).success(); + run_boat(["start", "2"], &config_path).success(); + run_boat(["pause"], &config_path).success(); + + run_boat(["list", "--json", "--filter-by-tags", "rust"], &config_path) + .success() + .stdout(predicates::str::contains("RustWork")) + .stdout(predicates::str::contains("PythonWork").not()); + + Ok(()) +} + +#[test] +fn list_filter_by_nonexistent_tag_shows_no_data() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + run_boat(["new", "SomeWork", "--tags", "rust"], &config_path).success(); + run_boat(["start", "1"], &config_path).success(); + run_boat(["pause"], &config_path).success(); + + run_boat(["list", "--filter-by-tags", "nonexistent"], &config_path) + .success() + .stdout(predicates::str::contains("no available data")); + + Ok(()) +} + +#[test] +fn list_filter_by_multiple_tags_requires_all() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + // Activity with both required tags + run_boat(["new", "FullMatch", "--tags", "rust,backend"], &config_path).success(); + // Activity with only one of the required tags + run_boat(["new", "PartialMatch", "--tags", "rust"], &config_path).success(); + run_boat(["start", "1"], &config_path).success(); + run_boat(["pause"], &config_path).success(); + run_boat(["start", "2"], &config_path).success(); + run_boat(["pause"], &config_path).success(); + + // Filtering by both tags should only return the activity that has all of them + run_boat( + ["list", "--json", "--filter-by-tags", "rust,backend"], + &config_path, + ) + .success() + .stdout(predicates::str::contains("FullMatch")) + .stdout(predicates::str::contains("PartialMatch").not()); Ok(()) } diff --git a/tests/cli_report.rs b/tests/cli_report.rs new file mode 100644 index 0000000..184aeb4 --- /dev/null +++ b/tests/cli_report.rs @@ -0,0 +1,64 @@ +//! Integration tests for the `report` CLI command. +use anyhow::Result; +use predicates::prelude::*; + +use crate::utils::{cli_args_for_temp, run_boat}; + +mod utils; + +#[test] +fn report_with_no_activities_shows_no_data() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + run_boat(["report"], config_path) + .success() + .stdout(predicates::str::contains("no available data")); + + Ok(()) +} + +#[test] +fn report_filter_by_nonexistent_tag_shows_no_data() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + run_boat( + ["new", "RustWork", "--tags", "rust", "--start-now"], + &config_path, + ) + .success(); + run_boat(["pause"], &config_path).success(); + + // "python" tag doesn't exist on any activity, so report should be empty + run_boat(["report", "--filter-by-tags", "python"], &config_path) + .success() + .stdout(predicates::str::contains("no available data")); + + Ok(()) +} + +#[test] +fn report_filter_by_tag_excludes_untagged_activities() -> Result<()> { + let (_tmp, config_path) = cli_args_for_temp()?; + + // Activity with matching tag + run_boat( + ["new", "Tagged", "--tags", "keep", "--start-now"], + &config_path, + ) + .success(); + run_boat(["pause"], &config_path).success(); + + // Activity without any tag + run_boat(["new", "Untagged", "--start-now"], &config_path).success(); + run_boat(["pause"], &config_path).success(); + + // JSON output should not contain the untagged activity name when filtering + run_boat( + ["report", "--json", "--filter-by-tags", "keep"], + &config_path, + ) + .success() + .stdout(predicates::str::contains("Untagged").not()); + + Ok(()) +}