Skip to content

Commit 9a239f8

Browse files
authored
feat\!: improve list command + UX + tests
* feat(list): add date range filter and summaries - support date ranges (`--date`) and per-activity summaries in list command. - refactor internals for clearer filtering and output. - update table output and error handling for new options. * refactor(list,activity): improve data modeling and output formatting - replace ad-hoc activity/log output logic with SimpleActivity presentation type - refactor list command to use cleaner models and helpers - adjust table output to show durations with new format helpers - improve error message and update test for invalid date input * test: gen/write more integration tests + improve error handling on some cmds
1 parent 52ee2b3 commit 9a239f8

25 files changed

Lines changed: 1010 additions & 237 deletions

src/cli.rs

Lines changed: 130 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
use boat_lib::repository::Id;
2+
use chrono::Datelike;
3+
use chrono::Local;
4+
use chrono::Months;
25
use chrono::NaiveDate;
36
use clap::ColorChoice;
47
use clap::Parser;
58
use clap::{ArgAction, Args, Subcommand, ValueEnum};
9+
use std::str::FromStr;
610

711
use crate::utils;
12+
use crate::utils::date::DateTimeRenderMode;
813

914
#[derive(Parser)]
1015
#[command(
@@ -70,12 +75,12 @@ pub enum Commands {
7075
#[command(alias = "l", alias = "ls")]
7176
List(ListActivityArgs),
7277

73-
/// Query boat objects
74-
#[command(alias = "q")]
75-
Query {
76-
#[command(subcommand)]
77-
command: QuerySubcommand,
78-
},
78+
// /// Query boat objects
79+
// #[command(alias = "q")]
80+
// Query {
81+
// #[command(subcommand)]
82+
// command: QuerySubcommand,
83+
// },
7984

8085
// This is ONLY way I could find to use the 'h' short alias for help.
8186
#[command(alias = "h", hide = true)]
@@ -120,35 +125,111 @@ pub enum QuerySubcommand {
120125
Tags(ListArgs),
121126
}
122127

128+
#[derive(Debug, Clone, Copy)]
129+
pub enum DateInput {
130+
Single(NaiveDate),
131+
Range {
132+
start: NaiveDate,
133+
end: NaiveDate,
134+
inclusive: bool,
135+
},
136+
}
137+
138+
impl DateInput {
139+
const ERR_MSG: &'static str =
140+
"Provide either a range (YYYY-MM-DD..YYYY-MM-DD) or a single date (YYYY-MM-DD)";
141+
}
142+
143+
impl std::fmt::Display for DateInput {
144+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
145+
match self {
146+
DateInput::Single(naive_date) => {
147+
let dt = DateTimeRenderMode::DateOnly.render_naive_date(naive_date);
148+
write!(f, "{dt}")
149+
}
150+
DateInput::Range {
151+
start,
152+
end,
153+
inclusive,
154+
} => {
155+
let start = DateTimeRenderMode::DateOnly.render_naive_date(start);
156+
let end = DateTimeRenderMode::DateOnly.render_naive_date(end);
157+
let inclusion_msg = (if *inclusive { "included" } else { "excluded" }).to_string();
158+
write!(f, "{start} to {end} ({inclusion_msg})")
159+
}
160+
}
161+
}
162+
}
163+
164+
impl FromStr for DateInput {
165+
type Err = String;
166+
167+
fn from_str(s: &str) -> Result<Self, Self::Err> {
168+
// Match range
169+
if let Some((start, end)) = s.split_once("..") {
170+
let start = utils::date::parse_date(start).map_err(|_| Self::ERR_MSG)?;
171+
let (end, inclusive) = match end.strip_prefix('=') {
172+
Some(substr) => (substr, true),
173+
None => (end, false),
174+
};
175+
let end = utils::date::parse_date(end).map_err(|_| Self::ERR_MSG)?;
176+
177+
if start > end {
178+
return Err("DateInput: start cannot be after end when using range".to_string());
179+
}
180+
181+
return Ok(DateInput::Range {
182+
start,
183+
end,
184+
inclusive,
185+
});
186+
}
187+
188+
// Single date
189+
let date = utils::date::parse_date(s).map_err(|_| Self::ERR_MSG)?;
190+
Ok(DateInput::Single(date))
191+
}
192+
}
193+
123194
#[derive(Args, Debug)]
124195
pub struct ListActivityArgs {
125196
/// Restrict to entries starting in the given <PERIOD>
126-
#[arg(short = 'p', long = "period", value_name = "PERIOD", default_value_t = Period::ThisWeek, value_enum, conflicts_with_all = ["from", "to", "date"])]
127-
pub period: Period,
128-
129-
/// Restrict to entries starting after <DATE> (YYYY-MM-DD format)
130-
#[arg(short = 'f', long = "from", value_name = "DATE", value_parser = utils::date::parse_date, conflicts_with = "date")]
131-
pub from: Option<NaiveDate>,
132-
133-
/// Restrict to entries starting before <DATE> (YYYY-MM-DD format)
134-
#[arg(short = 't', long = "to", value_name = "DATE", value_parser = utils::date::parse_date, conflicts_with = "date")]
135-
pub to: Option<NaiveDate>,
197+
#[arg(
198+
short = 'p',
199+
long = "period",
200+
value_name = "PERIOD",
201+
value_enum,
202+
conflicts_with = "date_range"
203+
)]
204+
pub period: Option<Period>,
205+
206+
/// Restrict to entries matching <DATE_RANGE> (YYYY-MM-DD format)
207+
#[arg(
208+
short = 'd',
209+
long = "date",
210+
value_name = "DATE_RANGE",
211+
conflicts_with = "period"
212+
)]
213+
pub date_range: Option<DateInput>,
136214

137-
/// Restrict to entries starting and ending on <DATE> (YYYY-MM-DD format)
138-
#[arg(short = 'd', long = "date", value_name = "DATE", value_parser = utils::date::parse_date, conflicts_with_all = ["period", "from", "to"])]
139-
pub date: Option<NaiveDate>,
215+
/// Show a per-activity summary instead of listing all logs
216+
#[arg(short = 's', long = "summary", conflicts_with = "no_grouping")]
217+
pub show_summary: bool,
140218

141-
/// Only show activities, do not include their respective logs
142-
#[arg(short = 'a', long = "activities-only", conflicts_with = "no_grouping")]
143-
pub activities_only: bool,
219+
/// Show all activities, even the ones with no log
220+
#[arg(short = 'a', long = "all", conflicts_with = "no_grouping")]
221+
pub show_all: bool,
144222

145-
/// Do not group activities by date
146-
#[arg(short = 'n', long = "no-grouping", conflicts_with = "activities_only")]
223+
/// Do not group activity logs by date
224+
#[arg(short = 'n', long = "no-grouping", conflicts_with_all = ["show_summary", "show_all"])]
147225
pub no_grouping: bool,
148226

149227
/// Output in JSON
150228
#[arg(short = 'j', long = "json")]
151229
pub use_json_format: bool,
230+
// /// Only show tags
231+
// #[arg(short = 't', long = "tags-only", conflicts_with = "no_grouping")]
232+
// pub tags_only: bool,
152233
}
153234

154235
#[derive(Args, Debug)]
@@ -158,14 +239,18 @@ pub struct ListArgs {
158239
pub use_json_format: bool,
159240
}
160241

161-
#[derive(ValueEnum, Clone, Debug)]
242+
#[derive(ValueEnum, Clone, Copy, Debug, Default)]
162243
pub enum Period {
163244
#[value(name = "today", alias = "td", alias = "tod")]
164245
Today,
246+
165247
#[value(name = "yesterday", alias = "yd", alias = "ytd")]
166248
Yesterday,
249+
167250
#[value(name = "this-week", alias = "tw", alias = "twk", alias = "wk")]
251+
#[default]
168252
ThisWeek,
253+
169254
#[value(
170255
name = "last-week",
171256
alias = "lw",
@@ -175,8 +260,10 @@ pub enum Period {
175260
alias = "ywk"
176261
)]
177262
LastWeek,
263+
178264
#[value(name = "this-month", alias = "tm", alias = "tmo", alias = "mo")]
179265
ThisMonth,
266+
180267
#[value(
181268
name = "last-month",
182269
alias = "lm",
@@ -188,35 +275,35 @@ pub enum Period {
188275
LastMonth,
189276
}
190277

191-
#[derive(Args, Debug)]
278+
impl std::fmt::Display for Period {
279+
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
280+
let now = Local::now();
281+
let last_month = now - Months::new(1);
282+
283+
let period = match self {
284+
Period::Today => "Today".to_string(),
285+
Period::Yesterday => "Yesterday".to_string(),
286+
Period::ThisWeek => "This week".to_string(),
287+
Period::LastWeek => "Last week".to_string(),
288+
Period::ThisMonth => format!("{} {}", now.format("%B"), now.year()),
289+
Period::LastMonth => format!("{} {}", last_month.format("%B"), last_month.year()),
290+
};
291+
write!(f, "{period}")
292+
}
293+
}
294+
295+
#[derive(Args, Debug, Default)]
192296
#[group(multiple = false)]
193297
pub struct PrintActivityArgs {
194-
/// Output in pretty format
195-
#[arg(short = 'p', long = "pretty")]
196-
pub use_pretty_format: bool,
197-
198298
/// Output in JSON
199299
#[arg(short = 'j', long = "json")]
200300
pub use_json_format: bool,
201301
}
202302

203-
impl Default for PrintActivityArgs {
204-
fn default() -> Self {
205-
Self {
206-
use_pretty_format: true,
207-
use_json_format: false,
208-
}
209-
}
210-
}
211-
212303
#[derive(Args, Debug)]
213304
pub struct SelectActivityArgs {
214305
/// ID of the activity
215306
pub activity_id: Id,
216-
217-
/// Output in JSON
218-
#[arg(short = 'j', long = "json")]
219-
pub use_json_format: bool,
220307
}
221308

222309
#[derive(Args, Debug)]

0 commit comments

Comments
 (0)