jobber 0.1.2-alpha

Minimalistic console work time tracker
//! CLI commands

pub(crate) use config::Config;
pub(crate) use info::Info;

use crate::{
    database::Database,
    jobber::{JobberGet, JobberIter},
};
use clap::{Parser, Subcommand};
use color_print::cformat;
pub(crate) use error::*;
pub(crate) use interpret::*;
use std::{fs::OpenOptions, path::PathBuf};

mod add;
mod calendar;
mod cancel;
mod completions;
mod config;
mod create;
mod delete;
mod edit;
mod end;
mod error;
mod export;
mod info;
mod init;
mod interpret;
mod list;
mod markdown_doc;
mod restore;
mod select;
mod show;
mod sort;
mod start;
mod undo;

use add::Add;
use calendar::Calendar;
use cancel::Cancel;
use completions::Completions;
use create::Create;
use delete::Delete;
use edit::Edit;
use end::End;
use export::Export;
use init::Init;
use list::List;
use markdown_doc::MarkdownDoc;
use restore::Restore;
use select::Select;
use show::Show;
use sort::Sort;
use start::Start;
use undo::Undo;

/// Jobber Command Line Interpreter
///
/// Jobber is a minimalistic work time manager.
#[derive(Parser)]
#[clap(verbatim_doc_comment)]
pub(crate) struct Cli {
    #[command(subcommand)]
    pub(crate) command: Option<Command>,
    /// Database file to use
    #[clap(short, long)]
    file: Option<PathBuf>,
    /// Prevent usage of ANSI color codes
    #[clap(short = 'a', long)]
    no_ansi: bool,
    /// suppress user tips
    #[clap(short = 't', long)]
    pub no_tips: bool,
}

impl Cli {
    fn database_file(&self) -> Result<std::path::PathBuf> {
        if let Some(file) = &self.file {
            Ok(file.clone())
        } else {
            Config::load()?.database_path()
        }
    }

    pub(crate) fn open_database(&self) -> Result<Database> {
        let database_file = self.database_file()?;
        let target = &database_file.canonicalize()?;
        match OpenOptions::new().read(true).open(target) {
            Ok(file) => match serde_json::from_reader(file) {
                Ok(database) => Ok(database),
                Err(err) => Err(Error::CannotReadDatabase(database_file, err)),
            },
            Err(err) => Err(Error::CannotOpenDatabase(database_file, err)),
        }
    }

    pub(crate) fn close_database(&self, database: Database) -> Result<()> {
        let database_file = self.database_file()?;
        let target = &database_file.canonicalize()?;
        let tmp = target.with_extension("tmp");
        let bak = target.with_extension("bak");
        match OpenOptions::new()
            .create(true)
            .write(true)
            .truncate(true)
            .open(&tmp)
        {
            Ok(file) => match serde_json::to_writer_pretty(file, &database) {
                Ok(database) => {
                    std::fs::rename(target, bak)?;
                    std::fs::rename(tmp, target)?;
                    eprintln!("Database has been updated.");
                    Ok(database)
                }
                Err(err) => Err(Error::CannotWriteDatabase(database_file, err)),
            },
            Err(err) => Err(Error::CannotOpenDatabase(database_file, err)),
        }
    }

    pub(crate) fn backup_database(&self) -> Result<()> {
        let target = &self.database_file()?.canonicalize()?;
        let bak = target.with_extension("bak");
        if std::fs::exists(target)? {
            std::fs::copy(target, bak)?;
        }
        Ok(())
    }

    pub(crate) fn restore_database(&self) -> Result<()> {
        let target = &self.database_file()?.canonicalize()?;
        let tmp = &target.with_extension("tmp");
        let bak = &target.with_extension("bak");
        std::fs::rename(target, tmp)?;
        std::fs::rename(bak, target)?;
        std::fs::rename(tmp, bak)?;
        Ok(())
    }

    pub(crate) fn ansi(&self) -> bool {
        !self.no_ansi
    }
}

#[derive(Subcommand)]
pub(crate) enum Command {
    Init(Init),
    Create(Create),

    Select(Select),
    Start(Start),
    End(End),
    Add(Add),
    Cancel(Cancel),

    List(List),
    Show(Show),
    Calendar(Calendar),
    Export(Export),
    Info(Info),

    Edit(Edit),
    Delete(Delete),
    Restore(Restore),
    Sort(Sort),
    Undo(Undo),

    Completions(Completions),
    #[clap(hide = true)]
    MarkdownDoc(MarkdownDoc),
}

impl Command {
    pub(crate) fn run(&self, cli: &Cli) -> Result<()> {
        match self {
            Command::Init(init) => init.run(cli),
            Command::Create(create) => create.run(cli),

            Command::Select(select) => select.run(cli),
            Command::Start(start) => start.run(cli),
            Command::End(end) => end.run(cli),
            Command::Add(add) => add.run(cli),
            Command::Cancel(cancel) => cancel.run(cli),

            Command::List(list) => list.run(cli),
            Command::Show(show) => show.run(cli),
            Command::Calendar(calendar) => calendar.run(cli),
            Command::Export(export) => export.run(cli),
            Command::Info(info) => info.run(cli),

            Command::Edit(edit) => edit.run(cli),
            Command::Delete(delete) => delete.run(cli),
            Command::Restore(restore) => restore.run(cli),
            Command::Sort(sort) => sort.run(cli),
            Command::Undo(undo) => undo.run(cli),

            Command::Completions(completions) => completions.run(cli),
            Command::MarkdownDoc(markdown_doc) => markdown_doc.run(cli),
        }
    }
}

pub(crate) trait RunCommand<T = ()> {
    fn run(&self, cli: &Cli) -> Result<T>;
}

pub(crate) fn current_job(config: &Config, database: &impl JobberGet) -> Result<usize> {
    match config.current_job {
        Some(pos) => {
            if database.get_job(pos)?.deleted {
                Err(Error::CurrentJobDeleted(pos))
            } else {
                Ok(pos)
            }
        }
        None => Err(Error::NoCurrentJob),
    }
}

pub(crate) fn current_client(
    config: &Config,
    database: &(impl JobberGet + JobberIter),
    ansi: bool,
) -> Result<String> {
    let current_job = current_job(config, database).ok();
    if let Some(client_id) =
        current_job.and_then(|j| database.get_job(j).ok().map(|j| j.client.clone()))
    {
        Ok(client_id.to_string())
    } else {
        Ok(not_available(ansi))
    }
}

pub(crate) fn current_worker(
    config: &Config,
    database: &(impl JobberGet + JobberIter),
    ansi: bool,
) -> Result<String> {
    let current_job = current_job(config, database).ok();
    if let Some(worker_id) =
        current_job.and_then(|j| database.get_job(j).ok().map(|j| j.worker.clone()))
    {
        Ok(worker_id.to_string())
    } else {
        Ok(not_available(ansi))
    }
}

pub(crate) fn not_available(ansi: bool) -> String {
    if ansi {
        cformat!("<dim>-</>")
    } else {
        "-".to_string()
    }
}

pub(crate) fn is_available(value: &str) -> bool {
    value != not_available(true) && value != not_available(false)
}

fn shorten_date(start: &mut String, prev: &str) {
    let s: Vec<_> = start.split(' ').collect();
    let p: Vec<_> = prev.split(' ').collect();
    if s[0] == p[0] {
        if s[2] == p[2] && s[3] == p[3] {
            *start = s[4..].to_vec().join(" ");
        } else {
            *start = s[1..].to_vec().join(" ");
        }
    }
}

#[macro_export]
macro_rules! print_tip {
    ($cli:ident) => {
        if !$cli.no_tips {
            color_print::ceprintln!();
        }
    };
    ($cli:ident, $($arg:tt)*) => {
        if !$cli.no_tips {
            color_print::ceprintln!($($arg)*);
        }
    };
}