use std::path::Path;
use super::pinned_gh_env_in;
use crate::core::trusty_tools_config::GithubConfig;
use crate::project::ProjectRegistry;
use crate::project::record::Project;
use crate::session_manager::worktree_reclaim_gh::resolve_daemon_gh_env_in;
const ORIGIN: &str = "https://github.com/duettoresearch/jev-matching";
fn project(name: &str, repo_url: &str, gh_account: Option<&str>) -> Project {
Project {
name: name.to_string(),
repo_url: repo_url.to_string(),
default_branch: "main".to_string(),
stack_hint: None,
tags: vec![],
description: None,
gh_user: None,
gh_account: gh_account.map(str::to_string),
github: None,
commit_name: None,
commit_email: None,
worktree: None,
}
}
fn write_hosts_yml(config_dir: &Path, account: &str) {
std::fs::create_dir_all(config_dir).expect("config dir");
std::fs::write(
config_dir.join("hosts.yml"),
format!("github.com:\n git_protocol: https\n user: {account}\n"),
)
.expect("hosts.yml");
}
fn write_registry(registry_dir: &Path, body: &str) {
std::fs::create_dir_all(registry_dir).expect("registry dir");
std::fs::write(registry_dir.join("projects.json"), body).expect("projects.json");
}
fn value_of(env: &crate::core::gh_identity::GhEnv, key: &str) -> String {
env.vars()
.iter()
.find(|(k, _)| k == key)
.map(|(_, v)| v.clone())
.unwrap_or_else(|| panic!("{key} not set; got {:?}", env.vars()))
}
#[tokio::test]
async fn registry_pin_resolves_the_projects_scoped_config_dir() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
let registry = ProjectRegistry::load(registry_dir.path())
.await
.expect("load");
registry
.register(Project {
github: Some(GithubConfig {
config_dir: Some(config_dir.path().to_path_buf()),
..GithubConfig::default()
}),
..project("jev-matching", ORIGIN, Some("bob-duetto"))
})
.await
.expect("register");
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("a readable registry must not fail")
.expect("the registered pin must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy(),
"the daemon must spawn gh inside the project's own scoped config dir"
);
assert!(
env.unset_vars().iter().any(|k| k == "GH_TOKEN"),
"an inherited GH_TOKEN must be cleared; got {:?}",
env.unset_vars()
);
}
#[tokio::test]
async fn registry_pin_is_absent_for_an_unregistered_origin() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let registry = ProjectRegistry::load(registry_dir.path())
.await
.expect("load");
registry
.register(project("widget", "https://github.com/acme/widget", None))
.await
.expect("register");
assert_eq!(
pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("readable"),
None,
"an unregistered origin pins nothing and must fall through"
);
}
#[test]
fn an_absent_registry_file_is_not_a_pin() {
let registry_dir = tempfile::tempdir().expect("tempdir");
assert_eq!(
pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("absent is not an error"),
None
);
}
#[test]
fn an_unreadable_registry_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
std::fs::create_dir_all(registry_dir.path().join("projects.json")).expect("blocker");
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an unreadable registry must refuse, never fall back");
assert!(
err.contains("refusing to probe"),
"the refusal must say it refused; got: {err}"
);
}
#[test]
fn a_malformed_registry_document_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(registry_dir.path(), "{ this is not json");
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an unparsable registry must refuse, never fall back");
assert!(
err.contains("did not parse") && err.contains("refusing to probe"),
"got: {err}"
);
}
#[test]
fn a_malformed_matching_record_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":42}}}}}}"#
),
);
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an unparsable matching record must refuse");
assert!(
err.contains("jev-matching") && err.contains("refusing to probe"),
"the refusal must name the record; got: {err}"
);
}
#[test]
fn a_malformed_unrelated_record_does_not_block_a_match() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"broken":{{"repo_url":"https://github.com/acme/widget","gh_account":42}},
"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
}}}}"#,
config_dir.path().display()
),
);
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("a sibling's corruption must not block this repository")
.expect("the pin must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}
#[test]
fn a_pinned_config_dir_without_a_credential_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}}}}}"#,
config_dir.path().display()
),
);
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an empty config dir must refuse, never fall back");
assert!(
err.contains("bob-duetto") && err.contains("refusing to fall back"),
"the refusal must name the pinned account; got: {err}"
);
}
#[tokio::test]
async fn an_account_only_pin_fails_closed_naming_the_account() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let registry = ProjectRegistry::load(registry_dir.path())
.await
.expect("load");
registry
.register(project("jev-matching", ORIGIN, Some("bob-duetto")))
.await
.expect("register");
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an account-only pin must refuse, never fall back");
assert!(
err.contains("bob-duetto") && err.contains("refusing to probe"),
"the refusal must name the pinned account; got: {err}"
);
}
#[test]
fn an_unset_token_env_pin_fails_closed() {
const VAR: &str = "TRUSTY_MPM_TEST_5850_UNSET_TOKEN_ENV";
assert!(std::env::var_os(VAR).is_none(), "{VAR} must stay unset");
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","github":{{"token_env":"{VAR}"}}}}}}}}"#
),
);
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an unreadable token_env pin must refuse, never fall back");
assert!(
err.contains(VAR) && err.contains("refusing to fall back"),
"the refusal must name the variable; got: {err}"
);
}
#[test]
fn a_record_pinning_nothing_is_not_a_pin() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main"}}}}}}"#
),
);
assert_eq!(
pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("readable"),
None,
"a record that pins nothing must fall through"
);
}
#[test]
fn an_unpinned_record_does_not_shadow_a_pinned_one() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"Jev-Matching":{{"name":"Jev-Matching","repo_url":"{ORIGIN}.git","default_branch":"main"}},
"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
}}}}"#,
config_dir.path().display()
),
);
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("readable")
.expect("the pinned record must win over the unpinned one");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}
#[test]
fn two_disagreeing_pins_for_one_repository_fail_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bobmatnyc"}},
"b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto"}}
}}}}"#
),
);
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("disagreeing pins must refuse, never pick one");
assert!(
err.contains("a-jev") && err.contains("b-jev") && err.contains("refusing to probe"),
"the refusal must name both records; got: {err}"
);
}
#[test]
fn a_malformed_second_matching_record_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}},
"b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":42}}
}}}}"#,
config_dir.path().display()
),
);
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("an unparsable matching record must refuse");
assert!(
err.contains("b-jev"),
"the refusal must name the record; got: {err}"
);
}
#[test]
fn same_login_pins_prefer_the_one_with_a_config_dir() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"jev":{{"name":"jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto"}},
"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
}}}}"#,
config_dir.path().display()
),
);
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("one login is not a disagreement")
.expect("the config_dir pin must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}
#[test]
fn same_login_in_a_different_case_agrees() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"Bob-Duetto","github":{{"config_dir":"{0}"}}}},
"b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{0}"}}}}
}}}}"#,
config_dir.path().display()
),
);
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("a case difference is not a disagreement")
.expect("the pin must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}
#[test]
fn same_login_with_conflicting_config_dirs_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"/tmp/gh-one"}}}},
"b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"/tmp/gh-two"}}}}
}}}}"#
),
);
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("two config dirs for one repository must refuse");
assert!(
err.contains("a-jev") && err.contains("b-jev") && err.contains("refusing to probe"),
"got: {err}"
);
}
#[test]
fn a_no_login_pin_agrees_with_a_login_pin_on_the_same_config_dir() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"jev":{{"name":"jev","repo_url":"{ORIGIN}","default_branch":"main","github":{{"config_dir":"{0}"}}}},
"widget":{{"name":"widget","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{0}","account":"bob-duetto"}}}}
}}}}"#,
config_dir.path().display()
),
);
let pin = super::read_pin(registry_dir.path(), ORIGIN)
.expect("a missing login is not a disagreement")
.expect("the pins must resolve");
assert_eq!(
pin.account.as_deref(),
Some("bob-duetto"),
"the login must be inherited"
);
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("a missing login is not a disagreement")
.expect("the pin must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}
#[test]
fn a_no_login_pin_with_a_different_config_dir_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"jev":{{"name":"jev","repo_url":"{ORIGIN}","default_branch":"main","github":{{"config_dir":"/tmp/gh-one"}}}},
"widget":{{"name":"widget","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"/tmp/gh-two"}}}}
}}}}"#
),
);
let err =
pinned_gh_env_in(registry_dir.path(), ORIGIN).expect_err("two config dirs must refuse");
assert!(
err.contains("different gh config dirs") && !err.contains("different gh accounts"),
"the refusal must name the real conflict; got: {err}"
);
}
#[test]
fn a_host_only_duplicate_does_not_block_the_pinned_record() {
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{
"a-jev":{{"name":"a-jev","repo_url":"{ORIGIN}","default_branch":"main","github":{{"host":"github.com"}}}},
"b-jev":{{"name":"b-jev","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}
}}}}"#,
config_dir.path().display()
),
);
let env = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect("a host-only duplicate must not block")
.expect("the pinned record must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}
#[test]
fn a_host_only_binding_is_not_a_pin() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","github":{{"host":"github.example.com"}}}}}}}}"#
),
);
assert_eq!(
pinned_gh_env_in(registry_dir.path(), ORIGIN).expect("readable"),
None
);
}
#[test]
fn a_registry_without_a_projects_key_fails_closed() {
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(registry_dir.path(), "{}");
let err = pinned_gh_env_in(registry_dir.path(), ORIGIN)
.expect_err("a document without `projects` must refuse");
assert!(
err.contains("did not parse") && err.contains("refusing to probe"),
"got: {err}"
);
}
#[test]
fn daemon_gh_env_refuses_when_the_registry_cannot_answer() {
let dir = tempfile::tempdir().expect("tempdir");
let registry_dir = tempfile::tempdir().expect("tempdir");
write_registry(registry_dir.path(), "{ this is not json");
let failure = resolve_daemon_gh_env_in(dir.path(), ORIGIN, registry_dir.path())
.expect_err("a registry refusal must block the gh spawn, never fall back");
assert!(
failure.to_string().contains("refusing to probe"),
"the caller must surface the refusal; got: {failure}"
);
}
#[test]
fn daemon_gh_env_uses_the_registry_pin() {
let dir = tempfile::tempdir().expect("tempdir");
let registry_dir = tempfile::tempdir().expect("tempdir");
let config_dir = tempfile::tempdir().expect("tempdir");
write_hosts_yml(config_dir.path(), "bob-duetto");
write_registry(
registry_dir.path(),
&format!(
r#"{{"projects":{{"jev-matching":{{"name":"jev-matching","repo_url":"{ORIGIN}","default_branch":"main","gh_account":"bob-duetto","github":{{"config_dir":"{}"}}}}}}}}"#,
config_dir.path().display()
),
);
let env = resolve_daemon_gh_env_in(
dir.path(),
"duettoresearch/jev-matching",
registry_dir.path(),
)
.expect("a usable pin must resolve");
assert_eq!(
value_of(&env, "GH_CONFIG_DIR"),
config_dir.path().to_string_lossy()
);
}