use super::*;
use crate::git::GitRepo;
use crate::remote::TrustedRemoteInfo;
use std::env;
use std::fs;
use std::path::Path;
use std::sync::{Mutex, OnceLock};
fn env_lock() -> std::sync::MutexGuard<'static, ()> {
static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
LOCK.get_or_init(|| Mutex::new(())).lock().unwrap()
}
fn write_auth_config(
home: &Path,
use_gh_cli: bool,
allow_github_token_env: bool,
gh_hostname: Option<&str>,
) {
let config_dir = home.join(".config").join("stax");
fs::create_dir_all(&config_dir).unwrap();
let mut contents = format!(
"[auth]\nuse_gh_cli = {}\nallow_github_token_env = {}\n",
use_gh_cli, allow_github_token_env
);
if let Some(hostname) = gh_hostname {
contents.push_str(&format!("gh_hostname = \"{}\"\n", hostname));
}
fs::write(config_dir.join("config.toml"), contents).unwrap();
}
fn restore_env_var(name: &str, value: Option<String>) {
match value {
Some(value) => unsafe { env::set_var(name, value) },
None => unsafe { env::remove_var(name) },
}
}
#[test]
fn config_load_for_repo_uses_selected_root_outside_process_cwd() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("selected-repo");
let elsewhere = temp_dir.path().join("elsewhere");
let home_dir = temp_dir.path().join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&elsewhere).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
"[ui]\ntips = true\n[remote]\nname = \"global\"\n",
)
.unwrap();
fs::write(
repo_dir.join("stax.toml"),
"[ui]\ntips = false\n[remote]\nname = \"selected\"\n",
)
.unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
env::set_current_dir(&elsewhere).unwrap();
let config = Config::load_for_repo(&repo_dir).unwrap();
assert!(!config.ui.tips);
assert_eq!(config.remote_name(), "selected");
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
}
#[test]
fn config_load_for_repo_preserves_stax_config_dir_isolation() {
let _guard = env_lock();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("selected-repo");
let elsewhere = temp_dir.path().join("elsewhere");
let override_dir = temp_dir.path().join("isolated-config");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&elsewhere).unwrap();
fs::create_dir_all(&override_dir).unwrap();
fs::write(
override_dir.join("config.toml"),
"[ui]\ntips = true\n[remote]\nname = \"isolated\"\n",
)
.unwrap();
fs::write(
repo_dir.join("stax.toml"),
"[ui]\ntips = false\n[remote]\nname = \"repo-overlay\"\n",
)
.unwrap();
unsafe { env::set_var("STAX_CONFIG_DIR", &override_dir) };
env::set_current_dir(&elsewhere).unwrap();
let config = Config::load_for_repo(&repo_dir).unwrap();
assert!(config.ui.tips);
assert_eq!(config.remote_name(), "isolated");
env::set_current_dir(original_dir).unwrap();
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
}
#[test]
fn trusted_network_config_only_accepts_repo_local_remote_name() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("selected-repo");
let home_dir = temp_dir.path().join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"
[remote]
name = "global"
base_url = "https://git.trusted.example"
api_base_url = "https://api.trusted.example/v1"
forge = "gitea"
[auth]
use_gh_cli = false
allow_github_token_env = false
gh_hostname = "git.trusted.example"
"#,
)
.unwrap();
fs::write(
repo_dir.join("stax.toml"),
r#"
[remote]
name = "selected"
base_url = "http://attacker.invalid"
api_base_url = "http://attacker.invalid/api"
forge = "github"
[auth]
use_gh_cli = true
allow_github_token_env = true
gh_hostname = "attacker.invalid"
"#,
)
.unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
let config = Config::load_for_trusted_network(&repo_dir).unwrap();
assert_eq!(config.remote_name(), "selected");
assert_eq!(config.remote.base_url, "https://git.trusted.example");
assert_eq!(
config.remote.api_base_url.as_deref(),
Some("https://api.trusted.example/v1")
);
assert_eq!(config.remote.forge, Some(ForgeType::Gitea));
assert!(!config.auth.use_gh_cli);
assert!(!config.auth.allow_github_token_env);
assert_eq!(
config.auth.gh_hostname.as_deref(),
Some("git.trusted.example")
);
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
}
#[test]
fn trusted_network_config_preserves_repository_remote_name_only() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("selected-repo");
let missing_remote_repo_dir = temp_dir.path().join("missing-remote-repo");
let home_dir = temp_dir.path().join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&missing_remote_repo_dir).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"
[remote]
name = "origin"
base_url = "https://git.trusted.example"
api_base_url = "https://api.trusted.example/v3"
forge = "github"
[auth]
use_gh_cli = false
allow_github_token_env = false
gh_hostname = "git.trusted.example"
"#,
)
.unwrap();
let repo_config = r#"
[remote]
name = "upstream"
base_url = "http://attacker.invalid"
api_base_url = "http://attacker.invalid/api"
forge = "gitlab"
[auth]
use_gh_cli = true
allow_github_token_env = true
gh_hostname = "attacker.invalid"
"#;
fs::write(repo_dir.join("stax.toml"), repo_config).unwrap();
fs::write(missing_remote_repo_dir.join("stax.toml"), repo_config).unwrap();
git2::Repository::init(&repo_dir)
.unwrap()
.remote(
"upstream",
"https://git.trusted.example/platform/service.git",
)
.unwrap();
git2::Repository::init(&missing_remote_repo_dir)
.unwrap()
.remote("origin", "https://git.trusted.example/platform/service.git")
.unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
let config = Config::load_for_trusted_network(&repo_dir).unwrap();
let repo = GitRepo::open_from_path(&repo_dir).unwrap();
let trusted = TrustedRemoteInfo::from_repo(&repo, &config).unwrap();
let remote = trusted.remote();
assert_eq!(remote.name, "upstream");
assert_eq!(remote.base_url, "https://git.trusted.example");
assert_eq!(
remote.api_base_url.as_deref(),
Some("https://api.trusted.example/v3")
);
assert_eq!(remote.forge, ForgeType::GitHub);
assert!(!config.auth.use_gh_cli);
assert!(!config.auth.allow_github_token_env);
assert_eq!(
config.auth.gh_hostname.as_deref(),
Some("git.trusted.example")
);
let missing_config = Config::load_for_trusted_network(&missing_remote_repo_dir).unwrap();
let missing_repo = GitRepo::open_from_path(&missing_remote_repo_dir).unwrap();
let error = TrustedRemoteInfo::from_repo(&missing_repo, &missing_config).unwrap_err();
assert!(error.to_string().contains("upstream"));
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
}
#[test]
fn repository_submit_preferences_overlay_without_redirecting_trusted_network() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let temp_dir = tempfile::tempdir().unwrap();
let repo_dir = temp_dir.path().join("selected-repo");
let home_dir = temp_dir.path().join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
r#"
[remote]
name = "origin"
base_url = "https://git.trusted.example"
api_base_url = "https://api.trusted.example/v3"
forge = "github"
[auth]
use_gh_cli = false
allow_github_token_env = false
gh_hostname = "git.trusted.example"
[submit]
stack_links = "comment"
single_stack = "on"
native_stack = "off"
"#,
)
.unwrap();
fs::write(
repo_dir.join("stax.toml"),
r#"
[remote]
name = "upstream"
base_url = "http://attacker.invalid"
api_base_url = "http://attacker.invalid/api"
forge = "gitlab"
[auth]
use_gh_cli = true
allow_github_token_env = true
gh_hostname = "attacker.invalid"
[submit]
stack_links = "both"
single_stack = "off"
native_stack = "link"
"#,
)
.unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
let trusted = Config::load_for_trusted_network(&repo_dir).unwrap();
let preferences = Config::load_repository_submit_preferences(&repo_dir).unwrap();
assert_eq!(trusted.remote_name(), "upstream");
assert_eq!(trusted.remote.base_url, "https://git.trusted.example");
assert_eq!(
trusted.remote.api_base_url.as_deref(),
Some("https://api.trusted.example/v3")
);
assert_eq!(trusted.remote.forge, Some(ForgeType::GitHub));
assert!(!trusted.auth.use_gh_cli);
assert!(!trusted.auth.allow_github_token_env);
assert_eq!(
trusted.auth.gh_hostname.as_deref(),
Some("git.trusted.example")
);
assert_eq!(trusted.submit.stack_links, StackLinksMode::Comment);
assert_eq!(preferences.remote_name(), "origin");
assert_eq!(preferences.remote.base_url, "https://git.trusted.example");
assert!(!preferences.auth.use_gh_cli);
assert_eq!(preferences.submit.stack_links, StackLinksMode::Both);
assert_eq!(preferences.submit.single_stack, SingleStackMode::Off);
assert_eq!(preferences.submit.native_stack, NativeStackMode::Link);
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
}
#[cfg(unix)]
fn write_mock_gh(home: &Path, script_body: &str) -> String {
use std::os::unix::fs::PermissionsExt;
let bin_dir = home.join("bin");
fs::create_dir_all(&bin_dir).unwrap();
let gh_path = bin_dir.join("gh");
fs::write(&gh_path, script_body).unwrap();
let mut perms = fs::metadata(&gh_path).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&gh_path, perms).unwrap();
let original_path = env::var("PATH").unwrap_or_default();
format!("{}:{}", bin_dir.display(), original_path)
}
#[test]
fn config_load_overlays_repo_stax_toml_when_present() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-local-config-{}", std::process::id()));
let repo_dir = temp_dir.join("repo");
let home_dir = temp_dir.join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
"[ui]\ntips = true\n[branch]\nformat = \"{user}/{message}\"\nreplacement = \"_\"\n[worktree.hooks]\npost_start = \"global-start\"\npost_go = \"global-go\"\n",
)
.unwrap();
fs::write(
repo_dir.join("stax.toml"),
"[ui]\ntips = false\n[worktree.hooks]\npost_go = \"repo-go\"\n",
)
.unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&repo_dir).unwrap();
assert_eq!(
Config::path().unwrap(),
global_config_dir.join("config.toml")
);
let config = Config::load().unwrap();
assert!(!config.ui.tips);
assert_eq!(config.branch.format.as_deref(), Some("{user}/{message}"));
assert_eq!(config.branch.replacement, "_");
assert_eq!(
config.worktree.hooks.post_start.as_deref(),
Some("global-start")
);
assert_eq!(config.worktree.hooks.post_go.as_deref(), Some("repo-go"));
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn worktree_reuse_slots_default() {
let config = Config::default();
assert!(config.worktree.reuse_slots);
assert_eq!(config.worktree.max_idle_slots, 4);
assert!(config.worktree.reconcile.is_none());
}
#[test]
fn worktree_pool_config_parse_and_overlay() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-pool-config-{}", std::process::id()));
let repo_dir = temp_dir.join("repo");
let home_dir = temp_dir.join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(
global_config_dir.join("config.toml"),
"[worktree]\nreuse_slots = true\nmax_idle_slots = 8\n",
)
.unwrap();
fs::write(
repo_dir.join("stax.toml"),
"[worktree]\nreuse_slots = false\nmax_idle_slots = 2\nreconcile = \"pnpm install\"\n",
)
.unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&repo_dir).unwrap();
let config = Config::load().unwrap();
assert!(!config.worktree.reuse_slots);
assert_eq!(config.worktree.max_idle_slots, 2);
assert_eq!(config.worktree.reconcile.as_deref(), Some("pnpm install"));
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn config_load_discovers_repo_stax_toml_from_nested_directory() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir = std::env::temp_dir().join(format!(
"stax-test-nested-local-config-{}",
std::process::id()
));
let repo_dir = temp_dir.join("repo");
let nested_dir = repo_dir.join("src").join("nested");
let home_dir = temp_dir.join("home");
let global_config_dir = home_dir.join(".config").join("stax");
fs::create_dir_all(&nested_dir).unwrap();
fs::create_dir_all(&global_config_dir).unwrap();
fs::write(global_config_dir.join("config.toml"), "[ui]\ntips = true\n").unwrap();
fs::write(repo_dir.join("stax.toml"), "[ui]\ntips = false\n").unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&nested_dir).unwrap();
assert!(!Config::load().unwrap().ui.tips);
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn config_path_uses_global_config_when_repo_local_config_is_absent() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-global-config-{}", std::process::id()));
let repo_dir = temp_dir.join("repo");
let home_dir = temp_dir.join("home");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&home_dir).unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&repo_dir).unwrap();
assert_eq!(
Config::path().unwrap(),
home_dir.join(".config").join("stax").join("config.toml")
);
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn config_path_env_override_wins_over_repo_local_config() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-env-config-{}", std::process::id()));
let repo_dir = temp_dir.join("repo");
let local_config_dir = repo_dir.join(".config").join("stax");
let override_config_dir = temp_dir.join("override-stax");
fs::create_dir_all(&local_config_dir).unwrap();
fs::create_dir_all(&override_config_dir).unwrap();
fs::write(local_config_dir.join("config.toml"), "[ui]\ntips = false\n").unwrap();
fs::write(repo_dir.join("stax.toml"), "[ui]\ntips = false\n").unwrap();
fs::write(
override_config_dir.join("config.toml"),
"[ui]\ntips = true\n",
)
.unwrap();
unsafe { env::set_var("HOME", temp_dir.join("home")) };
unsafe { env::set_var("STAX_CONFIG_DIR", &override_config_dir) };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&repo_dir).unwrap();
assert_eq!(
Config::path().unwrap(),
override_config_dir.join("config.toml")
);
assert!(Config::load().unwrap().ui.tips);
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn config_load_ignores_old_repo_dot_config_path() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-old-local-config-{}", std::process::id()));
let repo_dir = temp_dir.join("repo");
let old_config_dir = repo_dir.join(".config").join("stax");
let home_dir = temp_dir.join("home");
fs::create_dir_all(&old_config_dir).unwrap();
fs::create_dir_all(&home_dir).unwrap();
fs::write(old_config_dir.join("config.toml"), "[ui]\ntips = false\n").unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&repo_dir).unwrap();
assert!(Config::load().unwrap().ui.tips);
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn config_save_writes_global_config_when_repo_stax_toml_exists() {
let _guard = env_lock();
let original_home = env::var("HOME").ok();
let original_stax_config_dir = env::var("STAX_CONFIG_DIR").ok();
let original_dir = env::current_dir().unwrap();
let temp_dir = std::env::temp_dir().join(format!(
"stax-test-save-global-config-{}",
std::process::id()
));
let repo_dir = temp_dir.join("repo");
let home_dir = temp_dir.join("home");
let global_config_path = home_dir.join(".config").join("stax").join("config.toml");
fs::create_dir_all(&repo_dir).unwrap();
fs::create_dir_all(&home_dir).unwrap();
fs::write(repo_dir.join("stax.toml"), "[ui]\ntips = false\n").unwrap();
unsafe { env::set_var("HOME", &home_dir) };
unsafe { env::remove_var("STAX_CONFIG_DIR") };
Command::new("git")
.arg("init")
.current_dir(&repo_dir)
.output()
.unwrap();
env::set_current_dir(&repo_dir).unwrap();
let mut config = Config::default();
config.ui.tips = false;
config.save().unwrap();
let saved_global_config = fs::read_to_string(&global_config_path).unwrap();
let saved_config: Config = toml::from_str(&saved_global_config).unwrap();
assert!(!saved_config.ui.tips);
assert!(saved_global_config.contains("tips = false"));
assert_eq!(
fs::read_to_string(repo_dir.join("stax.toml")).unwrap(),
"[ui]\ntips = false\n"
);
env::set_current_dir(original_dir).unwrap();
restore_env_var("HOME", original_home);
restore_env_var("STAX_CONFIG_DIR", original_stax_config_dir);
let _ = fs::remove_dir_all(&temp_dir);
}
#[test]
fn test_default_config() {
let config = Config::default();
assert!(config.branch.prefix.is_none());
assert!(!config.branch.date);
assert_eq!(config.branch.replacement, "-");
assert_eq!(config.remote.name, "origin");
assert_eq!(config.remote.base_url, "https://github.com");
assert_eq!(config.submit.stack_links, StackLinksMode::Comment);
assert_eq!(config.submit.single_stack, SingleStackMode::On);
assert!(!config.ci.alert);
assert!(config.ci.success_alert_sound.is_none());
assert!(config.ci.error_alert_sound.is_none());
assert!(config.ui.tips);
assert!(config.auth.use_gh_cli);
assert!(!config.auth.allow_github_token_env);
assert!(config.auth.gh_hostname.is_none());
}
#[test]
fn test_ci_alert_config_round_trip() {
let config: Config = toml::from_str(
r#"
[ci]
alert = true
success_alert_sound = "/tmp/ci-success.wav"
error_alert_sound = "/tmp/ci-error.wav"
"#,
)
.unwrap();
assert!(config.ci.alert);
assert_eq!(
config.ci.success_alert_sound.as_deref(),
Some("/tmp/ci-success.wav")
);
assert_eq!(
config.ci.error_alert_sound.as_deref(),
Some("/tmp/ci-error.wav")
);
let encoded = toml::to_string(&config).unwrap();
assert!(encoded.contains("[ci]"));
assert!(encoded.contains("alert = true"));
assert!(encoded.contains("success_alert_sound = \"/tmp/ci-success.wav\""));
assert!(encoded.contains("error_alert_sound = \"/tmp/ci-error.wav\""));
}
#[test]
fn test_submit_stack_links_round_trip() {
let config: Config = toml::from_str(
r#"
[submit]
stack_links = "both"
"#,
)
.unwrap();
assert_eq!(config.submit.stack_links, StackLinksMode::Both);
let encoded = toml::to_string(&config).unwrap();
assert!(encoded.contains("stack_links = \"both\""));
}
#[test]
fn test_submit_stack_links_defaults_to_comment_when_missing() {
let config: Config = toml::from_str(
r#"
[remote]
name = "origin"
"#,
)
.unwrap();
assert_eq!(config.submit.stack_links, StackLinksMode::Comment);
}
#[test]
fn test_submit_stack_links_rejects_unknown_value() {
let err = toml::from_str::<Config>(
r#"
[submit]
stack_links = "weird"
"#,
)
.unwrap_err();
assert!(err.to_string().contains("unknown variant"));
}
#[test]
fn test_submit_single_stack_defaults_to_on() {
let config: Config = toml::from_str(
r#"
[remote]
name = "origin"
"#,
)
.unwrap();
assert_eq!(config.submit.single_stack, SingleStackMode::On);
}
#[test]
fn test_submit_single_stack_round_trip_off() {
let config: Config = toml::from_str(
r#"
[submit]
single_stack = "off"
"#,
)
.unwrap();
assert_eq!(config.submit.single_stack, SingleStackMode::Off);
let encoded = toml::to_string(&config).unwrap();
assert!(encoded.contains("single_stack = \"off\""));
}
#[test]
fn test_submit_single_stack_round_trip_on() {
let config: Config = toml::from_str(
r#"
[submit]
single_stack = "on"
"#,
)
.unwrap();
assert_eq!(config.submit.single_stack, SingleStackMode::On);
}
#[test]
fn test_submit_single_stack_rejects_unknown_value() {
let err = toml::from_str::<Config>(
r#"
[submit]
single_stack = "maybe"
"#,
)
.unwrap_err();
assert!(err.to_string().contains("unknown variant"));
}
#[test]
fn test_clear_ai_defaults_clears_agent_and_model() {
let mut config = Config::default();
config.ai.agent = Some("codex".to_string());
config.ai.model = Some("gpt-5.3-codex".to_string());
assert!(config.clear_ai_defaults());
assert_eq!(config.ai.agent, None);
assert_eq!(config.ai.model, None);
}
#[test]
fn test_clear_ai_defaults_reports_noop_when_unset() {
let mut config = Config::default();
assert!(!config.clear_ai_defaults());
assert_eq!(config.ai.agent, None);
assert_eq!(config.ai.model, None);
}
#[test]
fn test_clear_ai_defaults_also_clears_per_feature_configs() {
let mut config = Config::default();
config.ai.agent = Some("codex".to_string());
config.ai.standup.agent = Some("claude".to_string());
config.ai.generate.model = Some("o4-mini".to_string());
assert!(config.clear_ai_defaults());
assert_eq!(config.ai.agent, None);
assert_eq!(config.ai.standup.agent, None);
assert_eq!(config.ai.generate.model, None);
}
#[test]
fn ai_config_agent_for_includes_global_fallback() {
let mut config = Config::default();
config.ai.agent = Some("codex".to_string());
assert_eq!(config.ai.agent_for("standup"), Some("codex"));
}
#[test]
fn ai_config_feature_slot_is_none_when_only_global_is_set() {
let mut config = Config::default();
config.ai.agent = Some("codex".to_string());
assert!(config.ai.standup.agent.is_none());
assert!(config.ai.generate.agent.is_none());
assert!(config.ai.resolve.agent.is_none());
assert!(config.ai.lane.agent.is_none());
}
#[test]
fn ai_config_feature_slot_is_some_after_per_feature_is_set() {
let mut config = Config::default();
config.ai.agent = Some("codex".to_string()); config.ai.standup.agent = Some("claude".to_string());
assert!(config.ai.standup.agent.is_some());
assert_eq!(config.ai.agent_for("standup"), Some("claude"));
}
#[test]
fn ai_config_agent_for_prefers_per_feature_over_global() {
let mut config = Config::default();
config.ai.agent = Some("codex".to_string());
config.ai.generate.agent = Some("claude".to_string());
assert_eq!(config.ai.agent_for("generate"), Some("claude"));
}
#[test]
fn ai_config_model_for_falls_back_to_global_when_feature_unset() {
let mut config = Config::default();
config.ai.model = Some("gpt-5.4".to_string());
assert_eq!(config.ai.model_for("standup"), Some("gpt-5.4"));
}
#[test]
fn ai_config_model_for_prefers_per_feature_over_global() {
let mut config = Config::default();
config.ai.model = Some("gpt-5.4".to_string());
config.ai.standup.model = Some("claude-opus-4-5".to_string());
assert_eq!(config.ai.model_for("standup"), Some("claude-opus-4-5"));
}
#[test]
fn test_format_branch_name_no_prefix() {
let config = Config::default();
assert_eq!(config.format_branch_name("my-feature"), "my-feature");
}
#[test]
fn test_format_branch_name_with_prefix() {
let mut config = Config::default();
config.branch.prefix = Some("cesar/".to_string());
assert_eq!(config.format_branch_name("my-feature"), "cesar/my-feature");
}
#[test]
fn test_format_branch_name_prefix_not_duplicated() {
let mut config = Config::default();
config.branch.prefix = Some("cesar/".to_string());
assert_eq!(
config.format_branch_name("cesar/my-feature"),
"cesar/my-feature"
);
}
#[test]
fn test_format_branch_name_prefix_override() {
let mut config = Config::default();
config.branch.prefix = Some("cesar/".to_string());
assert_eq!(
config.format_branch_name_with_prefix_override("auth", Some("feature")),
"feature/auth"
);
}
#[test]
fn test_format_branch_name_prefix_override_empty_disables() {
let mut config = Config::default();
config.branch.prefix = Some("cesar/".to_string());
assert_eq!(
config.format_branch_name_with_prefix_override("auth", Some("")),
"auth"
);
}
#[test]
fn test_format_branch_name_spaces_replaced() {
let config = Config::default();
assert_eq!(
config.format_branch_name("my cool feature"),
"my-cool-feature"
);
}
#[test]
fn test_format_branch_name_special_chars_replaced() {
let config = Config::default();
assert_eq!(
config.format_branch_name("feat: add stuff!"),
"feat-add-stuff"
);
}
#[test]
fn test_format_branch_name_custom_replacement() {
let mut config = Config::default();
config.branch.replacement = "_".to_string();
assert_eq!(
config.format_branch_name("my cool feature"),
"my_cool_feature"
);
}
#[test]
fn test_format_branch_name_consecutive_replacements_collapsed() {
let config = Config::default();
assert_eq!(config.format_branch_name("my feature"), "my-feature");
}
#[test]
fn test_token_priority_stax_env_first() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-stax-first-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
write_auth_config(&temp_dir, false, true, None);
unsafe { env::set_var("STAX_GITHUB_TOKEN", "stax-token") };
unsafe { env::set_var("GITHUB_TOKEN", "github-token") };
let token = Config::github_token();
assert_eq!(token, Some("stax-token".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_github_token_env_ignored_by_default() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-env-ignored-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
write_auth_config(&temp_dir, false, false, None);
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::set_var("GITHUB_TOKEN", "github-token") };
let token = Config::github_token();
assert_eq!(token, None);
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_github_token_env_opt_in_fallback() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-env-opt-in-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
write_auth_config(&temp_dir, false, true, None);
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::set_var("GITHUB_TOKEN", "github-token") };
let token = Config::github_token();
assert_eq!(token, Some("github-token".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_empty_stax_token_falls_back_to_credentials() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-empty-stax-{}", std::process::id()));
let config_dir = temp_dir.join(".config").join("stax");
fs::create_dir_all(&config_dir).unwrap();
write_auth_config(&temp_dir, false, true, None);
fs::write(config_dir.join(".credentials"), "file-token").unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::set_var("STAX_GITHUB_TOKEN", "") };
unsafe { env::set_var("GITHUB_TOKEN", "github-token") };
let token = Config::github_token();
assert_eq!(token, Some("file-token".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_default_ui_config() {
let ui_config = UiConfig::default();
assert!(ui_config.tips);
}
#[test]
fn test_ui_tips_serialization() {
let config = Config::default();
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("[ui]"));
assert!(toml_str.contains("tips = true"));
let toml_with_tips_false = r#"
[ui]
tips = false
"#;
let parsed: Config = toml::from_str(toml_with_tips_false).unwrap();
assert!(!parsed.ui.tips);
let toml_without_ui = r#"
[branch]
prefix = "test/"
"#;
let parsed: Config = toml::from_str(toml_without_ui).unwrap();
assert!(parsed.ui.tips);
}
#[test]
fn test_set_github_token_writes_to_file() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let temp_dir = std::env::temp_dir().join(format!("stax-test-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
let test_token = "ghp_test_token_12345";
let result = Config::set_github_token(test_token);
assert!(result.is_ok(), "set_github_token should succeed");
let creds_path = temp_dir.join(".config").join("stax").join(".credentials");
assert!(creds_path.exists(), "Credentials file should exist");
let contents = fs::read_to_string(&creds_path).unwrap();
assert_eq!(contents, test_token);
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let perms = fs::metadata(&creds_path).unwrap().permissions();
assert_eq!(
perms.mode() & 0o777,
0o600,
"File should have 600 permissions"
);
}
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
}
#[test]
fn test_github_token_reads_from_credentials_file() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir = std::env::temp_dir().join(format!("stax-test-read-{}", std::process::id()));
let config_dir = temp_dir.join(".config").join("stax");
fs::create_dir_all(&config_dir).unwrap();
let test_token = "ghp_file_token_67890";
fs::write(config_dir.join(".credentials"), test_token).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::remove_var("GITHUB_TOKEN") };
let token = Config::github_token();
assert_eq!(token, Some(test_token.to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_github_token_roundtrip() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let thread_id = std::thread::current().id();
let temp_dir = std::env::temp_dir().join(format!(
"stax-test-roundtrip-{}-{:?}",
std::process::id(),
thread_id
));
fs::create_dir_all(&temp_dir).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
let test_token = "ghp_roundtrip_token_abcdef";
Config::set_github_token(test_token).unwrap();
let creds_path = temp_dir.join(".config").join("stax").join(".credentials");
let contents = fs::read_to_string(&creds_path).unwrap();
assert_eq!(contents, test_token);
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
}
#[test]
fn test_github_token_credentials_take_priority_over_env_when_enabled() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir = std::env::temp_dir().join(format!("stax-test-priority-{}", std::process::id()));
let config_dir = temp_dir.join(".config").join("stax");
fs::create_dir_all(&config_dir).unwrap();
let file_token = "ghp_from_file";
let env_token = "ghp_from_env";
fs::write(config_dir.join(".credentials"), file_token).unwrap();
write_auth_config(&temp_dir, false, true, None);
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::set_var("GITHUB_TOKEN", env_token) };
let token = Config::github_token();
assert_eq!(token, Some(file_token.to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_github_token_trims_whitespace_from_file() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir = std::env::temp_dir().join(format!("stax-test-trim-{}", std::process::id()));
let config_dir = temp_dir.join(".config").join("stax");
fs::create_dir_all(&config_dir).unwrap();
let token_with_whitespace = " ghp_token_with_spaces \n";
fs::write(config_dir.join(".credentials"), token_with_whitespace).unwrap();
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::remove_var("GITHUB_TOKEN") };
let token = Config::github_token();
assert_eq!(token, Some("ghp_token_with_spaces".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[cfg(unix)]
#[test]
fn test_github_token_falls_back_to_gh_cli() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_path = env::var("PATH").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-gh-fallback-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
write_auth_config(&temp_dir, true, false, None);
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::remove_var("GITHUB_TOKEN") };
let mock_path = write_mock_gh(
&temp_dir,
"#!/bin/sh\nif [ \"$1\" = \"auth\" ] && [ \"$2\" = \"token\" ]; then\n echo \"gh-cli-token\"\n exit 0\nfi\nexit 1\n",
);
unsafe { env::set_var("PATH", mock_path) };
let token = Config::github_token();
assert_eq!(token, Some("gh-cli-token".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_path {
Some(v) => unsafe { env::set_var("PATH", v) },
None => unsafe { env::remove_var("PATH") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[cfg(unix)]
#[test]
fn test_github_token_skips_gh_cli_when_disabled() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_path = env::var("PATH").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-gh-disabled-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
write_auth_config(&temp_dir, false, false, None);
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::remove_var("GITHUB_TOKEN") };
let mock_path = write_mock_gh(&temp_dir, "#!/bin/sh\necho \"gh-cli-token\"\n");
unsafe { env::set_var("PATH", mock_path) };
let token = Config::github_token();
assert_eq!(token, None);
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_path {
Some(v) => unsafe { env::set_var("PATH", v) },
None => unsafe { env::remove_var("PATH") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[cfg(unix)]
#[test]
fn test_github_token_passes_gh_hostname() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_path = env::var("PATH").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-gh-hostname-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
write_auth_config(&temp_dir, true, false, Some("github.example.com"));
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::remove_var("GITHUB_TOKEN") };
let mock_path = write_mock_gh(
&temp_dir,
"#!/bin/sh\nif [ \"$1\" = \"auth\" ] && [ \"$2\" = \"token\" ] && [ \"$3\" = \"--hostname\" ] && [ \"$4\" = \"github.example.com\" ]; then\n echo \"gh-host-token\"\n exit 0\nfi\nexit 1\n",
);
unsafe { env::set_var("PATH", mock_path) };
let token = Config::github_token();
assert_eq!(token, Some("gh-host-token".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_path {
Some(v) => unsafe { env::set_var("PATH", v) },
None => unsafe { env::remove_var("PATH") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[cfg(unix)]
#[test]
fn trusted_network_github_auth_rejects_configured_hostname_mismatch_before_gh_lookup() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_path = env::var("PATH").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-gh-host-mismatch-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
write_auth_config(&temp_dir, true, false, Some("github.corp.example"));
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::remove_var("GITHUB_TOKEN") };
let marker = temp_dir.join("gh-was-called");
let mock_path = write_mock_gh(
&temp_dir,
&format!(
"#!/bin/sh\ntouch \"{}\"\necho \"must-not-be-read\"\n",
marker.display()
),
);
unsafe { env::set_var("PATH", mock_path) };
let config = Config::load().unwrap();
let error = config
.github_token_with_source_for_host("github.other.example")
.unwrap_err();
assert!(error.to_string().contains("hostname"));
assert!(!marker.exists(), "gh token lookup must not run on mismatch");
assert!(!error.to_string().contains("must-not-be-read"));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_path {
Some(v) => unsafe { env::set_var("PATH", v) },
None => unsafe { env::remove_var("PATH") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[cfg(unix)]
#[test]
fn test_github_token_gh_failure_falls_back_to_opt_in_env() {
let _guard = env_lock();
let orig_home = env::var("HOME").ok();
let orig_path = env::var("PATH").ok();
let orig_stax = env::var("STAX_GITHUB_TOKEN").ok();
let orig_github = env::var("GITHUB_TOKEN").ok();
let temp_dir =
std::env::temp_dir().join(format!("stax-test-gh-fallback-env-{}", std::process::id()));
fs::create_dir_all(&temp_dir).unwrap();
write_auth_config(&temp_dir, true, true, None);
unsafe { env::set_var("HOME", &temp_dir) };
unsafe { env::set_var("STAX_CONFIG_DIR", temp_dir.join(".config").join("stax")) };
unsafe { env::remove_var("STAX_GITHUB_TOKEN") };
unsafe { env::set_var("GITHUB_TOKEN", "env-token") };
let mock_path = write_mock_gh(&temp_dir, "#!/bin/sh\nexit 1\n");
unsafe { env::set_var("PATH", mock_path) };
let token = Config::github_token();
assert_eq!(token, Some("env-token".to_string()));
let _ = fs::remove_dir_all(&temp_dir);
unsafe { env::remove_var("STAX_CONFIG_DIR") };
match orig_home {
Some(v) => unsafe { env::set_var("HOME", v) },
None => unsafe { env::remove_var("HOME") },
}
match orig_path {
Some(v) => unsafe { env::set_var("PATH", v) },
None => unsafe { env::remove_var("PATH") },
}
match orig_stax {
Some(v) => unsafe { env::set_var("STAX_GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("STAX_GITHUB_TOKEN") },
}
match orig_github {
Some(v) => unsafe { env::set_var("GITHUB_TOKEN", v) },
None => unsafe { env::remove_var("GITHUB_TOKEN") },
}
}
#[test]
fn test_format_template_message_only() {
let mut config = Config::default();
config.branch.format = Some("{message}".to_string());
assert_eq!(config.format_branch_name("my-feature"), "my-feature");
}
#[test]
fn test_format_template_user_message() {
let mut config = Config::default();
config.branch.format = Some("{user}/{message}".to_string());
config.branch.user = Some("alice".to_string());
assert_eq!(config.format_branch_name("my-feature"), "alice/my-feature");
}
#[test]
fn test_format_template_user_date_message() {
let mut config = Config::default();
config.branch.format = Some("{user}/{date}/{message}".to_string());
config.branch.user = Some("bob".to_string());
config.branch.date_format = "%m-%d".to_string();
let result = config.format_branch_name("add login");
assert!(
result.starts_with("bob/"),
"expected bob/ prefix, got: {}",
result
);
assert!(
result.ends_with("/add-login"),
"expected /add-login suffix, got: {}",
result
);
let parts: Vec<&str> = result.split('/').collect();
assert_eq!(parts.len(), 3);
assert_eq!(parts[0], "bob");
assert!(
parts[1].len() == 5 && parts[1].chars().nth(2) == Some('-'),
"Date should be MM-DD format, got: {}",
parts[1]
);
}
#[test]
fn test_format_template_date_message() {
let mut config = Config::default();
config.branch.format = Some("{date}/{message}".to_string());
config.branch.date_format = "%Y-%m-%d".to_string();
let result = config.format_branch_name("fix bug");
assert!(
result.ends_with("/fix-bug"),
"expected /fix-bug suffix, got: {}",
result
);
let parts: Vec<&str> = result.split('/').collect();
assert_eq!(parts.len(), 2);
assert_eq!(
parts[0].len(),
10,
"Date should be YYYY-MM-DD format, got: {}",
parts[0]
);
}
#[test]
fn test_format_template_sanitizes_user() {
let mut config = Config::default();
config.branch.format = Some("{user}/{message}".to_string());
config.branch.user = Some("John Doe".to_string());
let result = config.format_branch_name("feature");
assert_eq!(result, "John-Doe/feature");
}
#[test]
fn test_format_template_sanitizes_message() {
let mut config = Config::default();
config.branch.format = Some("{user}/{message}".to_string());
config.branch.user = Some("alice".to_string());
let result = config.format_branch_name("add user login!");
assert_eq!(result, "alice/add-user-login");
}
#[test]
fn test_format_template_with_prefix_override() {
let mut config = Config::default();
config.branch.format = Some("{message}".to_string());
let result = config.format_branch_name_with_prefix_override("feature", Some("hotfix"));
assert_eq!(result, "hotfix/feature");
}
#[test]
fn test_format_template_collapses_consecutive_dashes() {
let mut config = Config::default();
config.branch.format = Some("{message}".to_string());
let result = config.format_branch_name("fix multiple spaces");
assert_eq!(result, "fix-multiple-spaces");
}
#[test]
fn test_format_template_empty_user_no_leading_slash() {
let mut config = Config::default();
config.branch.format = Some("{user}/{date}/{message}".to_string());
config.branch.user = None; config.branch.date_format = "%m-%d".to_string();
let result = config.format_branch_name("my-feature");
assert!(
!result.starts_with('/'),
"branch name must not start with /, got: {}",
result
);
assert!(
!result.ends_with('/'),
"branch name must not end with /, got: {}",
result
);
assert!(
!result.contains("//"),
"branch name must not contain //, got: {}",
result
);
assert!(
result.ends_with("/my-feature") || result == "my-feature",
"branch name should end with message, got: {}",
result
);
}
#[test]
fn test_format_template_empty_user_message_only_format() {
let mut config = Config::default();
config.branch.format = Some("{user}/{message}".to_string());
config.branch.user = Some("".to_string());
let result = config.format_branch_name("my-feature");
assert_eq!(
result, "my-feature",
"empty user should collapse to just message"
);
}
#[test]
fn test_legacy_behavior_without_format() {
let mut config = Config::default();
config.branch.prefix = Some("legacy/".to_string());
config.branch.date = false;
let result = config.format_branch_name("my-feature");
assert_eq!(result, "legacy/my-feature");
}
#[test]
fn test_format_template_overrides_legacy_prefix() {
let mut config = Config::default();
config.branch.prefix = Some("legacy/".to_string());
config.branch.format = Some("{message}".to_string());
let result = config.format_branch_name("my-feature");
assert_eq!(result, "my-feature");
}
#[test]
fn test_format_template_custom_date_format() {
let mut config = Config::default();
config.branch.format = Some("{date}-{message}".to_string());
config.branch.date_format = "%Y%m%d".to_string();
let result = config.format_branch_name("feature");
assert!(
result.ends_with("-feature"),
"expected -feature suffix, got: {}",
result
);
let date_part = result.trim_end_matches("-feature");
assert_eq!(
date_part.len(),
8,
"Date should be YYYYMMDD format, got: {}",
date_part
);
}
#[test]
fn test_legacy_date_uses_original_format() {
let mut config = Config::default();
config.branch.date = true;
config.branch.date_format = "%m-%d".to_string();
let result = config.format_branch_name("feature");
let parts: Vec<&str> = result.splitn(2, "-feature").collect();
let date_part = parts[0].trim_end_matches('-');
assert_eq!(
date_part.len(),
10,
"Legacy date should be YYYY-MM-DD, got: {}",
date_part
);
}
#[test]
fn test_format_deserialization() {
let toml_str = r#"
[branch]
format = "{user}/{date}/{message}"
user = "testuser"
date_format = "%Y-%m-%d"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(
config.branch.format,
Some("{user}/{date}/{message}".to_string())
);
assert_eq!(config.branch.user, Some("testuser".to_string()));
assert_eq!(config.branch.date_format, "%Y-%m-%d");
}
#[test]
fn test_format_serialization() {
let mut config = Config::default();
config.branch.format = Some("{user}/{message}".to_string());
config.branch.user = Some("alice".to_string());
let toml_str = toml::to_string(&config).unwrap();
assert!(toml_str.contains("format = \"{user}/{message}\""));
assert!(toml_str.contains("user = \"alice\""));
}
#[test]
fn test_format_backward_compat_missing_fields() {
let toml_str = r#"
[branch]
prefix = "cesar/"
date = false
replacement = "-"
"#;
let config: Config = toml::from_str(toml_str).unwrap();
assert_eq!(config.branch.prefix, Some("cesar/".to_string()));
assert!(config.branch.format.is_none());
assert!(config.branch.user.is_none());
assert_eq!(config.branch.date_format, "%m-%d");
assert_eq!(config.format_branch_name("feature"), "cesar/feature");
}