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::args::FdlArgsTrait;
121 use crate::FdlArgs;
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 { std::env::set_var(name, value); }
144 EnvGuard(name)
145 }
146 }
147 impl Drop for EnvGuard {
148 fn drop(&mut self) {
149 unsafe { std::env::remove_var(self.0); }
150 }
151 }
152
153 #[derive(FdlArgs, Debug)]
155 struct OptArgs {
156 #[option(env = "FDL_TEST_PORT")]
158 port: Option<u16>,
159 }
160
161 #[test]
162 fn env_fills_absent_option() {
163 let _lock = env_lock();
164 let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
165 let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
166 assert_eq!(cli.port, Some(8080));
167 }
168
169 #[test]
170 fn argv_flag_beats_env() {
171 let _lock = env_lock();
172 let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
173 let cli: OptArgs =
174 OptArgs::try_parse_from(&mk_args(&["prog", "--port", "9999"])).unwrap();
175 assert_eq!(cli.port, Some(9999));
176 }
177
178 #[test]
179 fn equals_form_beats_env() {
180 let _lock = env_lock();
181 let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
182 let cli: OptArgs =
183 OptArgs::try_parse_from(&mk_args(&["prog", "--port=9999"])).unwrap();
184 assert_eq!(cli.port, Some(9999));
185 }
186
187 #[test]
188 fn empty_env_falls_through() {
189 let _lock = env_lock();
190 let _g = EnvGuard::set("FDL_TEST_PORT", "");
191 let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
192 assert_eq!(cli.port, None);
193 }
194
195 #[derive(FdlArgs, Debug)]
197 struct ScalarArgs {
198 #[option(default = "3", env = "FDL_TEST_RETRIES")]
200 retries: u32,
201 }
202
203 #[test]
204 fn env_overrides_default_on_scalar() {
205 let _lock = env_lock();
206 let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
207 let cli: ScalarArgs = ScalarArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
208 assert_eq!(cli.retries, 7);
209 }
210
211 #[test]
212 fn argv_beats_env_beats_default_on_scalar() {
213 let _lock = env_lock();
214 let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
215 let cli: ScalarArgs =
216 ScalarArgs::try_parse_from(&mk_args(&["prog", "--retries", "42"])).unwrap();
217 assert_eq!(cli.retries, 42);
218 }
219
220 #[derive(FdlArgs, Debug)]
222 struct ChoiceArgs {
223 #[option(choices = &["a", "b"], env = "FDL_TEST_CHOICE")]
225 pick: Option<String>,
226 }
227
228 #[test]
229 fn env_value_is_validated_against_choices() {
230 let _lock = env_lock();
231 let _g = EnvGuard::set("FDL_TEST_CHOICE", "z"); let err = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap_err();
233 assert!(
234 err.contains("invalid value") && err.contains("z") && err.contains("allowed:"),
235 "env-sourced invalid choice should error like an argv one; got: {err}"
236 );
237 }
238
239 #[test]
240 fn env_valid_choice_accepted() {
241 let _lock = env_lock();
242 let _g = EnvGuard::set("FDL_TEST_CHOICE", "a");
243 let cli: ChoiceArgs = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
244 assert_eq!(cli.pick.as_deref(), Some("a"));
245 }
246
247 #[derive(FdlArgs, Debug)]
249 struct ShortArgs {
250 #[option(short = 'p', env = "FDL_TEST_SHORT")]
252 port: Option<u16>,
253 }
254
255 #[test]
256 fn short_form_suppresses_env_fallback() {
257 let _lock = env_lock();
258 let _g = EnvGuard::set("FDL_TEST_SHORT", "8080");
259 let cli: ShortArgs =
260 ShortArgs::try_parse_from(&mk_args(&["prog", "-p", "9999"])).unwrap();
261 assert_eq!(cli.port, Some(9999));
262 }
263}
264
265#[cfg(test)]
266mod enum_tests {
267 use crate::args::FdlArgsTrait;
271 use crate::FdlArgs;
272
273 fn mk_args(xs: &[&str]) -> Vec<String> {
274 xs.iter().map(|s| s.to_string()).collect()
275 }
276
277 #[derive(FdlArgs, Debug)]
279 struct TrainArgs {
280 #[option(short = 'n', default = "10")]
282 epochs: u32,
283 }
284
285 #[derive(FdlArgs, Debug)]
287 struct EvalArgs {
288 #[arg]
290 checkpoint: String,
291 }
292
293 #[derive(FdlArgs, Debug)]
295 enum Cli {
296 Train(TrainArgs),
298 Eval(EvalArgs),
300 #[command(name = "gen")]
302 Generate(TrainArgs),
303 }
304
305 #[test]
306 fn dispatches_to_variant_and_parses_its_flags() {
307 let cli = Cli::try_parse_from(&mk_args(&["prog", "train", "--epochs", "5"])).unwrap();
308 match cli {
309 Cli::Train(a) => assert_eq!(a.epochs, 5),
310 other => panic!("expected Train, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn variant_default_applies_when_flag_absent() {
316 let cli = Cli::try_parse_from(&mk_args(&["prog", "train"])).unwrap();
317 match cli {
318 Cli::Train(a) => assert_eq!(a.epochs, 10),
319 other => panic!("expected Train, got {other:?}"),
320 }
321 }
322
323 #[test]
324 fn dispatches_positional_to_variant() {
325 let cli = Cli::try_parse_from(&mk_args(&["prog", "eval", "model.fdl"])).unwrap();
326 match cli {
327 Cli::Eval(a) => assert_eq!(a.checkpoint, "model.fdl"),
328 other => panic!("expected Eval, got {other:?}"),
329 }
330 }
331
332 #[test]
333 fn command_name_override_is_honored() {
334 let cli = Cli::try_parse_from(&mk_args(&["prog", "gen"])).unwrap();
335 match cli {
336 Cli::Generate(a) => assert_eq!(a.epochs, 10),
338 other => panic!("`gen` must map to Generate, got {other:?}"),
339 }
340 let err = Cli::try_parse_from(&mk_args(&["prog", "generate"])).unwrap_err();
342 assert!(err.contains("unknown command"), "got: {err}");
343 }
344
345 #[test]
346 fn missing_command_errors_with_list() {
347 let err = Cli::try_parse_from(&mk_args(&["prog"])).unwrap_err();
348 assert!(
349 err.contains("missing command") && err.contains("train") && err.contains("eval"),
350 "got: {err}"
351 );
352 }
353
354 #[test]
355 fn unknown_command_suggests_close_match() {
356 let err = Cli::try_parse_from(&mk_args(&["prog", "trian"])).unwrap_err();
357 assert!(
358 err.contains("did you mean `train`"),
359 "near-miss must suggest; got: {err}"
360 );
361 }
362
363 #[test]
364 fn unknown_command_far_miss_lists_options() {
365 let err = Cli::try_parse_from(&mk_args(&["prog", "zzzzz"])).unwrap_err();
366 assert!(
367 err.contains("expected one of") && err.contains("train"),
368 "far miss must list commands; got: {err}"
369 );
370 }
371
372 #[test]
373 fn schema_is_a_branch_with_described_children() {
374 let s = Cli::schema();
375 assert!(s.args.is_empty() && s.options.is_empty(), "root is a branch, not a leaf");
376 assert_eq!(s.commands.len(), 3);
377 assert_eq!(
378 s.commands["train"].description.as_deref(),
379 Some("Train a letter model on a dataset")
380 );
381 assert!(s.commands["train"].options.contains_key("epochs"));
383 assert!(s.commands.contains_key("gen"));
385 crate::config::validate_schema(&s).expect("derived tree schema must validate");
387 }
388
389 #[test]
390 fn root_help_lists_commands() {
391 let help = Cli::render_help();
392 assert!(help.contains("Commands"), "root help has a Commands section");
393 assert!(help.contains("train") && help.contains("eval") && help.contains("gen"));
394 assert!(
395 help.contains("Train a letter model on a dataset"),
396 "command descriptions come from variant docs; got:\n{help}"
397 );
398 }
399
400 #[test]
401 fn help_path_renders_the_subcommands_help() {
402 let help = Cli::render_help_path(&mk_args(&["prog", "train", "--help"]));
405 assert!(help.contains("epochs"), "train help must show its flags; got:\n{help}");
406 assert!(!help.contains("Commands"), "must not fall back to the command list");
407 }
408
409 #[test]
410 fn help_path_falls_back_to_root_when_no_subcommand() {
411 let help = Cli::render_help_path(&mk_args(&["prog"]));
412 assert!(help.contains("Commands"), "bare --help shows the command list");
413 }
414
415 #[derive(FdlArgs, Debug)]
420 enum WordCli {
421 Train(TrainGroup),
423 Eval(EvalArgs),
425 }
426
427 #[derive(FdlArgs, Debug)]
428 enum TrainGroup {
429 Full(TrainArgs),
431 Subscan(TrainArgs),
433 }
434
435 #[test]
436 fn nested_enum_dispatches_two_levels() {
437 let cli =
438 WordCli::try_parse_from(&mk_args(&["prog", "train", "subscan", "--epochs", "3"]))
439 .unwrap();
440 match cli {
441 WordCli::Train(TrainGroup::Subscan(a)) => assert_eq!(a.epochs, 3),
442 other => panic!("expected Train>Subscan, got {other:?}"),
443 }
444 match WordCli::try_parse_from(&mk_args(&["prog", "train", "full"])).unwrap() {
446 WordCli::Train(TrainGroup::Full(a)) => assert_eq!(a.epochs, 10),
447 other => panic!("expected Train>Full, got {other:?}"),
448 }
449 match WordCli::try_parse_from(&mk_args(&["prog", "eval", "ckpt.fdl"])).unwrap() {
450 WordCli::Eval(a) => assert_eq!(a.checkpoint, "ckpt.fdl"),
451 other => panic!("expected Eval, got {other:?}"),
452 }
453 }
454
455 #[test]
456 fn nested_enum_schema_is_a_two_level_tree() {
457 let s = WordCli::schema();
458 let train = &s.commands["train"];
459 assert!(train.options.is_empty(), "the train node is itself a branch");
460 assert!(train.commands.contains_key("subscan"));
461 assert!(train.commands["full"].options.contains_key("epochs"));
462 crate::config::validate_schema(&s).expect("nested tree must validate");
463 }
464
465 #[test]
466 fn nested_enum_help_drills_to_leaf() {
467 let help =
469 WordCli::render_help_path(&mk_args(&["prog", "train", "subscan", "--help"]));
470 assert!(help.contains("epochs"), "must reach the leaf struct help; got:\n{help}");
471 }
472}
473