use serde_json::{json, Value};
use std::path::PathBuf;
use std::process::Output;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
const PRODUCTION_URL: &str = "https://anvil.fangorn.io";
struct ConfigHome {
root: PathBuf,
}
impl ConfigHome {
fn new(tag: &str) -> Self {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let root = std::env::temp_dir().join(format!("anvil-defaulturl-{tag}-{nanos}"));
std::fs::create_dir_all(&root).expect("create scratch config dir");
Self { root }
}
fn seed(self, contents: Value) -> Self {
std::fs::write(
self.config_path(),
serde_json::to_string_pretty(&contents).unwrap(),
)
.expect("seed config.json");
self
}
fn config_path(&self) -> PathBuf {
self.root.join("config.json")
}
fn run(&self, args: &[&str]) -> Output {
self.run_env(args, &[])
}
fn run_env(&self, args: &[&str], env: &[(&str, &str)]) -> Output {
let mut cmd = std::process::Command::new(env!("CARGO_BIN_EXE_anvil"));
cmd.args(args)
.env("ANVIL_CONFIG", self.config_path())
.env_remove("ANVIL_TOKEN")
.env_remove("ANVIL_SERVER_URL");
for (k, v) in env {
cmd.env(k, v);
}
cmd.output().expect("run anvil")
}
}
impl Drop for ConfigHome {
fn drop(&mut self) {
let _ = std::fs::remove_dir_all(&self.root);
}
}
fn stdout_json(out: &Output) -> Value {
let s = String::from_utf8_lossy(&out.stdout);
serde_json::from_str(&s).unwrap_or_else(|e| {
panic!(
"stdout is not valid JSON ({e}):\nSTDOUT:\n{s}\nSTDERR:\n{}",
String::from_utf8_lossy(&out.stderr)
)
})
}
fn stderr(out: &Output) -> String {
String::from_utf8_lossy(&out.stderr).to_string()
}
#[test]
fn no_config_file_resolves_production_url() {
let home = ConfigHome::new("no-config");
assert!(
!home.config_path().exists(),
"precondition: no config file on disk"
);
let out = home.run(&["--json", "auth", "status"]);
let v = stdout_json(&out);
assert_eq!(v["server"], json!(PRODUCTION_URL));
assert!(
out.status.success(),
"auth status must not fail without a config file; stderr:\n{}",
stderr(&out)
);
}
#[test]
fn config_without_server_url_resolves_production_url() {
let home = ConfigHome::new("null-url").seed(json!({
"server_url": null,
"token": null,
"default_repo": "fangorn/anvil-cli",
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["server"], json!(PRODUCTION_URL));
}
#[test]
fn config_file_server_url_overrides_the_default() {
let home = ConfigHome::new("file-wins").seed(json!({
"server_url": "https://staging.anvil.example",
"token": "t",
"default_repo": null,
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["server"], json!("https://staging.anvil.example"));
}
#[test]
fn env_server_url_overrides_the_config_file() {
let home = ConfigHome::new("env-wins").seed(json!({
"server_url": "https://staging.anvil.example",
"token": "t",
"default_repo": null,
}));
let v = stdout_json(&home.run_env(
&["--json", "auth", "status"],
&[("ANVIL_SERVER_URL", "http://localhost:4000")],
));
assert_eq!(v["server"], json!("http://localhost:4000"));
}
#[test]
fn env_server_url_overrides_the_default() {
let home = ConfigHome::new("env-over-default");
let v = stdout_json(&home.run_env(
&["--json", "auth", "status"],
&[("ANVIL_SERVER_URL", "http://localhost:4000")],
));
assert_eq!(v["server"], json!("http://localhost:4000"));
}
#[test]
fn resolving_the_default_does_not_persist_it() {
let home = ConfigHome::new("no-persist");
let _ = home.run(&["--json", "auth", "status"]);
assert!(
!home.config_path().exists(),
"auth status must not create a config file just by resolving the default"
);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn update_check_succeeds_with_no_config_and_no_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/runner/version"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({
"version": "2026.07.9",
"platforms": ["linux_arm64"],
})))
.mount(&server)
.await;
let home = ConfigHome::new("update-nologin");
let out = home.run_env(
&["--json", "update", "--check"],
&[("ANVIL_SERVER_URL", &server.uri())],
);
let err = stderr(&out);
assert!(
!err.contains("not logged in"),
"update sends no token and must not demand a login; stderr:\n{err}"
);
assert!(
out.status.success(),
"update --check must succeed without credentials; stderr:\n{err}"
);
let v = stdout_json(&out);
assert_eq!(v["latest"], json!("2026.07.9"), "payload: {v}");
assert_eq!(v["update_available"], json!(true), "payload: {v}");
}
#[tokio::test(flavor = "multi_thread", worker_threads = 2)]
async fn authenticated_command_still_requires_a_token() {
let server = MockServer::start().await;
Mock::given(method("GET"))
.and(path("/api/v1/fangorn/anvil-cli/issues"))
.respond_with(ResponseTemplate::new(200).set_body_json(json!({ "issues": [] })))
.mount(&server)
.await;
let home = ConfigHome::new("no-token").seed(json!({
"server_url": null,
"token": null,
"default_repo": "fangorn/anvil-cli",
}));
let out = home.run_env(
&["--json", "issue", "list", "fangorn/anvil-cli"],
&[("ANVIL_SERVER_URL", &server.uri())],
);
assert!(
!out.status.success(),
"issue list must fail without a token; stdout:\n{}",
String::from_utf8_lossy(&out.stdout)
);
let combined = format!("{}{}", String::from_utf8_lossy(&out.stdout), stderr(&out));
assert!(
combined.contains("not logged in"),
"expected a not-logged-in error, got:\n{combined}"
);
}
#[test]
fn auth_status_is_not_logged_in_without_a_token() {
let home = ConfigHome::new("status-notoken");
let out = home.run(&["--json", "auth", "status"]);
let v = stdout_json(&out);
assert_eq!(v["logged_in"], json!(false), "payload: {v}");
assert_eq!(v["server"], json!(PRODUCTION_URL), "payload: {v}");
}
#[test]
fn auth_status_is_logged_in_with_a_token() {
let home = ConfigHome::new("status-token").seed(json!({
"server_url": null,
"token": "tok_abcdef123456",
"default_repo": null,
}));
let v = stdout_json(&home.run(&["--json", "auth", "status"]));
assert_eq!(v["logged_in"], json!(true), "payload: {v}");
assert_eq!(v["server"], json!(PRODUCTION_URL), "payload: {v}");
}