use std::collections::BTreeMap;
use std::fs;
use std::path::PathBuf;
use std::process::Command;
use tempfile::TempDir;
fn resolve_with_config(config: &str, args: &[&str]) -> BTreeMap<String, String> {
let fixture = TempDir::new().expect("fixture root");
let home = fixture.path().join("sealed-home");
fs::create_dir_all(home.join(".codewhale")).expect("sealed config dir");
fs::write(home.join(".codewhale").join("config.toml"), config).expect("seed config");
let mut command = Command::new(codewhale_binary());
command.arg("model").arg("resolve").args(args);
let output = command
.env_clear()
.env("HOME", &home)
.env("USERPROFILE", &home)
.env("CODEWHALE_HOME", home.join(".codewhale"))
.env("CODEWHALE_SECRET_BACKEND", "file")
.output()
.expect("run model resolve");
assert!(
output.status.success(),
"model resolve {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(": "))
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
.collect()
}
fn resolve_failure_with_config(config: &str, args: &[&str]) -> std::process::Output {
let fixture = TempDir::new().expect("fixture root");
let home = fixture.path().join("sealed-home");
fs::create_dir_all(home.join(".codewhale")).expect("sealed config dir");
fs::write(home.join(".codewhale").join("config.toml"), config).expect("seed config");
Command::new(codewhale_binary())
.arg("model")
.arg("resolve")
.args(args)
.env_clear()
.env("HOME", &home)
.env("USERPROFILE", &home)
.env("CODEWHALE_HOME", home.join(".codewhale"))
.env("CODEWHALE_SECRET_BACKEND", "file")
.output()
.expect("run failing model resolve")
}
#[test]
fn resolve_reports_the_configured_provider_not_a_deepseek_fallback() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&[],
);
assert_eq!(
report.get("provider").map(String::as_str),
Some("zai"),
"configured provider must survive to the diagnostic: {report:?}"
);
assert_eq!(
report.get("provider_source").map(String::as_str),
Some("config"),
"provenance must name the config file: {report:?}"
);
}
#[test]
fn resolve_reports_a_provider_scoped_model_as_explicitly_configured() {
let report = resolve_with_config(
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\nmodel = \"kimi-k3-turbo\"\n",
&[],
);
assert_eq!(report.get("provider").map(String::as_str), Some("moonshot"));
assert_eq!(
report.get("requested").map(String::as_str),
Some("kimi-k3-turbo"),
"a configured model is a request, not a fallback: {report:?}"
);
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("false"),
"{report:?}"
);
assert_eq!(
report.get("model_source").map(String::as_str),
Some("config [providers.*].model"),
"{report:?}"
);
}
#[test]
fn resolve_admits_when_nothing_was_configured() {
let report = resolve_with_config("", &[]);
assert_eq!(
report.get("requested").map(String::as_str),
Some(""),
"an unconfigured model must not be presented as a request: {report:?}"
);
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("true"),
"{report:?}"
);
assert_eq!(
report.get("model_source").map(String::as_str),
Some("provider default"),
"{report:?}"
);
}
#[test]
fn a_foreign_model_argument_cannot_switch_the_configured_provider() {
let output = resolve_failure_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&["deepseek-v4-flash"],
);
assert!(!output.status.success());
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.to_ascii_lowercase();
assert!(
combined.contains("not available from provider 'zai'"),
"{combined}"
);
assert!(!combined.contains("provider: deepseek"), "{combined}");
}
#[test]
fn an_explicit_matching_provider_can_resolve_its_model() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&["deepseek-v4-flash", "--provider", "deepseek"],
);
assert_eq!(report.get("provider").map(String::as_str), Some("deepseek"));
assert_eq!(
report.get("requested").map(String::as_str),
Some("deepseek-v4-flash")
);
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("false")
);
assert_eq!(
report.get("provider_source").map(String::as_str),
Some("--provider")
);
}
#[test]
fn unknown_providerless_model_fails_without_printing_a_deepseek_route() {
let output = resolve_failure_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&["totally-unknown-model"],
);
assert!(!output.status.success(), "unknown model must fail closed");
let stdout = String::from_utf8_lossy(&output.stdout).to_ascii_lowercase();
let stderr = String::from_utf8_lossy(&output.stderr).to_ascii_lowercase();
let combined = format!("{stdout}\n{stderr}");
assert!(
combined.contains("not available from provider 'zai'"),
"{combined}"
);
assert!(!combined.contains("provider: deepseek"), "{combined}");
assert!(!combined.contains("deepseek-v4-pro"), "{combined}");
}
#[test]
fn an_explicit_provider_flag_is_reported_as_the_source() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&["--provider", "moonshot"],
);
assert_eq!(report.get("provider").map(String::as_str), Some("moonshot"));
assert_eq!(
report.get("provider_source").map(String::as_str),
Some("--provider"),
"{report:?}"
);
}
#[test]
fn explicit_openai_without_a_model_reports_its_own_documented_default() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\nmodel = \"GLM-5.2\"\n",
&["--provider", "openai"],
);
assert_eq!(report.get("provider").map(String::as_str), Some("openai"));
assert_eq!(
report.get("resolved").map(String::as_str),
Some("gpt-5.6"),
"an OpenAI query must use OpenAI's documented default, not the first catalog row or the configured Z.ai model: {report:?}"
);
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("true"),
"{report:?}"
);
assert_eq!(
report.get("provider_source").map(String::as_str),
Some("--provider"),
"{report:?}"
);
assert_eq!(
report.get("model_source").map(String::as_str),
Some("provider default"),
"the overridden Z.ai model provenance must not leak into the OpenAI hypothetical: {report:?}"
);
}
fn resolve_with_global_flags(
config: &str,
global: &[&str],
args: &[&str],
) -> BTreeMap<String, String> {
let fixture = TempDir::new().expect("fixture root");
let home = fixture.path().join("sealed-home");
fs::create_dir_all(home.join(".codewhale")).expect("sealed config dir");
fs::write(home.join(".codewhale").join("config.toml"), config).expect("seed config");
let mut command = Command::new(codewhale_binary());
command.args(global).arg("model").arg("resolve").args(args);
let output = command
.env_clear()
.env("HOME", &home)
.env("USERPROFILE", &home)
.env("CODEWHALE_HOME", home.join(".codewhale"))
.env("CODEWHALE_SECRET_BACKEND", "file")
.output()
.expect("run model resolve");
assert!(
output.status.success(),
"model resolve {global:?} {args:?} failed\nstdout:\n{}\nstderr:\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
);
String::from_utf8_lossy(&output.stdout)
.lines()
.filter_map(|line| line.split_once(": "))
.map(|(key, value)| (key.trim().to_string(), value.trim().to_string()))
.collect()
}
#[test]
fn top_level_provider_and_model_flags_report_the_runtime_route() {
let report = resolve_with_global_flags(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&["--provider", "moonshot", "--model", "kimi-k3"],
&[],
);
assert_eq!(report.get("provider").map(String::as_str), Some("moonshot"));
assert_eq!(
report.get("resolved").map(String::as_str),
Some("kimi-k3"),
"the diagnostic must not contradict the model the run will use: {report:?}"
);
assert_eq!(
report.get("requested").map(String::as_str),
Some("kimi-k3"),
"{report:?}"
);
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("false"),
"{report:?}"
);
assert_eq!(
report.get("model_source").map(String::as_str),
Some("--model"),
"{report:?}"
);
}
#[test]
fn moonshot_k3_products_resolve_without_crossing_providers() {
for model in ["kimi-k3", "k3"] {
let report = resolve_with_global_flags(
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\n",
&[],
&[model, "--provider", "moonshot"],
);
assert_eq!(
report.get("provider").map(String::as_str),
Some("moonshot"),
"a Moonshot question must not be answered by another provider: {report:?}"
);
assert_eq!(
report.get("resolved").map(String::as_str),
Some(model),
"{report:?}"
);
assert_eq!(
report.get("used_fallback").map(String::as_str),
Some("false"),
"{report:?}"
);
}
}
#[test]
fn an_unservable_model_on_the_selected_provider_is_rejected() {
let output = resolve_failure_with_config(
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\n",
&["glm-5.2", "--provider", "moonshot"],
);
assert!(!output.status.success());
let combined = format!(
"{}\n{}",
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr)
)
.to_ascii_lowercase();
assert!(
combined.contains("model 'glm-5.2' is not available from provider 'moonshot'"),
"{combined}"
);
}
#[test]
fn a_new_glm_sibling_is_servable_on_zai_but_not_on_moonshot() {
let served = resolve_with_global_flags(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&[],
&["glm-5.3", "--provider", "zai"],
);
assert_eq!(served.get("provider").map(String::as_str), Some("zai"));
assert_eq!(
served.get("resolved").map(String::as_str),
Some("GLM-5.3"),
"a catalogued model must resolve to itself, not to the provider default: {served:?}"
);
assert_eq!(
served.get("used_fallback").map(String::as_str),
Some("false"),
"a model the provider serves must not be reported as a fallback: {served:?}"
);
let refused = resolve_failure_with_config(
"provider = \"moonshot\"\n\n[providers.moonshot]\napi_key = \"k\"\n",
&["glm-5.3", "--provider", "moonshot"],
);
assert!(!refused.status.success());
let resolved = format!(
"{}\n{}",
String::from_utf8_lossy(&refused.stdout),
String::from_utf8_lossy(&refused.stderr)
)
.to_ascii_lowercase();
assert!(
resolved.contains("model 'glm-5.3' is not available from provider 'moonshot'"),
"a provider that cannot serve GLM must fail instead of choosing its default: {resolved}"
);
}
#[test]
fn the_openrouter_glm_sibling_resolves_to_its_own_gateway_wire_id() {
let served = resolve_with_global_flags(
"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"k\"\n",
&[],
&["glm-5.3", "--provider", "openrouter"],
);
assert_eq!(
served.get("provider").map(String::as_str),
Some("openrouter")
);
assert_eq!(
served.get("resolved").map(String::as_str),
Some("z-ai/glm-5.3"),
"the bare alias must be rewritten to the OpenRouter wire id, not passed through: {served:?}"
);
assert_eq!(
served.get("used_fallback").map(String::as_str),
Some("false"),
"a gateway row the provider serves must not be reported as a fallback: {served:?}"
);
let default_route = resolve_with_config(
"provider = \"openrouter\"\n\n[providers.openrouter]\napi_key = \"k\"\n",
&[],
);
let resolved = default_route
.get("resolved")
.map(String::as_str)
.unwrap_or_default();
assert!(
!resolved.to_ascii_lowercase().contains("glm"),
"adding a GLM sibling must not make GLM the OpenRouter default: {default_route:?}"
);
}
#[test]
fn zai_default_route_resolves_to_glm_5_3_with_provider_default_provenance() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\n",
&[],
);
assert_eq!(
report.get("resolved").map(String::as_str),
Some("GLM-5.3"),
"the Z.ai default is GLM-5.3: {report:?}"
);
assert_eq!(
report.get("model_source").map(String::as_str),
Some("provider default"),
"{report:?}"
);
}
#[test]
fn explicit_glm_5_2_selection_survives_the_default_move() {
let report = resolve_with_config(
"provider = \"zai\"\n\n[providers.zai]\napi_key = \"k\"\nmodel = \"GLM-5.2\"\n",
&[],
);
assert_eq!(
report.get("resolved").map(String::as_str),
Some("GLM-5.2"),
"an explicit GLM-5.2 route must not be upgraded: {report:?}"
);
assert_ne!(
report.get("model_source").map(String::as_str),
Some("provider default"),
"{report:?}"
);
}
fn codewhale_binary() -> PathBuf {
if let Some(path) = option_env!("CARGO_BIN_EXE_codewhale") {
return PathBuf::from(path);
}
if let Ok(path) = std::env::var("CARGO_BIN_EXE_codewhale") {
return PathBuf::from(path);
}
let mut path = std::env::current_exe().expect("current test executable path");
path.pop();
if path.ends_with("deps") {
path.pop();
}
path.push(format!("codewhale{}", std::env::consts::EXE_SUFFIX));
path
}