1use std::path::PathBuf;
4
5use clap::{Args, Parser, Subcommand};
6
7#[derive(Debug, Parser)]
9#[command(name = "talon", version, about, long_about = None)]
10pub struct Cli {
11 #[arg(long, global = true, env = "TALON_PROJECT_ROOT", value_name = "DIR")]
14 pub project_root: Option<PathBuf>,
15
16 #[command(subcommand)]
18 pub command: Option<Command>,
19}
20
21#[derive(Debug, Subcommand, PartialEq)]
23pub enum Command {
24 Run,
26 Setup(SetupArgs),
29 Doctor,
32 Audit {
34 #[command(subcommand)]
36 command: AuditCommand,
37 },
38 Eval {
42 #[command(subcommand)]
44 command: EvalCommand,
45 },
46 Version,
48}
49
50#[derive(Debug, Subcommand, PartialEq)]
52pub enum EvalCommand {
53 Score(EvalScoreArgs),
57 Run(EvalRunArgs),
62}
63
64#[derive(Debug, Args, PartialEq)]
66pub struct EvalCommonArgs {
67 #[arg(long, value_name = "DIR")]
69 pub cases: Option<PathBuf>,
70 #[arg(long)]
72 pub json: bool,
73 #[arg(long, value_name = "FRACTION", default_value_t = 1.0, value_parser = parse_pass_rate)]
75 pub min_pass_rate: f64,
76}
77
78#[derive(Debug, Args, PartialEq)]
80pub struct EvalScoreArgs {
81 #[arg(long, value_name = "DIR")]
84 pub reports: PathBuf,
85 #[command(flatten)]
87 pub common: EvalCommonArgs,
88}
89
90#[derive(Debug, Args, PartialEq)]
92pub struct EvalRunArgs {
93 #[arg(long, value_name = "URL")]
97 pub url: Option<String>,
98 #[arg(long, value_name = "SECONDS", default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..))]
100 pub timeout_secs: u64,
101 #[arg(long, value_name = "SECONDS", default_value_t = 5, value_parser = clap::value_parser!(u64).range(1..))]
103 pub poll_secs: u64,
104 #[arg(long, value_name = "FILE")]
108 pub alert_map: Option<PathBuf>,
109 #[arg(long, value_name = "CODE")]
112 pub customer_code: Option<String>,
113 #[arg(long, value_name = "DIR")]
116 pub save_dir: Option<PathBuf>,
117 #[arg(long)]
120 pub allow_deduplicated: bool,
121 #[command(flatten)]
123 pub common: EvalCommonArgs,
124}
125
126fn parse_pass_rate(raw: &str) -> Result<f64, String> {
127 match raw.trim().parse::<f64>() {
128 Ok(v) if (0.0..=1.0).contains(&v) => Ok(v),
129 _ => Err("expected a fraction between 0.0 and 1.0".to_string()),
130 }
131}
132
133#[derive(Debug, Args, PartialEq, Eq)]
135pub struct SetupArgs {
136 pub step: String,
138 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
140 pub args: Vec<String>,
141}
142
143#[derive(Debug, Subcommand, PartialEq, Eq)]
145pub enum AuditCommand {
146 Verify,
149 Head,
152 Tail {
154 #[arg(short = 'n', long = "lines", default_value_t = 50)]
156 lines: usize,
157 #[arg(long)]
159 kind: Option<String>,
160 },
161}
162
163impl Cli {
164 pub fn command(&self) -> &Command {
166 self.command.as_ref().unwrap_or(&Command::Run)
167 }
168
169 pub fn resolve_project_root(&self) -> std::io::Result<PathBuf> {
172 match &self.project_root {
173 Some(p) if p.is_absolute() => Ok(p.clone()),
174 Some(p) => Ok(std::env::current_dir()?.join(p)),
175 None => std::env::current_dir(),
176 }
177 }
178}
179
180#[cfg(test)]
181mod tests {
182 use super::*;
183
184 fn parse(args: &[&str]) -> Cli {
185 Cli::try_parse_from(args).unwrap()
186 }
187
188 #[test]
189 fn defaults_to_run() {
190 let cli = parse(&["talon"]);
191 assert_eq!(cli.command(), &Command::Run);
192 assert_eq!(parse(&["talon", "run"]).command(), &Command::Run);
193 }
194
195 #[test]
196 fn setup_passes_hyphenated_args_through() {
197 let cli = parse(&[
198 "talon",
199 "setup",
200 "timezone",
201 "--tz",
202 "Europe/Berlin",
203 "--force",
204 ]);
205 assert_eq!(
206 cli.command(),
207 &Command::Setup(SetupArgs {
208 step: "timezone".into(),
209 args: vec!["--tz".into(), "Europe/Berlin".into(), "--force".into()],
210 })
211 );
212 }
213
214 #[test]
215 fn audit_subcommands() {
216 assert_eq!(
217 parse(&["talon", "audit", "verify"]).command(),
218 &Command::Audit {
219 command: AuditCommand::Verify
220 }
221 );
222 assert_eq!(
223 parse(&["talon", "audit", "head"]).command(),
224 &Command::Audit {
225 command: AuditCommand::Head
226 }
227 );
228 assert_eq!(
229 parse(&["talon", "audit", "tail", "-n", "5", "--kind", "ipc."]).command(),
230 &Command::Audit {
231 command: AuditCommand::Tail {
232 lines: 5,
233 kind: Some("ipc.".into())
234 }
235 }
236 );
237 assert_eq!(
238 parse(&["talon", "audit", "tail"]).command(),
239 &Command::Audit {
240 command: AuditCommand::Tail {
241 lines: 50,
242 kind: None
243 }
244 }
245 );
246 }
247
248 #[test]
249 fn eval_subcommands() {
250 let Some(Command::Eval {
251 command: EvalCommand::Score(score),
252 }) = parse(&["talon", "eval", "score", "--reports", "out"]).command
253 else {
254 panic!("expected eval score");
255 };
256 assert_eq!(score.reports, PathBuf::from("out"));
257 assert_eq!(score.common.cases, None);
258 assert!(!score.common.json);
259 assert!((score.common.min_pass_rate - 1.0).abs() < f64::EPSILON);
260
261 let Some(Command::Eval {
262 command: EvalCommand::Run(run),
263 }) = parse(&[
264 "talon",
265 "eval",
266 "run",
267 "--cases",
268 "c",
269 "--json",
270 "--min-pass-rate",
271 "0.8",
272 "--timeout-secs",
273 "60",
274 "--alert-map",
275 "map.json",
276 "--allow-deduplicated",
277 ])
278 .command
279 else {
280 panic!("expected eval run");
281 };
282 assert_eq!(run.common.cases, Some(PathBuf::from("c")));
283 assert!(run.common.json && run.allow_deduplicated);
284 assert!((run.common.min_pass_rate - 0.8).abs() < f64::EPSILON);
285 assert_eq!((run.timeout_secs, run.poll_secs), (60, 5));
286 assert_eq!(run.alert_map, Some(PathBuf::from("map.json")));
287 assert_eq!(
288 (run.url, run.save_dir, run.customer_code),
289 (None, None, None)
290 );
291
292 let bad: [&[&str]; 5] = [
293 &["talon", "eval"],
294 &["talon", "eval", "score"],
295 &[
296 "talon",
297 "eval",
298 "score",
299 "--reports",
300 "o",
301 "--min-pass-rate",
302 "1.5",
303 ],
304 &[
305 "talon",
306 "eval",
307 "score",
308 "--reports",
309 "o",
310 "--min-pass-rate",
311 "NaN",
312 ],
313 &["talon", "eval", "run", "--timeout-secs", "0"],
314 ];
315 for args in bad {
316 assert!(Cli::try_parse_from(args).is_err(), "{args:?}");
317 }
318 }
319
320 #[test]
321 fn project_root_flag_is_global() {
322 let cli = parse(&["talon", "doctor", "--project-root", "/srv/talon"]);
323 assert_eq!(cli.command(), &Command::Doctor);
324 assert_eq!(
325 cli.resolve_project_root().unwrap(),
326 PathBuf::from("/srv/talon")
327 );
328 }
329
330 #[test]
331 fn unknown_command_is_rejected() {
332 assert!(Cli::try_parse_from(["talon", "frobnicate"]).is_err());
333 }
334}