timelog/cli/
mod.rs

1//! Support for accessing the timelog logic from a tool
2//!
3//! # Examples
4//!
5//! ```rust,no_run
6//! use clap::Parser;
7//!
8//! use timelog::Cli;
9//!
10//! # fn main() {
11//! let cli = Cli::parse();
12//! let _ = cli.run();
13//! #  }
14//! ```
15//!
16//! # Description
17//!
18//! The [`Cli`] struct handling the command line processing for the main program.
19use 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// Utility type for a vector of strings
29#[derive(Args, Default)]
30struct VecString {
31    args: Vec<String>
32}
33
34// Utility type for an optional string
35#[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
52/// Name of the program binary.
53const BIN_NAME: &str = "rtimelog";
54
55/// Regular expression for matching the alias template match
56static SUBST_RE: Lazy<Regex> =
57    Lazy::new(|| Regex::new(r"\{\}").expect("Template pattern must be correct"));
58
59// Enumeration for determining whether to expand aliases.
60#[derive(Clone, Copy)]
61enum ExpandAlias {
62    Yes,
63    No
64}
65
66// Help message explaining a task description
67const 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
73// Help message explaining the format of an event description.
74const 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/// Specify the stack subcommands supported by the program.
85#[derive(Subcommand)]
86enum StackCommands {
87    /// Clear all of the items from the stack.
88    Clear,
89
90    /// Drop items from stack.
91    Drop {
92        /// Number of items to drop from stack.
93        ///
94        /// If an positive integer is supplied, that number of items is dropped from the top of the
95        /// stack. Otherwise, drop only the top item.
96        #[arg(name = "num", default_value = "1")]
97        num: NonZeroU32
98    },
99
100    /// Remove all except the top number of items from the stack.
101    Keep {
102        /// Number of items to keep on stack.
103        ///
104        /// If an positive integer is supplied, that number of items are kept on the top of stack,
105        /// discarding the rest. Otherwise, drop all but the top 10 items
106        #[arg(name = "num", default_value = "10")]
107        num: NonZeroU32
108    },
109
110    /// Display items on the stack.
111    Ls,
112
113    /// Display just the top item on the stack.
114    Top
115}
116
117/// Specify the task subcommands supported by the program.
118#[derive(Subcommand)]
119enum EntryCommands {
120    /// Discard the most recent entry.
121    Discard,
122
123    /// Reset the time of the most recent entry to now.
124    Now,
125
126    /// Return to the previous task, setting the current entry as ignored.
127    Ignore,
128
129    /// Rewrite the most recent entry.
130    #[command(after_help = TASK_DESC)]
131    Rewrite {
132        #[arg(name = "task_desc")]
133        task: Vec<String>
134    },
135
136    /// Set the time on the most recent entry
137    Was {
138        /// The time to use for the last entry either as "hh:mm" or "hh:mm:ss".
139        time: String
140    },
141
142    /// Shift the time on the most recent entry back the specified number of minutes.
143    Rewind {
144        /// Positive number of minutes
145        minutes: NonZeroU32
146    }
147}
148
149/// Specify the stack subcommands supported by the program.
150#[derive(Subcommand)]
151enum ReportCommands {
152    /// Display a report for the specified days and projects.
153    #[command(after_help = DATE_DESC)]
154    Detail {
155        /// Projects to report
156        #[arg(name = "proj", short, long = "proj")]
157        projs: Vec<String>,
158
159        /// Date range specification
160        #[arg(name = "date_range")]
161        dates: Vec<String>
162    },
163
164    /// Display a summary of the appropriate days' projects.
165    #[command(after_help = DATE_DESC)]
166    Summary {
167        /// Projects to report
168        #[arg(name = "proj", short, long = "proj")]
169        projs: Vec<String>,
170
171        /// Date range specification
172        #[arg(name = "date_range")]
173        dates: Vec<String>
174    },
175
176    /// Display the hours worked for each of the appropriate days and projects.
177    #[command(after_help = DATE_DESC)]
178    Hours {
179        /// Projects to report
180        #[arg(name = "proj", short, long = "proj")]
181        projs: Vec<String>,
182
183        /// Date range specification
184        #[arg(name = "date_range")]
185        dates: Vec<String>
186    },
187
188    /// Display the zero duration events for each of the appropriate days and projects.
189    #[command(after_help = DATE_DESC)]
190    Events {
191        /// Boolean option for a more compact format.
192        #[arg(short)]
193        compact: bool,
194
195        /// Projects to report
196        #[arg(name = "proj", short, long = "proj")]
197        projs: Vec<String>,
198
199        /// Date range specification
200        #[arg(name = "date_range")]
201        dates: Vec<String>
202    },
203
204    /// Display the intervals between zero duration events for each of the appropriate days and
205    /// projects.
206    #[command(after_help = DATE_DESC)]
207    Intervals {
208        /// Projects to report
209        #[arg(name = "proj", short, long = "proj")]
210        projs: Vec<String>,
211
212        /// Date range specification
213        #[arg(name = "date_range")]
214        dates: Vec<String>
215    },
216
217    /// Display a chart of the hours worked for each of the appropriate days and projects.
218    #[command(after_help = DATE_DESC)]
219    Chart {
220        /// Date range specification
221        #[arg(name = "date_range")]
222        dates: Vec<String>
223    }
224}
225
226/// Specify the subcommands supported by the program.
227#[derive(Subcommand)]
228enum Subcommands {
229    /// Create the timelog directory and configuration.
230    Init {
231        /// Directory for logging the task events and stack. Default to `~/timelog` if not
232        /// supplied.
233        #[arg(name = "dir")]
234        dir: Option<String>
235    },
236
237    /// Start timing a new task.
238    ///
239    /// Stop timing the current task (if any) and start timing a new task.
240    #[command(after_help = TASK_DESC)]
241    Start {
242        #[arg(name = "task_desc")]
243        task: Vec<String>
244    },
245
246    /// Stop timing the current task.
247    Stop,
248
249    /// Save the current task and start timing a new task.
250    ///
251    /// This command works the same as the `start` command, except that the
252    /// current task description is saved on the top of the stack. This makes
253    /// resuming the previous task easier.
254    #[command(after_help = TASK_DESC)]
255    Push {
256        #[arg(name = "task_desc")]
257        task: Vec<String>
258    },
259
260    /// Stop last task and restart top task on stack.
261    Resume,
262
263    /// Save the current task and stop timing.
264    Pause,
265
266    /// Pop the top task description on the stack and push the current task.
267    Swap,
268
269    /// List entries for the specified day. Default to today.
270    // #[command(arg(name = "date_desc"))]
271    #[command(after_help = DATE_DESC)]
272    Ls {
273        #[arg(name = "date_desc")]
274        date: Option<String>
275    },
276
277    /// Add a comment line.
278    Comment(VecString),
279
280    /// Add a zero duration event
281    Event(VecString),
282
283    /// List known projects.
284    Lsproj,
285
286    /// Open the timelog file in the current editor.
287    Edit,
288
289    /// Display the current task.
290    Curr,
291
292    /// Check the logfile for problems.
293    Check,
294
295    /// Archive the first year from the timelog file, as long as it isn't the current year.
296    Archive,
297
298    /// List the aliases from the config file.
299    Aliases,
300
301    /// Reporting commands
302    #[command(subcommand)]
303    Report(ReportCommands),
304
305    /// Stack specific commands
306    #[command(subcommand)]
307    Stack(StackCommands),
308
309    /// Entry specific commands
310    #[command(subcommand)]
311    Entry(EntryCommands),
312
313    // `external_subcommand` tells clap to put
314    // all the extra arguments into this Vec
315    #[command(external_subcommand)]
316    Other(Vec<String>)
317}
318
319/// Specify all of the command line parameters supported by the program.
320#[derive(Parser)]
321#[command(author, name = "rtimelog", version, about, long_about = None, after_help = FULL_DESC.as_str())]
322pub struct Cli {
323    /// Specify a directory for logging the task events and stack.
324    #[arg(long, name = "dir")]
325    dir: Option<PathBuf>,
326
327    /// Specify the editor to use for modifying events.
328    #[arg(long)]
329    editor: Option<PathBuf>,
330
331    /// Specify the path to the configuration file.
332    #[arg(long, name = "filepath")]
333    conf: Option<PathBuf>,
334
335    /// Specify the command to execute the browser.
336    #[arg(long)]
337    browser: Option<String>,
338
339    /// Sub-commands which determine what actions to take
340    #[command(subcommand)]
341    cmd: Option<Subcommands>
342}
343
344impl Cli {
345    /// Execute the action specified on the command line.
346    ///
347    /// # Errors
348    ///
349    /// - Return [`PathError::FilenameMissing`] if no configuration file is known.
350    /// - Return [`PathError::InvalidPath`] if the timelog directory is not a valid path.
351    /// - Return [`PathError::FilenameMissing`] if no editor has been configured.
352    /// - Return other errors specific to the commands.
353    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    // Execute the action built from alias
362    //
363    // # Errors
364    //
365    // - Return [`PathError::FilenameMissing`] if no configuration file is known.
366    // - Return [`PathError::InvalidPath`] if the timelog directory is not a valid path.
367    // - Return [`PathError::FilenameMissing`] if no editor has been configured.
368    // - Return other errors specific to the commands.
369    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    // Retrieve the configuration from the file.
377    //
378    // # Errors
379    //
380    // - Return [`PathError::FilenameMissing`] if no configuration file is known.
381    // - Return [`PathError::InvalidPath`] if the timelog directory is not a valid path.
382    // - Return [`PathError::FilenameMissing`] if no editor has been configured.
383    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    /// Execute the action associated with the current variant.
410    ///
411    /// # Errors
412    ///
413    /// - Return [`Error`]s for particular commands failing.
414    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
415    /// matches.
416    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    // Replace an alias as the first argument with the definition of the alias.
467    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    /// Execute the action associated with the current variant.
499    ///
500    /// # Errors
501    ///
502    /// - Return [`Error`]s for particular commands failing.
503    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
504    /// matches.
505    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    /// Execute the action associated with the current variant.
519    ///
520    /// # Errors
521    ///
522    /// - Return [`Error`]s for particular commands failing.
523    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
524    /// matches.
525    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    /// Execute the action associated with the current variant.
540    ///
541    /// # Errors
542    ///
543    /// - Return [`Error`]s for particular commands failing.
544    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
545    /// matches.
546    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}