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::args::FdlArgsTrait;
121    use crate::FdlArgs;
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 { 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    /// Port the server binds to.
154    #[derive(FdlArgs, Debug)]
155    struct OptArgs {
156        /// Port override.
157        #[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    /// Retry count — scalar with default + env fallback.
196    #[derive(FdlArgs, Debug)]
197    struct ScalarArgs {
198        /// Retries.
199        #[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    /// Env-sourced values must still satisfy `choices`.
221    #[derive(FdlArgs, Debug)]
222    struct ChoiceArgs {
223        /// Pick.
224        #[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"); // not in choices
232        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    /// Short-form presence should suppress env fallback.
248    #[derive(FdlArgs, Debug)]
249    struct ShortArgs {
250        /// Port.
251        #[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    //! Variant-shaped CLI: `#[derive(FdlArgs)]` on an enum of newtype
268    //! variants dispatches a subcommand to the wrapped type.
269
270    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    /// Train a model on a dataset.
278    #[derive(FdlArgs, Debug)]
279    struct TrainArgs {
280        /// Number of epochs.
281        #[option(short = 'n', default = "10")]
282        epochs: u32,
283    }
284
285    /// Evaluate a trained model.
286    #[derive(FdlArgs, Debug)]
287    struct EvalArgs {
288        /// Checkpoint to load.
289        #[arg]
290        checkpoint: String,
291    }
292
293    /// flodl demo CLI.
294    #[derive(FdlArgs, Debug)]
295    enum Cli {
296        /// Train a letter model on a dataset
297        Train(TrainArgs),
298        /// Evaluate a trained letter model
299        Eval(EvalArgs),
300        /// Generate samples (renamed)
301        #[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            // Reading the wrapped value also confirms the tail parsed.
337            Cli::Generate(a) => assert_eq!(a.epochs, 10),
338            other => panic!("`gen` must map to Generate, got {other:?}"),
339        }
340        // And the original kebab name no longer dispatches.
341        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        // Child carries the wrapped struct's own leaf shape.
382        assert!(s.commands["train"].options.contains_key("epochs"));
383        // Renamed variant keys by its override.
384        assert!(s.commands.contains_key("gen"));
385        // The whole tree must clear validation.
386        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        // `prog train --help` → train's help (mentions its own flag), not
403        // the command list.
404        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    // ── Nested enums: arbitrary subcommand depth, for free ─────────────
416
417    /// A variant that wraps *another* `FdlArgs` enum nests the tree one
418    /// level deeper via plain tail-recursive delegation.
419    #[derive(FdlArgs, Debug)]
420    enum WordCli {
421        /// Train group
422        Train(TrainGroup),
423        /// Evaluate
424        Eval(EvalArgs),
425    }
426
427    #[derive(FdlArgs, Debug)]
428    enum TrainGroup {
429        /// Plain training
430        Full(TrainArgs),
431        /// Subscan sweep
432        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        // Exercise the other two paths (inner-group default + outer leaf).
445        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        // `prog train subscan --help` reaches the innermost struct's help.
468        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