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