timerd-rs 0.1.0

A small foreground/background terminal timer for Linux
mod cli;
mod client;
mod daemon;
mod duration;
mod model;
mod paths;
mod protocol;
mod ui;

use anyhow::{Result, bail};
use clap::{CommandFactory, Parser};
use clap_complete::generate;

use crate::cli::{Cli, Command};
use crate::duration::{format_millis, parse_duration};
use crate::model::{TimerKind, TimerView};
use crate::paths::AppPaths;
use crate::protocol::{Request, Response};

fn main() {
    if let Err(error) = run() {
        eprintln!("timer: {error:#}");
        std::process::exit(1);
    }
}

fn run() -> Result<()> {
    let cli = Cli::parse();

    if matches!(cli.command, Some(Command::Daemon)) {
        return daemon::run(AppPaths::discover()?);
    }

    match cli.command {
        Some(Command::Stopwatch { note, detach }) => {
            let paths = AppPaths::discover()?;
            create_timer(&paths, TimerKind::Stopwatch, None, note, detach)
        }
        Some(Command::Countdown {
            duration,
            note,
            detach,
        }) => {
            let duration = parse_duration(&duration)?;
            let paths = AppPaths::discover()?;
            create_timer(&paths, TimerKind::Countdown, Some(duration), note, detach)
        }
        Some(Command::Attach { id }) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            ui::run_foreground(&paths, id)
        }
        Some(Command::List) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            list_timers(&paths)
        }
        Some(Command::Pause { id }) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            print_changed(client::request(&paths, &Request::Pause { id })?)
        }
        Some(Command::Resume { id }) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            print_changed(client::request(&paths, &Request::Resume { id })?)
        }
        Some(Command::Stop { id }) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            print_changed(client::request(&paths, &Request::Stop { id })?)
        }
        Some(Command::Clear) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            match client::expect_response(client::request(&paths, &Request::Clear)?)? {
                Response::Cleared { count } => {
                    println!("cleared {count} finished timer(s)");
                    Ok(())
                }
                _ => bail!("unexpected response from timer daemon"),
            }
        }
        Some(Command::Completions { shell }) => {
            let mut command = Cli::command();
            generate(shell, &mut command, "timer", &mut std::io::stdout());
            Ok(())
        }
        Some(Command::Shutdown) => {
            let paths = AppPaths::discover()?;
            daemon::ensure_running(&paths)?;
            match client::expect_response(client::request(&paths, &Request::Shutdown)?)? {
                Response::Ok { message } => {
                    println!("{message}");
                    Ok(())
                }
                _ => bail!("unexpected response from timer daemon"),
            }
        }
        Some(Command::Daemon) => unreachable!(),
        None => {
            if let Some(duration) = cli.duration {
                let duration = parse_duration(&duration)?;
                let paths = AppPaths::discover()?;
                return create_timer(
                    &paths,
                    TimerKind::Countdown,
                    Some(duration),
                    cli.note,
                    cli.detach,
                );
            }
            interactive_create(cli.note, cli.detach)
        }
    }
}

fn interactive_create(note: Option<String>, detach: bool) -> Result<()> {
    let kind = ui::choose_timer_kind()?;
    let duration = if kind == TimerKind::Countdown {
        let input = ui::prompt("Duration [s/m/h] (e.g. 15s, 5m, 1h30m, 00:15:00): ")?;
        Some(parse_duration(&input)?)
    } else {
        None
    };
    let note = match note {
        Some(note) => Some(note),
        None => {
            let note = ui::prompt("Note (optional): ")?;
            (!note.is_empty()).then_some(note)
        }
    };
    let paths = AppPaths::discover()?;
    create_timer(&paths, kind, duration, note, detach)
}

fn create_timer(
    paths: &AppPaths,
    kind: TimerKind,
    duration_millis: Option<u64>,
    note: Option<String>,
    detach: bool,
) -> Result<()> {
    daemon::ensure_running(paths)?;
    let response = client::expect_response(client::request(
        paths,
        &Request::Create {
            kind,
            duration_millis,
            note,
            tty_path: client::current_tty_path(),
        },
    )?)?;

    let Response::Created {
        task,
        terminal_notify,
    } = response
    else {
        bail!("unexpected response from timer daemon");
    };

    if detach {
        let round_up = task.kind == TimerKind::Countdown;
        println!(
            "[{}] {} started · {} · detached",
            task.id,
            task.kind.as_str(),
            format_millis(task.value_millis, round_up)
        );
        if task.kind == TimerKind::Countdown && !terminal_notify {
            eprintln!("warning: the current terminal is unavailable for completion messages");
        }
        return Ok(());
    }

    ui::run_foreground(paths, Some(task.id))
}

fn list_timers(paths: &AppPaths) -> Result<()> {
    let response = client::expect_response(client::request(paths, &Request::List)?)?;
    let Response::Tasks { tasks } = response else {
        bail!("unexpected response from timer daemon");
    };
    if tasks.is_empty() {
        println!("no timers");
        return Ok(());
    }

    println!(
        "{:<4} {:<11} {:<10} {:<10} NOTE",
        "ID", "TYPE", "STATUS", "TIME"
    );
    for task in tasks {
        let round_up = task.kind == TimerKind::Countdown && task.status.is_active();
        println!(
            "{:<4} {:<11} {:<10} {:<10} {}",
            task.id,
            task.kind.as_str(),
            task.status.as_str(),
            format_millis(task.value_millis, round_up),
            task.note.as_deref().unwrap_or("-")
        );
    }
    Ok(())
}

fn print_changed(response: Response) -> Result<()> {
    match client::expect_response(response)? {
        Response::Changed { task } => {
            println!("{}", summary(&task));
            Ok(())
        }
        _ => bail!("unexpected response from timer daemon"),
    }
}

fn summary(task: &TimerView) -> String {
    let round_up = task.kind == TimerKind::Countdown && task.status.is_active();
    let note = task
        .note
        .as_deref()
        .map(|note| format!(" · {note}"))
        .unwrap_or_default();
    format!(
        "[{}] {} · {}{}",
        task.id,
        task.status.as_str(),
        format_millis(task.value_millis, round_up),
        note
    )
}