1pub mod parser;
10
11use crate::config::Schema;
12
13pub trait FdlArgsTrait: Sized {
20 fn parse() -> Self {
22 let args: Vec<String> = std::env::args().collect();
23 match Self::try_parse_from(&args) {
24 Ok(t) => t,
25 Err(msg) => {
26 eprintln!("{msg}");
27 std::process::exit(2);
28 }
29 }
30 }
31
32 fn try_parse_from(args: &[String]) -> Result<Self, String>;
35
36 fn schema() -> Schema;
38
39 fn render_help() -> String;
41
42 fn render_help_path(argv: &[String]) -> String {
53 let _ = argv;
54 Self::render_help()
55 }
56}
57
58pub fn parse_or_schema<T: FdlArgsTrait>() -> T {
68 let argv: Vec<String> = std::env::args().collect();
69 parse_or_schema_from::<T>(&argv)
70}
71
72pub fn parse_or_schema_from<T: FdlArgsTrait>(argv: &[String]) -> T {
78 let scan_end = argv.iter().position(|a| a == "--").unwrap_or(argv.len());
83 let before = &argv[..scan_end];
84 if before.iter().any(|a| a == "--fdl-schema") {
85 let schema = T::schema();
86 let json = serde_json::to_string_pretty(&schema)
87 .expect("Schema serializes cleanly by construction");
88 println!("{json}");
89 std::process::exit(0);
90 }
91 if before.iter().any(|a| a == "--help" || a == "-h") {
92 println!("{}", T::render_help_path(argv));
96 std::process::exit(0);
97 }
98 match T::try_parse_from(argv) {
99 Ok(t) => t,
100 Err(msg) => {
101 eprintln!("{msg}");
102 eprintln!();
103 eprintln!("{}", T::render_help_path(argv));
104 std::process::exit(2);
105 }
106 }
107}
108
109#[cfg(test)]
110mod env_tests {
111 use std::sync::{Mutex, MutexGuard};
119
120 use crate::FdlArgs;
121 use crate::args::FdlArgsTrait;
122
123 static ENV_LOCK: Mutex<()> = Mutex::new(());
127
128 fn env_lock() -> MutexGuard<'static, ()> {
129 ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
130 }
131
132 fn mk_args(xs: &[&str]) -> Vec<String> {
133 xs.iter().map(|s| s.to_string()).collect()
134 }
135
136 struct EnvGuard(&'static str);
139 impl EnvGuard {
140 fn set(name: &'static str, value: &str) -> Self {
141 unsafe {
144 std::env::set_var(name, value);
145 }
146 EnvGuard(name)
147 }
148 }
149 impl Drop for EnvGuard {
150 fn drop(&mut self) {
151 unsafe {
152 std::env::remove_var(self.0);
153 }
154 }
155 }
156
157 #[derive(FdlArgs, Debug)]
159 struct OptArgs {
160 #[option(env = "FDL_TEST_PORT")]
162 port: Option<u16>,
163 }
164
165 #[test]
166 fn env_fills_absent_option() {
167 let _lock = env_lock();
168 let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
169 let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
170 assert_eq!(cli.port, Some(8080));
171 }
172
173 #[test]
174 fn argv_flag_beats_env() {
175 let _lock = env_lock();
176 let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
177 let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog", "--port", "9999"])).unwrap();
178 assert_eq!(cli.port, Some(9999));
179 }
180
181 #[test]
182 fn equals_form_beats_env() {
183 let _lock = env_lock();
184 let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
185 let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog", "--port=9999"])).unwrap();
186 assert_eq!(cli.port, Some(9999));
187 }
188
189 #[test]
190 fn empty_env_falls_through() {
191 let _lock = env_lock();
192 let _g = EnvGuard::set("FDL_TEST_PORT", "");
193 let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
194 assert_eq!(cli.port, None);
195 }
196
197 #[derive(FdlArgs, Debug)]
199 struct ScalarArgs {
200 #[option(default = "3", env = "FDL_TEST_RETRIES")]
202 retries: u32,
203 }
204
205 #[test]
206 fn env_overrides_default_on_scalar() {
207 let _lock = env_lock();
208 let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
209 let cli: ScalarArgs = ScalarArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
210 assert_eq!(cli.retries, 7);
211 }
212
213 #[test]
214 fn argv_beats_env_beats_default_on_scalar() {
215 let _lock = env_lock();
216 let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
217 let cli: ScalarArgs =
218 ScalarArgs::try_parse_from(&mk_args(&["prog", "--retries", "42"])).unwrap();
219 assert_eq!(cli.retries, 42);
220 }
221
222 #[derive(FdlArgs, Debug)]
224 struct ChoiceArgs {
225 #[option(choices = &["a", "b"], env = "FDL_TEST_CHOICE")]
227 pick: Option<String>,
228 }
229
230 #[test]
231 fn env_value_is_validated_against_choices() {
232 let _lock = env_lock();
233 let _g = EnvGuard::set("FDL_TEST_CHOICE", "z"); let err = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap_err();
235 assert!(
236 err.contains("invalid value") && err.contains("z") && err.contains("allowed:"),
237 "env-sourced invalid choice should error like an argv one; got: {err}"
238 );
239 }
240
241 #[test]
242 fn env_valid_choice_accepted() {
243 let _lock = env_lock();
244 let _g = EnvGuard::set("FDL_TEST_CHOICE", "a");
245 let cli: ChoiceArgs = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
246 assert_eq!(cli.pick.as_deref(), Some("a"));
247 }
248
249 #[derive(FdlArgs, Debug)]
251 struct ShortArgs {
252 #[option(short = 'p', env = "FDL_TEST_SHORT")]
254 port: Option<u16>,
255 }
256
257 #[test]
258 fn short_form_suppresses_env_fallback() {
259 let _lock = env_lock();
260 let _g = EnvGuard::set("FDL_TEST_SHORT", "8080");
261 let cli: ShortArgs = ShortArgs::try_parse_from(&mk_args(&["prog", "-p", "9999"])).unwrap();
262 assert_eq!(cli.port, Some(9999));
263 }
264}
265
266#[cfg(test)]
267mod enum_tests {
268 use crate::FdlArgs;
272 use crate::args::FdlArgsTrait;
273
274 fn mk_args(xs: &[&str]) -> Vec<String> {
275 xs.iter().map(|s| s.to_string()).collect()
276 }
277
278 #[derive(FdlArgs, Debug)]
280 struct TrainArgs {
281 #[option(short = 'n', default = "10")]
283 epochs: u32,
284 }
285
286 #[derive(FdlArgs, Debug)]
288 struct EvalArgs {
289 #[arg]
291 checkpoint: String,
292 }
293
294 #[derive(FdlArgs, Debug)]
296 enum Cli {
297 Train(TrainArgs),
299 Eval(EvalArgs),
301 #[command(name = "gen")]
303 Generate(TrainArgs),
304 }
305
306 #[test]
307 fn dispatches_to_variant_and_parses_its_flags() {
308 let cli = Cli::try_parse_from(&mk_args(&["prog", "train", "--epochs", "5"])).unwrap();
309 match cli {
310 Cli::Train(a) => assert_eq!(a.epochs, 5),
311 other => panic!("expected Train, got {other:?}"),
312 }
313 }
314
315 #[test]
316 fn variant_default_applies_when_flag_absent() {
317 let cli = Cli::try_parse_from(&mk_args(&["prog", "train"])).unwrap();
318 match cli {
319 Cli::Train(a) => assert_eq!(a.epochs, 10),
320 other => panic!("expected Train, got {other:?}"),
321 }
322 }
323
324 #[test]
325 fn dispatches_positional_to_variant() {
326 let cli = Cli::try_parse_from(&mk_args(&["prog", "eval", "model.fdl"])).unwrap();
327 match cli {
328 Cli::Eval(a) => assert_eq!(a.checkpoint, "model.fdl"),
329 other => panic!("expected Eval, got {other:?}"),
330 }
331 }
332
333 #[test]
334 fn command_name_override_is_honored() {
335 let cli = Cli::try_parse_from(&mk_args(&["prog", "gen"])).unwrap();
336 match cli {
337 Cli::Generate(a) => assert_eq!(a.epochs, 10),
339 other => panic!("`gen` must map to Generate, got {other:?}"),
340 }
341 let err = Cli::try_parse_from(&mk_args(&["prog", "generate"])).unwrap_err();
343 assert!(err.contains("unknown command"), "got: {err}");
344 }
345
346 #[test]
347 fn missing_command_errors_with_list() {
348 let err = Cli::try_parse_from(&mk_args(&["prog"])).unwrap_err();
349 assert!(
350 err.contains("missing command") && err.contains("train") && err.contains("eval"),
351 "got: {err}"
352 );
353 }
354
355 #[test]
356 fn unknown_command_suggests_close_match() {
357 let err = Cli::try_parse_from(&mk_args(&["prog", "trian"])).unwrap_err();
358 assert!(
359 err.contains("did you mean `train`"),
360 "near-miss must suggest; got: {err}"
361 );
362 }
363
364 #[test]
365 fn unknown_command_far_miss_lists_options() {
366 let err = Cli::try_parse_from(&mk_args(&["prog", "zzzzz"])).unwrap_err();
367 assert!(
368 err.contains("expected one of") && err.contains("train"),
369 "far miss must list commands; got: {err}"
370 );
371 }
372
373 #[test]
374 fn schema_is_a_branch_with_described_children() {
375 let s = Cli::schema();
376 assert!(
377 s.args.is_empty() && s.options.is_empty(),
378 "root is a branch, not a leaf"
379 );
380 assert_eq!(s.commands.len(), 3);
381 assert_eq!(
382 s.commands["train"].description.as_deref(),
383 Some("Train a letter model on a dataset")
384 );
385 assert!(s.commands["train"].options.contains_key("epochs"));
387 assert!(s.commands.contains_key("gen"));
389 crate::config::validate_schema(&s).expect("derived tree schema must validate");
391 }
392
393 #[test]
394 fn root_help_lists_commands() {
395 let help = Cli::render_help();
396 assert!(
397 help.contains("Commands"),
398 "root help has a Commands section"
399 );
400 assert!(help.contains("train") && help.contains("eval") && help.contains("gen"));
401 assert!(
402 help.contains("Train a letter model on a dataset"),
403 "command descriptions come from variant docs; got:\n{help}"
404 );
405 }
406
407 #[test]
408 fn help_path_renders_the_subcommands_help() {
409 let help = Cli::render_help_path(&mk_args(&["prog", "train", "--help"]));
412 assert!(
413 help.contains("epochs"),
414 "train help must show its flags; got:\n{help}"
415 );
416 assert!(
417 !help.contains("Commands"),
418 "must not fall back to the command list"
419 );
420 }
421
422 #[test]
423 fn help_path_falls_back_to_root_when_no_subcommand() {
424 let help = Cli::render_help_path(&mk_args(&["prog"]));
425 assert!(
426 help.contains("Commands"),
427 "bare --help shows the command list"
428 );
429 }
430
431 #[derive(FdlArgs, Debug)]
436 enum WordCli {
437 Train(TrainGroup),
439 Eval(EvalArgs),
441 }
442
443 #[derive(FdlArgs, Debug)]
444 enum TrainGroup {
445 Full(TrainArgs),
447 Subscan(TrainArgs),
449 }
450
451 #[test]
452 fn nested_enum_dispatches_two_levels() {
453 let cli = WordCli::try_parse_from(&mk_args(&["prog", "train", "subscan", "--epochs", "3"]))
454 .unwrap();
455 match cli {
456 WordCli::Train(TrainGroup::Subscan(a)) => assert_eq!(a.epochs, 3),
457 other => panic!("expected Train>Subscan, got {other:?}"),
458 }
459 match WordCli::try_parse_from(&mk_args(&["prog", "train", "full"])).unwrap() {
461 WordCli::Train(TrainGroup::Full(a)) => assert_eq!(a.epochs, 10),
462 other => panic!("expected Train>Full, got {other:?}"),
463 }
464 match WordCli::try_parse_from(&mk_args(&["prog", "eval", "ckpt.fdl"])).unwrap() {
465 WordCli::Eval(a) => assert_eq!(a.checkpoint, "ckpt.fdl"),
466 other => panic!("expected Eval, got {other:?}"),
467 }
468 }
469
470 #[test]
471 fn nested_enum_schema_is_a_two_level_tree() {
472 let s = WordCli::schema();
473 let train = &s.commands["train"];
474 assert!(
475 train.options.is_empty(),
476 "the train node is itself a branch"
477 );
478 assert!(train.commands.contains_key("subscan"));
479 assert!(train.commands["full"].options.contains_key("epochs"));
480 crate::config::validate_schema(&s).expect("nested tree must validate");
481 }
482
483 #[test]
484 fn nested_enum_help_drills_to_leaf() {
485 let help = WordCli::render_help_path(&mk_args(&["prog", "train", "subscan", "--help"]));
487 assert!(
488 help.contains("epochs"),
489 "must reach the leaf struct help; got:\n{help}"
490 );
491 }
492}