Skip to main content

flodl_cli/args/
mod.rs

1//! Argv parser and `FdlArgs` trait — the library side of the
2//! `#[derive(FdlArgs)]` machinery.
3//!
4//! The derive macro in `flodl-cli-macros` emits an `impl FdlArgsTrait for
5//! Cli` that delegates to the parser exposed here. Binary authors do not
6//! import this module directly — they use `#[derive(FdlArgs)]` and
7//! `parse_or_schema::<Cli>()` from the top-level `flodl_cli` crate.
8
9pub mod parser;
10
11use crate::config::Schema;
12
13/// Trait implemented by `#[derive(FdlArgs)]`. Carries the metadata needed
14/// to parse argv into a concrete type and to emit the `--fdl-schema` JSON.
15///
16/// The name is `FdlArgsTrait` to avoid colliding with the re-exported
17/// derive macro `FdlArgs` (which lives in the derive-macro namespace).
18/// Users never refer to this trait directly — the derive implements it.
19pub trait FdlArgsTrait: Sized {
20    /// Parse argv into `Self`. Uses `std::env::args()` by default.
21    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    /// Parse from an explicit argv slice. First element is the program
33    /// name (ignored), following elements are flags/values/positionals.
34    fn try_parse_from(args: &[String]) -> Result<Self, String>;
35
36    /// Return the JSON schema for this CLI shape.
37    fn schema() -> Schema;
38
39    /// Render `--help` to a string.
40    fn render_help() -> String;
41
42    /// Render `--help` for a specific argv path (program name first, then
43    /// the tokens typed). The default ignores the path and returns
44    /// [`Self::render_help`] — correct for a single struct, whose help is
45    /// context-free.
46    ///
47    /// The enum derive overrides this to peel the leading subcommand token
48    /// and render that subcommand's help, recursing for nested trees. An
49    /// absent or unknown subcommand falls back to the root (command-list)
50    /// help. This is why `bin train --help` shows train's flags rather than
51    /// the top-level command list.
52    fn render_help_path(argv: &[String]) -> String {
53        let _ = argv;
54        Self::render_help()
55    }
56}
57
58/// Intercept `--fdl-schema` and `--help`, otherwise parse argv.
59///
60/// - `--fdl-schema` anywhere in argv: print the JSON schema to stdout, exit 0.
61/// - `--help` / `-h` anywhere in argv: print help to stdout, exit 0.
62/// - Otherwise: parse via `T::try_parse_from`. On parse error (missing
63///   required positional, unknown flag, invalid value, ...) the error
64///   message AND the rendered help are printed to stderr; the binary
65///   exits with code 2. Showing help on error keeps `<bin>` (no args)
66///   and `<bin> --help` consistent.
67pub 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
72/// Slice-based variant of [`parse_or_schema`]. The first element is the
73/// program name (displayed in help text), the rest are arguments.
74///
75/// Used by the `fdl` driver itself when dispatching to sub-commands: each
76/// sub-command parses its own `args[2..]` tail without re-reading `env::args`.
77pub fn parse_or_schema_from<T: FdlArgsTrait>(argv: &[String]) -> T {
78    // Only intercept `--fdl-schema` / `--help` when they appear BEFORE the
79    // first standalone `--`. A token after `--` is bound for the inner
80    // program (e.g. `bin train -- --help` asks the inner for its help), so
81    // scanning the whole argv would hijack it.
82    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        // `render_help_path` is context-aware: for a variant-shaped CLI,
93        // `bin train --help` renders train's help, not the command list.
94        // For a single struct it is identical to `render_help`.
95        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    //! End-to-end coverage of `#[option(env = "...")]` fallback.
112    //!
113    //! These tests mutate process-global `std::env` state, so they must
114    //! hold [`ENV_LOCK`] for the duration of set/parse/drop. Without the
115    //! lock, `cargo test`'s default parallel execution races on shared
116    //! env var names and produces flaky failures in CI.
117
118    use std::sync::{Mutex, MutexGuard};
119
120    use crate::FdlArgs;
121    use crate::args::FdlArgsTrait;
122
123    /// Serializes every test in this module. Poison is ignored because a
124    /// panicking test that leaves the lock poisoned still left the env
125    /// clean (`EnvGuard::drop` runs during unwind).
126    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    /// Scoped env-var guard — `Drop` unsets on the way out so assertions
137    /// that panic mid-test can't leak state into the next one.
138    struct EnvGuard(&'static str);
139    impl EnvGuard {
140        fn set(name: &'static str, value: &str) -> Self {
141            // SAFETY: caller holds `ENV_LOCK` for the duration of this
142            // test, so no other test thread writes env concurrently.
143            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    /// Port the server binds to.
158    #[derive(FdlArgs, Debug)]
159    struct OptArgs {
160        /// Port override.
161        #[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    /// Retry count — scalar with default + env fallback.
198    #[derive(FdlArgs, Debug)]
199    struct ScalarArgs {
200        /// Retries.
201        #[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    /// Env-sourced values must still satisfy `choices`.
223    #[derive(FdlArgs, Debug)]
224    struct ChoiceArgs {
225        /// Pick.
226        #[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"); // not in choices
234        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    /// Short-form presence should suppress env fallback.
250    #[derive(FdlArgs, Debug)]
251    struct ShortArgs {
252        /// Port.
253        #[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    //! Variant-shaped CLI: `#[derive(FdlArgs)]` on an enum of newtype
269    //! variants dispatches a subcommand to the wrapped type.
270
271    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    /// Train a model on a dataset.
279    #[derive(FdlArgs, Debug)]
280    struct TrainArgs {
281        /// Number of epochs.
282        #[option(short = 'n', default = "10")]
283        epochs: u32,
284    }
285
286    /// Evaluate a trained model.
287    #[derive(FdlArgs, Debug)]
288    struct EvalArgs {
289        /// Checkpoint to load.
290        #[arg]
291        checkpoint: String,
292    }
293
294    /// flodl demo CLI.
295    #[derive(FdlArgs, Debug)]
296    enum Cli {
297        /// Train a letter model on a dataset
298        Train(TrainArgs),
299        /// Evaluate a trained letter model
300        Eval(EvalArgs),
301        /// Generate samples (renamed)
302        #[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            // Reading the wrapped value also confirms the tail parsed.
338            Cli::Generate(a) => assert_eq!(a.epochs, 10),
339            other => panic!("`gen` must map to Generate, got {other:?}"),
340        }
341        // And the original kebab name no longer dispatches.
342        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        // Child carries the wrapped struct's own leaf shape.
386        assert!(s.commands["train"].options.contains_key("epochs"));
387        // Renamed variant keys by its override.
388        assert!(s.commands.contains_key("gen"));
389        // The whole tree must clear validation.
390        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        // `prog train --help` → train's help (mentions its own flag), not
410        // the command list.
411        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    // ── Nested enums: arbitrary subcommand depth, for free ─────────────
432
433    /// A variant that wraps *another* `FdlArgs` enum nests the tree one
434    /// level deeper via plain tail-recursive delegation.
435    #[derive(FdlArgs, Debug)]
436    enum WordCli {
437        /// Train group
438        Train(TrainGroup),
439        /// Evaluate
440        Eval(EvalArgs),
441    }
442
443    #[derive(FdlArgs, Debug)]
444    enum TrainGroup {
445        /// Plain training
446        Full(TrainArgs),
447        /// Subscan sweep
448        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        // Exercise the other two paths (inner-group default + outer leaf).
460        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        // `prog train subscan --help` reaches the innermost struct's help.
486        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}