1use std::iter::once;
20use std::num::NonZeroU32;
21use std::path::PathBuf;
22use std::result;
23
24use clap::{Args, Parser, Subcommand};
25use once_cell::sync::Lazy;
26use regex::Regex;
27
28#[derive(Args, Default)]
30struct VecString {
31 args: Vec<String>
32}
33
34#[derive(Args, Default)]
36struct OptString {
37 arg: Option<String>
38}
39
40use crate::config::{Config, DEFAULT_CONF};
41#[doc(inline)]
42use crate::error::Error;
43#[doc(inline)]
44use crate::error::PathError;
45
46pub mod args;
47pub mod cmd;
48
49pub use args::DateRangeArgs;
50pub use args::FilterArgs;
51
52const BIN_NAME: &str = "rtimelog";
54
55static SUBST_RE: Lazy<Regex> =
57 Lazy::new(|| Regex::new(r"\{\}").expect("Template pattern must be correct"));
58
59#[derive(Clone, Copy)]
61enum ExpandAlias {
62 Yes,
63 No
64}
65
66const TASK_DESC: &str = "The command takes a 'task description' consisting of an optional project \
68 formatted with a leading '+', an optional task name formatted with a \
69 leading '@', and potentially more text adding details to the task. If no \
70 task name starting with '@' is supplied, any extra text is treated as \
71 the task.";
72
73const DATE_DESC: &str = "The 'date range description' consists of a single date string or a pair \
75 of date strings of the form 'YYYY-MM-DD', or one of a set of relative \
76 date strings including: today, yesterday, sunday, monday, tuesday, \
77 wednesday, thursday, friday, or saturday. The first two are obvious. The \
78 others refer to the previous instance of that day. The range can also be \
79 described by a month name (like january), the string 'ytd', or a range \
80 specified by 'this' or 'last' followed by 'week', 'month', or 'year'.";
81
82static FULL_DESC: Lazy<String> = Lazy::new(|| format!("{TASK_DESC}\n\n{DATE_DESC}"));
83
84#[derive(Subcommand)]
86enum StackCommands {
87 Clear,
89
90 Drop {
92 #[arg(name = "num", default_value = "1")]
97 num: NonZeroU32
98 },
99
100 Keep {
102 #[arg(name = "num", default_value = "10")]
107 num: NonZeroU32
108 },
109
110 Ls,
112
113 Top
115}
116
117#[derive(Subcommand)]
119enum EntryCommands {
120 Discard,
122
123 Now,
125
126 Ignore,
128
129 #[command(after_help = TASK_DESC)]
131 Rewrite {
132 #[arg(name = "task_desc")]
133 task: Vec<String>
134 },
135
136 Was {
138 time: String
140 },
141
142 Rewind {
144 minutes: NonZeroU32
146 }
147}
148
149#[derive(Subcommand)]
151enum ReportCommands {
152 #[command(after_help = DATE_DESC)]
154 Detail {
155 #[arg(name = "proj", short, long = "proj")]
157 projs: Vec<String>,
158
159 #[arg(name = "date_range")]
161 dates: Vec<String>
162 },
163
164 #[command(after_help = DATE_DESC)]
166 Summary {
167 #[arg(name = "proj", short, long = "proj")]
169 projs: Vec<String>,
170
171 #[arg(name = "date_range")]
173 dates: Vec<String>
174 },
175
176 #[command(after_help = DATE_DESC)]
178 Hours {
179 #[arg(name = "proj", short, long = "proj")]
181 projs: Vec<String>,
182
183 #[arg(name = "date_range")]
185 dates: Vec<String>
186 },
187
188 #[command(after_help = DATE_DESC)]
190 Events {
191 #[arg(short)]
193 compact: bool,
194
195 #[arg(name = "proj", short, long = "proj")]
197 projs: Vec<String>,
198
199 #[arg(name = "date_range")]
201 dates: Vec<String>
202 },
203
204 #[command(after_help = DATE_DESC)]
207 Intervals {
208 #[arg(name = "proj", short, long = "proj")]
210 projs: Vec<String>,
211
212 #[arg(name = "date_range")]
214 dates: Vec<String>
215 },
216
217 #[command(after_help = DATE_DESC)]
219 Chart {
220 #[arg(name = "date_range")]
222 dates: Vec<String>
223 }
224}
225
226#[derive(Subcommand)]
228enum Subcommands {
229 Init {
231 #[arg(name = "dir")]
234 dir: Option<String>
235 },
236
237 #[command(after_help = TASK_DESC)]
241 Start {
242 #[arg(name = "task_desc")]
243 task: Vec<String>
244 },
245
246 Stop,
248
249 #[command(after_help = TASK_DESC)]
255 Push {
256 #[arg(name = "task_desc")]
257 task: Vec<String>
258 },
259
260 Resume,
262
263 Pause,
265
266 Swap,
268
269 #[command(after_help = DATE_DESC)]
272 Ls {
273 #[arg(name = "date_desc")]
274 date: Option<String>
275 },
276
277 Comment(VecString),
279
280 Event(VecString),
282
283 Lsproj,
285
286 Edit,
288
289 Curr,
291
292 Check,
294
295 Archive,
297
298 Aliases,
300
301 #[command(subcommand)]
303 Report(ReportCommands),
304
305 #[command(subcommand)]
307 Stack(StackCommands),
308
309 #[command(subcommand)]
311 Entry(EntryCommands),
312
313 #[command(external_subcommand)]
316 Other(Vec<String>)
317}
318
319#[derive(Parser)]
321#[command(author, name = "rtimelog", version, about, long_about = None, after_help = FULL_DESC.as_str())]
322pub struct Cli {
323 #[arg(long, name = "dir")]
325 dir: Option<PathBuf>,
326
327 #[arg(long)]
329 editor: Option<PathBuf>,
330
331 #[arg(long, name = "filepath")]
333 conf: Option<PathBuf>,
334
335 #[arg(long)]
337 browser: Option<String>,
338
339 #[command(subcommand)]
341 cmd: Option<Subcommands>
342}
343
344impl Cli {
345 pub fn run(&self) -> crate::Result<()> {
354 let config = self.config()?;
355 match &self.cmd {
356 Some(cmd) => cmd.run(&config, ExpandAlias::Yes),
357 None => Subcommands::default_command(&config).run(&config, ExpandAlias::No)
358 }
359 }
360
361 fn run_alias(&self, config: &Config, expand: ExpandAlias) -> crate::Result<()> {
370 match &self.cmd {
371 Some(cmd) => cmd.run(config, expand),
372 None => Subcommands::default_command(config).run(config, ExpandAlias::No)
373 }
374 }
375
376 fn config(&self) -> result::Result<Config, PathError> {
384 let mut config = match &self.conf {
385 Some(conf_file) => {
386 Config::from_file(conf_file.to_str().ok_or(PathError::FilenameMissing)?)
387 }
388 None => Config::from_file(&DEFAULT_CONF)
389 }
390 .unwrap_or_default();
391
392 if let Some(dir) = &self.dir {
393 config.set_dir(
394 dir.to_str()
395 .ok_or_else(|| PathError::InvalidPath(String::new(), String::new()))?
396 );
397 }
398 if let Some(editor) = &self.editor {
399 config.set_editor(editor.to_str().ok_or(PathError::FilenameMissing)?);
400 }
401 if let Some(browser) = &self.browser {
402 config.set_browser(browser);
403 }
404 Ok(config)
405 }
406}
407
408impl Subcommands {
409 pub fn run(&self, config: &Config, expand: ExpandAlias) -> crate::Result<()> {
417 #![allow(clippy::unit_arg)]
418 use Subcommands::*;
419
420 match &self {
421 Init { dir } => Ok(cmd::initialize(config, dir)?),
422 Start { task } => cmd::start_task(config, task),
423 Stop => cmd::stop_task(config),
424 Comment(VecString { args }) => cmd::add_comment(config, args),
425 Event(VecString { args }) => cmd::add_event(config, args),
426 Push { task } => cmd::push_task(config, task),
427 Resume => cmd::resume_task(config),
428 Pause => cmd::pause_task(config),
429 Swap => cmd::swap_entry(config),
430 Ls { date } => cmd::list_entries(config, date),
431 Lsproj => cmd::list_projects(config),
432 Edit => cmd::edit(config),
433 Curr => cmd::current_task(config),
434 Check => cmd::check_logfile(config),
435 Archive => cmd::archive_year(config),
436 Aliases => Ok(cmd::list_aliases(config)),
437 Report(cmd) => cmd.run(config),
438 Stack(cmd) => cmd.run(config),
439 Entry(cmd) => cmd.run(config),
440 Other(args) => match expand {
441 ExpandAlias::Yes => Self::expand_alias(config, args),
442 ExpandAlias::No => Err(Error::InvalidCommand(args[0].clone()))
443 }
444 }
445 }
446
447 fn default_command(config: &Config) -> Self {
448 match config.defcmd() {
449 "init" => Self::Init { dir: None },
450 "start" => Self::Start { task: Vec::new() },
451 "stop" => Self::Stop,
452 "push" => Self::Push { task: Vec::new() },
453 "resume" => Self::Resume,
454 "pause" => Self::Pause,
455 "swap" => Self::Swap,
456 "ls" => Self::Ls { date: None },
457 "lsproj" => Self::Lsproj,
458 "edit" => Self::Edit,
459 "curr" => Self::Curr,
460 "archive" => Self::Archive,
461 "aliases" => Self::Aliases,
462 _ => Self::Curr
463 }
464 }
465
466 fn expand_alias(config: &Config, args: &[String]) -> crate::Result<()> {
468 let alias = &args[0];
469 let mut args_iter = args[1..].iter().map(String::as_str);
470
471 let expand: Vec<String> = config
472 .alias(alias)
473 .ok_or_else(|| Error::InvalidCommand(alias.clone()))?
474 .split(' ')
475 .map(|w| {
476 if SUBST_RE.is_match(w) {
477 args_iter.next().map_or_else(
478 || w.to_string(),
479 |val| SUBST_RE.replace(w, val).into_owned()
480 )
481 }
482 else {
483 w.to_string()
484 }
485 })
486 .collect();
487
488 let cmd = Cli::parse_from(
489 once(BIN_NAME)
490 .chain(expand.iter().map(String::as_str))
491 .chain(args_iter)
492 );
493 cmd.run_alias(config, ExpandAlias::No)
494 }
495}
496
497impl StackCommands {
498 pub fn run(&self, config: &Config) -> crate::Result<()> {
506 use StackCommands::*;
507 match &self {
508 Clear => cmd::stack_clear(config),
509 Drop { num } => cmd::stack_drop(config, *num),
510 Keep { num } => cmd::stack_keep(config, *num),
511 Ls => cmd::list_stack(config),
512 Top => cmd::stack_top(config)
513 }
514 }
515}
516
517impl EntryCommands {
518 pub fn run(&self, config: &Config) -> crate::Result<()> {
526 use EntryCommands::*;
527 match &self {
528 Discard => cmd::discard_last_entry(config),
529 Now => cmd::reset_last_entry(config),
530 Ignore => cmd::ignore_last_entry(config),
531 Rewrite { task } => cmd::rewrite_last_entry(config, task),
532 Was { time } => cmd::retime_last_entry(config, time),
533 Rewind { minutes } => cmd::rewind_last_entry(config, *minutes)
534 }
535 }
536}
537
538impl ReportCommands {
539 pub fn run(&self, config: &Config) -> crate::Result<()> {
547 use ReportCommands::*;
548 match &self {
549 Detail { projs, dates } => cmd::report_daily(config, dates, projs),
550 Summary { projs, dates } => cmd::report_summary(config, dates, projs),
551 Hours { projs, dates } => cmd::report_hours(config, dates, projs),
552 Events { compact, projs, dates } => cmd::report_events(config, dates, projs, *compact),
553 Intervals { projs, dates } => cmd::report_intervals(config, dates, projs),
554 Chart { dates } => cmd::chart_daily(config, dates)
555 }
556 }
557}