figue 2.0.5

Type-safe CLI arguments, config files, and environment variables powered by Facet reflection
Documentation
use crate::assert_diag_snapshot;
use facet::Facet;
use figue as args;

#[test]
fn test_arg_parse_easy() {
    #[derive(Facet)]
    struct Args {
        #[facet(args::positional)]
        path: String,

        #[facet(args::named, args::short = 'v')]
        verbose: bool,

        #[facet(args::named, args::short = 'j')]
        concurrency: usize,

        #[facet(args::named, args::short = 'x')]
        consider_casing: usize,
    }

    let args: Args = figue::from_slice(&[
        "--verbose",
        "-j",
        "14",
        "--consider-casing",
        "0",
        "example.rs",
    ])
    .unwrap();
    assert!(args.verbose);
    assert_eq!(args.path, "example.rs");
    assert_eq!(args.concurrency, 14);
    assert_eq!(args.consider_casing, 0);
}

#[test]
fn test_arg_parse_nums() {
    #[derive(Facet)]
    struct Args {
        #[facet(args::named, args::short)]
        x: i64,

        #[facet(args::named, args::short)]
        y: u64,

        #[facet(args::named, args::short = 'z')]
        zzz: f64,
    }

    let args: Args = figue::from_slice(&["-x", "1", "-y", "2", "-z", "3"]).unwrap();
    assert_eq!(args.x, 1);
    assert_eq!(args.y, 2);
    assert_eq!(args.zzz, 3.0);
}

#[test]
fn test_missing_bool_is_false() {
    #[derive(Facet)]
    struct Args {
        #[facet(args::named, args::short = 'v')]
        verbose: bool,
        #[facet(args::positional)]
        path: String,
    }
    let args: Args = figue::from_slice(&["absence_is_falsey.rs"]).unwrap();
    assert!(!args.verbose);
}

#[test]
fn test_missing_default() {
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::positional, default = 42)]
        answer: usize,
        #[facet(args::named, args::short = 'p')]
        path: String,
    }

    let args: Args = figue::from_slice(&["-p", "absence_uses_default.rs"]).unwrap();
    assert_eq!(args.answer, 42);
    assert_eq!(args.path, "absence_uses_default.rs".to_string());

    let args: Args = figue::from_slice(&["100", "-p", "presence_overrides_default.rs"]).unwrap();
    assert_eq!(args.answer, 100);
    assert_eq!(args.path, "presence_overrides_default.rs".to_string());
}

#[test]
fn test_missing_default_fn() {
    // Could be done e.g. using `num_cpus::get()`, but just mock it as 2 + 2 = 4
    fn default_concurrency() -> usize {
        2 + 2
    }

    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named, args::short = 'p')]
        path: String,
        #[facet(args::named, args::short = 'j', default = default_concurrency())]
        concurrency: usize,
    }

    let args: Args = figue::from_slice(&["-p", "absence_uses_default_fn.rs"]).unwrap();
    assert_eq!(args.path, "absence_uses_default_fn.rs".to_string());
    assert_eq!(args.concurrency, 4);

    let args: Args =
        figue::from_slice(&["-p", "presence_overrides_default_fn.rs", "-j", "2"]).unwrap();
    assert_eq!(args.path, "presence_overrides_default_fn.rs".to_string());
    assert_eq!(args.concurrency, 2);
}

#[test]
fn test_inf_float_parsing() {
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named)]
        rate: f64,
    }
    let args: Args = figue::from_slice(&["--rate", "infinity"]).unwrap();
    assert_eq!(args.rate, f64::INFINITY);
}

#[test]
fn test_short_rename() {
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named, args::short = 'j')]
        concurrency: i64,
    }
    let args: Args = figue::from_slice(&["-j", "4"]).unwrap();
    assert_eq!(args.concurrency, 4);
}

#[test]
fn test_bool_str_before() {
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named)]
        foo: bool,
        #[facet(args::named)]
        hello: String,
    }
    let args: Args = figue::from_slice(&["--foo", "--hello", "world"]).unwrap();
    assert!(args.foo);
    assert_eq!(args.hello, "world".to_string());
}

#[test]
fn test_option_string_positional() {
    // Repro case for Option<String> positional argument
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::positional, default)]
        path: Option<String>,
    }

    // Test with a value
    let args: Args = figue::from_slice(&["."]).unwrap();
    assert_eq!(args.path, Some(".".to_string()));

    // Test without a value (should default to None)
    let args: Args = figue::from_slice(&[]).unwrap();
    assert_eq!(args.path, None);
}

/// Regression test for issue #1193: String field defaults should work with string literals
#[test]
fn test_string_default_value() {
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named)]
        name: String,
        // String literals work directly via Into<String>
        #[facet(args::named, default = "0.0.0-test")]
        version: String,
    }

    // Test that when version is not provided, it uses the default
    let args: Args = figue::from_slice(&["--name", "myapp"]).unwrap();
    assert_eq!(args.name, "myapp");
    assert_eq!(args.version, "0.0.0-test");

    // Test that when version is provided, it overrides the default
    let args: Args = figue::from_slice(&["--name", "myapp", "--version", "1.0.0"]).unwrap();
    assert_eq!(args.name, "myapp");
    assert_eq!(args.version, "1.0.0");
}

/// Test that default values work for IP addresses
#[test]
fn test_ip_address_default_value() {
    use std::net::{IpAddr, Ipv4Addr};

    #[derive(Facet, Debug)]
    struct ServerConfig {
        #[facet(args::named)]
        name: String,
        // IP address from string literal
        #[facet(args::named, default = "127.0.0.1")]
        bind_addr: IpAddr,
    }

    // Test that when bind_addr is not provided, it uses the default
    let config: ServerConfig = figue::from_slice(&["--name", "myserver"]).unwrap();
    assert_eq!(config.name, "myserver");
    assert_eq!(config.bind_addr, IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)));

    // Test that when bind_addr is provided, it overrides the default
    let config: ServerConfig =
        figue::from_slice(&["--name", "myserver", "--bind-addr", "0.0.0.0"]).unwrap();
    assert_eq!(config.bind_addr, IpAddr::V4(Ipv4Addr::new(0, 0, 0, 0)));
}

/// Test that default values work for PathBuf
#[test]
fn test_pathbuf_default_value() {
    use std::path::PathBuf;

    #[derive(Facet, Debug)]
    struct FileConfig {
        #[facet(args::named)]
        name: String,
        #[facet(args::named, default = "/tmp/default.log")]
        log_path: PathBuf,
    }

    // Test that when log_path is not provided, it uses the default
    let config: FileConfig = figue::from_slice(&["--name", "myapp"]).unwrap();
    assert_eq!(config.name, "myapp");
    assert_eq!(config.log_path, PathBuf::from("/tmp/default.log"));

    // Test that when log_path is provided, it overrides the default
    let config: FileConfig =
        figue::from_slice(&["--name", "myapp", "--log-path", "/var/log/app.log"]).unwrap();
    assert_eq!(config.name, "myapp");
    assert_eq!(config.log_path, PathBuf::from("/var/log/app.log"));
}

/// Regression test for issue #1348: Option<T> with args::named should be optional
#[test]
fn test_option_named_struct() {
    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named)]
        name: String,
        #[facet(args::named)]
        filter: Option<String>,
    }

    // Test without the --filter flag (should succeed with None)
    let args: Args = figue::from_slice(&["--name", "test"]).unwrap();
    assert_eq!(args.name, "test");
    assert_eq!(args.filter, None);

    // Test with the --filter flag (should succeed with Some)
    let args: Args = figue::from_slice(&["--name", "test", "--filter", "active"]).unwrap();
    assert_eq!(args.name, "test");
    assert_eq!(args.filter, Some("active".to_string()));
}

/// Regression test for issue #1348: Option<String> with args::named in enum variant
#[test]
fn test_option_named_enum_variant() {
    #[derive(Facet, Debug)]
    #[repr(u8)]
    enum CliCommand {
        Explain {
            /// Explain only this specific node ID
            #[facet(args::named)]
            node: Option<String>,
        },
    }

    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::subcommand)]
        command: CliCommand,
    }

    // Test without the --node flag (should succeed with None)
    let args: Args = figue::from_slice(&["explain"]).unwrap();
    match args.command {
        CliCommand::Explain { node } => {
            assert_eq!(node, None);
        }
    }

    // Test with the --node flag (should succeed with Some)
    let args: Args = figue::from_slice(&["explain", "--node", "42"]).unwrap();
    match args.command {
        CliCommand::Explain { node } => {
            assert_eq!(node, Some("42".to_string()));
        }
    }
}

/// Test that demonstrates the helpful error when using from_slice with FigueBuiltins
/// and --completions flag without specifying a shell. The error message now shows valid values.
#[test]
fn test_from_slice_with_completions_shows_helpful_error() {
    use figue::FigueBuiltins;

    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named)]
        name: String,

        #[facet(flatten)]
        builtins: FigueBuiltins,
    }

    // When using from_slice with --completions but no shell argument, the error is helpful
    let err = figue::from_slice::<Args>(&["--name", "test", "--completions"]).unwrap_err();

    // Snapshot the error to show that it now helpfully lists valid values
    // It says "requires one of: bash, zsh, fish"
    assert_diag_snapshot!(err);
}

/// Test that help text shows enum variants for --completions flag.
#[test]
fn test_completions_help_shows_enum_variants() {
    use figue::FigueBuiltins;

    #[derive(Facet, Debug)]
    struct Args {
        #[facet(args::named)]
        name: String,

        #[facet(flatten)]
        builtins: FigueBuiltins,
    }

    let help = figue::generate_help::<Args>(&figue::HelpConfig::default());

    // Snapshot the help to show that --completions displays <bash,zsh,fish>
    assert_diag_snapshot!(help);
}