rotom 1.3.0

OpenAI- and Anthropic-compatible local API gateway backed by OAuth providers.
Documentation
use super::{
    AppConfig, DEFAULT_MODEL_FALLBACK, bind_from_config, build_update_command, credential_subject,
    daemon_endpoint_lines, format_login_provider_choice, format_models,
    new_provider_daemon_restart_hint, parse_login_provider_choice, resolve_model_fallback,
    resolve_status_providers, token_expiry_message,
};
use rotom::config::{Credentials, Provider, now_unix};
use rotom::timefmt::format_duration;

#[test]
fn reuses_shared_duration_formatting() {
    assert_eq!(format_duration(90_061), "1d 01h 01m 01s");
}

#[test]
fn builds_bind_address_from_config() {
    let config = AppConfig {
        bind_host: Some("127.0.0.1".into()),
        bind_port: Some(14550),
        model_fallback: None,
        ..AppConfig::default()
    };

    assert_eq!(
        bind_from_config(Some(&config)).map(|item| item.to_string()),
        Some("127.0.0.1:14550".to_owned())
    );
}

#[test]
fn builds_update_command_for_latest_release() {
    let command = build_update_command(None);
    let args = command
        .get_args()
        .map(|item| item.to_string_lossy().into_owned())
        .collect::<Vec<_>>();

    assert_eq!(args, ["install", "--locked", "--force", "rotom"]);
}

#[test]
fn builds_update_command_for_specific_version() {
    let command = build_update_command(Some("0.3.3"));
    let args = command
        .get_args()
        .map(|item| item.to_string_lossy().into_owned())
        .collect::<Vec<_>>();

    assert_eq!(
        args,
        [
            "install",
            "--locked",
            "--force",
            "rotom",
            "--version",
            "0.3.3"
        ]
    );
}

#[test]
fn uses_default_model_fallback_when_unset() {
    assert_eq!(
        resolve_model_fallback(None, None),
        Some(DEFAULT_MODEL_FALLBACK.to_owned())
    );
}

#[test]
fn prefers_explicit_model_fallback_over_default() {
    let config = AppConfig {
        model_fallback: Some("gpt-5.4".into()),
        ..AppConfig::default()
    };

    assert_eq!(
        resolve_model_fallback(Some("gpt-5.3-codex".into()), Some(&config)),
        Some("gpt-5.3-codex".into())
    );
    assert_eq!(
        resolve_model_fallback(None, Some(&config)),
        Some("gpt-5.4".into())
    );
}

#[test]
fn formats_models_grouped_by_provider() {
    let output = format_models(&[Provider::Codex, Provider::Grok]);

    assert!(output.contains("OpenAI (codex)\n  gpt-5.1"));
    assert!(output.contains("Grok (grok)\n  grok-4.3"));
    assert!(output.contains("\n\nGrok (grok)"));
}

#[test]
fn formats_single_provider_models() {
    let output = format_models(&[Provider::Grok]);

    assert!(output.starts_with("Grok (grok)\n"));
    assert!(output.contains("  grok-4\n"));
    assert!(!output.contains("gpt-5.5"));
}

#[test]
fn parses_login_provider_choices() {
    assert_eq!(parse_login_provider_choice("").unwrap(), Provider::Codex);
    assert_eq!(parse_login_provider_choice("1").unwrap(), Provider::Codex);
    assert_eq!(parse_login_provider_choice("2").unwrap(), Provider::Grok);
    assert_eq!(
        parse_login_provider_choice("openai").unwrap(),
        Provider::Codex
    );
    assert_eq!(parse_login_provider_choice("grok").unwrap(), Provider::Grok);
}

#[test]
fn formats_login_provider_choice_with_status() {
    let credentials = vec![Credentials {
        provider: Provider::Codex,
        access_token: "access".into(),
        refresh_token: "refresh".into(),
        expires_at: now_unix() + 90,
        account_id: "account".into(),
    }];

    let openai = format_login_provider_choice(Provider::Codex, &credentials);
    let grok = format_login_provider_choice(Provider::Grok, &credentials);

    assert!(openai.starts_with("openai (logged in, expires in "));
    assert_eq!(grok, "grok");
}

#[test]
fn credential_subject_does_not_include_account_id() {
    let credentials = Credentials {
        provider: Provider::Codex,
        access_token: "access".into(),
        refresh_token: "refresh".into(),
        expires_at: now_unix() + 90,
        account_id: "account-secret".into(),
    };

    assert_eq!(credential_subject(&credentials), "Codex credentials");
    let token_message = token_expiry_message(&credentials);
    assert!(token_message.contains("(Codex credentials)"));
    assert!(!token_message.contains("account-secret"));
}

#[test]
fn resolves_all_saved_status_providers_by_default() {
    let providers = resolve_status_providers(
        vec![
            Credentials {
                provider: Provider::Grok,
                access_token: "access".into(),
                refresh_token: "refresh".into(),
                expires_at: now_unix() + 90,
                account_id: String::new(),
            },
            Credentials {
                provider: Provider::Codex,
                access_token: "access".into(),
                refresh_token: "refresh".into(),
                expires_at: now_unix() + 90,
                account_id: "account".into(),
            },
        ],
        None,
    )
    .unwrap();

    assert_eq!(providers, [Provider::Codex, Provider::Grok]);
}

#[test]
fn filters_status_provider_when_requested() {
    let providers = resolve_status_providers(
        vec![
            Credentials {
                provider: Provider::Codex,
                access_token: "access".into(),
                refresh_token: "refresh".into(),
                expires_at: now_unix() + 90,
                account_id: "account".into(),
            },
            Credentials {
                provider: Provider::Grok,
                access_token: "access".into(),
                refresh_token: "refresh".into(),
                expires_at: now_unix() + 90,
                account_id: String::new(),
            },
        ],
        Some(Provider::Grok),
    )
    .unwrap();

    assert_eq!(providers, [Provider::Grok]);
}

#[test]
fn rejects_missing_status_provider() {
    assert!(
        resolve_status_providers(
            vec![Credentials {
                provider: Provider::Codex,
                access_token: "access".into(),
                refresh_token: "refresh".into(),
                expires_at: now_unix() + 90,
                account_id: "account".into(),
            }],
            Some(Provider::Grok),
        )
        .is_err()
    );
}

#[test]
fn formats_daemon_endpoint_lines_with_base_url() {
    let lines = daemon_endpoint_lines("http://127.0.0.1:14550");

    assert!(lines.contains(&"  GET        http://127.0.0.1:14550/health".to_owned()));
    assert!(lines.contains(&"  POST       http://127.0.0.1:14550/v1/chat/completions".to_owned()));
    assert!(lines.contains(&"  GET,POST   http://127.0.0.1:14550/v1/messages/batches".to_owned()));
    assert!(
        lines.contains(&"  POST       http://127.0.0.1:14550/v1/images/generations".to_owned())
    );
}

#[test]
fn formats_new_provider_daemon_restart_hint() {
    assert_eq!(
        new_provider_daemon_restart_hint(Provider::Grok),
        "If rotom daemon is already running, run `rotom daemon restart` to serve newly logged-in Grok models."
    );
}