use anyhow::Result;
use clap::{Args, Subcommand, builder::PossibleValuesParser};
use crate::{commands::daemon as daemon_cmd, config};
pub fn handle_daemon(cmd: DaemonCommand) -> Result<()> {
let resolved = config::resolve_from_cwd()?;
match cmd {
DaemonCommand::Start(args) => daemon_cmd::start(&resolved, args),
DaemonCommand::Stop => daemon_cmd::stop(&resolved),
DaemonCommand::Status => daemon_cmd::status(&resolved),
DaemonCommand::Serve(args) => daemon_cmd::serve(&resolved, args),
DaemonCommand::Logs(args) => daemon_cmd::logs(&resolved, args),
}
}
#[derive(Args)]
pub struct DaemonArgs {
#[command(subcommand)]
pub command: DaemonCommand,
}
#[derive(Subcommand)]
pub enum DaemonCommand {
#[command(
about = "Start Ralph as a background daemon",
after_long_help = "Examples:
ralph daemon start
ralph daemon start --empty-poll-ms 5000
ralph daemon start --wait-poll-ms 500"
)]
Start(DaemonStartArgs),
#[command(
about = "Request the daemon to stop",
after_long_help = "Examples:
ralph daemon stop"
)]
Stop,
#[command(
about = "Show daemon status",
after_long_help = "Examples:
ralph daemon status"
)]
Status,
#[command(hide = true)]
Serve(DaemonServeArgs),
#[command(
about = "Inspect daemon logs",
after_long_help = "Examples:
ralph daemon logs
ralph daemon logs --tail 50
ralph daemon logs --follow --tail 200
ralph daemon logs --since 'in 10 minutes'
ralph daemon logs --level error --contains \"webhook\"
ralph daemon logs --json --since 2026-02-01T00:00:00Z"
)]
Logs(DaemonLogsArgs),
}
#[derive(Args)]
pub struct DaemonStartArgs {
#[arg(
long,
default_value_t = 30_000,
value_parser = clap::value_parser!(u64).range(50..)
)]
pub empty_poll_ms: u64,
#[arg(
long,
default_value_t = 1_000,
value_parser = clap::value_parser!(u64).range(50..)
)]
pub wait_poll_ms: u64,
#[arg(long)]
pub notify_when_unblocked: bool,
}
#[derive(Args)]
pub struct DaemonServeArgs {
#[arg(
long,
default_value_t = 30_000,
value_parser = clap::value_parser!(u64).range(50..)
)]
pub empty_poll_ms: u64,
#[arg(
long,
default_value_t = 1_000,
value_parser = clap::value_parser!(u64).range(50..)
)]
pub wait_poll_ms: u64,
#[arg(long)]
pub notify_when_unblocked: bool,
}
#[derive(Args)]
pub struct DaemonLogsArgs {
#[arg(short = 'n', long = "tail", default_value_t = 100)]
pub tail: usize,
#[arg(short, long)]
pub follow: bool,
#[arg(long, value_name = "DURATION_OR_TIMESTAMP", value_parser = parse_daemon_log_since)]
pub since: Option<time::OffsetDateTime>,
#[arg(long = "level", value_name = "LEVEL", value_parser = PossibleValuesParser::new([
"trace",
"debug",
"info",
"warn",
"error",
"fatal",
"critical"
]))]
pub level: Option<String>,
#[arg(long)]
pub contains: Option<String>,
#[arg(long)]
pub json: bool,
}
fn parse_daemon_log_since(raw: &str) -> anyhow::Result<time::OffsetDateTime> {
crate::timeutil::parse_relative_time(raw)
.and_then(|value| crate::timeutil::parse_rfc3339(&value))
}
#[cfg(test)]
mod tests {
use clap::Parser;
use crate::cli::Cli;
#[test]
fn daemon_start_wait_poll_ms_rejects_below_minimum() {
let args = vec!["ralph", "daemon", "start", "--wait-poll-ms", "10"];
let result = Cli::try_parse_from(args);
assert!(
result.is_err(),
"daemon start --wait-poll-ms should reject values below 50"
);
}
#[test]
fn daemon_start_empty_poll_ms_rejects_below_minimum() {
let args = vec!["ralph", "daemon", "start", "--empty-poll-ms", "10"];
let result = Cli::try_parse_from(args);
assert!(
result.is_err(),
"daemon start --empty-poll-ms should reject values below 50"
);
}
#[test]
fn daemon_start_wait_poll_ms_accepts_minimum() {
let args = vec!["ralph", "daemon", "start", "--wait-poll-ms", "50"];
let result = Cli::try_parse_from(args);
assert!(
result.is_ok(),
"daemon start --wait-poll-ms should accept 50"
);
}
#[test]
fn daemon_start_empty_poll_ms_accepts_minimum() {
let args = vec!["ralph", "daemon", "start", "--empty-poll-ms", "50"];
let result = Cli::try_parse_from(args);
assert!(
result.is_ok(),
"daemon start --empty-poll-ms should accept 50"
);
}
}