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,
148 Tail {
150 #[arg(short = 'n', long = "lines", default_value_t = 50)]
152 lines: usize,
153 #[arg(long)]
155 kind: Option<String>,
156 },
157}
158
159impl Cli {
160 pub fn command(&self) -> &Command {
162 self.command.as_ref().unwrap_or(&Command::Run)
163 }
164
165 pub fn resolve_project_root(&self) -> std::io::Result<PathBuf> {
168 match &self.project_root {
169 Some(p) if p.is_absolute() => Ok(p.clone()),
170 Some(p) => Ok(std::env::current_dir()?.join(p)),
171 None => std::env::current_dir(),
172 }
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179
180 fn parse(args: &[&str]) -> Cli {
181 Cli::try_parse_from(args).unwrap()
182 }
183
184 #[test]
185 fn defaults_to_run() {
186 let cli = parse(&["talon"]);
187 assert_eq!(cli.command(), &Command::Run);
188 assert_eq!(parse(&["talon", "run"]).command(), &Command::Run);
189 }
190
191 #[test]
192 fn setup_passes_hyphenated_args_through() {
193 let cli = parse(&[
194 "talon",
195 "setup",
196 "timezone",
197 "--tz",
198 "Europe/Berlin",
199 "--force",
200 ]);
201 assert_eq!(
202 cli.command(),
203 &Command::Setup(SetupArgs {
204 step: "timezone".into(),
205 args: vec!["--tz".into(), "Europe/Berlin".into(), "--force".into()],
206 })
207 );
208 }
209
210 #[test]
211 fn audit_subcommands() {
212 assert_eq!(
213 parse(&["talon", "audit", "verify"]).command(),
214 &Command::Audit {
215 command: AuditCommand::Verify
216 }
217 );
218 assert_eq!(
219 parse(&["talon", "audit", "tail", "-n", "5", "--kind", "ipc."]).command(),
220 &Command::Audit {
221 command: AuditCommand::Tail {
222 lines: 5,
223 kind: Some("ipc.".into())
224 }
225 }
226 );
227 assert_eq!(
228 parse(&["talon", "audit", "tail"]).command(),
229 &Command::Audit {
230 command: AuditCommand::Tail {
231 lines: 50,
232 kind: None
233 }
234 }
235 );
236 }
237
238 #[test]
239 fn eval_subcommands() {
240 let Some(Command::Eval {
241 command: EvalCommand::Score(score),
242 }) = parse(&["talon", "eval", "score", "--reports", "out"]).command
243 else {
244 panic!("expected eval score");
245 };
246 assert_eq!(score.reports, PathBuf::from("out"));
247 assert_eq!(score.common.cases, None);
248 assert!(!score.common.json);
249 assert!((score.common.min_pass_rate - 1.0).abs() < f64::EPSILON);
250
251 let Some(Command::Eval {
252 command: EvalCommand::Run(run),
253 }) = parse(&[
254 "talon",
255 "eval",
256 "run",
257 "--cases",
258 "c",
259 "--json",
260 "--min-pass-rate",
261 "0.8",
262 "--timeout-secs",
263 "60",
264 "--alert-map",
265 "map.json",
266 "--allow-deduplicated",
267 ])
268 .command
269 else {
270 panic!("expected eval run");
271 };
272 assert_eq!(run.common.cases, Some(PathBuf::from("c")));
273 assert!(run.common.json && run.allow_deduplicated);
274 assert!((run.common.min_pass_rate - 0.8).abs() < f64::EPSILON);
275 assert_eq!((run.timeout_secs, run.poll_secs), (60, 5));
276 assert_eq!(run.alert_map, Some(PathBuf::from("map.json")));
277 assert_eq!(
278 (run.url, run.save_dir, run.customer_code),
279 (None, None, None)
280 );
281
282 let bad: [&[&str]; 5] = [
283 &["talon", "eval"],
284 &["talon", "eval", "score"],
285 &[
286 "talon",
287 "eval",
288 "score",
289 "--reports",
290 "o",
291 "--min-pass-rate",
292 "1.5",
293 ],
294 &[
295 "talon",
296 "eval",
297 "score",
298 "--reports",
299 "o",
300 "--min-pass-rate",
301 "NaN",
302 ],
303 &["talon", "eval", "run", "--timeout-secs", "0"],
304 ];
305 for args in bad {
306 assert!(Cli::try_parse_from(args).is_err(), "{args:?}");
307 }
308 }
309
310 #[test]
311 fn project_root_flag_is_global() {
312 let cli = parse(&["talon", "doctor", "--project-root", "/srv/talon"]);
313 assert_eq!(cli.command(), &Command::Doctor);
314 assert_eq!(
315 cli.resolve_project_root().unwrap(),
316 PathBuf::from("/srv/talon")
317 );
318 }
319
320 #[test]
321 fn unknown_command_is_rejected() {
322 assert!(Cli::try_parse_from(["talon", "frobnicate"]).is_err());
323 }
324}