1use std::path::PathBuf;
4
5use clap::{Args, CommandFactory, FromArgMatches, Parser, Subcommand};
6
7pub fn with_legacy_env(
11 cmd: clap::Command,
12 legacy_in_use: &dyn Fn(&str) -> Option<String>,
13) -> clap::Command {
14 cmd.mut_args(|arg| {
15 match arg
16 .get_env()
17 .and_then(|key| key.to_str())
18 .and_then(legacy_in_use)
19 {
20 Some(legacy) => arg.env(&*Box::leak(legacy.into_boxed_str())),
23 None => arg,
24 }
25 })
26 .mut_subcommands(|sub| with_legacy_env(sub, legacy_in_use))
27}
28
29#[derive(Debug, Parser)]
31#[command(name = "talahon", version, about, long_about = None)]
32pub struct Cli {
33 #[arg(long, global = true, env = "TALAHON_PROJECT_ROOT", value_name = "DIR")]
36 pub project_root: Option<PathBuf>,
37
38 #[command(subcommand)]
40 pub command: Option<Command>,
41}
42
43impl Cli {
44 pub fn parse_with_legacy_env() -> Self {
47 let cmd = with_legacy_env(
48 <Self as CommandFactory>::command(),
49 &talon_core::env::legacy_process_key_in_use,
50 );
51 let matches = cmd.get_matches();
52 Self::from_arg_matches(&matches).unwrap_or_else(|e| e.exit())
53 }
54}
55
56#[derive(Debug, Subcommand, PartialEq)]
58pub enum Command {
59 Run,
61 Setup(SetupArgs),
64 Doctor,
67 Audit {
69 #[command(subcommand)]
71 command: AuditCommand,
72 },
73 Eval {
77 #[command(subcommand)]
79 command: EvalCommand,
80 },
81 Version,
83}
84
85#[derive(Debug, Subcommand, PartialEq)]
87pub enum EvalCommand {
88 Score(EvalScoreArgs),
92 Run(EvalRunArgs),
97}
98
99#[derive(Debug, Args, PartialEq)]
101pub struct EvalCommonArgs {
102 #[arg(long, value_name = "DIR")]
104 pub cases: Option<PathBuf>,
105 #[arg(long)]
107 pub json: bool,
108 #[arg(long, value_name = "FRACTION", default_value_t = 1.0, value_parser = parse_pass_rate)]
110 pub min_pass_rate: f64,
111}
112
113#[derive(Debug, Args, PartialEq)]
115pub struct EvalScoreArgs {
116 #[arg(long, value_name = "DIR")]
119 pub reports: PathBuf,
120 #[command(flatten)]
122 pub common: EvalCommonArgs,
123}
124
125#[derive(Debug, Args, PartialEq)]
127pub struct EvalRunArgs {
128 #[arg(long, value_name = "URL")]
132 pub url: Option<String>,
133 #[arg(long, value_name = "SECONDS", default_value_t = 900, value_parser = clap::value_parser!(u64).range(1..))]
135 pub timeout_secs: u64,
136 #[arg(long, value_name = "SECONDS", default_value_t = 5, value_parser = clap::value_parser!(u64).range(1..))]
138 pub poll_secs: u64,
139 #[arg(long, value_name = "FILE")]
143 pub alert_map: Option<PathBuf>,
144 #[arg(long, value_name = "CODE")]
147 pub customer_code: Option<String>,
148 #[arg(long, value_name = "DIR")]
151 pub save_dir: Option<PathBuf>,
152 #[arg(long)]
155 pub allow_deduplicated: bool,
156 #[command(flatten)]
158 pub common: EvalCommonArgs,
159}
160
161fn parse_pass_rate(raw: &str) -> Result<f64, String> {
162 match raw.trim().parse::<f64>() {
163 Ok(v) if (0.0..=1.0).contains(&v) => Ok(v),
164 _ => Err("expected a fraction between 0.0 and 1.0".to_string()),
165 }
166}
167
168#[derive(Debug, Args, PartialEq, Eq)]
170pub struct SetupArgs {
171 pub step: String,
173 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
175 pub args: Vec<String>,
176}
177
178#[derive(Debug, Subcommand, PartialEq, Eq)]
180pub enum AuditCommand {
181 Verify,
184 Head,
187 Tail {
189 #[arg(short = 'n', long = "lines", default_value_t = 50)]
191 lines: usize,
192 #[arg(long)]
194 kind: Option<String>,
195 },
196}
197
198impl Cli {
199 pub fn command(&self) -> &Command {
201 self.command.as_ref().unwrap_or(&Command::Run)
202 }
203
204 pub fn resolve_project_root(&self) -> std::io::Result<PathBuf> {
207 match &self.project_root {
208 Some(p) if p.is_absolute() => Ok(p.clone()),
209 Some(p) => Ok(std::env::current_dir()?.join(p)),
210 None => std::env::current_dir(),
211 }
212 }
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218
219 fn parse(args: &[&str]) -> Cli {
220 Cli::try_parse_from(args).unwrap()
221 }
222
223 #[test]
224 fn env_backed_arguments_read_the_old_name_when_only_that_is_set() {
225 let env_of = |cmd: &clap::Command| {
226 cmd.get_arguments()
227 .find(|a| a.get_id() == "project_root")
228 .and_then(|a| a.get_env())
229 .and_then(|e| e.to_str())
230 .map(str::to_string)
231 };
232 let cmd = with_legacy_env(<Cli as CommandFactory>::command(), &|_| None);
234 assert_eq!(env_of(&cmd).as_deref(), Some("TALAHON_PROJECT_ROOT"));
235 let asked = std::sync::Mutex::new(Vec::new());
237 let cmd = with_legacy_env(<Cli as CommandFactory>::command(), &|key| {
238 asked.lock().unwrap().push(key.to_string());
239 talon_core::env::legacy_key(key)
240 });
241 assert_eq!(env_of(&cmd).as_deref(), Some("TALON_PROJECT_ROOT"));
242 assert!(
243 asked
244 .lock()
245 .unwrap()
246 .contains(&"TALAHON_PROJECT_ROOT".to_string())
247 );
248 assert!(cmd.get_subcommands().any(|c| c.get_name() == "doctor"));
250 }
251
252 #[test]
253 fn defaults_to_run() {
254 let cli = parse(&["talahon"]);
255 assert_eq!(cli.command(), &Command::Run);
256 assert_eq!(parse(&["talahon", "run"]).command(), &Command::Run);
257 }
258
259 #[test]
260 fn setup_passes_hyphenated_args_through() {
261 let cli = parse(&[
262 "talahon",
263 "setup",
264 "timezone",
265 "--tz",
266 "Europe/Berlin",
267 "--force",
268 ]);
269 assert_eq!(
270 cli.command(),
271 &Command::Setup(SetupArgs {
272 step: "timezone".into(),
273 args: vec!["--tz".into(), "Europe/Berlin".into(), "--force".into()],
274 })
275 );
276 }
277
278 #[test]
279 fn audit_subcommands() {
280 assert_eq!(
281 parse(&["talahon", "audit", "verify"]).command(),
282 &Command::Audit {
283 command: AuditCommand::Verify
284 }
285 );
286 assert_eq!(
287 parse(&["talahon", "audit", "head"]).command(),
288 &Command::Audit {
289 command: AuditCommand::Head
290 }
291 );
292 assert_eq!(
293 parse(&["talahon", "audit", "tail", "-n", "5", "--kind", "ipc."]).command(),
294 &Command::Audit {
295 command: AuditCommand::Tail {
296 lines: 5,
297 kind: Some("ipc.".into())
298 }
299 }
300 );
301 assert_eq!(
302 parse(&["talahon", "audit", "tail"]).command(),
303 &Command::Audit {
304 command: AuditCommand::Tail {
305 lines: 50,
306 kind: None
307 }
308 }
309 );
310 }
311
312 #[test]
313 fn eval_subcommands() {
314 let Some(Command::Eval {
315 command: EvalCommand::Score(score),
316 }) = parse(&["talahon", "eval", "score", "--reports", "out"]).command
317 else {
318 panic!("expected eval score");
319 };
320 assert_eq!(score.reports, PathBuf::from("out"));
321 assert_eq!(score.common.cases, None);
322 assert!(!score.common.json);
323 assert!((score.common.min_pass_rate - 1.0).abs() < f64::EPSILON);
324
325 let Some(Command::Eval {
326 command: EvalCommand::Run(run),
327 }) = parse(&[
328 "talahon",
329 "eval",
330 "run",
331 "--cases",
332 "c",
333 "--json",
334 "--min-pass-rate",
335 "0.8",
336 "--timeout-secs",
337 "60",
338 "--alert-map",
339 "map.json",
340 "--allow-deduplicated",
341 ])
342 .command
343 else {
344 panic!("expected eval run");
345 };
346 assert_eq!(run.common.cases, Some(PathBuf::from("c")));
347 assert!(run.common.json && run.allow_deduplicated);
348 assert!((run.common.min_pass_rate - 0.8).abs() < f64::EPSILON);
349 assert_eq!((run.timeout_secs, run.poll_secs), (60, 5));
350 assert_eq!(run.alert_map, Some(PathBuf::from("map.json")));
351 assert_eq!(
352 (run.url, run.save_dir, run.customer_code),
353 (None, None, None)
354 );
355
356 let bad: [&[&str]; 5] = [
357 &["talahon", "eval"],
358 &["talahon", "eval", "score"],
359 &[
360 "talahon",
361 "eval",
362 "score",
363 "--reports",
364 "o",
365 "--min-pass-rate",
366 "1.5",
367 ],
368 &[
369 "talahon",
370 "eval",
371 "score",
372 "--reports",
373 "o",
374 "--min-pass-rate",
375 "NaN",
376 ],
377 &["talahon", "eval", "run", "--timeout-secs", "0"],
378 ];
379 for args in bad {
380 assert!(Cli::try_parse_from(args).is_err(), "{args:?}");
381 }
382 }
383
384 #[test]
385 fn project_root_flag_is_global() {
386 let cli = parse(&["talahon", "doctor", "--project-root", "/srv/talahon"]);
387 assert_eq!(cli.command(), &Command::Doctor);
388 assert_eq!(
389 cli.resolve_project_root().unwrap(),
390 PathBuf::from("/srv/talahon")
391 );
392 }
393
394 #[test]
395 fn unknown_command_is_rejected() {
396 assert!(Cli::try_parse_from(["talahon", "frobnicate"]).is_err());
397 }
398}