standout-input 10.0.0

Declarative input collection for CLI applications
Documentation
use clap::{Arg, Command};
use standout_input::{
    ArgSource, ClipboardSource, EnvSource, FlagSource, InputChain, InputError, InputSourceKind,
    MockClipboard, MockEnv, MockStdin, StdinSource,
};

fn create_test_command() -> Command {
    Command::new("test")
        .arg(Arg::new("message").long("message").short('m'))
        .arg(Arg::new("body").long("body").short('b'))
        .arg(
            Arg::new("yes")
                .long("yes")
                .short('y')
                .action(clap::ArgAction::SetTrue),
        )
        .arg(
            Arg::new("no-editor")
                .long("no-editor")
                .action(clap::ArgAction::SetTrue),
        )
}

#[test]
fn gh_pattern_arg_provided() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--body", "from argument"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("body"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .default("from default".to_string());

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "from argument");
    assert_eq!(result.source, InputSourceKind::Arg);
}

#[test]
fn gh_pattern_stdin_piped() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("body"))
        .try_source(StdinSource::with_reader(MockStdin::piped("from stdin")))
        .default("from default".to_string());

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "from stdin");
    assert_eq!(result.source, InputSourceKind::Stdin);
}

#[test]
fn gh_pattern_falls_through_to_default() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("body"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .default("from default".to_string());

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "from default");
    assert_eq!(result.source, InputSourceKind::Default);
}

#[test]
fn confirmation_with_yes_flag() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--yes"])
        .unwrap();

    let chain = InputChain::<bool>::new()
        .try_source(FlagSource::new("yes"))
        .default(false);

    let result = chain.resolve(&matches).unwrap();
    assert!(result);
}

#[test]
fn confirmation_without_flag_uses_default() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<bool>::new()
        .try_source(FlagSource::new("yes"))
        .default(false);

    let result = chain.resolve(&matches).unwrap();
    assert!(!result);
}

#[test]
fn inverted_flag_for_no_editor() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--no-editor"])
        .unwrap();

    let chain = InputChain::<bool>::new()
        .try_source(FlagSource::new("no-editor").inverted())
        .default(true);

    let result = chain.resolve(&matches).unwrap();
    assert!(!result);
}

#[test]
fn env_var_priority_over_default() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let env = MockEnv::new().with_var("MY_TOKEN", "secret-from-env");

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(EnvSource::with_reader("MY_TOKEN", env))
        .default("no-token".to_string());

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "secret-from-env");
    assert_eq!(result.source, InputSourceKind::Env);
}

#[test]
fn arg_overrides_env_var() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--message", "from-arg"])
        .unwrap();

    let env = MockEnv::new().with_var("MY_TOKEN", "secret-from-env");

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(EnvSource::with_reader("MY_TOKEN", env))
        .default("no-token".to_string());

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "from-arg");
    assert_eq!(result.source, InputSourceKind::Arg);
}

#[test]
fn clipboard_as_fallback() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .try_source(ClipboardSource::with_reader(MockClipboard::with_content(
            "clipboard content",
        )));

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "clipboard content");
    assert_eq!(result.source, InputSourceKind::Clipboard);
}

#[test]
fn empty_clipboard_falls_through() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(ClipboardSource::with_reader(MockClipboard::empty()))
        .default("fallback".to_string());

    let result = chain.resolve_with_source(&matches).unwrap();
    assert_eq!(result.value, "fallback");
    assert_eq!(result.source, InputSourceKind::Default);
}

#[test]
fn validation_passes() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--message", "user@example.com"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .validate(|s| s.contains('@'), "Must be an email");

    let result = chain.resolve(&matches);
    assert!(result.is_ok());
    assert_eq!(result.unwrap(), "user@example.com");
}

#[test]
fn validation_fails_with_error() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--message", "not-an-email"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .validate(|s| s.contains('@'), "Must be an email");

    let result = chain.resolve(&matches);
    assert!(matches!(result, Err(InputError::ValidationFailed(_))));
}

#[test]
fn multiple_validations_all_pass() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--message", "hello@world.com"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .validate(|s| !s.is_empty(), "Cannot be empty")
        .validate(|s| s.contains('@'), "Must contain @")
        .validate(|s| s.len() >= 5, "Must be at least 5 chars");

    let result = chain.resolve(&matches);
    assert!(result.is_ok());
}

#[test]
fn multiple_validations_first_fails() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--message", ""])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::piped("")))
        .default("".to_string())
        .validate(|s| !s.is_empty(), "Cannot be empty");

    let result = chain.resolve(&matches);
    assert!(matches!(result, Err(InputError::ValidationFailed(_))));
}

#[test]
fn no_input_returns_error() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .try_source(EnvSource::with_reader("MISSING", MockEnv::new()));

    let result = chain.resolve(&matches);
    assert!(matches!(result, Err(InputError::NoInput)));
}

#[test]
fn complex_chain_priority() {
    let matches = create_test_command()
        .try_get_matches_from(["test", "--message", "from-arg"])
        .unwrap();

    let chain = build_complex_chain("env-value", "clipboard-value");
    assert_eq!(chain.resolve(&matches).unwrap(), "from-arg");

    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::piped("from-stdin")))
        .try_source(EnvSource::with_reader(
            "MY_VAR",
            MockEnv::new().with_var("MY_VAR", "env-value"),
        ))
        .try_source(ClipboardSource::with_reader(MockClipboard::with_content(
            "clipboard-value",
        )))
        .default("default-value".to_string());

    assert_eq!(chain.resolve(&matches).unwrap(), "from-stdin");

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .try_source(EnvSource::with_reader(
            "MY_VAR",
            MockEnv::new().with_var("MY_VAR", "env-value"),
        ))
        .try_source(ClipboardSource::with_reader(MockClipboard::with_content(
            "clipboard-value",
        )))
        .default("default-value".to_string());

    assert_eq!(chain.resolve(&matches).unwrap(), "env-value");

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .try_source(EnvSource::with_reader("MY_VAR", MockEnv::new()))
        .try_source(ClipboardSource::with_reader(MockClipboard::with_content(
            "clipboard-value",
        )))
        .default("default-value".to_string());

    assert_eq!(chain.resolve(&matches).unwrap(), "clipboard-value");

    let chain = InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .try_source(EnvSource::with_reader("MY_VAR", MockEnv::new()))
        .try_source(ClipboardSource::with_reader(MockClipboard::empty()))
        .default("default-value".to_string());

    assert_eq!(chain.resolve(&matches).unwrap(), "default-value");
}

fn build_complex_chain(env_value: &str, clipboard_value: &str) -> InputChain<String> {
    InputChain::<String>::new()
        .try_source(ArgSource::new("message"))
        .try_source(StdinSource::with_reader(MockStdin::terminal()))
        .try_source(EnvSource::with_reader(
            "MY_VAR",
            MockEnv::new().with_var("MY_VAR", env_value),
        ))
        .try_source(ClipboardSource::with_reader(MockClipboard::with_content(
            clipboard_value,
        )))
        .default("default-value".to_string())
}

#[test]
fn mock_ensures_consistent_behavior_in_ci() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let ci_stdin = MockStdin::terminal();
    let chain = InputChain::<String>::new()
        .try_source(StdinSource::with_reader(ci_stdin))
        .default("ci-default".to_string());

    assert_eq!(chain.resolve(&matches).unwrap(), "ci-default");

    let piped_stdin = MockStdin::piped("piped-content");
    let chain = InputChain::<String>::new()
        .try_source(StdinSource::with_reader(piped_stdin))
        .default("ci-default".to_string());

    assert_eq!(chain.resolve(&matches).unwrap(), "piped-content");
}

#[test]
fn mock_stdin_preserves_whitespace_when_configured() {
    let matches = create_test_command()
        .try_get_matches_from(["test"])
        .unwrap();

    let chain = InputChain::<String>::new()
        .try_source(StdinSource::with_reader(MockStdin::piped("  hello  \n")));
    assert_eq!(chain.resolve(&matches).unwrap(), "hello");

    let chain = InputChain::<String>::new()
        .try_source(StdinSource::with_reader(MockStdin::piped("  hello  \n")).trim(false));
    assert_eq!(chain.resolve(&matches).unwrap(), "  hello  \n");
}