Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
443 changes: 443 additions & 0 deletions README.md

Large diffs are not rendered by default.

19 changes: 10 additions & 9 deletions src/cli/args.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -149,12 +153,8 @@ pub enum SortBy {

#[derive(Args, Debug)]
pub struct FilterActivitiesArgs {
/// Restrict to entries matching the given <PERIOD>
#[arg(
short = 'p',
long = "period",
help = "Period: day|d, week|w, month|m, year|y, <date>, or <start>..<end>"
)]
/// Restrict matches to a given period: today, yesterday... (--period help to see all options)
#[arg(short = 'p', long = "period")]
pub period: Option<PeriodInput>,

/// Specify how entries should be grouped
Expand All @@ -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<Vec<String>>,

/// 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)]
Expand Down
46 changes: 38 additions & 8 deletions src/cli/period.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -65,14 +66,43 @@ 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 {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
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)),
Expand All @@ -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))
}
}
Expand Down
10 changes: 10 additions & 0 deletions src/commands/init.rs
Original file line number Diff line number Diff line change
@@ -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(())
}
19 changes: 16 additions & 3 deletions src/commands/list.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<Vec<_>>();

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)?;
Expand Down
2 changes: 2 additions & 0 deletions src/commands/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down
18 changes: 15 additions & 3 deletions src/commands/report.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<Vec<String>>,
) -> 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() {
Expand Down
3 changes: 2 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 },
Expand Down Expand Up @@ -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);
Expand Down
37 changes: 34 additions & 3 deletions src/main.rs
Original file line number Diff line number Diff line change
@@ -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};
Expand Down Expand Up @@ -31,15 +31,23 @@ 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() {
config::initialize_config()?;
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)?;

Expand All @@ -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(())
Expand Down
30 changes: 30 additions & 0 deletions tests/cli_init.rs
Original file line number Diff line number Diff line change
@@ -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"));
}
Loading
Loading