use std::collections::BTreeMap;
use crate::git::remote_url::RemoteUrl;
use super::name::ProfileName;
use super::profile::Profile;
#[derive(Debug, Clone, Copy, Default)]
pub struct Inputs<'a> {
pub flag: Option<&'a ProfileName>,
pub env: Option<&'a ProfileName>,
pub git_config: Option<&'a ProfileName>,
pub remote: Option<&'a RemoteUrl>,
pub default: Option<&'a ProfileName>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum Source {
Flag,
Env,
GitConfig,
HostMatch,
VerifiedHome,
Default,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Resolved<'a> {
pub name: &'a ProfileName,
pub profile: &'a Profile,
pub source: Source,
}
#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)]
#[non_exhaustive]
pub enum ResolveError {
#[error("profile `{name}` (from {origin}) doesn't exist; see `gitaway profile list`")]
NotFound {
name: ProfileName,
origin: &'static str,
},
#[error(
"profiles {} all handle this remote and none is verifiably home; pick one with `gitaway use <profile>`",
.0.iter().map(|n| format!("`{n}`")).collect::<Vec<_>>().join(", ")
)]
Ambiguous(Vec<ProfileName>),
}
pub fn resolve<'a>(
inputs: Inputs<'_>,
profiles: &'a BTreeMap<ProfileName, Profile>,
mut is_home: impl FnMut(&ProfileName, &Profile) -> bool,
) -> Result<Option<Resolved<'a>>, ResolveError> {
let named = [
(inputs.flag, Source::Flag, "--profile"),
(inputs.env, Source::Env, "GITAWAY_PROFILE"),
(
inputs.git_config,
Source::GitConfig,
"git config gitaway.profile",
),
];
for (name, source, label) in named {
if let Some(name) = name {
return lookup(profiles, name, source, label).map(Some);
}
}
if let Some(remote) = inputs.remote {
let candidates: Vec<_> = profiles
.iter()
.filter(|(_, profile)| profile.hosts.handles(remote))
.collect();
match candidates.as_slice() {
[] => {}
[(name, profile)] => {
return Ok(Some(Resolved {
name,
profile,
source: Source::HostMatch,
}));
}
several => {
let mut home = several
.iter()
.filter(|(name, profile)| is_home(name, profile));
return match (home.next(), home.next()) {
(Some((name, profile)), None) => Ok(Some(Resolved {
name,
profile,
source: Source::VerifiedHome,
})),
_ => Err(ResolveError::Ambiguous(
several.iter().map(|(name, _)| (*name).clone()).collect(),
)),
};
}
}
}
match inputs.default {
Some(name) => lookup(profiles, name, Source::Default, "default_profile").map(Some),
None => Ok(None),
}
}
fn lookup<'a>(
profiles: &'a BTreeMap<ProfileName, Profile>,
name: &ProfileName,
source: Source,
label: &'static str,
) -> Result<Resolved<'a>, ResolveError> {
let (name, profile) = profiles
.get_key_value(name)
.ok_or_else(|| ResolveError::NotFound {
name: name.clone(),
origin: label,
})?;
Ok(Resolved {
name,
profile,
source,
})
}
#[cfg(test)]
mod tests {
use super::*;
use crate::git::remote_url;
fn profile(hosts: &[&str]) -> Profile {
let hosts: String = hosts
.iter()
.map(|host| format!("\"{host}\" = \"10.0.0.1\"\n"))
.collect();
Profile::from_toml(&format!(
r#"
[transport.wireguard]
endpoint = "vpn.example.net:51820"
server_public_key = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
[hosts]
{hosts}
"#
))
.unwrap()
}
fn name(s: &str) -> ProfileName {
s.parse().unwrap()
}
fn profiles() -> BTreeMap<ProfileName, Profile> {
BTreeMap::from([
(name("home"), profile(&["192.0.2.10"])),
(name("cabin"), profile(&["192.0.2.10"])),
(name("office"), profile(&["198.51.100.5"])),
])
}
fn never_home(_: &ProfileName, _: &Profile) -> bool {
panic!("home check should not run")
}
fn resolved_name(
inputs: Inputs<'_>,
is_home: impl FnMut(&ProfileName, &Profile) -> bool,
) -> Result<Option<String>, ResolveError> {
let profiles = profiles();
resolve(inputs, &profiles, is_home).map(|r| r.map(|r| r.name.to_string()))
}
#[test]
fn explicit_sources_win_in_order() {
let (flag, env, git, default) = (name("office"), name("home"), name("cabin"), name("home"));
let all = Inputs {
flag: Some(&flag),
env: Some(&env),
git_config: Some(&git),
remote: None,
default: Some(&default),
};
assert_eq!(resolved_name(all, never_home), Ok(Some("office".into())));
let no_flag = Inputs { flag: None, ..all };
assert_eq!(resolved_name(no_flag, never_home), Ok(Some("home".into())));
let only_git = Inputs {
env: None,
..no_flag
};
assert_eq!(
resolved_name(only_git, never_home),
Ok(Some("cabin".into()))
);
}
#[test]
fn explicit_source_beats_host_match() {
let remote = remote_url::parse("ssh://git@198.51.100.5/app.git").unwrap();
let git = name("home");
let inputs = Inputs {
git_config: Some(&git),
remote: Some(&remote),
..Inputs::default()
};
assert_eq!(resolved_name(inputs, never_home), Ok(Some("home".into())));
}
#[test]
fn unique_host_match_wins_over_default() {
let remote = remote_url::parse("ssh://git@198.51.100.5/app.git").unwrap();
let default = name("home");
let inputs = Inputs {
remote: Some(&remote),
default: Some(&default),
..Inputs::default()
};
let profiles = profiles();
let resolved = resolve(inputs, &profiles, never_home).unwrap().unwrap();
assert_eq!(resolved.name.as_str(), "office");
assert_eq!(resolved.source, Source::HostMatch);
}
#[test]
fn several_matches_use_the_one_verified_home() {
let remote = remote_url::parse("git@192.0.2.10:app.git").unwrap();
let inputs = Inputs {
remote: Some(&remote),
..Inputs::default()
};
let profiles = profiles();
let resolved = resolve(inputs, &profiles, |name, _| name.as_str() == "cabin")
.unwrap()
.unwrap();
assert_eq!(resolved.name.as_str(), "cabin");
assert_eq!(resolved.source, Source::VerifiedHome);
}
#[test]
fn several_matches_without_one_verified_home_is_an_error() {
let remote = remote_url::parse("git@192.0.2.10:app.git").unwrap();
let default = name("office");
let inputs = Inputs {
remote: Some(&remote),
default: Some(&default),
..Inputs::default()
};
let expected = Err(ResolveError::Ambiguous(vec![name("cabin"), name("home")]));
assert_eq!(resolved_name(inputs, |_, _| false), expected, "none home");
assert_eq!(resolved_name(inputs, |_, _| true), expected, "both home");
}
#[test]
fn unmatched_remote_falls_back_to_default() {
let remote = remote_url::parse("git@github.com:alice/app.git").unwrap();
let default = name("office");
let inputs = Inputs {
remote: Some(&remote),
default: Some(&default),
..Inputs::default()
};
assert_eq!(resolved_name(inputs, never_home), Ok(Some("office".into())));
}
#[test]
fn nothing_applies_means_plain_git() {
let remote = remote_url::parse("git@github.com:alice/app.git").unwrap();
let inputs = Inputs {
remote: Some(&remote),
..Inputs::default()
};
assert_eq!(resolved_name(inputs, never_home), Ok(None));
assert_eq!(resolved_name(Inputs::default(), never_home), Ok(None));
}
#[test]
fn named_profile_that_does_not_exist_is_an_error() {
let missing = name("missing");
for inputs in [
Inputs {
flag: Some(&missing),
..Inputs::default()
},
Inputs {
env: Some(&missing),
..Inputs::default()
},
Inputs {
git_config: Some(&missing),
..Inputs::default()
},
Inputs {
default: Some(&missing),
..Inputs::default()
},
] {
assert!(matches!(
resolved_name(inputs, never_home),
Err(ResolveError::NotFound { .. })
));
}
}
}