Skip to main content

timelog/
cli.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
28use crate::date::Time;
29
30// Utility type for a vector of strings
31#[derive(Args, Default)]
32struct VecString {
33    args: Vec<String>
34}
35
36// Utility type for an optional string
37#[derive(Args, Default)]
38struct OptString {
39    arg: Option<String>
40}
41
42use crate::config::{Config, DEFAULT_CONF};
43#[doc(inline)]
44use crate::error::Error;
45#[doc(inline)]
46use crate::error::PathError;
47
48pub mod args;
49pub mod cmd;
50
51pub use args::DateRangeArgs;
52pub use args::FilterArgs;
53
54/// Name of the program binary.
55const BIN_NAME: &str = "rtimelog";
56
57/// Regular expression for matching the alias template match
58static SUBST_RE: Lazy<Regex> =
59    Lazy::new(|| Regex::new(r"\{\}").expect("Template pattern must be correct"));
60
61// Enumeration for determining whether to expand aliases.
62#[derive(Clone, Copy)]
63enum ExpandAlias {
64    Yes,
65    No
66}
67
68// Help message explaining a task description
69const TASK_DESC: &str = "The command takes a 'task description' consisting of an optional project \
70                         formatted with a leading '+', an optional task name formatted with a \
71                         leading '@', and potentially more text adding details to the task. If no \
72                         task name starting with '@' is supplied, any extra text is treated as \
73                         the task.";
74
75// Help message explaining the format of an event description.
76const DATE_DESC: &str = "The 'date range description' consists of a single date string or a pair \
77                         of date strings of the form 'YYYY-MM-DD', or one of a set of relative \
78                         date strings including: today, yesterday, sunday, monday, tuesday, \
79                         wednesday, thursday, friday, or saturday. The first two are obvious. The \
80                         others refer to the previous instance of that day. The range can also be \
81                         described by a month name (like january), the string 'ytd', or a range \
82                         specified by 'this' or 'last' followed by 'week', 'month', or 'year'.";
83
84static FULL_DESC: Lazy<String> = Lazy::new(|| format!("{TASK_DESC}\n\n{DATE_DESC}"));
85
86/// Specify the stack subcommands supported by the program.
87#[derive(Subcommand)]
88enum StackCommands {
89    /// Clear all of the items from the stack.
90    Clear,
91
92    /// Drop items from stack.
93    Drop {
94        /// Number of items to drop from stack.
95        ///
96        /// If an positive integer is supplied, that number of items is dropped from the top of the
97        /// stack. Otherwise, drop only the top item.
98        #[arg(name = "num", default_value = "1")]
99        num: NonZeroU32
100    },
101
102    /// Remove all except the top number of items from the stack.
103    Keep {
104        /// Number of items to keep on stack.
105        ///
106        /// If an positive integer is supplied, that number of items are kept on the top of stack,
107        /// discarding the rest. Otherwise, drop all but the top 10 items
108        #[arg(name = "num", default_value = "10")]
109        num: NonZeroU32
110    },
111
112    /// Display items on the stack.
113    Ls,
114
115    /// Display just the top item on the stack.
116    Top
117}
118
119/// Specify the task subcommands supported by the program.
120#[derive(Subcommand)]
121enum EntryCommands {
122    /// Discard the most recent entry.
123    Discard,
124
125    /// Reset the time of the most recent entry to now.
126    Now,
127
128    /// Return to the previous task, setting the current entry as ignored.
129    Ignore,
130
131    /// Rewrite the most recent entry.
132    #[command(after_help = TASK_DESC)]
133    Rewrite {
134        #[arg(name = "task_desc")]
135        task: Vec<String>
136    },
137
138    /// Set the time on the most recent entry
139    Was {
140        /// The time to use for the last entry either as "hh:mm" or "hh:mm:ss".
141        time: Time
142    },
143
144    /// Shift the time on the most recent entry back the specified number of minutes.
145    Rewind {
146        /// Positive number of minutes
147        minutes: NonZeroU32
148    }
149}
150
151/// Specify the stack subcommands supported by the program.
152#[derive(Subcommand)]
153enum ReportCommands {
154    /// Display a report for the specified days and projects.
155    #[command(after_help = DATE_DESC)]
156    Detail {
157        /// Projects to report
158        #[arg(name = "proj", short, long = "proj")]
159        projs: Vec<String>,
160
161        /// Date range specification
162        #[arg(name = "date_range")]
163        dates: Vec<String>
164    },
165
166    /// Display a summary of the appropriate days' projects.
167    #[command(after_help = DATE_DESC)]
168    Summary {
169        /// Projects to report
170        #[arg(name = "proj", short, long = "proj")]
171        projs: Vec<String>,
172
173        /// Date range specification
174        #[arg(name = "date_range")]
175        dates: Vec<String>
176    },
177
178    /// Display the hours worked for each of the appropriate days and projects.
179    #[command(after_help = DATE_DESC)]
180    Hours {
181        /// Projects to report
182        #[arg(name = "proj", short, long = "proj")]
183        projs: Vec<String>,
184
185        /// Date range specification
186        #[arg(name = "date_range")]
187        dates: Vec<String>
188    },
189
190    /// Display the zero duration events for each of the appropriate days and projects.
191    #[command(after_help = DATE_DESC)]
192    Events {
193        /// Boolean option for a more compact format.
194        #[arg(short)]
195        compact: bool,
196
197        /// Projects to report
198        #[arg(name = "proj", short, long = "proj")]
199        projs: Vec<String>,
200
201        /// Date range specification
202        #[arg(name = "date_range")]
203        dates: Vec<String>
204    },
205
206    /// Display the intervals between zero duration events for each of the appropriate days and
207    /// projects.
208    #[command(after_help = DATE_DESC)]
209    Intervals {
210        /// Projects to report
211        #[arg(name = "proj", short, long = "proj")]
212        projs: Vec<String>,
213
214        /// Date range specification
215        #[arg(name = "date_range")]
216        dates: Vec<String>
217    },
218
219    /// Display a chart of the hours worked for each of the appropriate days and projects.
220    #[command(after_help = DATE_DESC)]
221    Chart {
222        /// Date range specification
223        #[arg(name = "date_range")]
224        dates: Vec<String>
225    }
226}
227
228/// Specify the subcommands supported by the program.
229#[derive(Subcommand)]
230enum Subcommands {
231    /// Create the timelog directory and configuration.
232    Init {
233        /// Directory for logging the task events and stack. Default to `~/timelog` if not
234        /// supplied.
235        #[arg(name = "dir")]
236        dir: Option<String>
237    },
238
239    /// Start timing a new task.
240    ///
241    /// Stop timing the current task (if any) and start timing a new task.
242    #[command(after_help = TASK_DESC)]
243    Start {
244        #[arg(name = "task_desc")]
245        task: Vec<String>
246    },
247
248    /// Stop timing the current task.
249    Stop,
250
251    /// Save the current task and start timing a new task.
252    ///
253    /// This command works the same as the `start` command, except that the
254    /// current task description is saved on the top of the stack. This makes
255    /// resuming the previous task easier.
256    #[command(after_help = TASK_DESC)]
257    Push {
258        #[arg(name = "task_desc")]
259        task: Vec<String>
260    },
261
262    /// Stop last task and restart top task on stack.
263    Resume,
264
265    /// Save the current task and stop timing.
266    Pause,
267
268    /// Pop the top task description on the stack and push the current task.
269    Swap,
270
271    /// List non-event entries for the specified day. Default to today.
272    // #[command(arg(name = "date_desc"))]
273    #[command(after_help = DATE_DESC)]
274    Ls {
275        /// List events as well as time entries.
276        #[arg(short)]
277        all: bool,
278
279        #[arg(name = "date_desc")]
280        date: Option<String>
281    },
282
283    /// Add a comment line.
284    Comment(VecString),
285
286    /// Add a zero duration event
287    Event(VecString),
288
289    /// List known projects.
290    Lsproj,
291
292    /// Open the timelog file in the current editor.
293    Edit,
294
295    /// Display the current task.
296    Curr,
297
298    /// Check the logfile for problems.
299    Check,
300
301    /// Archive the first year from the timelog file, as long as it isn't the current year.
302    Archive,
303
304    /// List the aliases from the config file.
305    Aliases,
306
307    /// Reporting commands
308    #[command(subcommand)]
309    Report(ReportCommands),
310
311    /// Stack specific commands
312    #[command(subcommand)]
313    Stack(StackCommands),
314
315    /// Entry specific commands
316    #[command(subcommand)]
317    Entry(EntryCommands),
318
319    // `external_subcommand` tells clap to put
320    // all the extra arguments into this Vec
321    #[command(external_subcommand)]
322    Other(Vec<String>)
323}
324
325/// Specify all of the command line parameters supported by the program.
326#[derive(Parser)]
327#[command(author, name = "rtimelog", version, about, long_about = None, after_help = FULL_DESC.as_str())]
328pub struct Cli {
329    /// Specify a directory for logging the task events and stack.
330    #[arg(long, name = "dir")]
331    dir: Option<PathBuf>,
332
333    /// Specify the editor to use for modifying events.
334    #[arg(long)]
335    editor: Option<PathBuf>,
336
337    /// Specify the path to the configuration file.
338    #[arg(long, name = "filepath")]
339    conf: Option<PathBuf>,
340
341    /// Specify the command to execute the browser.
342    #[arg(long)]
343    browser: Option<String>,
344
345    /// Sub-commands which determine what actions to take
346    #[command(subcommand)]
347    cmd: Option<Subcommands>
348}
349
350impl Cli {
351    /// Execute the action specified on the command line.
352    ///
353    /// # Errors
354    ///
355    /// - Return [`PathError::FilenameMissing`] if no configuration file is known.
356    /// - Return [`PathError::InvalidPath`] if the timelog directory is not a valid path.
357    /// - Return [`PathError::FilenameMissing`] if no editor has been configured.
358    /// - Return other errors specific to the commands.
359    pub fn run(&self) -> crate::Result<()> {
360        let config = self.config()?;
361        match &self.cmd {
362            Some(cmd) => cmd.run(&config, ExpandAlias::Yes),
363            None => Subcommands::default_command(&config).run(&config, ExpandAlias::No)
364        }
365    }
366
367    // Execute the action built from alias
368    //
369    // # Errors
370    //
371    // - Return [`PathError::FilenameMissing`] if no configuration file is known.
372    // - Return [`PathError::InvalidPath`] if the timelog directory is not a valid path.
373    // - Return [`PathError::FilenameMissing`] if no editor has been configured.
374    // - Return other errors specific to the commands.
375    fn run_alias(&self, config: &Config, expand: ExpandAlias) -> crate::Result<()> {
376        match &self.cmd {
377            Some(cmd) => cmd.run(config, expand),
378            None => Subcommands::default_command(config).run(config, ExpandAlias::No)
379        }
380    }
381
382    // Retrieve the configuration from the file.
383    //
384    // # Errors
385    //
386    // - Return [`PathError::FilenameMissing`] if no configuration file is known.
387    // - Return [`PathError::InvalidPath`] if the timelog directory is not a valid path.
388    // - Return [`PathError::FilenameMissing`] if no editor has been configured.
389    fn config(&self) -> result::Result<Config, PathError> {
390        let mut config = match &self.conf {
391            Some(conf_file) => {
392                Config::from_file(conf_file.to_str().ok_or(PathError::FilenameMissing)?)
393            }
394            None => Config::from_file(&DEFAULT_CONF)
395        }
396            .unwrap_or_default();
397
398        if let Some(dir) = &self.dir {
399            config.set_dir(
400                dir.to_str()
401                    .ok_or_else(|| PathError::InvalidPath(String::new(), String::new()))?
402            );
403        }
404        if let Some(editor) = &self.editor {
405            config.set_editor(editor.to_str().ok_or(PathError::FilenameMissing)?);
406        }
407        if let Some(browser) = &self.browser {
408            config.set_browser(browser);
409        }
410        Ok(config)
411    }
412}
413
414impl Subcommands {
415    /// Execute the action associated with the current variant.
416    ///
417    /// # Errors
418    ///
419    /// - Return [`Error`]s for particular commands failing.
420    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
421    ///   matches.
422    pub fn run(&self, config: &Config, expand: ExpandAlias) -> crate::Result<()> {
423        use Subcommands as C;
424
425        match &self {
426            C::Init { dir } => Ok(cmd::initialize(config, dir.as_ref().map(String::as_str))?),
427            C::Start { task } => cmd::start_task(config, task),
428            C::Stop => cmd::stop_task(config),
429            C::Comment(VecString { args }) => cmd::add_comment(config, args),
430            C::Event(VecString { args }) => cmd::add_event(config, args),
431            C::Push { task } => cmd::push_task(config, task),
432            C::Resume => cmd::resume_task(config),
433            C::Pause => cmd::pause_task(config),
434            C::Swap => cmd::swap_entry(config),
435            C::Ls { all, date } => cmd::list_entries(config, date.as_ref().map(String::as_str), *all),
436            C::Lsproj => cmd::list_projects(config),
437            C::Edit => cmd::edit(config),
438            C::Curr => cmd::current_task(config),
439            C::Check => cmd::check_logfile(config),
440            C::Archive => cmd::archive_year(config),
441            C::Aliases => {
442                cmd::list_aliases(config);
443                Ok(())
444            }
445            C::Report(cmd) => cmd.run(config),
446            C::Stack(cmd) => cmd.run(config),
447            C::Entry(cmd) => cmd.run(config),
448            C::Other(args) => match expand {
449                ExpandAlias::Yes => Self::expand_alias(config, args),
450                ExpandAlias::No => Err(Error::InvalidCommand(args[0].clone()))
451            }
452        }
453    }
454
455    fn default_command(config: &Config) -> Self {
456        match config.defcmd() {
457            "init" => Self::Init { dir: None },
458            "start" => Self::Start { task: Vec::new() },
459            "stop" => Self::Stop,
460            "push" => Self::Push { task: Vec::new() },
461            "resume" => Self::Resume,
462            "pause" => Self::Pause,
463            "swap" => Self::Swap,
464            "ls" => Self::Ls { all: false, date: None },
465            "lsproj" => Self::Lsproj,
466            "edit" => Self::Edit,
467            "curr" => Self::Curr,
468            "archive" => Self::Archive,
469            "aliases" => Self::Aliases,
470            _ => Self::Curr
471        }
472    }
473
474    // Replace an alias as the first argument with the definition of the alias.
475    fn expand_alias(config: &Config, args: &[String]) -> crate::Result<()> {
476        let alias = &args[0];
477        let mut args_iter = args[1..].iter().map(String::as_str);
478
479        let expand: Vec<String> = config
480            .alias(alias)
481            .ok_or_else(|| Error::InvalidCommand(alias.clone()))?
482            .split(' ')
483            .map(|w| {
484                if SUBST_RE.is_match(w) {
485                    args_iter.next().map_or_else(
486                        || w.to_string(),
487                        |val| SUBST_RE.replace(w, val).into_owned()
488                    )
489                }
490                else {
491                    w.to_string()
492                }
493            })
494            .collect();
495
496        let cmd = Cli::parse_from(
497            once(BIN_NAME)
498                .chain(expand.iter().map(String::as_str))
499                .chain(args_iter)
500        );
501        cmd.run_alias(config, ExpandAlias::No)
502    }
503}
504
505impl StackCommands {
506    /// Execute the action associated with the current variant.
507    ///
508    /// # Errors
509    ///
510    /// - Return [`Error`]s for particular commands failing.
511    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
512    ///   matches.
513    pub fn run(&self, config: &Config) -> crate::Result<()> {
514        use StackCommands as SC;
515        match &self {
516            SC::Clear => cmd::stack_clear(config),
517            SC::Drop { num } => cmd::stack_drop(config, *num),
518            SC::Keep { num } => cmd::stack_keep(config, *num),
519            SC::Ls => cmd::list_stack(config),
520            SC::Top => cmd::stack_top(config)
521        }
522    }
523}
524
525impl EntryCommands {
526    /// Execute the action associated with the current variant.
527    ///
528    /// # Errors
529    ///
530    /// - Return [`Error`]s for particular commands failing.
531    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
532    ///   matches.
533    pub fn run(&self, config: &Config) -> crate::Result<()> {
534        use EntryCommands as EC;
535        match &self {
536            EC::Discard => cmd::discard_last_entry(config),
537            EC::Now => cmd::reset_last_entry(config),
538            EC::Ignore => cmd::ignore_last_entry(config),
539            EC::Rewrite { task } => cmd::rewrite_last_entry(config, task),
540            EC::Was { time } => cmd::retime_last_entry(config, *time),
541            EC::Rewind { minutes } => cmd::rewind_last_entry(config, *minutes)
542        }
543    }
544}
545
546impl ReportCommands {
547    /// Execute the action associated with the current variant.
548    ///
549    /// # Errors
550    ///
551    /// - Return [`Error`]s for particular commands failing.
552    /// - Return [`Error::InvalidCommand`] if the subcommand is not recognized and no alias
553    ///   matches.
554    pub fn run(&self, config: &Config) -> crate::Result<()> {
555        use ReportCommands as RC;
556        match &self {
557            RC::Detail { projs, dates } => cmd::report_daily(config, dates, projs),
558            RC::Summary { projs, dates } => cmd::report_summary(config, dates, projs),
559            RC::Hours { projs, dates } => cmd::report_hours(config, dates, projs),
560            RC::Events { compact, projs, dates } => {
561                cmd::report_events(config, dates, projs, *compact)
562            }
563            RC::Intervals { projs, dates } => cmd::report_intervals(config, dates, projs),
564            RC::Chart { dates } => cmd::chart_daily(config, dates)
565        }
566    }
567}