hackatime 0.2.0

Terminal CLI for viewing Hackatime stats with OAuth login
mod api;
mod auth_store;
mod config;
mod models;
mod oauth;
mod output;

use anyhow::{Context, Result};

use crate::{
    api::{HackatimeClient, ReportMode},
    config::AppConfig,
};

enum Command {
    Dashboard(ReportMode),
    FolderProject(String),
    Logout,
}

#[tokio::main]
async fn main() -> Result<()> {
    let command = parse_command(std::env::args().skip(1))?;

    if matches!(command, Command::Logout) {
        auth_store::clear_access_token()?;
        println!("Logged out of Hackatime.");
        return Ok(());
    }

    let config = AppConfig::load()?;
    let access_token = match auth_store::load_access_token()? {
        Some(saved_token) => saved_token,
        None => authenticate_and_store(&config).await?,
    };

    println!("Fetching your Hackatime stats...");
    let client = HackatimeClient::new(access_token.clone());
    let dashboard = match fetch_for_command(&client, &command).await {
        Ok(dashboard) => dashboard,
        Err(error) if is_unauthorized_error(&error) => {
            auth_store::clear_access_token()?;
            println!("Saved login expired or was revoked. Re-authenticating...");
            let fresh_token = authenticate_and_store(&config).await?;
            println!("Fetching your Hackatime stats...");
            let client = HackatimeClient::new(fresh_token);
            fetch_for_command(&client, &command)
                .await
                .context("failed to fetch dashboard data after re-authentication")?
        }
        Err(error) => return Err(error).context("failed to fetch dashboard data"),
    };

    output::print_dashboard(&dashboard);
    Ok(())
}

fn parse_command(args: impl Iterator<Item = String>) -> Result<Command> {
    let mut mode = ReportMode::Summary;

    for arg in args {
        mode = match arg.as_str() {
            "logout" => return Ok(Command::Logout),
            "." => return Ok(Command::FolderProject(current_folder_project_name()?)),
            "--fetch" | "-f" => ReportMode::Fetch,
            "--current" | "-c" => ReportMode::Current,
            "--day" | "--today" | "-d" => ReportMode::Day,
            "--week" | "-w" => ReportMode::Week,
            "--month" | "-m" => ReportMode::Month,
            "--year" | "-y" => ReportMode::Year,
            "--lifetime" | "-l" => ReportMode::Lifetime,
            "--help" | "-h" => {
                print_help();
                std::process::exit(0);
            }
            _ => anyhow::bail!(
                "unknown argument: {arg}\n\nUse `.`, `logout`, --fetch/-f, --current/-c, --today/-d, --week/-w, --month/-m, --year/-y, or --lifetime/-l."
            ),
        };
    }

    Ok(Command::Dashboard(mode))
}

fn print_help() {
    println!("hackatime");
    println!();
    println!("Usage:");
    println!("  hackatime");
    println!("  hackatime .");
    println!("  hackatime logout");
    println!("  hackatime --fetch (-f)");
    println!("  hackatime --current (-c)");
    println!("  hackatime --today (-d)");
    println!("  hackatime --week (-w)");
    println!("  hackatime --month (-m)");
    println!("  hackatime --year (-y)");
    println!("  hackatime --lifetime (-l)");
}

async fn fetch_for_command(
    client: &HackatimeClient,
    command: &Command,
) -> Result<crate::models::DashboardData> {
    match command {
        Command::Dashboard(mode) => client.fetch_dashboard(*mode).await,
        Command::FolderProject(project_name) => {
            client.fetch_named_project_report(project_name).await
        }
        Command::Logout => unreachable!(),
    }
}

fn current_folder_project_name() -> Result<String> {
    let current_dir = std::env::current_dir().context("failed to determine current directory")?;
    let folder_name = current_dir
        .file_name()
        .and_then(|name| name.to_str())
        .filter(|name| !name.is_empty())
        .context("could not determine current folder name")?;
    Ok(folder_name.to_string())
}

async fn authenticate_and_store(config: &AppConfig) -> Result<String> {
    println!("Starting OAuth login for Hackatime...");
    let access_token = oauth::authorize(config)
        .await
        .context("failed to complete OAuth login")?;
    auth_store::save_access_token(&access_token)?;
    Ok(access_token)
}

fn is_unauthorized_error(error: &anyhow::Error) -> bool {
    error.chain().any(|cause| {
        cause.to_string().contains("401 Unauthorized")
            || cause
                .to_string()
                .contains("status client error (401 Unauthorized)")
    })
}