#[cfg(unix)]
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use std::sync::Arc;
#[cfg(unix)]
use eyre::Result;
use eyre::bail;
use indexmap::IndexMap;
use serde::Deserialize;
use crate::config::{Config, ConfigMap};
use crate::system::defaults::{DefaultsRequest, DefaultsValue};
use crate::system::launchd::{LaunchdRequest, LaunchdTomlConfig};
use crate::system::packages::{PackageRequest, SystemPackageManager};
use crate::system::repos::{RepoRequest, RepoTomlConfig};
use crate::system::shell_activation::{
ShellActivationMode, ShellActivationRequest, ShellActivationShell, ShellActivationTarget,
};
use crate::system::systemd::{SystemdRequest, SystemdTomlConfig};
#[cfg(target_os = "linux")]
pub mod accounts;
#[cfg(not(target_os = "linux"))]
#[path = "accounts_non_linux.rs"]
pub mod accounts;
pub mod compose;
pub mod defaults;
pub mod deps;
pub mod edits;
pub mod files;
#[cfg(target_os = "linux")]
pub mod firewall;
#[cfg(not(target_os = "linux"))]
#[path = "firewall_non_linux.rs"]
pub mod firewall;
pub mod hooks;
pub mod launchd;
pub mod login_shell;
pub mod managed_files;
pub mod packages;
pub mod remote;
pub mod repos;
pub mod resources;
pub mod secrets;
#[cfg(target_os = "linux")]
pub mod services;
#[cfg(not(target_os = "linux"))]
#[path = "services_non_linux.rs"]
pub mod services;
pub mod shell_activation;
pub(crate) mod sudo;
pub mod systemd;
#[derive(Debug, Default, Clone, Deserialize)]
pub struct BootstrapTomlConfig {
pub config_roots: Option<Vec<String>>,
#[serde(default)]
pub secrets: IndexMap<String, secrets::SecretTomlConfig>,
#[serde(default)]
pub groups: IndexMap<String, accounts::GroupTomlConfig>,
#[serde(default)]
pub users: IndexMap<String, accounts::UserTomlConfig>,
#[serde(default)]
pub services: IndexMap<String, services::ServiceTomlConfig>,
#[serde(default)]
pub compose: IndexMap<String, compose::ComposeTomlConfig>,
#[serde(default)]
pub remote: remote::RemoteTomlConfig,
#[serde(default)]
pub plugins: IndexMap<String, String>,
#[serde(default)]
pub packages: IndexMap<String, PackageTomlConfig>,
#[serde(default)]
pub files: IndexMap<String, managed_files::ManagedFileTomlConfig>,
#[serde(default)]
pub directories: IndexMap<String, managed_files::ManagedDirectoryTomlConfig>,
#[serde(default)]
pub repos: IndexMap<String, RepoTomlConfig>,
#[serde(default)]
pub macos: BootstrapMacosTomlConfig,
#[serde(default)]
pub linux: BootstrapLinuxTomlConfig,
#[serde(default)]
pub user: BootstrapUserTomlConfig,
#[serde(default)]
pub brew: SystemBrewTomlConfig,
#[serde(default)]
pub mise_shell_activate: IndexMap<String, toml::Value>,
#[serde(default)]
pub hooks: IndexMap<String, toml::Value>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(untagged)]
pub enum PackageTomlConfig {
Version(String),
Options(PackageOptionsTomlConfig),
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PackageOptionsTomlConfig {
#[serde(default = "latest_package_version")]
pub version: String,
#[serde(default, deserialize_with = "deserialize_package_os")]
pub os: Vec<String>,
#[serde(default)]
pub adopt: Option<bool>,
}
impl PackageTomlConfig {
pub fn version(&self) -> &str {
match self {
Self::Version(version) => version,
Self::Options(options) => &options.version,
}
}
fn adopt(&self) -> Option<bool> {
match self {
Self::Options(options) => options.adopt,
Self::Version(_) => None,
}
}
fn is_os_supported(&self) -> bool {
let Self::Options(options) = self else {
return true;
};
options.os.is_empty() || options.os.iter().any(|entry| package_os_matches(entry))
}
}
fn latest_package_version() -> String {
"latest".to_string()
}
fn deserialize_package_os<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
where
D: serde::Deserializer<'de>,
{
#[derive(Deserialize)]
#[serde(untagged)]
enum OneOrMany {
One(String),
Many(Vec<String>),
}
let values = match OneOrMany::deserialize(deserializer)? {
OneOrMany::One(value) => vec![value],
OneOrMany::Many(values) => values,
};
if values.is_empty() || values.iter().any(|value| value.is_empty()) {
return Err(serde::de::Error::custom(
"package os must contain at least one non-empty selector",
));
}
Ok(values)
}
fn package_os_matches(entry: &str) -> bool {
let current_os = crate::cli::version::OS.as_str();
let current_arch = crate::cli::version::ARCH.as_str();
if let Some((os, arch)) = entry.split_once('/') {
normalize_package_os(os) == current_os && normalize_package_arch(arch) == current_arch
} else {
normalize_package_os(entry) == current_os
}
}
fn normalize_package_os(os: &str) -> &str {
match os {
"darwin" | "macos" => "macos",
"windows" | "win" => "windows",
other => other,
}
}
fn normalize_package_arch(arch: &str) -> &str {
match arch {
"x86_64" | "amd64" | "x64" => "x64",
"aarch64" | "arm64" => "arm64",
other => other,
}
}
pub fn plugins_from_config(config: &Config) -> IndexMap<String, String> {
let mut plugins = IndexMap::new();
for cf in config.config_files.values().rev() {
if let Some(bootstrap) = cf.bootstrap_config() {
for (name, url) in bootstrap.plugins {
plugins.insert(name, url);
}
}
}
plugins
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct BootstrapUserTomlConfig {
#[serde(default)]
pub login_shell: Option<String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct BootstrapMacosTomlConfig {
#[serde(default)]
pub dock: IndexMap<String, toml::Value>,
#[serde(default)]
pub finder: IndexMap<String, toml::Value>,
#[serde(default)]
pub keyboard: IndexMap<String, toml::Value>,
#[serde(default)]
pub trackpad: IndexMap<String, toml::Value>,
#[serde(default)]
pub defaults: IndexMap<String, toml::Value>,
#[serde(default)]
pub launchd: BootstrapMacosLaunchdTomlConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct BootstrapMacosLaunchdTomlConfig {
#[serde(default)]
pub agents: IndexMap<String, LaunchdTomlConfig>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct BootstrapLinuxTomlConfig {
#[serde(default)]
pub firewall: Option<firewall::FirewallTomlConfig>,
#[serde(default)]
pub systemd: BootstrapLinuxSystemdTomlConfig,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct BootstrapLinuxSystemdTomlConfig {
#[serde(default)]
pub units: IndexMap<String, SystemdTomlConfig>,
}
#[derive(Debug, Default, Clone, Deserialize)]
pub struct SystemBrewTomlConfig {
#[cfg(unix)]
#[serde(default)]
pub adopt: Option<bool>,
#[serde(default)]
pub taps: IndexMap<String, String>,
}
#[derive(Debug, Default, Clone, Deserialize)]
#[serde(transparent)]
pub struct DotfilesTomlConfig(pub IndexMap<String, toml::Value>);
pub struct ManagerPackages {
pub manager: Arc<dyn SystemPackageManager>,
pub requests: Vec<PackageRequest>,
pub options: ManagerPackageOptions,
pub disabled: bool,
}
#[derive(Debug, Clone, Default)]
pub enum ManagerPackageOptions {
#[default]
None,
#[cfg(unix)]
BrewCask { adopt: BTreeSet<String> },
}
impl ManagerPackageOptions {
#[cfg(unix)]
pub(crate) fn brew_cask_adopt(&self, name: &str) -> bool {
matches!(self, Self::BrewCask { adopt } if adopt.contains(name))
}
}
pub fn parse_spec(spec: &str) -> eyre::Result<(String, String)> {
match spec.split_once(':') {
Some((mgr, pkg)) if !mgr.is_empty() && !pkg.is_empty() => {
Ok((mgr.to_string(), pkg.to_string()))
}
_ => bail!(
"invalid system package spec '{spec}': expected '<manager>:<package>' (e.g. \"apt:curl\")"
),
}
}
pub fn parse_use_spec(spec: &str) -> eyre::Result<(String, PackageRequest)> {
let (mgr, rest) = parse_spec(spec)?;
let rest = normalize_use_spec_package_name(&mgr, &rest)?;
validate_package_name(&mgr, rest)?;
if is_opaque_package_manager(&mgr) {
return Ok((
mgr,
PackageRequest {
name: rest.to_string(),
version: None,
tap_url: None,
},
));
}
match rest.rsplit_once('@') {
Some((name, version)) if !name.is_empty() && !version.is_empty() => Ok((
mgr,
PackageRequest {
name: name.to_string(),
version: (version != "latest").then(|| version.to_string()),
tap_url: None,
},
)),
Some(_) => {
bail!("invalid system package spec '{spec}': expected '<manager>:<package>[@version]'")
}
None => Ok((
mgr,
PackageRequest {
name: rest.to_string(),
version: None,
tap_url: None,
},
)),
}
}
pub fn packages_from_requests(
by_mgr: IndexMap<String, Vec<PackageRequest>>,
) -> eyre::Result<Vec<ManagerPackages>> {
resolve_managers(by_mgr, IndexMap::new(), true)
}
pub fn attach_brew_tap_urls(config: &Config, by_mgr: &mut IndexMap<String, Vec<PackageRequest>>) {
let brew_taps = brew_taps_from_config(config);
for mgr in ["brew", "brew-cask"] {
if let Some(requests) = by_mgr.get_mut(mgr) {
for request in requests {
request.tap_url =
brew_tap_name(&request.name).and_then(|tap| brew_taps.get(tap).cloned());
}
}
}
}
pub fn packages_from_config(config: &Config) -> Vec<ManagerPackages> {
let brew_taps = brew_taps_from_config(config);
packages_from_config_files_with_brew_taps(&config.config_files, &brew_taps)
}
#[cfg(unix)]
pub(crate) fn package_configs_for_target(
config: &Config,
target: &Path,
) -> IndexMap<String, PackageTomlConfig> {
package_configs_from_config_files_for_target(&config.config_files, target)
}
pub fn pending_plugin_packages_from_config(
config: &Config,
) -> IndexMap<String, Vec<PackageRequest>> {
pending_plugin_packages_from_config_including_disabled(config)
.into_iter()
.filter(|(name, _)| package_manager_is_enabled(name))
.collect()
}
pub(crate) fn pending_plugin_packages_from_config_including_disabled(
config: &Config,
) -> IndexMap<String, Vec<PackageRequest>> {
let declared = plugins_from_config(config);
let brew_taps = brew_taps_from_config(config);
let installed = packages::all_managers()
.into_iter()
.map(|manager| manager.name().to_string())
.collect::<std::collections::HashSet<_>>();
package_requests_from_config_files(&config.config_files, &brew_taps)
.0
.into_iter()
.filter(|(name, _)| declared.contains_key(name) && !installed.contains(name))
.collect()
}
pub(crate) fn package_manager_is_enabled(name: &str) -> bool {
crate::config::Settings::get()
.system_packages
.managers
.as_ref()
.is_none_or(|names| names.iter().any(|enabled| enabled == name))
}
#[cfg(unix)]
pub async fn packages_from_config_and_tracked_config_files(
config: &Arc<Config>,
) -> Result<Vec<ManagerPackages>> {
let tracked_config_files = config.get_tracked_config_files().await?;
packages_from_config_files_and_tracked_config_files(&config.config_files, &tracked_config_files)
}
#[cfg(unix)]
fn packages_from_config_files_and_tracked_config_files(
current_config_files: &ConfigMap,
tracked_config_files: &ConfigMap,
) -> Result<Vec<ManagerPackages>> {
let mut by_mgr: IndexMap<String, Vec<PackageRequest>> = IndexMap::new();
let mut manager_options = IndexMap::new();
let current_brew_taps = brew_taps_from_config_files(current_config_files);
merge_manager_packages(
&mut by_mgr,
&mut manager_options,
packages_from_config_files_with_brew_taps(current_config_files, ¤t_brew_taps),
);
let mut tracked_brew_taps = current_brew_taps;
for (tap, url) in brew_taps_from_config_files(tracked_config_files) {
tracked_brew_taps.insert(tap, url);
}
merge_manager_packages(
&mut by_mgr,
&mut manager_options,
packages_from_config_files_with_brew_taps(tracked_config_files, &tracked_brew_taps),
);
resolve_managers(by_mgr, manager_options, false)
}
#[cfg(unix)]
fn merge_manager_packages(
by_mgr: &mut IndexMap<String, Vec<PackageRequest>>,
manager_options: &mut IndexMap<String, ManagerPackageOptions>,
manager_packages: Vec<ManagerPackages>,
) {
for mp in manager_packages {
let manager_name = mp.manager.name().to_string();
let requests = by_mgr.entry(manager_name.clone()).or_default();
for request in mp.requests {
let adopt = mp.options.brew_cask_adopt(&request.name);
match requests.iter_mut().find(|existing| {
existing.name == request.name && existing.version == request.version
}) {
Some(existing) if existing.tap_url.is_none() => {
existing.tap_url = request.tap_url;
}
Some(_) => {}
None => {
if adopt {
let options =
manager_options
.entry(manager_name.clone())
.or_insert_with(|| ManagerPackageOptions::BrewCask {
adopt: BTreeSet::new(),
});
if let ManagerPackageOptions::BrewCask { adopt } = options {
adopt.insert(request.name.clone());
}
}
requests.push(request);
}
}
}
}
}
pub fn packages_from_config_files(config_files: &ConfigMap) -> Vec<ManagerPackages> {
packages_from_config_files_with_brew_taps(config_files, &IndexMap::new())
}
fn packages_from_config_files_with_brew_taps(
config_files: &ConfigMap,
brew_taps: &IndexMap<String, String>,
) -> Vec<ManagerPackages> {
let (requests, options) = package_requests_from_config_files(config_files, brew_taps);
resolve_managers(requests, options, false).expect("non-strict resolve is infallible")
}
fn package_requests_from_config_files(
config_files: &ConfigMap,
brew_taps: &IndexMap<String, String>,
) -> (
IndexMap<String, Vec<PackageRequest>>,
IndexMap<String, ManagerPackageOptions>,
) {
let merged = package_configs_from_config_files(config_files);
let mut by_mgr: IndexMap<String, Vec<PackageRequest>> = IndexMap::new();
#[cfg(unix)]
let brew_adopt = brew_adopt_from_config_files(config_files);
#[cfg(unix)]
let mut cask_adopt = BTreeSet::new();
for (spec, package) in merged {
if !package.is_os_supported() {
debug!("[bootstrap.packages]: skipping '{spec}', not enabled for this platform");
continue;
}
match parse_spec(&spec) {
Ok((mgr, name)) => {
let version = package.version();
let version = (version != "latest").then(|| version.to_string());
if let Err(err) = validate_package_name(&mgr, &name) {
warn!("[bootstrap.packages]: {err}");
continue;
}
let tap_url = if is_brew_manager(&mgr) {
brew_tap_name(&name).and_then(|tap| brew_taps.get(tap).cloned())
} else {
None
};
let adopt_requested = package.adopt();
if adopt_requested.is_some() && mgr != "brew-cask" {
warn!(
"[bootstrap.packages]: adopt is only supported for brew-cask entries; ignoring it for '{spec}'"
);
}
#[cfg(unix)]
if mgr == "brew-cask" && adopt_requested.unwrap_or(brew_adopt) {
cask_adopt.insert(name.clone());
}
by_mgr.entry(mgr).or_default().push(PackageRequest {
name,
version,
tap_url,
});
}
Err(err) => warn!("[bootstrap.packages]: {err}"),
}
}
#[cfg(unix)]
let options = if cask_adopt.is_empty() {
IndexMap::new()
} else {
IndexMap::from([(
"brew-cask".to_string(),
ManagerPackageOptions::BrewCask { adopt: cask_adopt },
)])
};
#[cfg(not(unix))]
let options = IndexMap::new();
(by_mgr, options)
}
fn package_configs_from_config_files(
config_files: &ConfigMap,
) -> IndexMap<String, PackageTomlConfig> {
merge_package_configs(config_files.values())
}
#[cfg(unix)]
fn package_configs_from_config_files_for_target(
config_files: &ConfigMap,
target: &Path,
) -> IndexMap<String, PackageTomlConfig> {
let target_index = config_files.get_index_of(target).unwrap_or(0);
merge_package_configs(config_files.values().skip(target_index))
}
fn merge_package_configs<'a>(
config_files: impl DoubleEndedIterator<Item = &'a Arc<dyn crate::config::config_file::ConfigFile>>,
) -> IndexMap<String, PackageTomlConfig> {
let mut merged: IndexMap<String, PackageTomlConfig> = IndexMap::new();
for cf in config_files.rev() {
if let Some(sys) = cf.bootstrap_config() {
for (spec, version) in sys.packages {
merged.insert(spec, version);
}
}
}
merged
}
pub fn defaults_from_config(config: &Config) -> Vec<DefaultsRequest> {
let mut merged: IndexMap<(String, String), toml::Value> = IndexMap::new();
for cf in config.config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
let mut friendly: IndexMap<(String, String), toml::Value> = IndexMap::new();
let mut raw: IndexMap<(String, String), toml::Value> = IndexMap::new();
merge_friendly_macos_defaults(&mut friendly, &sys.macos);
for (domain, entries) in sys.macos.defaults {
match entries {
toml::Value::Table(entries) => {
for (key, value) in entries {
raw.insert((domain.clone(), key), value);
}
}
_ => warn!(
"[bootstrap.macos.defaults]: expected a table of key/value pairs for domain '{domain}'"
),
}
}
for (key, value) in merge_raw_over_friendly_macos_defaults(friendly, raw) {
merged.insert(key, value);
}
}
}
let mut out = vec![];
for ((domain, key), value) in merged {
match DefaultsValue::from_toml(&value) {
Some(value) => out.push(DefaultsRequest { domain, key, value }),
None => warn!(
"[bootstrap.macos.defaults]: unsupported value type for {domain} {key} \
(expected bool, integer, float, or string)"
),
}
}
out
}
pub fn launchd_from_config(config: &Config) -> Vec<LaunchdRequest> {
let mut merged: IndexMap<String, LaunchdTomlConfig> = IndexMap::new();
for cf in config.config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
for (name, agent) in sys.macos.launchd.agents {
merged.insert(name, agent);
}
}
}
let mut out = vec![];
for (name, agent) in merged {
match LaunchdRequest::from_toml(name, agent) {
Ok(request) => out.push(request),
Err(err) => warn!("[bootstrap.macos.launchd.agents]: {err}"),
}
}
out
}
pub fn repos_from_config(config: &Config) -> Vec<RepoRequest> {
let mut merged: IndexMap<PathBuf, RepoRequest> = IndexMap::new();
for cf in config.config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
for (path_raw, repo) in sys.repos {
match RepoRequest::from_toml(path_raw.clone(), repo, cf.project_root().as_deref()) {
Ok(request) => {
merged.insert(request.path.clone(), request);
}
Err(err) => warn!("[bootstrap.repos].\"{path_raw}\": {err}"),
}
}
}
}
merged.into_values().collect()
}
pub fn macos_defaults_entry_count(macos: &BootstrapMacosTomlConfig) -> usize {
let mut friendly: IndexMap<(String, String), toml::Value> = IndexMap::new();
let mut raw: IndexMap<(String, String), toml::Value> = IndexMap::new();
let mut malformed_domains = 0usize;
merge_friendly_macos_defaults(&mut friendly, macos);
for (domain, entries) in &macos.defaults {
match entries {
toml::Value::Table(entries) => {
for (key, value) in entries {
raw.insert((domain.clone(), key.clone()), value.clone());
}
}
_ => malformed_domains += 1,
}
}
merge_raw_over_friendly_macos_defaults(friendly, raw).len() + malformed_domains
}
fn merge_raw_over_friendly_macos_defaults(
mut friendly: IndexMap<(String, String), toml::Value>,
raw: IndexMap<(String, String), toml::Value>,
) -> IndexMap<(String, String), toml::Value> {
for (key, value) in raw {
friendly.insert(key, value);
}
friendly
}
fn merge_friendly_macos_defaults(
out: &mut IndexMap<(String, String), toml::Value>,
macos: &BootstrapMacosTomlConfig,
) {
merge_dock_defaults(out, &macos.dock);
merge_finder_defaults(out, &macos.finder);
merge_keyboard_defaults(out, &macos.keyboard);
merge_trackpad_defaults(out, &macos.trackpad);
}
#[derive(Clone, Copy)]
struct FriendlyDefaultSpec<'a> {
section: &'a str,
key: &'a str,
defaults_key: &'a str,
expected: fn(&toml::Value) -> bool,
expected_type: &'a str,
}
fn insert_friendly_default(
out: &mut IndexMap<(String, String), toml::Value>,
domain: &str,
spec: FriendlyDefaultSpec<'_>,
value: toml::Value,
) {
if (spec.expected)(&value) {
out.insert((domain.to_string(), spec.defaults_key.to_string()), value);
} else {
let FriendlyDefaultSpec {
section,
key,
expected_type,
..
} = spec;
warn!(
"[bootstrap.macos.{section}].{key}: unsupported value type \
(expected {expected_type})"
);
}
}
fn insert_friendly_multi_domain_default(
out: &mut IndexMap<(String, String), toml::Value>,
domains: &[&str],
spec: FriendlyDefaultSpec<'_>,
value: toml::Value,
) {
if (spec.expected)(&value) {
for domain in domains {
out.insert(
(domain.to_string(), spec.defaults_key.to_string()),
value.clone(),
);
}
} else {
let FriendlyDefaultSpec {
section,
key,
expected_type,
..
} = spec;
warn!(
"[bootstrap.macos.{section}].{key}: unsupported value type \
(expected {expected_type})"
);
}
}
fn is_bool(value: &toml::Value) -> bool {
matches!(value, toml::Value::Boolean(_))
}
fn is_integer(value: &toml::Value) -> bool {
matches!(value, toml::Value::Integer(_))
}
fn merge_dock_defaults(
out: &mut IndexMap<(String, String), toml::Value>,
entries: &IndexMap<String, toml::Value>,
) {
for (key, value) in entries {
match key.as_str() {
"autohide" => insert_friendly_default(
out,
"com.apple.dock",
FriendlyDefaultSpec {
section: "dock",
key,
defaults_key: "autohide",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"orientation" => match value {
toml::Value::String(s) if matches!(s.as_str(), "bottom" | "left" | "right") => {
out.insert(
("com.apple.dock".to_string(), "orientation".to_string()),
value.clone(),
);
}
toml::Value::String(_) => warn!(
"[bootstrap.macos.dock].orientation: invalid value \
(expected bottom, left, or right)"
),
_ => warn!(
"[bootstrap.macos.dock].orientation: unsupported value type (expected string)"
),
},
"tilesize" => insert_friendly_default(
out,
"com.apple.dock",
FriendlyDefaultSpec {
section: "dock",
key,
defaults_key: "tilesize",
expected: is_integer,
expected_type: "integer",
},
value.clone(),
),
"magnification" => insert_friendly_default(
out,
"com.apple.dock",
FriendlyDefaultSpec {
section: "dock",
key,
defaults_key: "magnification",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"largesize" => insert_friendly_default(
out,
"com.apple.dock",
FriendlyDefaultSpec {
section: "dock",
key,
defaults_key: "largesize",
expected: is_integer,
expected_type: "integer",
},
value.clone(),
),
"show_recents" => insert_friendly_default(
out,
"com.apple.dock",
FriendlyDefaultSpec {
section: "dock",
key,
defaults_key: "show-recents",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"mru_spaces" => insert_friendly_default(
out,
"com.apple.dock",
FriendlyDefaultSpec {
section: "dock",
key,
defaults_key: "mru-spaces",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
_ => warn!("[bootstrap.macos.dock].{key}: unknown key, ignoring entry"),
}
}
}
fn merge_finder_defaults(
out: &mut IndexMap<(String, String), toml::Value>,
entries: &IndexMap<String, toml::Value>,
) {
for (key, value) in entries {
match key.as_str() {
"show_all_files" => insert_friendly_default(
out,
"com.apple.finder",
FriendlyDefaultSpec {
section: "finder",
key,
defaults_key: "AppleShowAllFiles",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"show_pathbar" => insert_friendly_default(
out,
"com.apple.finder",
FriendlyDefaultSpec {
section: "finder",
key,
defaults_key: "ShowPathbar",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"show_status_bar" => insert_friendly_default(
out,
"com.apple.finder",
FriendlyDefaultSpec {
section: "finder",
key,
defaults_key: "ShowStatusBar",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"show_extensions_warning" => insert_friendly_default(
out,
"com.apple.finder",
FriendlyDefaultSpec {
section: "finder",
key,
defaults_key: "FXEnableExtensionChangeWarning",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"preferred_view_style" => match value {
toml::Value::String(s) => {
let mapped = match s.as_str() {
"icon" => Some("icnv"),
"list" => Some("Nlsv"),
"column" => Some("clmv"),
"gallery" => Some("glyv"),
_ => None,
};
if let Some(mapped) = mapped {
out.insert(
(
"com.apple.finder".to_string(),
"FXPreferredViewStyle".to_string(),
),
toml::Value::String(mapped.to_string()),
);
} else {
warn!(
"[bootstrap.macos.finder].preferred_view_style: invalid value \
(expected icon, list, column, or gallery)"
);
}
}
_ => warn!(
"[bootstrap.macos.finder].preferred_view_style: unsupported value type \
(expected string)"
),
},
_ => warn!("[bootstrap.macos.finder].{key}: unknown key, ignoring entry"),
}
}
}
fn merge_keyboard_defaults(
out: &mut IndexMap<(String, String), toml::Value>,
entries: &IndexMap<String, toml::Value>,
) {
for (key, value) in entries {
match key.as_str() {
"key_repeat" => insert_friendly_default(
out,
"NSGlobalDomain",
FriendlyDefaultSpec {
section: "keyboard",
key,
defaults_key: "KeyRepeat",
expected: is_integer,
expected_type: "integer",
},
value.clone(),
),
"initial_key_repeat" => insert_friendly_default(
out,
"NSGlobalDomain",
FriendlyDefaultSpec {
section: "keyboard",
key,
defaults_key: "InitialKeyRepeat",
expected: is_integer,
expected_type: "integer",
},
value.clone(),
),
"press_and_hold" => insert_friendly_default(
out,
"NSGlobalDomain",
FriendlyDefaultSpec {
section: "keyboard",
key,
defaults_key: "ApplePressAndHoldEnabled",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"fn_state" => insert_friendly_default(
out,
"NSGlobalDomain",
FriendlyDefaultSpec {
section: "keyboard",
key,
defaults_key: "com.apple.keyboard.fnState",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
_ => warn!("[bootstrap.macos.keyboard].{key}: unknown key, ignoring entry"),
}
}
}
fn merge_trackpad_defaults(
out: &mut IndexMap<(String, String), toml::Value>,
entries: &IndexMap<String, toml::Value>,
) {
for (key, value) in entries {
match key.as_str() {
"tap_to_click" => insert_friendly_multi_domain_default(
out,
&[
"com.apple.AppleMultitouchTrackpad",
"com.apple.driver.AppleBluetoothMultitouch.trackpad",
],
FriendlyDefaultSpec {
section: "trackpad",
key,
defaults_key: "Clicking",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
"three_finger_drag" => insert_friendly_multi_domain_default(
out,
&[
"com.apple.AppleMultitouchTrackpad",
"com.apple.driver.AppleBluetoothMultitouch.trackpad",
],
FriendlyDefaultSpec {
section: "trackpad",
key,
defaults_key: "TrackpadThreeFingerDrag",
expected: is_bool,
expected_type: "bool",
},
value.clone(),
),
_ => warn!("[bootstrap.macos.trackpad].{key}: unknown key, ignoring entry"),
}
}
}
pub fn systemd_from_config(config: &Config) -> Vec<SystemdRequest> {
let mut merged: IndexMap<String, SystemdTomlConfig> = IndexMap::new();
for cf in config.config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
for (name, unit) in sys.linux.systemd.units {
merged.insert(name, unit);
}
}
}
let mut out = vec![];
for (name, unit) in merged {
match SystemdRequest::from_toml(name, unit) {
Ok(request) => out.push(request),
Err(err) => warn!("[bootstrap.linux.systemd.units]: {err}"),
}
}
out
}
pub fn login_shell_from_config(config: &Config) -> Option<login_shell::LoginShellRequest> {
let mut shell = None;
for cf in config.config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config()
&& let Some(login_shell) = sys.user.login_shell
{
let login_shell = login_shell.trim().to_string();
if login_shell.is_empty() {
warn!("[bootstrap.user].login_shell: must not be empty, ignoring entry");
continue;
}
if !Path::new(&login_shell).is_absolute() {
warn!(
"[bootstrap.user].login_shell: shell must be an absolute path, ignoring entry"
);
continue;
}
shell = Some(login_shell);
}
}
shell.map(|shell| login_shell::LoginShellRequest { shell })
}
pub fn shell_activation_from_config(config: &Config) -> Vec<ShellActivationRequest> {
shell_activation_from_config_files(&config.config_files)
}
pub fn shell_activation_from_config_files(config_files: &ConfigMap) -> Vec<ShellActivationRequest> {
let explicit_files = files::files_from_config_files(config_files);
let explicit_edits = edits::edits_from_config_files(config_files);
let mut merged: IndexMap<ShellActivationTarget, Option<ShellActivationMode>> = IndexMap::new();
for cf in config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
let mut shell_entries = vec![];
let mut target_entries = vec![];
for (key, value) in sys.mise_shell_activate {
if let Some(shell) = ShellActivationShell::parse(&key) {
shell_entries.push((key, shell, value));
} else if let Some(target) = ShellActivationTarget::parse(&key) {
target_entries.push((key, target, value));
} else {
warn!(
"[bootstrap.mise_shell_activate]: unknown target '{key}' \
(expected {}), ignoring entry",
ShellActivationTarget::expected_keys()
);
continue;
};
}
for (key, shell, value) in shell_entries {
match shell_activation_setting(&key, value) {
Some(setting) => {
for &target in shell.default_targets() {
merge_shell_activation_target(&mut merged, target, setting);
}
}
None => {
warn_shell_activation_invalid_shell_retains_broader(&merged, &key, shell);
}
}
}
for (key, target, value) in target_entries {
match shell_activation_setting(&key, value) {
Some(setting) => {
merge_shell_activation_target(&mut merged, target, setting);
}
None => {
warn_shell_activation_invalid_target_retains_broader(&merged, &key, target);
}
}
}
}
}
merged
.into_iter()
.filter_map(|(target, mode)| {
mode.and_then(|mode| {
let request = ShellActivationRequest::new(target, mode);
if explicit_files
.iter()
.any(|file| file.target == request.edit.path)
{
debug!(
"bootstrap: shell activation for {} skipped because [dotfiles] owns {}",
target.name(),
request.edit.path_raw
);
return None;
}
if explicit_edits
.iter()
.any(|edit| edit.path == request.edit.path && edit.id == request.edit.id)
{
debug!(
"bootstrap: shell activation for {} skipped because [dotfiles] owns {}/{}",
target.name(),
request.edit.path_raw,
request.edit.id
);
None
} else {
Some(request)
}
})
})
.collect()
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
struct ShellActivationSetting {
enabled: bool,
mode: Option<ShellActivationMode>,
}
fn merge_shell_activation_target(
merged: &mut IndexMap<ShellActivationTarget, Option<ShellActivationMode>>,
target: ShellActivationTarget,
setting: ShellActivationSetting,
) {
let mode = setting
.enabled
.then(|| setting.mode.unwrap_or_else(|| target.default_mode()));
merged.insert(target, mode);
}
fn shell_activation_setting(key: &str, value: toml::Value) -> Option<ShellActivationSetting> {
match value {
toml::Value::Boolean(enabled) => Some(ShellActivationSetting {
enabled,
mode: None,
}),
toml::Value::String(mode) => {
let Some(mode) = ShellActivationMode::parse(&mode) else {
warn!(
"[bootstrap.mise_shell_activate.{key}]: expected \"activate\" or \"shims\", \
ignoring entry"
);
return None;
};
Some(ShellActivationSetting {
enabled: true,
mode: Some(mode),
})
}
toml::Value::Table(table) => {
let unknown = table
.keys()
.filter(|key| !matches!(key.as_str(), "enabled" | "mode"))
.cloned()
.collect::<Vec<_>>();
if !unknown.is_empty() {
warn!(
"[bootstrap.mise_shell_activate.{key}]: unknown field(s) {}, ignoring entry",
unknown.join(", ")
);
return None;
}
let enabled = match table.get("enabled") {
Some(toml::Value::Boolean(enabled)) => *enabled,
Some(_) => {
warn!(
"[bootstrap.mise_shell_activate.{key}].enabled: expected bool, \
ignoring entry"
);
return None;
}
None => {
warn!("[bootstrap.mise_shell_activate.{key}]: missing enabled, ignoring entry");
return None;
}
};
let mode = match table.get("mode") {
Some(toml::Value::String(mode)) => {
let Some(mode) = ShellActivationMode::parse(mode) else {
warn!(
"[bootstrap.mise_shell_activate.{key}].mode: expected \"activate\" or \
\"shims\", ignoring entry"
);
return None;
};
Some(mode)
}
Some(_) => {
warn!(
"[bootstrap.mise_shell_activate.{key}].mode: expected string, ignoring entry"
);
return None;
}
None => None,
};
Some(ShellActivationSetting { enabled, mode })
}
_ => {
warn!(
"[bootstrap.mise_shell_activate.{key}]: expected bool, \"activate\", \"shims\", \
or table, ignoring entry"
);
None
}
}
}
fn warn_shell_activation_invalid_shell_retains_broader(
merged: &IndexMap<ShellActivationTarget, Option<ShellActivationMode>>,
key: &str,
shell: ShellActivationShell,
) {
let retained = shell
.default_targets()
.iter()
.filter_map(|target| {
merged.get(target).map(|mode| {
format!(
"{}={}",
target.name(),
shell_activation_setting_display(*mode)
)
})
})
.collect::<Vec<_>>();
if !retained.is_empty() {
warn!(
"[bootstrap.mise_shell_activate.{key}]: invalid entry ignored; keeping broader config \
values {}",
retained.join(", ")
);
}
}
fn warn_shell_activation_invalid_target_retains_broader(
merged: &IndexMap<ShellActivationTarget, Option<ShellActivationMode>>,
key: &str,
target: ShellActivationTarget,
) {
if let Some(mode) = merged.get(&target) {
warn!(
"[bootstrap.mise_shell_activate.{key}]: invalid entry ignored; keeping broader config \
value {}",
shell_activation_setting_display(*mode)
);
}
}
fn shell_activation_setting_display(setting: Option<ShellActivationMode>) -> &'static str {
match setting {
Some(ShellActivationMode::Activate) => "activate",
Some(ShellActivationMode::Shims) => "shims",
None => "disabled",
}
}
pub fn hooks_from_config(config: &Config) -> Vec<hooks::BootstrapHook> {
hooks_from_config_files(&config.config_files)
}
pub(crate) fn hooks_from_config_files(config_files: &ConfigMap) -> Vec<hooks::BootstrapHook> {
let mut out = vec![];
for cf in config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
for (phase, value) in sys.hooks {
match hooks::BootstrapHook::from_toml(&phase, value) {
Ok(hooks) => out.extend(hooks),
Err(err) => warn!("[bootstrap.hooks.{phase}]: {err}"),
}
}
}
}
out
}
pub fn packages_from_specs_with_config(
specs: &[String],
config: Option<&Config>,
) -> eyre::Result<Vec<ManagerPackages>> {
let brew_taps = config.map(brew_taps_from_config).unwrap_or_default();
let mut by_mgr: IndexMap<String, Vec<PackageRequest>> = IndexMap::new();
for spec in specs {
let (mgr, name) = parse_spec(spec)?;
validate_package_name(&mgr, &name)?;
let tap_url = if is_brew_manager(&mgr) {
brew_tap_name(&name).and_then(|tap| brew_taps.get(tap).cloned())
} else {
None
};
let requests = by_mgr.entry(mgr).or_default();
let request = PackageRequest {
name,
version: None,
tap_url,
};
if !requests.contains(&request) {
requests.push(request);
}
}
resolve_managers(by_mgr, IndexMap::new(), true)
}
pub(crate) fn brew_tap_name(name: &str) -> Option<&str> {
let mut parts = name.split('/');
let owner = parts.next()?;
let tap = parts.next()?;
let formula = parts.next()?;
if parts.next().is_some() || owner.is_empty() || tap.is_empty() || formula.is_empty() {
return None;
}
if owner == "homebrew" && (tap == "core" || tap == "cask") {
None
} else {
name.rsplit_once('/').map(|(tap, _)| tap)
}
}
fn is_brew_manager(mgr: &str) -> bool {
matches!(mgr, "brew" | "brew-cask")
}
fn is_opaque_package_manager(mgr: &str) -> bool {
is_brew_manager(mgr) || mgr == "mas"
}
fn normalize_use_spec_package_name<'a>(mgr: &str, name: &'a str) -> eyre::Result<&'a str> {
if mgr == "mas"
&& let Some(name) = name.strip_suffix("@latest")
{
if name.is_empty() {
bail!("invalid system package spec: expected '<manager>:<package>[@version]'");
}
return Ok(name);
}
Ok(name)
}
fn validate_package_name(mgr: &str, name: &str) -> eyre::Result<()> {
if mgr == "mas" && !packages::mas::is_adam_id(name) {
bail!("mas app IDs must be numeric ADAM IDs (e.g. \"mas:497799835\")");
}
Ok(())
}
pub(crate) fn brew_taps_from_config(config: &Config) -> IndexMap<String, String> {
brew_taps_from_config_files(&config.config_files)
}
fn brew_taps_from_config_files(config_files: &ConfigMap) -> IndexMap<String, String> {
let mut brew_taps: IndexMap<String, String> = IndexMap::new();
for cf in config_files.values().rev() {
if let Some(sys) = cf.bootstrap_config() {
for (tap, url) in sys.brew.taps {
brew_taps.insert(tap, url);
}
}
}
brew_taps
}
#[cfg(unix)]
fn brew_adopt_from_config_files(config_files: &ConfigMap) -> bool {
let mut adopt = false;
for cf in config_files.values().rev() {
if let Some(value) = cf.bootstrap_config().and_then(|system| system.brew.adopt) {
adopt = value;
}
}
adopt
}
fn resolve_managers(
by_mgr: IndexMap<String, Vec<PackageRequest>>,
mut manager_options: IndexMap<String, ManagerPackageOptions>,
strict: bool,
) -> eyre::Result<Vec<ManagerPackages>> {
let enabled = crate::config::Settings::get()
.system_packages
.managers
.clone();
let managers = packages::all_managers()
.into_iter()
.map(|manager| (manager.name().to_string(), manager))
.collect::<IndexMap<_, _>>();
let mut out = vec![];
for (name, requests) in by_mgr {
let disabled = enabled.as_ref().is_some_and(|e| !e.contains(&name));
if disabled && strict {
bail!(
"manager '{name}' is excluded by the system_packages.managers setting \
(currently: {})",
enabled.as_deref().unwrap_or_default().join(", ")
);
}
match managers.get(&name) {
Some(manager) => out.push(ManagerPackages {
manager: manager.clone(),
requests,
options: manager_options.shift_remove(&name).unwrap_or_default(),
disabled,
}),
None => {
if strict {
bail!(
"unknown bootstrap package manager '{name}' — install a package plugin: mise plugin install package:{name} <url>"
);
}
if cfg!(windows) && name == "brew" {
debug!("system package manager 'brew' is not supported on windows");
} else if crate::config::is_loaded()
&& plugins_from_config(&crate::config::Config::get_()).contains_key(&name)
{
warn!(
"unknown bootstrap package manager '{name}' in [bootstrap.packages] — declared in [bootstrap.plugins] but not installed; run `mise bootstrap`"
);
} else {
warn!(
"unknown bootstrap package manager '{name}' in [bootstrap.packages], ignoring — install a package plugin: mise plugin install package:{name} <url>"
);
}
}
}
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[cfg(unix)]
use crate::config::config_file::{ConfigFile, mise_toml::MiseToml};
#[cfg(unix)]
use std::sync::Arc;
fn tv(s: &str) -> toml::Value {
s.parse().unwrap()
}
#[cfg(unix)]
fn config_map_from_toml(files: &[(&str, &str)]) -> Result<(tempfile::TempDir, ConfigMap)> {
let tmp = tempfile::tempdir()?;
let mut config_files = ConfigMap::default();
for (name, contents) in files {
let path = tmp.path().join(name);
if let Some(parent) = path.parent() {
crate::file::create_dir_all(parent)?;
}
crate::file::write(&path, contents)?;
let cf: Arc<dyn ConfigFile> = Arc::new(MiseToml::from_file(&path)?);
config_files.insert(path, cf);
}
Ok((tmp, config_files))
}
#[cfg(unix)]
#[test]
fn test_packages_from_config_files_and_tracked_config_files() -> Result<()> {
let (_current_dir, current) = config_map_from_toml(&[(
"current.toml",
r#"
[bootstrap.brew.taps]
"acme/tools" = "https://github.com/acme/homebrew-tools.git"
[bootstrap.packages]
"brew:jq" = "latest"
"#,
)])?;
let (_tracked_dir, tracked) = config_map_from_toml(&[(
"tracked.toml",
r#"
[bootstrap.packages]
"brew:jq" = "latest"
"brew:acme/tools/widget" = "latest"
"brew:ffmpeg" = "latest"
"#,
)])?;
let packages = packages_from_config_files_and_tracked_config_files(¤t, &tracked)?;
let brew = packages
.into_iter()
.find(|mp| mp.manager.name() == "brew")
.unwrap();
let requests = brew
.requests
.iter()
.map(|req| (req.name.as_str(), req.tap_url.as_deref()))
.collect::<Vec<_>>();
assert_eq!(
requests,
vec![
("jq", None),
(
"acme/tools/widget",
Some("https://github.com/acme/homebrew-tools.git")
),
("ffmpeg", None),
]
);
Ok(())
}
#[cfg(unix)]
#[test]
fn test_package_configs_preserve_inherited_selectors() -> Result<()> {
let (_dir, config_files) = config_map_from_toml(&[
(
"local.toml",
r#"
[bootstrap.packages]
"brew:jq" = "latest"
"brew-cask:1password" = { version = "latest", os = ["linux"] }
"#,
),
(
"global.toml",
r#"
[bootstrap.packages]
"brew-cask:1password" = { version = "latest", os = ["macos"] }
"#,
),
])?;
let local_path = config_files.get_index(0).unwrap().0.clone();
let global_path = config_files.get_index(1).unwrap().0.clone();
let packages = package_configs_from_config_files_for_target(&config_files, &local_path);
assert!(matches!(
packages.get("brew-cask:1password"),
Some(PackageTomlConfig::Options(options)) if options.os == ["linux"]
));
let global_packages =
package_configs_from_config_files_for_target(&config_files, &global_path);
assert!(matches!(
global_packages.get("brew-cask:1password"),
Some(PackageTomlConfig::Options(options)) if options.os == ["macos"]
));
assert!(!global_packages.contains_key("brew:jq"));
Ok(())
}
#[cfg(unix)]
#[test]
fn test_brew_cask_adopt_option_reaches_package_request() -> Result<()> {
let (_dir, config_files) = config_map_from_toml(&[(
"mise.toml",
r#"
[bootstrap.brew]
adopt = true
[bootstrap.packages]
"brew-cask:textmate" = "latest"
"brew-cask:replace-me" = { adopt = false }
"#,
)])?;
let packages = packages_from_config_files(&config_files);
let casks = packages
.into_iter()
.find(|packages| packages.manager.name() == "brew-cask")
.unwrap();
assert_eq!(casks.requests.len(), 2);
assert!(casks.options.brew_cask_adopt("textmate"));
assert!(!casks.options.brew_cask_adopt("replace-me"));
Ok(())
}
#[cfg(unix)]
#[test]
fn test_packages_from_config_files_filters_os_and_arch() -> Result<()> {
let current_os = crate::cli::version::OS.as_str();
let current_arch = crate::cli::version::ARCH.as_str();
let inactive_os = if current_os == "linux" {
"macos"
} else {
"linux"
};
let inactive_arch = if current_arch == "x64" {
"arm64"
} else {
"x64"
};
let config = format!(
r#"
[bootstrap.packages]
"brew:active-os" = {{ os = "{current_os}" }}
"brew:active-platform" = {{ version = "1", os = ["{current_os}/{current_arch}", "windows"] }}
"brew:inactive-os" = {{ os = ["{inactive_os}"] }}
"brew:inactive-arch" = {{ os = "{current_os}/{inactive_arch}" }}
"#
);
let (_dir, config_files) = config_map_from_toml(&[("config.toml", &config)])?;
let packages = packages_from_config_files(&config_files);
let brew = packages
.into_iter()
.find(|mp| mp.manager.name() == "brew")
.unwrap();
let requests = brew
.requests
.iter()
.map(|request| (request.name.as_str(), request.version.as_deref()))
.collect::<Vec<_>>();
assert_eq!(
requests,
vec![("active-os", None), ("active-platform", Some("1"))]
);
Ok(())
}
#[test]
fn test_parse_use_spec() {
let (mgr, req) = parse_use_spec("apt:curl").unwrap();
assert_eq!(
(mgr.as_str(), req.name.as_str(), req.version),
("apt", "curl", None)
);
let (mgr, req) = parse_use_spec("apt:curl@8.5.0-2").unwrap();
assert_eq!(mgr, "apt");
assert_eq!(req.name, "curl");
assert_eq!(req.version.as_deref(), Some("8.5.0-2"));
let (_, req) = parse_use_spec("dnf:bash@latest").unwrap();
assert_eq!(req.version, None);
let (mgr, req) = parse_use_spec("apk:zlib-dev@1.3.1-r2").unwrap();
assert_eq!(mgr, "apk");
assert_eq!(req.name, "zlib-dev");
assert_eq!(req.version.as_deref(), Some("1.3.1-r2"));
let (_, req) = parse_use_spec("apt:gcc:arm64@13.2").unwrap();
assert_eq!(req.name, "gcc:arm64");
assert_eq!(req.version.as_deref(), Some("13.2"));
let (mgr, req) = parse_use_spec("brew:postgresql@17").unwrap();
assert_eq!(mgr, "brew");
assert_eq!(req.name, "postgresql@17");
assert_eq!(req.version, None);
let (mgr, req) = parse_use_spec("brew-cask:temurin@17").unwrap();
assert_eq!(mgr, "brew-cask");
assert_eq!(req.name, "temurin@17");
assert_eq!(req.version, None);
let (mgr, req) = parse_use_spec("mas:497799835").unwrap();
assert_eq!(mgr, "mas");
assert_eq!(req.name, "497799835");
assert_eq!(req.version, None);
let (mgr, req) = parse_use_spec("mas:497799835@latest").unwrap();
assert_eq!(mgr, "mas");
assert_eq!(req.name, "497799835");
assert_eq!(req.version, None);
assert!(parse_use_spec("mas:com.example.App").is_err());
assert!(parse_use_spec("mas:497799835@1").is_err());
assert!(parse_use_spec("apt:curl@").is_err());
assert!(parse_use_spec("noprefix").is_err());
}
#[test]
fn test_brew_tap_name() {
assert_eq!(
brew_tap_name("railwaycat/emacsmacport/emacs-mac"),
Some("railwaycat/emacsmacport")
);
assert_eq!(brew_tap_name("homebrew/core/jq"), None);
assert_eq!(brew_tap_name("homebrew/cask/firefox"), None);
assert_eq!(brew_tap_name("jq"), None);
assert_eq!(brew_tap_name("too/many/slashes/here"), None);
}
#[test]
fn test_repo_request_validation() {
let request = repos::RepoRequest::from_toml(
"~/src/dotfiles".to_string(),
repos::RepoTomlConfig {
url: Some("https://github.com/jdx/dotfiles.git".to_string()),
git_ref: Some("main".to_string()),
},
None,
)
.unwrap();
assert!(request.path.is_absolute());
assert_eq!(request.path_raw, "~/src/dotfiles");
assert_eq!(request.url, "https://github.com/jdx/dotfiles.git");
assert_eq!(request.git_ref.as_deref(), Some("main"));
assert!(
repos::RepoRequest::from_toml(
"relative".to_string(),
repos::RepoTomlConfig {
url: Some("https://github.com/jdx/dotfiles.git".to_string()),
git_ref: None,
},
None,
)
.is_err()
);
}
#[test]
fn test_friendly_macos_defaults() {
let mut macos = BootstrapMacosTomlConfig::default();
macos.dock.insert("autohide".into(), tv("true"));
macos.dock.insert("orientation".into(), tv(r#""left""#));
macos.dock.insert("tilesize".into(), tv("48"));
macos.dock.insert("magnification".into(), tv("true"));
macos.dock.insert("largesize".into(), tv("96"));
macos.dock.insert("show_recents".into(), tv("false"));
macos.dock.insert("mru_spaces".into(), tv("false"));
macos.finder.insert("show_all_files".into(), tv("true"));
macos.finder.insert("show_pathbar".into(), tv("true"));
macos.finder.insert("show_status_bar".into(), tv("true"));
macos
.finder
.insert("show_extensions_warning".into(), tv("false"));
macos
.finder
.insert("preferred_view_style".into(), tv(r#""list""#));
macos.keyboard.insert("key_repeat".into(), tv("2"));
macos.keyboard.insert("initial_key_repeat".into(), tv("15"));
macos.keyboard.insert("press_and_hold".into(), tv("false"));
macos.keyboard.insert("fn_state".into(), tv("true"));
macos.trackpad.insert("tap_to_click".into(), tv("true"));
macos
.trackpad
.insert("three_finger_drag".into(), tv("true"));
let mut out = IndexMap::new();
merge_friendly_macos_defaults(&mut out, &macos);
assert_eq!(
out.get(&("com.apple.dock".into(), "autohide".into())),
Some(&tv("true"))
);
assert_eq!(
out.get(&("com.apple.dock".into(), "orientation".into())),
Some(&tv(r#""left""#))
);
assert_eq!(
out.get(&("com.apple.dock".into(), "tilesize".into())),
Some(&tv("48"))
);
assert_eq!(
out.get(&("com.apple.dock".into(), "magnification".into())),
Some(&tv("true"))
);
assert_eq!(
out.get(&("com.apple.dock".into(), "largesize".into())),
Some(&tv("96"))
);
assert_eq!(
out.get(&("com.apple.dock".into(), "show-recents".into())),
Some(&tv("false"))
);
assert_eq!(
out.get(&("com.apple.dock".into(), "mru-spaces".into())),
Some(&tv("false"))
);
assert_eq!(
out.get(&("com.apple.finder".into(), "AppleShowAllFiles".into())),
Some(&tv("true"))
);
assert_eq!(
out.get(&("com.apple.finder".into(), "ShowPathbar".into())),
Some(&tv("true"))
);
assert_eq!(
out.get(&("com.apple.finder".into(), "ShowStatusBar".into())),
Some(&tv("true"))
);
assert_eq!(
out.get(&(
"com.apple.finder".into(),
"FXEnableExtensionChangeWarning".into()
)),
Some(&tv("false"))
);
assert_eq!(
out.get(&("com.apple.finder".into(), "FXPreferredViewStyle".into())),
Some(&tv(r#""Nlsv""#))
);
assert_eq!(
out.get(&("NSGlobalDomain".into(), "KeyRepeat".into())),
Some(&tv("2"))
);
assert_eq!(
out.get(&("NSGlobalDomain".into(), "InitialKeyRepeat".into())),
Some(&tv("15"))
);
assert_eq!(
out.get(&("NSGlobalDomain".into(), "ApplePressAndHoldEnabled".into())),
Some(&tv("false"))
);
assert_eq!(
out.get(&("NSGlobalDomain".into(), "com.apple.keyboard.fnState".into())),
Some(&tv("true"))
);
assert_eq!(
out.get(&(
"com.apple.AppleMultitouchTrackpad".into(),
"Clicking".into()
)),
Some(&tv("true"))
);
assert_eq!(
out.get(&(
"com.apple.driver.AppleBluetoothMultitouch.trackpad".into(),
"Clicking".into()
)),
Some(&tv("true"))
);
assert_eq!(
out.get(&(
"com.apple.AppleMultitouchTrackpad".into(),
"TrackpadThreeFingerDrag".into()
)),
Some(&tv("true"))
);
assert_eq!(
out.get(&(
"com.apple.driver.AppleBluetoothMultitouch.trackpad".into(),
"TrackpadThreeFingerDrag".into()
)),
Some(&tv("true"))
);
}
#[test]
fn test_friendly_macos_defaults_validation() {
let mut macos = BootstrapMacosTomlConfig::default();
macos.dock.insert("orientation".into(), tv(r#""top""#));
macos.dock.insert("tilesize".into(), tv("true"));
macos.dock.insert("unknown".into(), tv("true"));
macos
.finder
.insert("preferred_view_style".into(), tv(r#""coverflow""#));
macos.keyboard.insert("key_repeat".into(), tv(r#""fast""#));
macos.trackpad.insert("tap_to_click".into(), tv("[true]"));
let mut out = IndexMap::new();
merge_friendly_macos_defaults(&mut out, &macos);
assert!(out.is_empty());
}
#[test]
fn test_raw_macos_defaults_override_friendly_defaults() {
let mut friendly = IndexMap::new();
friendly.insert(
("com.apple.dock".into(), "autohide".into()),
toml::Value::Boolean(true),
);
friendly.insert(
("com.apple.dock".into(), "tilesize".into()),
toml::Value::Integer(48),
);
let mut raw = IndexMap::new();
raw.insert(
("com.apple.dock".into(), "autohide".into()),
toml::Value::Boolean(false),
);
let merged = merge_raw_over_friendly_macos_defaults(friendly, raw);
assert_eq!(
merged.get(&("com.apple.dock".into(), "autohide".into())),
Some(&toml::Value::Boolean(false))
);
assert_eq!(
merged.get(&("com.apple.dock".into(), "tilesize".into())),
Some(&toml::Value::Integer(48))
);
}
#[test]
fn test_shell_activation_setting_bool_string_and_table_forms() {
assert_eq!(
shell_activation_setting("zshrc", tv("true")),
Some(ShellActivationSetting {
enabled: true,
mode: None
})
);
assert_eq!(
shell_activation_setting("bashrc", tv("false")),
Some(ShellActivationSetting {
enabled: false,
mode: None
})
);
assert_eq!(
shell_activation_setting("zprofile", tv(r#""shims""#)),
Some(ShellActivationSetting {
enabled: true,
mode: Some(ShellActivationMode::Shims)
})
);
assert_eq!(
shell_activation_setting("fish", tv(r#"{enabled = true, mode = "activate"}"#)),
Some(ShellActivationSetting {
enabled: true,
mode: Some(ShellActivationMode::Activate)
})
);
assert_eq!(
shell_activation_setting("fish", tv("{enabled = false}")),
Some(ShellActivationSetting {
enabled: false,
mode: None
})
);
}
#[test]
fn test_shell_activation_setting_skips_invalid_options() {
assert_eq!(
shell_activation_setting("zshrc", tv(r#"{enabled = true, prompt = true}"#)),
None
);
assert_eq!(shell_activation_setting("zshrc", tv("{}")), None);
assert_eq!(shell_activation_setting("zshrc", tv(r#""yes""#)), None);
assert_eq!(
shell_activation_setting("zshrc", tv(r#"{enabled = true, mode = "yes"}"#)),
None
);
}
#[test]
fn test_local_friendly_macos_defaults_override_global_raw_defaults() {
let mut merged = IndexMap::new();
let global_friendly = IndexMap::new();
let mut global_raw = IndexMap::new();
global_raw.insert(
("com.apple.dock".into(), "autohide".into()),
toml::Value::Boolean(false),
);
for (key, value) in merge_raw_over_friendly_macos_defaults(global_friendly, global_raw) {
merged.insert(key, value);
}
let mut local_friendly = IndexMap::new();
local_friendly.insert(
("com.apple.dock".into(), "autohide".into()),
toml::Value::Boolean(true),
);
let local_raw = IndexMap::new();
for (key, value) in merge_raw_over_friendly_macos_defaults(local_friendly, local_raw) {
merged.insert(key, value);
}
assert_eq!(
merged.get(&("com.apple.dock".into(), "autohide".into())),
Some(&toml::Value::Boolean(true))
);
}
#[test]
fn test_macos_defaults_entry_count_includes_friendly_defaults() {
let mut macos = BootstrapMacosTomlConfig::default();
macos.dock.insert("autohide".into(), tv("true"));
macos.trackpad.insert("tap_to_click".into(), tv("true"));
macos.defaults.insert(
"com.apple.dock".into(),
tv(r#"{ autohide = false, tilesize = 48 }"#),
);
macos.defaults.insert("malformed".into(), tv("true"));
assert_eq!(macos_defaults_entry_count(&macos), 5);
}
}