pub mod parser;
use crate::config::Schema;
pub trait FdlArgsTrait: Sized {
fn parse() -> Self {
let args: Vec<String> = std::env::args().collect();
match Self::try_parse_from(&args) {
Ok(t) => t,
Err(msg) => {
eprintln!("{msg}");
std::process::exit(2);
}
}
}
fn try_parse_from(args: &[String]) -> Result<Self, String>;
fn schema() -> Schema;
fn render_help() -> String;
fn render_help_path(argv: &[String]) -> String {
let _ = argv;
Self::render_help()
}
}
pub fn parse_or_schema<T: FdlArgsTrait>() -> T {
let argv: Vec<String> = std::env::args().collect();
parse_or_schema_from::<T>(&argv)
}
pub fn parse_or_schema_from<T: FdlArgsTrait>(argv: &[String]) -> T {
let scan_end = argv.iter().position(|a| a == "--").unwrap_or(argv.len());
let before = &argv[..scan_end];
if before.iter().any(|a| a == "--fdl-schema") {
let schema = T::schema();
let json = serde_json::to_string_pretty(&schema)
.expect("Schema serializes cleanly by construction");
println!("{json}");
std::process::exit(0);
}
if before.iter().any(|a| a == "--help" || a == "-h") {
println!("{}", T::render_help_path(argv));
std::process::exit(0);
}
match T::try_parse_from(argv) {
Ok(t) => t,
Err(msg) => {
eprintln!("{msg}");
eprintln!();
eprintln!("{}", T::render_help_path(argv));
std::process::exit(2);
}
}
}
#[cfg(test)]
mod env_tests {
use std::sync::{Mutex, MutexGuard};
use crate::args::FdlArgsTrait;
use crate::FdlArgs;
static ENV_LOCK: Mutex<()> = Mutex::new(());
fn env_lock() -> MutexGuard<'static, ()> {
ENV_LOCK.lock().unwrap_or_else(|e| e.into_inner())
}
fn mk_args(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
struct EnvGuard(&'static str);
impl EnvGuard {
fn set(name: &'static str, value: &str) -> Self {
unsafe { std::env::set_var(name, value); }
EnvGuard(name)
}
}
impl Drop for EnvGuard {
fn drop(&mut self) {
unsafe { std::env::remove_var(self.0); }
}
}
#[derive(FdlArgs, Debug)]
struct OptArgs {
#[option(env = "FDL_TEST_PORT")]
port: Option<u16>,
}
#[test]
fn env_fills_absent_option() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
assert_eq!(cli.port, Some(8080));
}
#[test]
fn argv_flag_beats_env() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
let cli: OptArgs =
OptArgs::try_parse_from(&mk_args(&["prog", "--port", "9999"])).unwrap();
assert_eq!(cli.port, Some(9999));
}
#[test]
fn equals_form_beats_env() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_PORT", "8080");
let cli: OptArgs =
OptArgs::try_parse_from(&mk_args(&["prog", "--port=9999"])).unwrap();
assert_eq!(cli.port, Some(9999));
}
#[test]
fn empty_env_falls_through() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_PORT", "");
let cli: OptArgs = OptArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
assert_eq!(cli.port, None);
}
#[derive(FdlArgs, Debug)]
struct ScalarArgs {
#[option(default = "3", env = "FDL_TEST_RETRIES")]
retries: u32,
}
#[test]
fn env_overrides_default_on_scalar() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
let cli: ScalarArgs = ScalarArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
assert_eq!(cli.retries, 7);
}
#[test]
fn argv_beats_env_beats_default_on_scalar() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_RETRIES", "7");
let cli: ScalarArgs =
ScalarArgs::try_parse_from(&mk_args(&["prog", "--retries", "42"])).unwrap();
assert_eq!(cli.retries, 42);
}
#[derive(FdlArgs, Debug)]
struct ChoiceArgs {
#[option(choices = &["a", "b"], env = "FDL_TEST_CHOICE")]
pick: Option<String>,
}
#[test]
fn env_value_is_validated_against_choices() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_CHOICE", "z"); let err = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap_err();
assert!(
err.contains("invalid value") && err.contains("z") && err.contains("allowed:"),
"env-sourced invalid choice should error like an argv one; got: {err}"
);
}
#[test]
fn env_valid_choice_accepted() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_CHOICE", "a");
let cli: ChoiceArgs = ChoiceArgs::try_parse_from(&mk_args(&["prog"])).unwrap();
assert_eq!(cli.pick.as_deref(), Some("a"));
}
#[derive(FdlArgs, Debug)]
struct ShortArgs {
#[option(short = 'p', env = "FDL_TEST_SHORT")]
port: Option<u16>,
}
#[test]
fn short_form_suppresses_env_fallback() {
let _lock = env_lock();
let _g = EnvGuard::set("FDL_TEST_SHORT", "8080");
let cli: ShortArgs =
ShortArgs::try_parse_from(&mk_args(&["prog", "-p", "9999"])).unwrap();
assert_eq!(cli.port, Some(9999));
}
}
#[cfg(test)]
mod enum_tests {
use crate::args::FdlArgsTrait;
use crate::FdlArgs;
fn mk_args(xs: &[&str]) -> Vec<String> {
xs.iter().map(|s| s.to_string()).collect()
}
#[derive(FdlArgs, Debug)]
struct TrainArgs {
#[option(short = 'n', default = "10")]
epochs: u32,
}
#[derive(FdlArgs, Debug)]
struct EvalArgs {
#[arg]
checkpoint: String,
}
#[derive(FdlArgs, Debug)]
enum Cli {
Train(TrainArgs),
Eval(EvalArgs),
#[command(name = "gen")]
Generate(TrainArgs),
}
#[test]
fn dispatches_to_variant_and_parses_its_flags() {
let cli = Cli::try_parse_from(&mk_args(&["prog", "train", "--epochs", "5"])).unwrap();
match cli {
Cli::Train(a) => assert_eq!(a.epochs, 5),
other => panic!("expected Train, got {other:?}"),
}
}
#[test]
fn variant_default_applies_when_flag_absent() {
let cli = Cli::try_parse_from(&mk_args(&["prog", "train"])).unwrap();
match cli {
Cli::Train(a) => assert_eq!(a.epochs, 10),
other => panic!("expected Train, got {other:?}"),
}
}
#[test]
fn dispatches_positional_to_variant() {
let cli = Cli::try_parse_from(&mk_args(&["prog", "eval", "model.fdl"])).unwrap();
match cli {
Cli::Eval(a) => assert_eq!(a.checkpoint, "model.fdl"),
other => panic!("expected Eval, got {other:?}"),
}
}
#[test]
fn command_name_override_is_honored() {
let cli = Cli::try_parse_from(&mk_args(&["prog", "gen"])).unwrap();
match cli {
Cli::Generate(a) => assert_eq!(a.epochs, 10),
other => panic!("`gen` must map to Generate, got {other:?}"),
}
let err = Cli::try_parse_from(&mk_args(&["prog", "generate"])).unwrap_err();
assert!(err.contains("unknown command"), "got: {err}");
}
#[test]
fn missing_command_errors_with_list() {
let err = Cli::try_parse_from(&mk_args(&["prog"])).unwrap_err();
assert!(
err.contains("missing command") && err.contains("train") && err.contains("eval"),
"got: {err}"
);
}
#[test]
fn unknown_command_suggests_close_match() {
let err = Cli::try_parse_from(&mk_args(&["prog", "trian"])).unwrap_err();
assert!(
err.contains("did you mean `train`"),
"near-miss must suggest; got: {err}"
);
}
#[test]
fn unknown_command_far_miss_lists_options() {
let err = Cli::try_parse_from(&mk_args(&["prog", "zzzzz"])).unwrap_err();
assert!(
err.contains("expected one of") && err.contains("train"),
"far miss must list commands; got: {err}"
);
}
#[test]
fn schema_is_a_branch_with_described_children() {
let s = Cli::schema();
assert!(s.args.is_empty() && s.options.is_empty(), "root is a branch, not a leaf");
assert_eq!(s.commands.len(), 3);
assert_eq!(
s.commands["train"].description.as_deref(),
Some("Train a letter model on a dataset")
);
assert!(s.commands["train"].options.contains_key("epochs"));
assert!(s.commands.contains_key("gen"));
crate::config::validate_schema(&s).expect("derived tree schema must validate");
}
#[test]
fn root_help_lists_commands() {
let help = Cli::render_help();
assert!(help.contains("Commands"), "root help has a Commands section");
assert!(help.contains("train") && help.contains("eval") && help.contains("gen"));
assert!(
help.contains("Train a letter model on a dataset"),
"command descriptions come from variant docs; got:\n{help}"
);
}
#[test]
fn help_path_renders_the_subcommands_help() {
let help = Cli::render_help_path(&mk_args(&["prog", "train", "--help"]));
assert!(help.contains("epochs"), "train help must show its flags; got:\n{help}");
assert!(!help.contains("Commands"), "must not fall back to the command list");
}
#[test]
fn help_path_falls_back_to_root_when_no_subcommand() {
let help = Cli::render_help_path(&mk_args(&["prog"]));
assert!(help.contains("Commands"), "bare --help shows the command list");
}
#[derive(FdlArgs, Debug)]
enum WordCli {
Train(TrainGroup),
Eval(EvalArgs),
}
#[derive(FdlArgs, Debug)]
enum TrainGroup {
Full(TrainArgs),
Subscan(TrainArgs),
}
#[test]
fn nested_enum_dispatches_two_levels() {
let cli =
WordCli::try_parse_from(&mk_args(&["prog", "train", "subscan", "--epochs", "3"]))
.unwrap();
match cli {
WordCli::Train(TrainGroup::Subscan(a)) => assert_eq!(a.epochs, 3),
other => panic!("expected Train>Subscan, got {other:?}"),
}
match WordCli::try_parse_from(&mk_args(&["prog", "train", "full"])).unwrap() {
WordCli::Train(TrainGroup::Full(a)) => assert_eq!(a.epochs, 10),
other => panic!("expected Train>Full, got {other:?}"),
}
match WordCli::try_parse_from(&mk_args(&["prog", "eval", "ckpt.fdl"])).unwrap() {
WordCli::Eval(a) => assert_eq!(a.checkpoint, "ckpt.fdl"),
other => panic!("expected Eval, got {other:?}"),
}
}
#[test]
fn nested_enum_schema_is_a_two_level_tree() {
let s = WordCli::schema();
let train = &s.commands["train"];
assert!(train.options.is_empty(), "the train node is itself a branch");
assert!(train.commands.contains_key("subscan"));
assert!(train.commands["full"].options.contains_key("epochs"));
crate::config::validate_schema(&s).expect("nested tree must validate");
}
#[test]
fn nested_enum_help_drills_to_leaf() {
let help =
WordCli::render_help_path(&mk_args(&["prog", "train", "subscan", "--help"]));
assert!(help.contains("epochs"), "must reach the leaf struct help; got:\n{help}");
}
}