tiempo 1.6.0

A command line time tracker
Documentation
use std::convert::TryInto;
use std::process::exit;
use std::io;

use clap::{
    App, Arg, SubCommand, AppSettings, ArgMatches, crate_version, crate_authors,
    crate_description, crate_name,
};
use chrono::Utc;
use regex::Regex;
use lazy_static::lazy_static;

use tiempo::error;
use tiempo::database::SqliteDatabase;
use tiempo::env::Env;
use tiempo::config::Config;
use tiempo::commands::{
    Command, Facts, r#in::InCommand, display::DisplayCommand,
    sheet::SheetCommand, today::TodayCommand, yesterday::YesterdayCommand,
    week::WeekCommand, month::MonthCommand, list::ListCommand, out::OutCommand,
    resume::ResumeCommand, backend::BackendCommand, kill::KillCommand,
    now::NowCommand, edit::EditCommand, archive::ArchiveCommand,
    configure::ConfigureCommand,
};
use tiempo::io::Streams;

lazy_static! {
    // https://regex101.com/r/V9zYQu/1/
    pub static ref NUMBER_RE: Regex = Regex::new(r"^\d+$").unwrap();
}

fn is_number(v: String) -> Result<(), String>{
    if NUMBER_RE.is_match(&v) {
        Ok(())
    } else {
        Err(format!("'{}' is not a valid number", v))
    }
}

fn error_trap(matches: ArgMatches) -> error::Result<()> {
    let env = Env::read();
    let facts = Facts {
        config: Config::read(env.timetrap_config_file.as_deref())?,
        env,
        now: Utc::now(),
    };

    if let Some(_matches) = matches.subcommand_matches("backend") {
        return BackendCommand::handle(&facts.config);
    }

    let mut streams = Streams {
        db: SqliteDatabase::from_path_or_create(&facts.config.database_file)?,
        r#in: io::BufReader::new(io::stdin()),
        out: io::stdout(),
        err: io::stderr(),
    };

    match matches.subcommand() {
        ("in", Some(matches)) => InCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("out", Some(matches)) => OutCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("resume", Some(matches)) => ResumeCommand::handle(matches.try_into()?, &mut streams, &facts),

        ("display", Some(matches)) => DisplayCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("today", Some(matches)) => TodayCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("yesterday", Some(matches)) => YesterdayCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("week", Some(matches)) => WeekCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("month", Some(matches)) => MonthCommand::handle(matches.try_into()?, &mut streams, &facts),

        ("sheet", Some(matches)) => SheetCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("list", Some(matches)) => ListCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("kill", Some(matches)) => KillCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("now", Some(matches)) => NowCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("edit", Some(matches)) => EditCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("archive", Some(matches)) => ArchiveCommand::handle(matches.try_into()?, &mut streams, &facts),
        ("configure", Some(matches)) => ConfigureCommand::handle(matches.try_into()?, &mut streams, &facts),

        (cmd, _) => Err(error::Error::UnimplementedCommand(cmd.into())),
    }
}

fn main() {
    // Let's first declare some args that repeat here and there
    let start_arg = Arg::with_name("start")
        .long("start").short("s")
        .takes_value(true).value_name("TIME")
        .help("Include entries that start on this date or later");

    let end_arg = Arg::with_name("end")
        .long("end").short("e")
        .takes_value(true).value_name("TIME")
        .help("Include entries that start on this date or earlier");

    let ids_arg = Arg::with_name("ids")
        .short("v").long("ids")
        .help("Print database ids (for use with edit)");

    let grep_arg = Arg::with_name("grep")
        .long("grep").short("g")
        .takes_value(true).value_name("REGEXP")
        .help("Only include entries whose note matches this regular expression");

    let format_arg = Arg::with_name("format")
        .short("f").long("format")
        .takes_value(true).value_name("FORMAT")
        .help(
            "The output format. Valid built-in formats are chart, text, ical, \
            csv, json and ids. Documentation on defining custom formats can be \
            found at https://tiempo.categulario.xyz or the man page included \
            with the installation."
        );

    let sheet_arg = Arg::with_name("sheet")
        .takes_value(true).value_name("SHEET")
        .help(
            "The sheet to display. Pass 'all' to see entries from all sheets \
            or 'full' to see hidden entries"
        );

    let at_arg = Arg::with_name("at")
        .long("at")
        .takes_value(true).value_name("TIME")
        .help("Use this time instead of now");

    let id_arg = Arg::with_name("id")
        .long("id")
        .takes_value(true).value_name("ID")
        .validator(is_number);

    let interactive_arg = Arg::with_name("interactive")
        .short("i")
        .long("interactive")
        .takes_value(false)
        .conflicts_with("id")
        .help("Choose an entry of the (unique) last N interactively");

    // Now declar this app's cli
    let matches = App::new("Tiempo")
        .name(crate_name!())
        .setting(AppSettings::SubcommandRequired)
        .version(crate_version!())
        .author(crate_authors!())
        .about(crate_description!())

        .subcommand(SubCommand::with_name("archive")
            .visible_alias("a")
            .about("Move entries to a hidden sheet (by default named '_[SHEET]') so they're out of the way.")
            .arg(start_arg.clone())
            .arg(end_arg.clone())
            .arg(grep_arg.clone())
            .arg(sheet_arg.clone().help("Archive entries from this sheet instead of the current one"))
            .arg(Arg::with_name("fake")
                 .short("f").long("fake")
                 .help("Don't actually archive the entries, just display them")
            )
            .arg(Arg::with_name("time")
                 .short("t").long("time")
                 .takes_value(true).value_name("HOURS")
                 .help("Time in hours to archive. Archived time will be equal or less than this.")
            )
        )

        .subcommand(SubCommand::with_name("backend")
            .visible_alias("b")
            .about("Open an sqlite shell to the database.")
        )

        .subcommand(SubCommand::with_name("configure")
            .visible_alias("c")
            .about("Configure tiempo in-place. If no arguments are given it just prints the path to the config file in use.")
            .arg(Arg::with_name("round_in_seconds")
                 .long("round-in-seconds")
                 .takes_value(true)
                 .value_name("SECONDS")
                 .validator(is_number)
                 .help("The duration of time to use for rounding with the -r flag. Default: 900 (15 m)"))
            .arg(Arg::with_name("database_file")
                 .long("database-file")
                 .takes_value(true)
                 .value_name("PATH")
                 .help("The file path of the sqlite database"))
            .arg(Arg::with_name("append_notes_delimiter")
                 .long("append-notes-delimiter")
                 .takes_value(true)
                 .value_name("DELIMITER")
                 .help("delimiter used when appending notes via t edit --append. Default: ' ' (space)"))
            .arg(Arg::with_name("formatter_search_paths")
                 .long("formatter-search-paths")
                 .takes_value(true)
                 .multiple(true)
                 .value_name("PATHS")
                 .help("comma separated directories to search for user defined fomatter classes"))
            .arg(Arg::with_name("default_formatter")
                 .long("default-formatter")
                 .takes_value(true)
                 .value_name("FORMATTER")
                 .help("The format to use when display is invoked without a `--format` option. Default 'text'"))
            .arg(Arg::with_name("require_note")
                 .long("require-note")
                 .help("Prompt for a note if one isn't provided when checking in (default)"))
            .arg(Arg::with_name("no_require_note")
                 .long("no-require-note")
                 .help("Entries can be created without notes"))
            .arg(Arg::with_name("auto_checkout")
                 .long("auto-checkout")
                 .help("Checkout of current running entry when starting a new one"))
            .arg(Arg::with_name("no_auto_checkout")
                 .long("no-auto-checkout")
                 .help("Starting a new entry fails if one is running (default)"))
            .arg(Arg::with_name("note_editor")
                 .long("note-editor")
                 .takes_value(true)
                 .value_name("EDITOR")
                 .help("Command to launch notes editor. Default: $EDITOR"))
            .arg(Arg::with_name("week_start")
                 .long("week-start")
                 .takes_value(true)
                 .value_name("DAY")
                 .possible_values(&["monday", "tuesday", "wednesday", "thursday", "friday", "saturday", "sunday"])
                 .help("The day of the week to use as the start of the week for t week. Default: monday"))
            .arg(Arg::with_name("interactive_entries")
                 .long("interactive-entries")
                 .takes_value(true)
                 .value_name("N")
                 .validator(is_number)
                 .help("How many unique previous notes to show when selecting interactively"))
        )

        .subcommand(SubCommand::with_name("display")
            .visible_alias("d")
            .about(
                "Display the current timesheet or a specific. Pass `all' as \
                SHEET to display all unarchived sheets or `full' to display \
                archived and unarchived sheets.")
            .arg(ids_arg.clone())
            .arg(start_arg.clone())
            .arg(end_arg.clone())
            .arg(format_arg.clone())
            .arg(grep_arg.clone())
            .arg(sheet_arg.clone())
        )

        .subcommand(SubCommand::with_name("today")
            .visible_alias("t")
            .about("Display entries that started today")
            .arg(ids_arg.clone())
            .arg(end_arg.clone())
            .arg(format_arg.clone())
            .arg(grep_arg.clone())
            .arg(sheet_arg.clone())
        )

        .subcommand(SubCommand::with_name("yesterday")
            .visible_alias("y")
            .about("Display entries that started yesterday")
            .arg(ids_arg.clone())
            .arg(format_arg.clone())
            .arg(grep_arg.clone())
            .arg(sheet_arg.clone())
        )

        .subcommand(SubCommand::with_name("week")
            .visible_alias("w")
            .about("Display entries starting last monday or later")
            .arg(ids_arg.clone())
            .arg(end_arg.clone())
            .arg(format_arg.clone())
            .arg(grep_arg.clone())
            .arg(sheet_arg.clone())
        )

        .subcommand(SubCommand::with_name("month")
            .visible_alias("m")
            .about("Display entries starting this month")
            .arg(ids_arg.clone())
            .arg(format_arg.clone())
            .arg(grep_arg.clone())
            .arg(sheet_arg.clone())
            .arg(Arg::with_name("month")
                .long("month").short("m")
                .takes_value(true).value_name("TIME")
                .aliases(&["s", "start"])
                .possible_values(&[
                    "this", "current", "last", "jan", "january", "feb",
                    "february", "mar", "march", "apr", "april", "may", "jun",
                    "june", "jul", "july", "aug", "august", "sep", "september",
                    "oct", "october", "nov", "november", "dic", "december",
                ])
                .hide_possible_values(true)
                .help(
                    "Include entries of the specified month instead of the \
                    current month"
                )
            )
        )

        .subcommand(SubCommand::with_name("in")
            .visible_alias("i")
            .about("Start an activity in the current timesheet")
            .arg(at_arg.clone())
            .arg(Arg::with_name("note")
                 .takes_value(true)
                 .value_name("NOTE")
                 .help("Text describing the activity to start"))
        )

        .subcommand(SubCommand::with_name("resume")
            .visible_alias("r")
            .about("Restart the timer for an entry. Defaults to the last active entry")
            .arg(at_arg.clone())
            .arg(id_arg.clone().help("Use entry with ID instead of the last entry"))
            .arg(interactive_arg.clone())
        )

        .subcommand(SubCommand::with_name("out")
            .visible_alias("o")
            .about("end the active entry in the current timesheet")
            .arg(at_arg.clone())
        )

        .subcommand(SubCommand::with_name("sheet")
            .visible_alias("s")
            .about("Change active timesheet or list existing timesheets")
            .arg(sheet_arg.clone().help("The sheet to switch to. Use - to quickly switch to the previous sheet"))
        )

        .subcommand(SubCommand::with_name("list")
            .visible_alias("l")
            .about("List existing sheets")
            .arg(Arg::with_name("all")
                 .short("a").long("all")
                 .help("List archive sheets also"))
            .arg(Arg::with_name("flat")
                 .short("f").long("flat")
                 .help("show only the sheet names"))
        )

        .subcommand(SubCommand::with_name("kill")
            .visible_alias("k")
            .about("Delete an entry or an entire sheet")
            .arg(id_arg.clone().help("Delete entry with this ID instead of sheet"))
            .arg(Arg::with_name("sheet")
                .takes_value(true).value_name("SHEET")
                .conflicts_with_all(&["id", "last"])
                .required_unless_one(&["id", "last"])
                .help(
                    "Delete an entire sheet by its name"
                ))
            .arg(Arg::with_name("last")
                 .short("l").long("last")
                 .takes_value(false)
                 .help("Delete the last entry of the current sheet"))
        )

        .subcommand(SubCommand::with_name("now")
            .visible_alias("n")
            .about("Show all running entries")
        )

        .subcommand(SubCommand::with_name("edit")
            .visible_alias("e")
            .about("Edit an entry")
            .arg(id_arg.clone().help("Edit entry with this ID instead of the last one in the current sheet"))
            .arg(start_arg.clone().help("Set this as the start time"))
            .arg(end_arg.clone().help("Set this as the end time"))
            .arg(
                Arg::with_name("append")
                    .long("append").short("z")
                    .help("Append to the current note instead of replacing it. The delimiter between appended notes is configurable (see configure)")
            )
            .arg(
                Arg::with_name("move")
                    .short("m").long("move")
                    .takes_value(true)
                    .value_name("SHEET")
                    .help("Move entry to another sheet")
            )
            .arg(
                Arg::with_name("note")
                    .takes_value(true)
                    .value_name("NOTE")
                    .help("The note text. It will replace the previous one unless --append is given")
            )
        )

        .get_matches();

    if let Err(e) = error_trap(matches) {
        eprintln!("{}", e);
        exit(1);
    }
}