1use anyhow::Result;
16use clap::{Args, Subcommand, builder::PossibleValuesParser};
17
18use crate::{commands::daemon as daemon_cmd, config};
19
20pub fn handle_daemon(cmd: DaemonCommand) -> Result<()> {
21 let resolved = config::resolve_from_cwd()?;
22 match cmd {
23 DaemonCommand::Start(args) => daemon_cmd::start(&resolved, args),
24 DaemonCommand::Stop => daemon_cmd::stop(&resolved),
25 DaemonCommand::Status => daemon_cmd::status(&resolved),
26 DaemonCommand::Serve(args) => daemon_cmd::serve(&resolved, args),
27 DaemonCommand::Logs(args) => daemon_cmd::logs(&resolved, args),
28 }
29}
30
31#[derive(Args)]
32pub struct DaemonArgs {
33 #[command(subcommand)]
34 pub command: DaemonCommand,
35}
36
37#[derive(Subcommand)]
38pub enum DaemonCommand {
39 #[command(
41 about = "Start Ralph as a background daemon",
42 after_long_help = "Examples:
43 ralph daemon start
44 ralph daemon start --empty-poll-ms 5000
45 ralph daemon start --wait-poll-ms 500"
46 )]
47 Start(DaemonStartArgs),
48 #[command(
50 about = "Request the daemon to stop",
51 after_long_help = "Examples:
52 ralph daemon stop"
53 )]
54 Stop,
55 #[command(
57 about = "Show daemon status",
58 after_long_help = "Examples:
59 ralph daemon status"
60 )]
61 Status,
62 #[command(hide = true)]
64 Serve(DaemonServeArgs),
65 #[command(
67 about = "Inspect daemon logs",
68 after_long_help = "Examples:
69 ralph daemon logs
70 ralph daemon logs --tail 50
71 ralph daemon logs --follow --tail 200
72 ralph daemon logs --since 'in 10 minutes'
73 ralph daemon logs --level error --contains \"webhook\"
74 ralph daemon logs --json --since 2026-02-01T00:00:00Z"
75 )]
76 Logs(DaemonLogsArgs),
77}
78
79#[derive(Args)]
80pub struct DaemonStartArgs {
81 #[arg(
84 long,
85 default_value_t = 30_000,
86 value_parser = clap::value_parser!(u64).range(50..)
87 )]
88 pub empty_poll_ms: u64,
89
90 #[arg(
92 long,
93 default_value_t = 1_000,
94 value_parser = clap::value_parser!(u64).range(50..)
95 )]
96 pub wait_poll_ms: u64,
97
98 #[arg(long)]
100 pub notify_when_unblocked: bool,
101}
102
103#[derive(Args)]
104pub struct DaemonServeArgs {
105 #[arg(
108 long,
109 default_value_t = 30_000,
110 value_parser = clap::value_parser!(u64).range(50..)
111 )]
112 pub empty_poll_ms: u64,
113
114 #[arg(
116 long,
117 default_value_t = 1_000,
118 value_parser = clap::value_parser!(u64).range(50..)
119 )]
120 pub wait_poll_ms: u64,
121
122 #[arg(long)]
124 pub notify_when_unblocked: bool,
125}
126
127#[derive(Args)]
128pub struct DaemonLogsArgs {
129 #[arg(short = 'n', long = "tail", default_value_t = 100)]
131 pub tail: usize,
132
133 #[arg(short, long)]
135 pub follow: bool,
136
137 #[arg(long, value_name = "DURATION_OR_TIMESTAMP", value_parser = parse_daemon_log_since)]
139 pub since: Option<time::OffsetDateTime>,
140
141 #[arg(long = "level", value_name = "LEVEL", value_parser = PossibleValuesParser::new([
143 "trace",
144 "debug",
145 "info",
146 "warn",
147 "error",
148 "fatal",
149 "critical"
150 ]))]
151 pub level: Option<String>,
152
153 #[arg(long)]
155 pub contains: Option<String>,
156
157 #[arg(long)]
159 pub json: bool,
160}
161
162fn parse_daemon_log_since(raw: &str) -> anyhow::Result<time::OffsetDateTime> {
163 crate::timeutil::parse_relative_time(raw)
164 .and_then(|value| crate::timeutil::parse_rfc3339(&value))
165}
166
167#[cfg(test)]
168mod tests {
169 use clap::Parser;
170
171 use crate::cli::Cli;
172
173 #[test]
174 fn daemon_start_wait_poll_ms_rejects_below_minimum() {
175 let args = vec!["ralph", "daemon", "start", "--wait-poll-ms", "10"];
176 let result = Cli::try_parse_from(args);
177 assert!(
178 result.is_err(),
179 "daemon start --wait-poll-ms should reject values below 50"
180 );
181 }
182
183 #[test]
184 fn daemon_start_empty_poll_ms_rejects_below_minimum() {
185 let args = vec!["ralph", "daemon", "start", "--empty-poll-ms", "10"];
186 let result = Cli::try_parse_from(args);
187 assert!(
188 result.is_err(),
189 "daemon start --empty-poll-ms should reject values below 50"
190 );
191 }
192
193 #[test]
194 fn daemon_start_wait_poll_ms_accepts_minimum() {
195 let args = vec!["ralph", "daemon", "start", "--wait-poll-ms", "50"];
196 let result = Cli::try_parse_from(args);
197 assert!(
198 result.is_ok(),
199 "daemon start --wait-poll-ms should accept 50"
200 );
201 }
202
203 #[test]
204 fn daemon_start_empty_poll_ms_accepts_minimum() {
205 let args = vec!["ralph", "daemon", "start", "--empty-poll-ms", "50"];
206 let result = Cli::try_parse_from(args);
207 assert!(
208 result.is_ok(),
209 "daemon start --empty-poll-ms should accept 50"
210 );
211 }
212}