use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use crate::{DodotError, Result};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Dimension {
Os,
Arch,
Hostname,
Username,
}
impl Dimension {
pub fn as_str(self) -> &'static str {
match self {
Self::Os => "os",
Self::Arch => "arch",
Self::Hostname => "hostname",
Self::Username => "username",
}
}
pub fn parse(s: &str) -> Result<Self> {
match s {
"os" => Ok(Self::Os),
"arch" => Ok(Self::Arch),
"hostname" => Ok(Self::Hostname),
"username" => Ok(Self::Username),
other => Err(DodotError::Config(format!(
"unknown gate dimension `{other}`: must be one of os, arch, hostname, username"
))),
}
}
}
#[derive(Debug, Clone)]
pub struct HostFacts {
pub os: String,
pub arch: String,
pub hostname: Option<String>,
pub username: Option<String>,
}
impl HostFacts {
pub fn detect() -> Self {
Self {
os: detect_os(),
arch: detect_arch(),
hostname: detect_hostname(),
username: detect_username(),
}
}
pub fn for_tests(os: impl Into<String>, arch: impl Into<String>) -> Self {
Self {
os: os.into(),
arch: arch.into(),
hostname: Some("test-host".into()),
username: Some("tester".into()),
}
}
pub fn get(&self, dim: Dimension) -> Option<&str> {
match dim {
Dimension::Os => Some(&self.os),
Dimension::Arch => Some(&self.arch),
Dimension::Hostname => self.hostname.as_deref(),
Dimension::Username => self.username.as_deref(),
}
}
}
fn detect_os() -> String {
if cfg!(target_os = "macos") {
"darwin".into()
} else if cfg!(target_os = "linux") {
"linux".into()
} else if cfg!(target_os = "windows") {
"windows".into()
} else {
std::env::consts::OS.into()
}
}
fn detect_arch() -> String {
std::env::consts::ARCH.into()
}
pub fn detect_hostname() -> Option<String> {
if let Ok(h) = std::env::var("HOSTNAME") {
if !h.is_empty() {
return Some(h);
}
}
let output = std::process::Command::new("hostname").output().ok()?;
if !output.status.success() {
return None;
}
let name = String::from_utf8_lossy(&output.stdout).trim().to_string();
if name.is_empty() {
None
} else {
Some(name)
}
}
pub fn detect_username() -> Option<String> {
for var in ["USER", "USERNAME", "LOGNAME"] {
if let Ok(v) = std::env::var(var) {
if !v.is_empty() {
return Some(v);
}
}
}
None
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct GatePredicate {
pub matchers: Vec<(Dimension, String)>,
}
impl GatePredicate {
pub fn single(dim: Dimension, value: impl Into<String>) -> Self {
Self {
matchers: vec![(dim, value.into())],
}
}
pub fn matches(&self, host: &HostFacts) -> bool {
self.matchers
.iter()
.all(|(dim, expected)| host.get(*dim) == Some(expected.as_str()))
}
pub fn describe(&self) -> String {
let parts: Vec<String> = self
.matchers
.iter()
.map(|(d, v)| format!("{}={}", d.as_str(), v))
.collect();
parts.join(", ")
}
}
#[derive(Debug, Clone, Default)]
pub struct GateTable {
labels: HashMap<String, GatePredicate>,
}
impl GateTable {
pub fn with_builtins() -> Self {
let mut labels = HashMap::new();
labels.insert(
"darwin".into(),
GatePredicate::single(Dimension::Os, "darwin"),
);
labels.insert(
"macos".into(),
GatePredicate::single(Dimension::Os, "darwin"),
);
labels.insert(
"linux".into(),
GatePredicate::single(Dimension::Os, "linux"),
);
labels.insert(
"aarch64".into(),
GatePredicate::single(Dimension::Arch, "aarch64"),
);
labels.insert(
"arm64".into(),
GatePredicate::single(Dimension::Arch, "aarch64"),
);
labels.insert(
"x86_64".into(),
GatePredicate::single(Dimension::Arch, "x86_64"),
);
Self { labels }
}
pub fn merge_user(&mut self, user: &HashMap<String, HashMap<String, String>>) -> Result<()> {
for (label, dims) in user {
if !is_valid_label(label) {
return Err(DodotError::Config(format!(
"gate label `{label}` is not a valid identifier; \
labels must match [A-Za-z0-9_-]+ to be parseable from \
filenames and `_<label>/` directories"
)));
}
if ROUTING_PREFIX_TOKENS.contains(&label.as_str()) {
return Err(DodotError::Config(format!(
"gate label `{label}` collides with a reserved routing-prefix \
token (home/xdg/app/lib); pick a different name"
)));
}
if dims.is_empty() {
return Err(DodotError::Config(format!(
"gate label `{label}` has no dimension matchers; \
each entry must have at least one of os/arch/hostname/username"
)));
}
let mut matchers = Vec::with_capacity(dims.len());
let mut keys: Vec<&String> = dims.keys().collect();
keys.sort();
for key in keys {
let dim = Dimension::parse(key)
.map_err(|e| DodotError::Config(format!("in gate label `{label}`: {e}")))?;
let val = dims.get(key).cloned().unwrap_or_default();
if val.is_empty() {
return Err(DodotError::Config(format!(
"in gate label `{label}`: dimension `{key}` has empty value"
)));
}
matchers.push((dim, val));
}
self.labels
.insert(label.clone(), GatePredicate { matchers });
}
Ok(())
}
pub fn lookup(&self, label: &str) -> Option<&GatePredicate> {
self.labels.get(label)
}
pub fn contains(&self, label: &str) -> bool {
self.labels.contains_key(label)
}
#[cfg(test)]
pub fn len(&self) -> usize {
self.labels.len()
}
#[cfg(test)]
pub fn is_empty(&self) -> bool {
self.labels.is_empty()
}
}
pub fn rel_path_for_glob(rel_path: &std::path::Path) -> String {
rel_path.to_string_lossy().replace('\\', "/")
}
pub fn compile_mapping_gates<'a>(
mappings_gates: &'a std::collections::HashMap<String, String>,
pack_name: &str,
) -> Result<Vec<(glob::Pattern, &'a str)>> {
let mut compiled: Vec<(glob::Pattern, &'a str, &'a str)> =
Vec::with_capacity(mappings_gates.len());
for (pat, label) in mappings_gates {
let pattern = glob::Pattern::new(pat).map_err(|e| {
DodotError::Config(format!(
"invalid `[mappings.gates]` glob `{pat}` in pack `{pack_name}`: {e}"
))
})?;
compiled.push((pattern, label.as_str(), pat.as_str()));
}
compiled.sort_by(|a, b| a.2.cmp(b.2));
Ok(compiled.into_iter().map(|(p, l, _)| (p, l)).collect())
}
pub fn pack_os_active(allowed: &[String], host: &HostFacts) -> bool {
if allowed.is_empty() {
return true;
}
allowed.iter().any(|os| {
let normalized = match os.as_str() {
"macos" => "darwin",
other => other,
};
normalized == host.os
})
}
pub const ROUTING_PREFIX_TOKENS: &[&str] = &["home", "xdg", "app", "lib"];
pub fn parse_dir_gate_label(segment: &str) -> Option<&str> {
let label = segment.strip_prefix('_')?;
if !is_valid_label(label) {
return None;
}
if ROUTING_PREFIX_TOKENS.contains(&label) {
return None;
}
Some(label)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum BasenameGate<'a> {
None,
Found { label: &'a str, stripped: String },
}
pub fn parse_basename_gate(basename: &str) -> BasenameGate<'_> {
let bytes = basename.as_bytes();
let mut i = bytes.len();
while i >= 2 {
i -= 1;
if bytes[i] == b'_' && bytes[i - 1] == b'.' {
let underscore = i;
let dot_before = i - 1;
let label_start = underscore + 1;
let label_end = bytes[label_start..]
.iter()
.position(|&b| b == b'.')
.map(|off| label_start + off)
.unwrap_or(bytes.len());
let label = &basename[label_start..label_end];
if !is_valid_label(label) {
continue;
}
let stem = &basename[..dot_before];
if stem.is_empty() {
continue;
}
let suffix = &basename[label_end..];
let stripped = format!("{stem}{suffix}");
return BasenameGate::Found { label, stripped };
}
}
BasenameGate::None
}
fn is_valid_label(s: &str) -> bool {
!s.is_empty()
&& s.bytes()
.all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
}
#[cfg(test)]
mod tests {
use super::*;
fn host(os: &str, arch: &str) -> HostFacts {
HostFacts {
os: os.into(),
arch: arch.into(),
hostname: Some("test-host".into()),
username: Some("tester".into()),
}
}
#[test]
fn builtins_cover_os_and_arch() {
let t = GateTable::with_builtins();
assert!(t.contains("darwin"));
assert!(t.contains("linux"));
assert!(t.contains("macos"));
assert!(t.contains("arm64"));
assert!(t.contains("aarch64"));
assert!(t.contains("x86_64"));
}
#[test]
fn macos_alias_resolves_to_darwin() {
let t = GateTable::with_builtins();
let macos = t.lookup("macos").unwrap();
let h = host("darwin", "x86_64");
assert!(macos.matches(&h));
}
#[test]
fn arm64_alias_resolves_to_aarch64() {
let t = GateTable::with_builtins();
let arm = t.lookup("arm64").unwrap();
let h = host("darwin", "aarch64");
assert!(arm.matches(&h));
}
#[test]
fn single_dim_predicate_matches() {
let p = GatePredicate::single(Dimension::Os, "darwin");
assert!(p.matches(&host("darwin", "aarch64")));
assert!(!p.matches(&host("linux", "aarch64")));
}
#[test]
fn compound_predicate_is_and() {
let p = GatePredicate {
matchers: vec![
(Dimension::Os, "darwin".into()),
(Dimension::Arch, "aarch64".into()),
],
};
assert!(p.matches(&host("darwin", "aarch64")));
assert!(!p.matches(&host("darwin", "x86_64")));
assert!(!p.matches(&host("linux", "aarch64")));
}
#[test]
fn missing_dimension_does_not_match() {
let p = GatePredicate {
matchers: vec![(Dimension::Hostname, "foo".into())],
};
let h = HostFacts {
os: "linux".into(),
arch: "x86_64".into(),
hostname: None,
username: None,
};
assert!(!p.matches(&h));
}
#[test]
fn describe_renders_inline_table() {
let p = GatePredicate {
matchers: vec![
(Dimension::Os, "darwin".into()),
(Dimension::Arch, "aarch64".into()),
],
};
assert_eq!(p.describe(), "os=darwin, arch=aarch64");
}
#[test]
fn merge_user_adds_labels() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut laptop = HashMap::new();
laptop.insert("hostname".to_string(), "mbp".to_string());
user.insert("laptop".to_string(), laptop);
t.merge_user(&user).unwrap();
assert!(t.contains("laptop"));
}
#[test]
fn merge_user_compound_label_is_and() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut arm_mac = HashMap::new();
arm_mac.insert("os".into(), "darwin".into());
arm_mac.insert("arch".into(), "aarch64".into());
user.insert("arm-mac".into(), arm_mac);
t.merge_user(&user).unwrap();
let p = t.lookup("arm-mac").unwrap();
assert!(p.matches(&host("darwin", "aarch64")));
assert!(!p.matches(&host("darwin", "x86_64")));
}
#[test]
fn merge_user_unknown_dimension_errors() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut bad = HashMap::new();
bad.insert("kernel".into(), "linux".into());
user.insert("bad".into(), bad);
let err = t.merge_user(&user).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("bad"), "missing label name: {msg}");
assert!(msg.contains("kernel"), "missing dim name: {msg}");
}
#[test]
fn merge_user_empty_label_errors() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
user.insert("nodims".into(), HashMap::new());
let err = t.merge_user(&user).unwrap_err();
assert!(err.to_string().contains("nodims"));
}
#[test]
fn merge_user_invalid_label_name_errors() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut dims = HashMap::new();
dims.insert("os".into(), "darwin".into());
user.insert("foo.bar".into(), dims);
let err = t.merge_user(&user).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("foo.bar"), "missing label: {msg}");
assert!(
msg.contains("[A-Za-z0-9_-]+") || msg.contains("identifier"),
"missing grammar hint: {msg}"
);
}
#[test]
fn merge_user_routing_prefix_label_errors() {
for reserved in &["home", "xdg", "app", "lib"] {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut dims = HashMap::new();
dims.insert("os".into(), "darwin".into());
user.insert((*reserved).to_string(), dims);
let err = t.merge_user(&user).unwrap_err();
let msg = err.to_string();
assert!(msg.contains(reserved), "missing token `{reserved}`: {msg}");
assert!(msg.contains("routing-prefix"), "missing reason: {msg}");
}
}
#[test]
fn merge_user_empty_value_errors() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut bad = HashMap::new();
bad.insert("os".into(), "".into());
user.insert("blank".into(), bad);
let err = t.merge_user(&user).unwrap_err();
assert!(err.to_string().contains("empty value"));
}
#[test]
fn merge_user_can_shadow_builtin() {
let mut t = GateTable::with_builtins();
let mut user = HashMap::new();
let mut darwin = HashMap::new();
darwin.insert("os".into(), "darwin".into());
darwin.insert("hostname".into(), "specific-mac".into());
user.insert("darwin".into(), darwin);
t.merge_user(&user).unwrap();
let p = t.lookup("darwin").unwrap();
assert_eq!(p.matchers.len(), 2);
}
#[test]
fn parse_simple_gate_with_extension() {
let g = parse_basename_gate("install._darwin.sh");
match g {
BasenameGate::Found { label, stripped } => {
assert_eq!(label, "darwin");
assert_eq!(stripped, "install.sh");
}
_ => panic!("expected Found"),
}
}
#[test]
fn parse_extensionless_gate() {
let g = parse_basename_gate("Brewfile._darwin");
match g {
BasenameGate::Found { label, stripped } => {
assert_eq!(label, "darwin");
assert_eq!(stripped, "Brewfile");
}
_ => panic!("expected Found, got {g:?}"),
}
}
#[test]
fn parse_compound_label_with_dash() {
let g = parse_basename_gate("install._arm-mac.sh");
match g {
BasenameGate::Found { label, stripped } => {
assert_eq!(label, "arm-mac");
assert_eq!(stripped, "install.sh");
}
_ => panic!("expected Found"),
}
}
#[test]
fn parse_no_gate_in_plain_filename() {
assert_eq!(parse_basename_gate("install.sh"), BasenameGate::None);
assert_eq!(parse_basename_gate("Brewfile"), BasenameGate::None);
assert_eq!(parse_basename_gate("vimrc"), BasenameGate::None);
}
#[test]
fn parse_underscore_without_dot_is_not_a_gate() {
assert_eq!(parse_basename_gate("install_darwin.sh"), BasenameGate::None);
}
#[test]
fn parse_dot_underscore_with_empty_label_is_not_a_gate() {
assert_eq!(parse_basename_gate("install._.sh"), BasenameGate::None);
}
#[test]
fn parse_template_extension_sees_inner_gate() {
let g = parse_basename_gate("aliases._darwin.sh.tmpl");
match g {
BasenameGate::Found { label, stripped } => {
assert_eq!(label, "darwin");
assert_eq!(stripped, "aliases.sh.tmpl");
}
_ => panic!("expected Found"),
}
}
#[test]
fn parse_routing_prefix_with_gate() {
let g = parse_basename_gate("home.bashrc._darwin");
match g {
BasenameGate::Found { label, stripped } => {
assert_eq!(label, "darwin");
assert_eq!(stripped, "home.bashrc");
}
_ => panic!("expected Found"),
}
}
#[test]
fn parse_only_takes_rightmost_label() {
let g = parse_basename_gate("foo._bar._baz.sh");
match g {
BasenameGate::Found { label, stripped } => {
assert_eq!(label, "baz");
assert_eq!(stripped, "foo._bar.sh");
}
_ => panic!("expected Found"),
}
}
#[test]
fn pack_os_empty_allowlist_is_active_everywhere() {
let allowed: Vec<String> = vec![];
assert!(pack_os_active(&allowed, &host("darwin", "aarch64")));
assert!(pack_os_active(&allowed, &host("linux", "x86_64")));
}
#[test]
fn pack_os_matches_listed_os() {
let allowed = vec!["darwin".to_string()];
assert!(pack_os_active(&allowed, &host("darwin", "aarch64")));
assert!(!pack_os_active(&allowed, &host("linux", "x86_64")));
}
#[test]
fn pack_os_macos_alias_matches_darwin() {
let allowed = vec!["macos".to_string()];
assert!(pack_os_active(&allowed, &host("darwin", "aarch64")));
}
#[test]
fn pack_os_multiple_oses_is_or() {
let allowed = vec!["darwin".into(), "linux".into()];
assert!(pack_os_active(&allowed, &host("darwin", "aarch64")));
assert!(pack_os_active(&allowed, &host("linux", "x86_64")));
assert!(!pack_os_active(&allowed, &host("windows", "x86_64")));
}
#[test]
fn dir_gate_recognised_for_underscore_label() {
assert_eq!(parse_dir_gate_label("_darwin"), Some("darwin"));
assert_eq!(parse_dir_gate_label("_arm64"), Some("arm64"));
assert_eq!(parse_dir_gate_label("_arm-mac"), Some("arm-mac"));
}
#[test]
fn dir_gate_routing_prefix_not_a_gate() {
assert_eq!(parse_dir_gate_label("_home"), None);
assert_eq!(parse_dir_gate_label("_xdg"), None);
assert_eq!(parse_dir_gate_label("_app"), None);
assert_eq!(parse_dir_gate_label("_lib"), None);
}
#[test]
fn dir_gate_no_underscore_is_not_a_gate() {
assert_eq!(parse_dir_gate_label("darwin"), None);
assert_eq!(parse_dir_gate_label("nvim"), None);
}
#[test]
fn dir_gate_invalid_chars_not_a_gate() {
assert_eq!(parse_dir_gate_label("_"), None);
assert_eq!(parse_dir_gate_label("_da.rwin"), None);
assert_eq!(parse_dir_gate_label("_dar win"), None);
}
#[test]
fn hostfacts_detect_runs() {
let h = HostFacts::detect();
assert!(!h.os.is_empty());
assert!(!h.arch.is_empty());
}
#[test]
fn hostfacts_get_returns_known_dims() {
let h = host("darwin", "aarch64");
assert_eq!(h.get(Dimension::Os), Some("darwin"));
assert_eq!(h.get(Dimension::Arch), Some("aarch64"));
}
}