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
43/// Intercept `--fdl-schema` and `--help`, otherwise parse argv.
44///
45/// - `--fdl-schema` anywhere in argv: print the JSON schema to stdout, exit 0.
46/// - `--help` / `-h` anywhere in argv: print help to stdout, exit 0.
47/// - Otherwise: parse via `T::try_parse_from`. On parse error (missing
48///   required positional, unknown flag, invalid value, ...) the error
49///   message AND the rendered help are printed to stderr; the binary
50///   exits with code 2. Showing help on error keeps `<bin>` (no args)
51///   and `<bin> --help` consistent for binaries that previously dumped
52///   usage on missing-args.
53pub fn parse_or_schema<T: FdlArgsTrait>() -> T {
54    let argv: Vec<String> = std::env::args().collect();
55    parse_or_schema_from::<T>(&argv)
56}
57
58/// Slice-based variant of [`parse_or_schema`]. The first element is the
59/// program name (displayed in help text), the rest are arguments.
60///
61/// Used by the `fdl` driver itself when dispatching to sub-commands: each
62/// sub-command parses its own `args[2..]` tail without re-reading `env::args`.
63pub fn parse_or_schema_from<T: FdlArgsTrait>(argv: &[String]) -> T {
64    if argv.iter().any(|a| a == "--fdl-schema") {
65        let schema = T::schema();
66        let json = serde_json::to_string_pretty(&schema)
67            .expect("Schema serializes cleanly by construction");
68        println!("{json}");
69        std::process::exit(0);
70    }
71    if argv.iter().any(|a| a == "--help" || a == "-h") {
72        println!("{}", T::render_help());
73        std::process::exit(0);
74    }
75    match T::try_parse_from(argv) {
76        Ok(t) => t,
77        Err(msg) => {
78            eprintln!("{msg}");
79            eprintln!();
80            eprintln!("{}", T::render_help());
81            std::process::exit(2);
82        }
83    }
84}
85
86#[cfg(test)]
87mod env_tests {
88    //! End-to-end coverage of `#[option(env = "...")]` fallback.
89    //!
90    //! These tests mutate process-global `std::env` state, so they must
91    //! hold [`ENV_LOCK`] for the duration of set/parse/drop. Without the
92    //! lock, `cargo test`'s default parallel execution races on shared
93    //! env var names and produces flaky failures in CI.
94
95    use std::sync::{Mutex, MutexGuard};
96
97    use crate::args::FdlArgsTrait;
98    use crate::FdlArgs;
99
100    /// Serializes every test in this module. Poison is ignored because a
101    /// panicking test that leaves the lock poisoned still left the env
102    /// clean (`EnvGuard::drop` runs during unwind).
103    static ENV_LOCK: Mutex<()> = Mutex::new(());
104
105    fn env_lock() -> MutexGuard<'static, ()> {
106        ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
107    }
108
109    fn mk_args(xs: &[&str]) -> Vec<String> {
110        xs.iter().map(|s| s.to_string()).collect()
111    }
112
113    /// Scoped env-var guard — `Drop` unsets on the way out so assertions
114    /// that panic mid-test can't leak state into the next one.
115    struct EnvGuard(&'static str);
116    impl EnvGuard {
117        fn set(name: &'static str, value: &str) -> Self {
118            // SAFETY: caller holds `ENV_LOCK` for the duration of this
119            // test, so no other test thread writes env concurrently.
120            unsafe { std::env::set_var(name, value); }
121            EnvGuard(name)
122        }
123    }
124    impl Drop for EnvGuard {
125        fn drop(&mut self) {
126            unsafe { std::env::remove_var(self.0); }
127        }
128    }
129
130    /// Port the server binds to.
131    #[derive(FdlArgs, Debug)]
132    struct OptArgs {
133        /// Port override.
134        #[option(env = "FDL_TEST_PORT")]
135        port: Option<u16>,
136    }
137
138    #[test]
139    fn env_fills_absent_option() {
140        let _lock = env_lock();
141        let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
142        let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
143        assert_eq!(cli.port, Some(8080));
144    }
145
146    #[test]
147    fn argv_flag_beats_env() {
148        let _lock = env_lock();
149        let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
150        let cli: OptArgs =
151            OptArgs::try_parse_from(&mk_args(&["prog", "--port", "9999"])).unwrap();
152        assert_eq!(cli.port, Some(9999));
153    }
154
155    #[test]
156    fn equals_form_beats_env() {
157        let _lock = env_lock();
158        let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
159        let cli: OptArgs =
160            OptArgs::try_parse_from(&mk_args(&["prog", "--port=9999"])).unwrap();
161        assert_eq!(cli.port, Some(9999));
162    }
163
164    #[test]
165    fn empty_env_falls_through() {
166        let _lock = env_lock();
167        let _g = EnvGuard::set("FDL_TEST_PORT", "");
168        let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
169        assert_eq!(cli.port, None);
170    }
171
172    /// Retry count — scalar with default + env fallback.
173    #[derive(FdlArgs, Debug)]
174    struct ScalarArgs {
175        /// Retries.
176        #[option(default = "3", env = "FDL_TEST_RETRIES")]
177        retries: u32,
178    }
179
180    #[test]
181    fn env_overrides_default_on_scalar() {
182        let _lock = env_lock();
183        let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
184        let cli: ScalarArgs = ScalarArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
185        assert_eq!(cli.retries, 7);
186    }
187
188    #[test]
189    fn argv_beats_env_beats_default_on_scalar() {
190        let _lock = env_lock();
191        let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
192        let cli: ScalarArgs =
193            ScalarArgs::try_parse_from(&mk_args(&["prog", "--retries", "42"])).unwrap();
194        assert_eq!(cli.retries, 42);
195    }
196
197    /// Env-sourced values must still satisfy `choices`.
198    #[derive(FdlArgs, Debug)]
199    struct ChoiceArgs {
200        /// Pick.
201        #[option(choices = &["a", "b"], env = "FDL_TEST_CHOICE")]
202        pick: Option<String>,
203    }
204
205    #[test]
206    fn env_value_is_validated_against_choices() {
207        let _lock = env_lock();
208        let _g = EnvGuard::set("FDL_TEST_CHOICE", "z"); // not in choices
209        let err = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap_err();
210        assert!(
211            err.contains("invalid value") && err.contains("z") && err.contains("allowed:"),
212            "env-sourced invalid choice should error like an argv one; got: {err}"
213        );
214    }
215
216    #[test]
217    fn env_valid_choice_accepted() {
218        let _lock = env_lock();
219        let _g = EnvGuard::set("FDL_TEST_CHOICE", "a");
220        let cli: ChoiceArgs = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
221        assert_eq!(cli.pick.as_deref(), Some("a"));
222    }
223
224    /// Short-form presence should suppress env fallback.
225    #[derive(FdlArgs, Debug)]
226    struct ShortArgs {
227        /// Port.
228        #[option(short = 'p', env = "FDL_TEST_SHORT")]
229        port: Option<u16>,
230    }
231
232    #[test]
233    fn short_form_suppresses_env_fallback() {
234        let _lock = env_lock();
235        let _g = EnvGuard::set("FDL_TEST_SHORT", "8080");
236        let cli: ShortArgs =
237            ShortArgs::try_parse_from(&mk_args(&["prog", "-p", "9999"])).unwrap();
238        assert_eq!(cli.port, Some(9999));
239    }
240}
241