use std::process::Command;
fn opi_bin() -> String {
let manifest = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"));
let workspace_root = manifest.parent().unwrap().parent().unwrap();
for profile in &["debug", "release"] {
let mut path = workspace_root.join("target").join(profile).join("opi");
if cfg!(windows) {
path.set_extension("exe");
}
if path.exists() {
return path.to_string_lossy().into_owned();
}
}
let mut path = workspace_root.join("target/debug/opi");
if cfg!(windows) {
path.set_extension("exe");
}
path.to_string_lossy().into_owned()
}
fn run_opi(args: &[&str], envs: &[(&str, &str)]) -> std::process::Output {
let bin = opi_bin();
let tmp = tempfile::tempdir().unwrap();
let mut cmd = Command::new(&bin);
cmd.args(args).current_dir(tmp.path()).env_clear();
for (k, v) in envs {
cmd.env(k, v);
}
cmd.output()
.unwrap_or_else(|e| panic!("failed to run {bin}: {e}"))
}
fn run_opi_with_config(
config_toml: &str,
extra_args: &[&str],
envs: &[(&str, &str)],
) -> std::process::Output {
let bin = opi_bin();
let tmp = tempfile::tempdir().unwrap();
let config_path = tmp.path().join("test-config.toml");
std::fs::write(&config_path, config_toml).unwrap();
let mut args = vec![
"--config".to_string(),
config_path.to_string_lossy().into_owned(),
];
for a in extra_args {
args.push((*a).to_string());
}
let mut cmd = Command::new(&bin);
cmd.args(&args).current_dir(tmp.path()).env_clear();
for (k, v) in envs {
cmd.env(k, v);
}
cmd.output()
.unwrap_or_else(|e| panic!("failed to run {bin}: {e}"))
}
#[test]
fn list_models_without_credentials_exits_nonzero() {
let output = run_opi(&["--list-models"], &[]);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
!output.status.success(),
"expected non-zero exit without credentials, got {:?}\nstderr: {stderr}",
output.status.code()
);
assert!(
stderr.contains("no models available"),
"stderr should mention no models available, got: {stderr}"
);
}
#[test]
fn list_models_with_anthropic_key_outputs_models() {
let output = run_opi(
&["--list-models"],
&[("ANTHROPIC_API_KEY", "test-key-for-listing")],
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected exit 0 with ANTHROPIC_API_KEY, got {:?}\nstdout: {stdout}\nstderr: {stderr}",
output.status.code()
);
assert!(
stdout.contains("anthropic"),
"output should mention 'anthropic' provider, got: {stdout}"
);
assert!(
stdout.contains("claude"),
"output should contain claude model IDs, got: {stdout}"
);
}
#[test]
fn list_models_json_outputs_ndjson() {
let output = run_opi(
&["--list-models", "--json"],
&[("ANTHROPIC_API_KEY", "test-key-for-listing")],
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected exit 0, got {:?}\nstdout: {stdout}\nstderr: {stderr}",
output.status.code()
);
let mut found_anthropic = false;
for line in stdout.lines() {
if line.trim().is_empty() {
continue;
}
let v: serde_json::Value = serde_json::from_str(line)
.unwrap_or_else(|e| panic!("line is not valid JSON: {line}\nerror: {e}"));
assert!(
v.get("model").is_some(),
"JSON line missing 'model' field: {line}"
);
assert!(
v.get("provider").is_some(),
"JSON line missing 'provider' field: {line}"
);
assert!(
v.get("display_name").is_some(),
"JSON line missing 'display_name' field: {line}"
);
if v["provider"].as_str() == Some("anthropic") {
found_anthropic = true;
}
}
assert!(
found_anthropic,
"expected at least one anthropic model in JSON output"
);
}
#[test]
fn list_models_includes_provider_column() {
let output = run_opi(
&["--list-models"],
&[("ANTHROPIC_API_KEY", "test-key-for-listing")],
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected exit 0, got {:?}",
output.status.code()
);
let lines: Vec<&str> = stdout.lines().collect();
assert!(
lines.len() >= 3,
"expected at least header + separator + 1 model, got {} lines",
lines.len()
);
assert!(
lines[0].contains("PROVIDER"),
"header should contain PROVIDER, got: {}",
lines[0]
);
assert!(
lines[0].contains("MODEL ID"),
"header should contain MODEL ID, got: {}",
lines[0]
);
}
#[test]
fn list_models_invalid_proxy_exits_config_error() {
let output = run_opi_with_config(
r#"
[providers.anthropic]
api_key_env = "ANTHROPIC_API_KEY"
[providers.anthropic.proxy]
url = "not a proxy url"
"#,
&["--list-models"],
&[("ANTHROPIC_API_KEY", "test-key-for-listing")],
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
let code = output.status.code();
assert!(
code == Some(2),
"expected exit code 2 for config error, got {code:?}\nstdout: {stdout}\nstderr: {stderr}",
);
assert!(
stderr.contains("config error"),
"stderr should mention config error, got: {stderr}",
);
assert!(
stderr.contains("failed to build HTTP client with proxy config"),
"stderr should mention proxy config failure, got: {stderr}",
);
assert!(
stdout.is_empty(),
"stdout should be empty on config error, got: {stdout}",
);
}
#[test]
fn list_models_valid_proxy_with_credentials_succeeds() {
let output = run_opi_with_config(
r#"
[providers.anthropic]
api_key_env = "ANTHROPIC_API_KEY"
[providers.anthropic.proxy]
url = "http://proxy.example.com:8080"
"#,
&["--list-models"],
&[("ANTHROPIC_API_KEY", "test-key-for-listing")],
);
let stdout = String::from_utf8_lossy(&output.stdout);
let stderr = String::from_utf8_lossy(&output.stderr);
assert!(
output.status.success(),
"expected exit 0 with valid proxy config, got {:?}\nstdout: {stdout}\nstderr: {stderr}",
output.status.code()
);
assert!(
stdout.contains("claude"),
"output should contain claude model IDs, got: {stdout}",
);
}
#[test]
fn list_models_missing_credentials_skips_provider_silently() {
let output = run_opi_with_config(
r#"
[providers.anthropic]
api_key_env = "ANTHROPIC_API_KEY"
"#,
&["--list-models"],
&[("OPENAI_API_KEY", "test-key-for-listing")],
);
let stdout = String::from_utf8_lossy(&output.stdout);
assert!(
output.status.success(),
"expected exit 0, got {:?}",
output.status.code()
);
assert!(
stdout.contains("openai"),
"output should contain openai models, got: {stdout}",
);
}