pub mod app;
pub mod audio;
pub mod cli;
pub mod config;
pub mod display;
pub mod fonts;
pub mod notification;
pub mod schedule;
pub mod timer;
use anyhow::{bail, Context, Result};
use chrono::Local;
use clap::Parser;
use config::{Config, Overrides, SoundSetting};
use inquire::Confirm;
use schedule::Candidate;
use std::{
io::{BufRead, BufReader, Write},
time::Duration,
};
pub const APP_NAME: &str = "clck";
pub fn run() -> Result<()> {
let cli = cli::Cli::parse();
let config = Config::load()?;
let command_config = config.resolve(Overrides {
font: cli.font.clone(),
notification: cli.no_notification.then_some(false),
sound: cli.sound.clone().map(SoundSetting::File),
});
if let Some(command) = cli.command {
match command {
cli::Command::At { value } => return run_direct_schedule(value, command_config),
cli::Command::FromText => return run_text_schedule(command_config),
cli::Command::Fonts => {
let catalog = fonts::FontCatalog::default();
for name in catalog.names() {
println!("{name}");
if let Some(font) = catalog.by_name(name) {
for line in font.render("01:30") {
println!("{line}");
}
}
println!();
}
}
cli::Command::Sounds => {
for (name, path) in audio::discover_sounds_in(&audio::system_sound_directories())? {
println!("{name}: {}", path.display());
}
}
cli::Command::Config { show, reset } => {
if reset {
if Confirm::new("Reset saved clck settings?")
.with_default(false)
.prompt()?
{
Config::default().save()?;
println!("Configuration reset.");
}
} else if show {
println!("{}", toml::to_string_pretty(&config)?);
} else {
println!("Configuration: {}", Config::path()?.display());
println!("{}", toml::to_string_pretty(&config)?);
}
}
}
return Ok(());
}
let (duration, effective) = if let Some(duration) = cli.duration {
let duration = cli::parse_duration(&duration)?;
(duration, command_config)
} else {
let answers = cli::prompt_for_alarm(&config)?;
let effective = Config {
font: answers.font,
notification: answers.notification,
sound: answers.sound,
};
if answers.save_defaults {
effective.save()?;
}
(answers.duration, effective)
};
app::run_alarm(build_alarm_request(duration, effective, None)?).context("alarm failed")
}
fn run_direct_schedule(mut expression: String, effective: Config) -> Result<()> {
let Some(mut terminal) = cli::open_controlling_terminal() else {
let candidate = schedule::parse_direct(&expression)?;
bail!(
"confirmation is required before starting an alarm; detected candidate: {} -> {}",
candidate.source,
candidate.display_target()
);
};
let candidate = loop {
match schedule::parse_direct(&expression) {
Ok(candidate) => break candidate,
Err(error) => {
writeln!(terminal.writer, "{error}")?;
write!(terminal.writer, "Enter another date and time: ")?;
terminal.writer.flush()?;
expression.clear();
if terminal.reader.read_line(&mut expression)? == 0 {
bail!("date and time input closed");
}
}
}
};
if !cli::confirm_candidate(&candidate, &mut terminal.reader, &mut terminal.writer)? {
return Ok(());
}
drop(terminal);
start_scheduled_alarm(candidate, effective)
}
fn run_text_schedule(effective: Config) -> Result<()> {
if cli::stdin_is_interactive() {
let stdin = std::io::stdin();
let mut reader = stdin.lock();
let mut writer = std::io::stdout();
loop {
let text = cli::read_text_source(&mut reader, &mut writer, true)?;
let candidates = schedule::extract_candidates(&text)?;
if candidates.is_empty() {
writeln!(
writer,
"No explicit date and time found; try `2:50pm`, `tomorrow at 9am`, or `June 12 at 09:00`."
)?;
continue;
}
let selected = cli::select_candidate(&candidates, &mut reader, &mut writer)?;
let candidate = candidates[selected].clone();
if !cli::confirm_candidate(&candidate, &mut reader, &mut writer)? {
return Ok(());
}
return start_scheduled_alarm(candidate, effective);
}
}
let stdin = std::io::stdin();
let mut reader = BufReader::new(stdin.lock());
let mut sink = std::io::sink();
let text = cli::read_text_source(&mut reader, &mut sink, false)?;
let candidates = schedule::extract_candidates(&text)?;
if candidates.is_empty() {
bail!(
"no explicit date and time found; try `2:50pm`, `tomorrow at 9am`, or `June 12 at 09:00`"
);
}
let Some(mut terminal) = cli::open_controlling_terminal() else {
let detected = candidates
.iter()
.map(|candidate| format!("{} -> {}", candidate.source, candidate.display_target()))
.collect::<Vec<_>>()
.join("\n");
bail!("confirmation is required before starting an alarm; rerun interactively\n{detected}");
};
let selected = cli::select_candidate(&candidates, &mut terminal.reader, &mut terminal.writer)?;
let candidate = candidates[selected].clone();
if !cli::confirm_candidate(&candidate, &mut terminal.reader, &mut terminal.writer)? {
return Ok(());
}
drop(terminal);
start_scheduled_alarm(candidate, effective)
}
fn start_scheduled_alarm(candidate: Candidate, effective: Config) -> Result<()> {
let duration = schedule::duration_until(Local::now().fixed_offset(), candidate.target)?;
app::run_alarm(build_alarm_request(
duration,
effective,
Some(candidate.display_target()),
)?)
.context("alarm failed")
}
fn build_alarm_request(
duration: Duration,
effective: Config,
target: Option<String>,
) -> Result<app::AlarmRequest> {
let (sound_name, sound) = match &effective.sound {
SoundSetting::System(name) => (name.clone(), audio::resolve_system_sound(name)?),
SoundSetting::File(path) => (
path.file_name()
.and_then(|name| name.to_str())
.unwrap_or("custom sound")
.to_owned(),
audio::resolve_custom_sound(path)?,
),
SoundSetting::TerminalBell => ("terminal bell".into(), audio::ResolvedSound::TerminalBell),
};
if duration.is_zero() {
bail!("duration must be greater than zero");
}
Ok(app::AlarmRequest {
duration,
font: effective.font,
sound_name,
sound,
notification: effective.notification,
target,
})
}
#[cfg(test)]
mod tests {
use super::build_alarm_request;
use crate::config::Config;
use std::time::Duration;
#[test]
fn package_name_is_stable() {
assert_eq!(super::APP_NAME, "clck");
}
#[test]
fn scheduled_request_keeps_target_metadata() {
let request = build_alarm_request(
Duration::from_secs(60),
Config::default(),
Some("2026-06-11 09:00:00 -04:00 (America/New_York)".into()),
)
.unwrap();
assert_eq!(
request.target.as_deref(),
Some("2026-06-11 09:00:00 -04:00 (America/New_York)")
);
}
}