use clap::{CommandFactory, Parser, Subcommand};
use clap_complete::{Shell, generate};
use std::io;
use std::process;
mod daemon;
mod database;
mod parser;
use database::Database;
const SECONDS_PER_MINUTE: i64 = 60;
const SECONDS_PER_HOUR: i64 = 60 * SECONDS_PER_MINUTE;
#[derive(Parser)]
#[command(name = "breakrs")]
#[command(about = "A simple CLI timer for breaks", long_about = None)]
struct Cli {
#[command(subcommand)]
command: Option<Commands>,
#[arg(trailing_var_arg = true)]
input: Vec<String>,
#[arg(long, short = 'u')]
urgent: bool,
#[arg(long, short = 's')]
sound: bool,
#[arg(long, short = 'r')]
recurring: bool,
#[arg(long, hide = true)]
daemon_mode: bool,
}
#[derive(Subcommand)]
enum Commands {
#[command(aliases = ["l", "li", "lis", "sh", "sho", "show", "dis", "display"])]
List,
#[command(aliases = ["h", "hi", "his", "hist", "histo", "histor"])]
History,
#[command(aliases = ["r", "rm", "rem", "remo", "remov", "del", "dele", "delet", "delete"])]
Remove { id: u32 },
#[command(aliases = ["c", "cl", "cle", "clea"])]
Clear,
#[command(aliases = ["ch", "clh", "clear-h", "clear-hi", "clear-his", "clear-hist", "clear-histo", "clear-histor"])]
ClearHistory,
#[command(aliases = ["s", "st", "sta", "stat", "statu", "stats"])]
Status,
#[command(aliases = ["d", "da", "dae", "daem", "daemo"])]
Daemon,
#[command(hide = true)]
Completions { shell: Shell },
}
fn format_duration(seconds: i64, show_seconds_threshold_mins: i64) -> String {
let hours = seconds / SECONDS_PER_HOUR;
let minutes = (seconds % SECONDS_PER_HOUR) / SECONDS_PER_MINUTE;
let secs = seconds % SECONDS_PER_MINUTE;
let mut parts = Vec::new();
if hours > 0 {
parts.push(format!("{}h", hours));
}
if minutes > 0 || hours > 0 {
parts.push(format!("{}m", minutes));
}
if hours == 0 && minutes < show_seconds_threshold_mins {
parts.push(format!("{}s", secs));
}
parts.join(" ")
}
fn format_flags(timer: &database::Timer) -> String {
if !timer.urgent && !timer.sound && !timer.recurring {
return String::new();
}
let mut flags = Vec::new();
if timer.urgent {
flags.push("urgent");
}
if timer.sound {
flags.push("sound");
}
if timer.recurring {
flags.push("recurring");
}
format!(" [{}]", flags.join(", "))
}
fn main() {
let cli = Cli::parse();
if cli.daemon_mode {
if let Err(e) = daemon::run_daemon() {
eprintln!("Daemon error: {}", e);
process::exit(1);
}
return;
}
let result = match cli.command {
Some(Commands::List) => list_timers(),
Some(Commands::History) => show_history(),
Some(Commands::Remove { id }) => remove_timer(id),
Some(Commands::Clear) => clear_timers(),
Some(Commands::ClearHistory) => clear_history(),
Some(Commands::Status) => show_status(),
Some(Commands::Daemon) => start_daemon(),
Some(Commands::Completions { shell }) => {
generate_completions(shell);
return;
}
None => {
if cli.input.is_empty() {
eprintln!("Error: Please provide duration and message");
eprintln!("Usage: break [FLAGS] <input with duration and message>");
eprintln!("Examples:");
eprintln!(" break 5m Tea is ready");
eprintln!(" break 15mins 1 hour 20s take a break");
eprintln!(" break --urgent 5m get coffee");
eprintln!(" break 5m get coffee --urgent");
eprintln!(" break --recurring --sound 1h stretch");
process::exit(1);
}
let (input_cleaned, urgent_flag, sound_flag, recurring_flag) =
extract_flags_from_input(&cli.input);
let urgent = cli.urgent || urgent_flag;
let sound = cli.sound || sound_flag;
let recurring = cli.recurring || recurring_flag;
add_timer(&input_cleaned, urgent, sound, recurring)
}
};
if let Err(e) = result {
eprintln!("Error: {}", e);
process::exit(1);
}
}
fn extract_flags_from_input(input: &[String]) -> (String, bool, bool, bool) {
let mut urgent = false;
let mut sound = false;
let mut recurring = false;
let mut cleaned_input = Vec::new();
for arg in input {
match arg.as_str() {
"--urgent" => urgent = true,
"--sound" => sound = true,
"--recurring" => recurring = true,
s if s.starts_with('-') && !s.starts_with("--") => {
for ch in s.chars().skip(1) {
match ch {
'u' => urgent = true,
's' => sound = true,
'r' => recurring = true,
_ => {
cleaned_input.push(arg.clone());
break;
}
}
}
}
_ => cleaned_input.push(arg.clone()),
}
}
(cleaned_input.join(" "), urgent, sound, recurring)
}
fn add_timer(
input: &str,
urgent: bool,
sound: bool,
recurring: bool,
) -> Result<(), Box<dyn std::error::Error>> {
let (duration_seconds, message) = parser::parse_input(input)?;
let timer = Database::with_transaction(|db| {
db.add_timer(message.clone(), duration_seconds, urgent, sound, recurring)
.map_err(|e| format!("Failed to add timer: {}", e).into())
})?;
println!(
"Timer #{} set for \"{}\" ({} seconds){}",
timer.id,
message,
duration_seconds,
format_flags(&timer)
);
let now = time::OffsetDateTime::now_utc();
let duration_until = timer.due_at - now;
let seconds = duration_until.whole_seconds();
if seconds > 0 {
println!("Break will notify you in {}", format_duration(seconds, 5));
} else {
println!("Break notification is ready!");
}
daemon::ensure_daemon_running()?;
Ok(())
}
fn list_timers() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::load()?;
if db.timers.is_empty() {
println!("No active timers");
return Ok(());
}
daemon::ensure_daemon_running()?;
println!("Active timers:");
for timer in &db.timers {
let now = time::OffsetDateTime::now_utc();
let remaining = timer.due_at - now;
let remaining_secs = remaining.whole_seconds();
if remaining_secs > 0 {
println!(
" #{}: \"{}\" - {} remaining{}",
timer.id,
timer.message,
format_duration(remaining_secs, i64::MAX), format_flags(timer)
);
} else {
println!(
" #{}: \"{}\" - EXPIRED{}",
timer.id,
timer.message,
format_flags(timer)
);
}
}
Ok(())
}
fn remove_timer(id: u32) -> Result<(), Box<dyn std::error::Error>> {
let timer_opt = Database::with_transaction(|db| Ok(db.remove_timer(id)))?;
if let Some(timer) = timer_opt {
println!("Removed timer #{}: \"{}\"", timer.id, timer.message);
} else {
println!("Timer #{} not found", id);
}
Ok(())
}
fn show_history() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::load()?;
if db.history.is_empty() {
println!("No completed timers in history");
return Ok(());
}
println!("Recently completed timers:");
for timer in &db.history {
let now = time::OffsetDateTime::now_utc();
let elapsed = now - timer.due_at;
let elapsed_secs = elapsed.whole_seconds().abs();
let time_ago = if elapsed_secs < SECONDS_PER_MINUTE {
"< 1m".to_string()
} else {
format_duration(elapsed_secs, i64::MAX)
};
println!(
" #{}: \"{}\" - completed {} ago{}",
timer.id,
timer.message,
time_ago,
format_flags(timer)
);
}
Ok(())
}
fn clear_timers() -> Result<(), Box<dyn std::error::Error>> {
let count = Database::with_transaction(|db| {
let count = db.timers.len();
db.clear_all();
Ok(count)
})?;
println!("Cleared {} timer(s)", count);
Ok(())
}
fn clear_history() -> Result<(), Box<dyn std::error::Error>> {
let count = Database::with_transaction(|db| {
let count = db.history.len();
db.clear_history();
Ok(count)
})?;
println!("Cleared {} completed timer(s) from history", count);
Ok(())
}
fn show_status() -> Result<(), Box<dyn std::error::Error>> {
let db = Database::load()?;
let timer_count = db.timers.len();
if daemon::is_daemon_running()? {
println!("Daemon is running");
println!("Active timers: {}", timer_count);
} else {
println!("Daemon is not running");
if timer_count > 0 {
println!("Active timers: {} (restarting daemon...)", timer_count);
daemon::ensure_daemon_running()?;
println!("Daemon restarted");
} else {
println!("Active timers: 0");
}
}
Ok(())
}
fn start_daemon() -> Result<(), Box<dyn std::error::Error>> {
daemon::start_daemon_process()?;
println!("Daemon started");
Ok(())
}
fn generate_completions(shell: Shell) {
let mut cmd = Cli::command();
let bin_name = cmd.get_name().to_string();
generate(shell, &mut cmd, bin_name, &mut io::stdout());
}