use std::path::{Path, PathBuf};
use crate::datastore::DataStore;
use crate::fs::Fs;
use crate::handlers::{
ExecutionPhase, Handler, HandlerConfig, HandlerScope, HandlerStatus, MatchMode, HANDLER_SYMLINK,
};
use crate::operations::HandlerIntent;
use crate::paths::Pather;
use crate::rules::RuleMatch;
use crate::Result;
pub struct SymlinkHandler;
impl Handler for SymlinkHandler {
fn name(&self) -> &str {
HANDLER_SYMLINK
}
fn phase(&self) -> ExecutionPhase {
ExecutionPhase::Link
}
fn match_mode(&self) -> MatchMode {
MatchMode::Catchall
}
fn scope(&self) -> HandlerScope {
HandlerScope::Exclusive
}
fn to_intents(
&self,
matches: &[RuleMatch],
config: &HandlerConfig,
paths: &dyn Pather,
fs: &dyn Fs,
) -> Result<Vec<HandlerIntent>> {
let mut intents = Vec::new();
for m in matches {
let rel_str = m.relative_path.to_string_lossy();
if is_protected(&rel_str, &config.protected_paths) {
continue;
}
if m.is_dir {
intents.extend(dir_intents(m, config, paths, fs)?);
} else {
match resolve_target_full(&m.pack, &rel_str, config, paths) {
Resolution::Path(user_path) => intents.push(HandlerIntent::Link {
pack: m.pack.clone(),
handler: HANDLER_SYMLINK.into(),
source: m.absolute_path.clone(),
user_path,
}),
Resolution::Skip { .. } => {
}
}
}
}
Ok(intents)
}
fn warnings_for_matches(
&self,
matches: &[RuleMatch],
config: &HandlerConfig,
paths: &dyn Pather,
) -> Vec<String> {
if cfg!(target_os = "macos") {
return Vec::new();
}
let mut out = Vec::new();
for m in matches {
let rel_str = m.relative_path.to_string_lossy();
if is_protected(&rel_str, &config.protected_paths) {
continue;
}
if rel_str == "_lib" || rel_str.starts_with("_lib/") {
out.push(format!(
"warning: pack `{}` contains `{rel_str}` — \
macOS-only path, skipping on this platform",
m.pack
));
}
}
let _ = paths; out
}
fn check_status(
&self,
file: &Path,
pack: &str,
datastore: &dyn DataStore,
) -> Result<HandlerStatus> {
let has_state = datastore.has_handler_state(pack, HANDLER_SYMLINK)?;
Ok(HandlerStatus {
file: file.to_string_lossy().into_owned(),
handler: HANDLER_SYMLINK.into(),
deployed: has_state,
message: if has_state {
"symlink deployed".into()
} else {
"symlink pending".into()
},
})
}
}
fn dir_intents(
m: &RuleMatch,
config: &HandlerConfig,
paths: &dyn Pather,
fs: &dyn Fs,
) -> Result<Vec<HandlerIntent>> {
let rel_str = m.relative_path.to_string_lossy();
let dir_prefix = format!("{rel_str}/");
let has_override = config.protected_paths.iter().any(|p| {
let normalized = p.strip_prefix('.').unwrap_or(p);
normalized.starts_with(&dir_prefix)
|| p.starts_with(&dir_prefix)
|| normalized == rel_str
|| p == rel_str.as_ref()
}) || config
.targets
.keys()
.any(|k| k.starts_with(&dir_prefix) || k == rel_str.as_ref());
let is_escape_prefix_dir = matches!(rel_str.as_ref(), "_home" | "_xdg" | "_app" | "_lib");
if !has_override && !is_escape_prefix_dir {
let user_path = resolve_target(&m.pack, &rel_str, config, paths);
return Ok(vec![HandlerIntent::Link {
pack: m.pack.clone(),
handler: HANDLER_SYMLINK.into(),
source: m.absolute_path.clone(),
user_path,
}]);
}
let mut intents = Vec::new();
collect_per_file_intents(m, &m.absolute_path, config, paths, fs, &mut intents)?;
Ok(intents)
}
fn collect_per_file_intents(
m: &RuleMatch,
dir: &Path,
config: &HandlerConfig,
paths: &dyn Pather,
fs: &dyn Fs,
out: &mut Vec<HandlerIntent>,
) -> Result<()> {
let entries = fs.read_dir(dir)?;
for entry in entries {
if crate::rules::should_skip_entry(&entry.name, &config.pack_ignore) {
continue;
}
if entry.is_dir {
collect_per_file_intents(m, &entry.path, config, paths, fs, out)?;
continue;
}
let rel = entry
.path
.strip_prefix(&m.absolute_path)
.ok()
.map(|r| m.relative_path.join(r))
.unwrap_or_else(|| PathBuf::from(&entry.name));
let rel_str = rel.to_string_lossy();
if is_protected(&rel_str, &config.protected_paths) {
continue;
}
match resolve_target_full(&m.pack, &rel_str, config, paths) {
Resolution::Path(user_path) => out.push(HandlerIntent::Link {
pack: m.pack.clone(),
handler: HANDLER_SYMLINK.into(),
source: entry.path.clone(),
user_path,
}),
Resolution::Skip { .. } => continue,
}
}
Ok(())
}
fn strip_home_prefix(rel_path: &str) -> Option<String> {
if !rel_path.contains('/') {
if let Some(rest) = rel_path.strip_prefix("home.") {
if !rest.is_empty() {
return Some(format!(".{rest}"));
}
}
}
None
}
#[derive(Debug, Clone)]
pub(crate) enum Resolution {
Path(PathBuf),
Skip {
#[allow(dead_code)]
reason: String,
},
}
pub(crate) fn resolve_target(
pack: &str,
rel_path: &str,
config: &HandlerConfig,
paths: &dyn Pather,
) -> PathBuf {
match resolve_target_full(pack, rel_path, config, paths) {
Resolution::Path(p) => p,
Resolution::Skip { .. } => {
paths.xdg_config_home().to_path_buf()
}
}
}
pub(crate) fn resolve_target_full(
pack: &str,
rel_path: &str,
config: &HandlerConfig,
paths: &dyn Pather,
) -> Resolution {
let pack = crate::packs::display_name_for(pack);
let home = paths.home_dir();
let xdg_config = paths.xdg_config_home();
let app_support = paths.app_support_dir();
if let Some(target) = config.targets.get(rel_path) {
if target.starts_with('/') {
return Resolution::Path(PathBuf::from(target));
}
return Resolution::Path(xdg_config.join(target));
}
if let Some(dotted) = strip_home_prefix(rel_path) {
return Resolution::Path(home.join(&dotted));
}
if let Some(stripped) = rel_path.strip_prefix("_home/") {
let parts: Vec<&str> = stripped.split('/').collect();
if let Some(first) = parts.first() {
if !first.is_empty() && !first.starts_with('.') {
let mut new_parts = vec![format!(".{first}")];
new_parts.extend(parts[1..].iter().map(|s| s.to_string()));
return Resolution::Path(home.join(new_parts.join("/")));
}
}
return Resolution::Path(home.join(stripped));
}
if let Some(stripped) = rel_path.strip_prefix("_xdg/") {
return Resolution::Path(xdg_config.join(stripped));
}
if let Some(stripped) = rel_path.strip_prefix("_app/") {
return Resolution::Path(app_support.join(stripped));
}
if let Some(stripped) = rel_path.strip_prefix("_lib/") {
if cfg!(target_os = "macos") {
return Resolution::Path(home.join("Library").join(stripped));
}
return Resolution::Skip {
reason: format!("_lib/{stripped} — macOS-only path, skipping on this platform"),
};
}
if is_force_home(rel_path, &config.force_home) {
if rel_path.contains('/') {
let parts: Vec<&str> = rel_path.split('/').collect();
let first = parts[0];
let dotted = if first.starts_with('.') {
first.to_string()
} else {
format!(".{first}")
};
let rest: Vec<&str> = parts[1..].to_vec();
let mut result = home.join(dotted);
for part in rest {
result = result.join(part);
}
return Resolution::Path(result);
}
let filename = Path::new(rel_path)
.file_name()
.unwrap_or_default()
.to_string_lossy();
let dotted = if filename.starts_with('.') {
filename.to_string()
} else {
format!(".{filename}")
};
return Resolution::Path(home.join(dotted));
}
if is_force_app(rel_path, &config.force_app) {
return Resolution::Path(app_support.join(rel_path));
}
if let Some(alias) = config.app_aliases.get(pack) {
return Resolution::Path(app_support.join(alias).join(rel_path));
}
Resolution::Path(xdg_config.join(pack).join(rel_path))
}
fn is_force_home(rel_path: &str, force_home: &[String]) -> bool {
let first_segment = rel_path.split('/').next().unwrap_or(rel_path);
let without_dot = first_segment.strip_prefix('.').unwrap_or(first_segment);
force_home.iter().any(|entry| {
let entry_without_dot = entry.strip_prefix('.').unwrap_or(entry);
entry_without_dot == without_dot
})
}
fn is_force_app(rel_path: &str, force_app: &[String]) -> bool {
let first_segment = rel_path.split('/').next().unwrap_or(rel_path);
force_app.iter().any(|entry| entry == first_segment)
}
fn is_protected(rel_path: &str, protected_paths: &[String]) -> bool {
let normalized = rel_path.strip_prefix("./").unwrap_or(rel_path);
let with_dot = if !normalized.starts_with('.') {
format!(".{normalized}")
} else {
normalized.to_string()
};
for protected in protected_paths {
if protected == normalized || protected == &with_dot {
return true;
}
if normalized.starts_with(&format!("{protected}/"))
|| with_dot.starts_with(&format!("{protected}/"))
{
return true;
}
}
false
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::XdgPather;
fn test_pather() -> XdgPather {
XdgPather::builder()
.home("/home/alice")
.dotfiles_root("/home/alice/dotfiles")
.xdg_config_home("/home/alice/.config")
.app_support_dir("/home/alice/Library/Application Support")
.build()
.unwrap()
}
fn default_config() -> HandlerConfig {
HandlerConfig {
force_home: vec![
"ssh".into(),
"bashrc".into(),
"zshrc".into(),
"profile".into(),
],
protected_paths: vec![
".ssh/id_rsa".into(),
".ssh/id_ed25519".into(),
".gnupg".into(),
],
targets: std::collections::HashMap::new(),
..HandlerConfig::default()
}
}
#[test]
fn top_level_file_goes_to_pack_xdg_dir() {
let config = HandlerConfig::default();
let target = resolve_target("vim", "vimrc", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/vim/vimrc"));
}
#[test]
fn top_level_dir_goes_to_pack_xdg_dir() {
let config = HandlerConfig::default();
let target = resolve_target("nvim", "lua", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/nvim/lua"));
}
#[test]
fn top_level_dir_wholesale_goes_to_pack_xdg_dir() {
let config = HandlerConfig::default();
let target = resolve_target("warp", "themes", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/warp/themes"));
}
#[test]
fn prefixed_pack_deploys_under_display_name_dir() {
let config = HandlerConfig::default();
let target = resolve_target("010-nvim", "init.lua", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/nvim/init.lua"));
}
#[test]
fn prefixed_pack_works_with_underscore_separator() {
let config = HandlerConfig::default();
let target = resolve_target("020_zsh", "zshrc", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/zsh/zshrc"));
}
#[test]
fn prefixed_pack_with_force_home_still_strips_prefix() {
let target = resolve_target("030-net", "ssh/config", &default_config(), &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.ssh/config"));
}
#[test]
fn top_level_file_named_config_goes_under_pack_no_xdg_collision() {
let config = HandlerConfig::default();
let target = resolve_target("ghostty", "config", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/ghostty/config"));
}
#[test]
fn nested_file_namespaced_under_pack() {
let config = HandlerConfig::default();
let target = resolve_target("nvim", "lua/options.lua", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/.config/nvim/lua/options.lua")
);
}
#[test]
fn force_home_top_level_file() {
let target = resolve_target("shell", "bashrc", &default_config(), &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.bashrc"));
}
#[test]
fn force_home_subdirectory_file() {
let target = resolve_target("net", "ssh/config", &default_config(), &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.ssh/config"));
}
#[test]
fn force_home_top_level_dir_wholesale() {
let target = resolve_target("net", "ssh", &default_config(), &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.ssh"));
}
#[test]
fn home_prefix_dir_escapes_pack_namespace() {
let config = HandlerConfig::default();
let target = resolve_target("misc", "_home/vim/vimrc", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.vim/vimrc"));
}
#[test]
fn xdg_prefix_dir_escapes_pack_namespace() {
let config = HandlerConfig::default();
let target = resolve_target(
"term-config",
"_xdg/ghostty/config",
&config,
&test_pather(),
);
assert_eq!(target, PathBuf::from("/home/alice/.config/ghostty/config"));
}
#[test]
fn app_prefix_routes_to_app_support_root() {
let config = HandlerConfig::default();
let target = resolve_target(
"macapps",
"_app/Code/User/settings.json",
&config,
&test_pather(),
);
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Code/User/settings.json")
);
}
#[test]
fn app_prefix_outranks_default() {
let config = HandlerConfig::default();
let target = resolve_target("Code", "_app/Code/x", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Code/x")
);
}
#[test]
fn lib_prefix_resolution_full_returns_skip_on_non_macos() {
let config = HandlerConfig::default();
let resolution = resolve_target_full(
"macapps",
"_lib/LaunchAgents/com.example.foo.plist",
&config,
&test_pather(),
);
if cfg!(target_os = "macos") {
match resolution {
Resolution::Path(p) => assert_eq!(
p,
PathBuf::from("/home/alice/Library/LaunchAgents/com.example.foo.plist")
),
Resolution::Skip { reason } => {
panic!("expected Path on macOS, got Skip({reason})")
}
}
} else {
assert!(
matches!(resolution, Resolution::Skip { .. }),
"_lib/ on non-macOS must skip; got {resolution:?}"
);
}
}
#[test]
fn force_app_routes_first_segment_to_app_support() {
let config = HandlerConfig {
force_app: vec!["Code".into()],
..HandlerConfig::default()
};
let target = resolve_target(
"macapps",
"Code/User/settings.json",
&config,
&test_pather(),
);
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Code/User/settings.json")
);
}
#[test]
fn force_app_is_case_sensitive() {
let config = HandlerConfig {
force_app: vec!["Code".into()],
..HandlerConfig::default()
};
let target = resolve_target("misc", "code/foo", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/misc/code/foo"));
}
#[test]
fn force_app_loses_to_explicit_app_prefix() {
let config = HandlerConfig {
force_app: vec!["Code".into()],
..HandlerConfig::default()
};
let target = resolve_target("misc", "_app/Code/x", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Code/x")
);
}
#[test]
fn app_alias_reroutes_default_rule() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("vscode".into(), "Code".into());
let config = HandlerConfig {
app_aliases: aliases,
..HandlerConfig::default()
};
let target = resolve_target("vscode", "User/settings.json", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Code/User/settings.json")
);
}
#[test]
fn app_alias_loses_to_explicit_xdg_prefix() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("vscode".into(), "Code".into());
let config = HandlerConfig {
app_aliases: aliases,
..HandlerConfig::default()
};
let target = resolve_target("vscode", "_xdg/Code/User/foo", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/Code/User/foo"));
}
#[test]
fn app_alias_loses_to_home_prefix() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("vscode".into(), "Code".into());
let config = HandlerConfig {
app_aliases: aliases,
..HandlerConfig::default()
};
let target = resolve_target("vscode", "home.editorconfig", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.editorconfig"));
}
#[test]
fn app_alias_uses_pack_display_name() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("vscode".into(), "Code".into());
let config = HandlerConfig {
app_aliases: aliases,
..HandlerConfig::default()
};
let target = resolve_target("010-vscode", "settings.json", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Code/settings.json")
);
}
#[test]
fn force_app_outranks_app_alias() {
let mut aliases = std::collections::HashMap::new();
aliases.insert("anything".into(), "AliasedFolder".into());
let config = HandlerConfig {
force_app: vec!["Cursor".into()],
app_aliases: aliases,
..HandlerConfig::default()
};
let target = resolve_target("anything", "Cursor/x", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/Library/Application Support/Cursor/x")
);
}
#[test]
fn protected_exact_match() {
assert!(is_protected("ssh/id_rsa", &[".ssh/id_rsa".into()]));
assert!(is_protected(".ssh/id_rsa", &[".ssh/id_rsa".into()]));
}
#[test]
fn protected_parent_directory() {
assert!(is_protected(
"gnupg/private-keys-v1.d/key",
&[".gnupg".into()]
));
}
#[test]
fn not_protected() {
assert!(!is_protected("vimrc", &[".ssh/id_rsa".into()]));
}
#[test]
fn force_home_matches_without_dot() {
assert!(is_force_home("ssh/config", &["ssh".into()]));
assert!(is_force_home("bashrc", &["bashrc".into()]));
}
#[test]
fn force_home_does_not_match_unrelated() {
assert!(!is_force_home("vimrc", &["ssh".into(), "bashrc".into()]));
}
#[test]
fn home_prefix_routes_top_level_file_to_home() {
let config = HandlerConfig::default();
let target = resolve_target("git", "home.gitconfig", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.gitconfig"));
}
#[test]
fn home_prefix_works_even_when_pack_not_force_home() {
let config = HandlerConfig::default();
let target = resolve_target("misc", "home.vimrc", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.vimrc"));
}
#[test]
fn home_prefix_not_applied_to_subdirs() {
let config = HandlerConfig::default();
let target = resolve_target("misc", "subdir/home.conf", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/.config/misc/subdir/home.conf")
);
}
#[test]
fn strip_home_prefix_unit() {
assert_eq!(strip_home_prefix("home.bashrc"), Some(".bashrc".into()));
assert_eq!(strip_home_prefix("home.vimrc"), Some(".vimrc".into()));
assert_eq!(strip_home_prefix("vimrc"), None);
assert_eq!(strip_home_prefix(".bashrc"), None);
assert_eq!(strip_home_prefix("sub/home.conf"), None);
assert_eq!(strip_home_prefix("home."), None);
}
#[test]
fn literal_home_dot_filename_does_not_target_home_root() {
let config = HandlerConfig::default();
let target = resolve_target("misc", "home.", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/misc/home."));
}
#[test]
fn custom_target_absolute_path() {
let mut config = HandlerConfig::default();
config
.targets
.insert("misterious.conf".into(), "/var/etc/misterious.conf".into());
let target = resolve_target("pack", "misterious.conf", &config, &test_pather());
assert_eq!(target, PathBuf::from("/var/etc/misterious.conf"));
}
#[test]
fn custom_target_relative_path() {
let mut config = HandlerConfig::default();
config.targets.insert(
"home-bound.conf".into(),
"my-documents/home-bound.conf".into(),
);
let target = resolve_target("pack", "home-bound.conf", &config, &test_pather());
assert_eq!(
target,
PathBuf::from("/home/alice/.config/my-documents/home-bound.conf")
);
}
#[test]
fn custom_target_overrides_all_layers() {
let mut config = default_config();
config
.targets
.insert("bashrc".into(), "/custom/bashrc".into());
let target = resolve_target("shell", "bashrc", &config, &test_pather());
assert_eq!(target, PathBuf::from("/custom/bashrc"));
}
#[test]
fn no_custom_target_falls_through_to_pack_namespaced_default() {
let config = HandlerConfig::default();
let target = resolve_target("vim", "vimrc", &config, &test_pather());
assert_eq!(target, PathBuf::from("/home/alice/.config/vim/vimrc"));
}
fn build_dir_match(env: &crate::testing::TempEnvironment, pack: &str, dir: &str) -> RuleMatch {
RuleMatch {
relative_path: PathBuf::from(dir),
absolute_path: env.dotfiles_root.join(pack).join(dir),
pack: pack.into(),
handler: HANDLER_SYMLINK.into(),
is_dir: true,
options: std::collections::HashMap::new(),
preprocessor_source: None,
}
}
#[test]
fn plain_top_level_dir_produces_single_wholesale_intent() {
let env = crate::testing::TempEnvironment::builder()
.pack("warp")
.file("themes/nord.yaml", "a")
.file("themes/vs_code.yaml", "b")
.done()
.build();
let m = build_dir_match(&env, "warp", "themes");
let handler = SymlinkHandler;
let paths = crate::paths::XdgPather::builder()
.home(&env.home)
.dotfiles_root(&env.dotfiles_root)
.build()
.unwrap();
let intents = handler
.to_intents(&[m], &HandlerConfig::default(), &paths, env.fs.as_ref())
.unwrap();
assert_eq!(intents.len(), 1, "plain dir -> single wholesale intent");
if let HandlerIntent::Link {
source, user_path, ..
} = &intents[0]
{
assert!(source.ends_with("warp/themes"));
assert!(
user_path.ends_with(".config/warp/themes"),
"user_path={}",
user_path.display()
);
} else {
panic!("expected Link intent");
}
}
#[test]
fn dir_with_protected_path_falls_back_to_per_file_and_skips_protected() {
let env = crate::testing::TempEnvironment::builder()
.pack("secret")
.file("ssh/config", "Host *")
.file("ssh/id_rsa", "DO NOT LINK")
.done()
.build();
let m = build_dir_match(&env, "secret", "ssh");
let handler = SymlinkHandler;
let config = HandlerConfig {
protected_paths: vec!["ssh/id_rsa".into()],
force_home: vec!["ssh".into()],
..HandlerConfig::default()
};
let paths = crate::paths::XdgPather::builder()
.home(&env.home)
.dotfiles_root(&env.dotfiles_root)
.build()
.unwrap();
let intents = handler
.to_intents(&[m], &config, &paths, env.fs.as_ref())
.unwrap();
assert_eq!(
intents.len(),
1,
"only ssh/config should be linked; id_rsa skipped. Got: {intents:?}"
);
if let HandlerIntent::Link {
source, user_path, ..
} = &intents[0]
{
assert!(source.ends_with("ssh/config"));
assert!(user_path.ends_with(".ssh/config"));
} else {
panic!("expected Link intent");
}
}
#[test]
fn per_file_fallback_skips_special_and_pack_ignored_files() {
let env = crate::testing::TempEnvironment::builder()
.pack("cfg")
.file("ssh/config", "Host *")
.file("ssh/id_rsa", "secret")
.file("ssh/.DS_Store", "garbage")
.file("ssh/.dodot.toml", "# pack config")
.done()
.build();
let m = build_dir_match(&env, "cfg", "ssh");
let handler = SymlinkHandler;
let config = HandlerConfig {
protected_paths: vec!["ssh/id_rsa".into()],
pack_ignore: vec![".DS_Store".into()],
..HandlerConfig::default()
};
let paths = crate::paths::XdgPather::builder()
.home(&env.home)
.dotfiles_root(&env.dotfiles_root)
.build()
.unwrap();
let intents = handler
.to_intents(&[m], &config, &paths, env.fs.as_ref())
.unwrap();
assert_eq!(
intents.len(),
1,
"only ssh/config should be linked. Got: {intents:?}"
);
if let HandlerIntent::Link { source, .. } = &intents[0] {
assert!(source.ends_with("ssh/config"));
}
}
#[test]
fn lib_prefix_emits_warning_on_non_macos() {
let env = crate::testing::TempEnvironment::builder()
.pack("macapps")
.file("_lib/LaunchAgents/com.example.foo.plist", "# stub plist")
.done()
.build();
let m = RuleMatch {
relative_path: PathBuf::from("_lib/LaunchAgents/com.example.foo.plist"),
absolute_path: env
.dotfiles_root
.join("macapps/_lib/LaunchAgents/com.example.foo.plist"),
pack: "macapps".into(),
handler: HANDLER_SYMLINK.into(),
is_dir: false,
options: std::collections::HashMap::new(),
preprocessor_source: None,
};
let handler = SymlinkHandler;
let config = HandlerConfig::default();
let warnings =
handler.warnings_for_matches(std::slice::from_ref(&m), &config, env.paths.as_ref());
if cfg!(target_os = "macos") {
assert!(
warnings.is_empty(),
"_lib/ should not warn on macOS; got {warnings:?}"
);
let intents = handler
.to_intents(&[m], &config, env.paths.as_ref(), env.fs.as_ref())
.unwrap();
assert_eq!(intents.len(), 1);
} else {
assert_eq!(warnings.len(), 1, "expected one warning, got {warnings:?}");
assert!(
warnings[0].contains("macOS-only path"),
"warning text should mention macOS-only: {warnings:?}"
);
let intents = handler
.to_intents(&[m], &config, env.paths.as_ref(), env.fs.as_ref())
.unwrap();
assert!(
intents.is_empty(),
"_lib/ on non-macOS must not emit Link intents: {intents:?}"
);
}
}
#[test]
fn top_level_app_and_lib_dirs_force_per_file_mode() {
for prefix in ["_app", "_lib"] {
let env = crate::testing::TempEnvironment::builder()
.pack("macapps")
.file(&format!("{prefix}/Code/x.json"), "x")
.done()
.build();
let m = build_dir_match(&env, "macapps", prefix);
let handler = SymlinkHandler;
let intents = handler
.to_intents(
&[m],
&HandlerConfig::default(),
env.paths.as_ref(),
env.fs.as_ref(),
)
.unwrap();
let expected = match prefix {
"_lib" if !cfg!(target_os = "macos") => 0,
_ => 1,
};
assert_eq!(
intents.len(),
expected,
"prefix={prefix}: expected {expected} intents, got {intents:?}"
);
if let Some(HandlerIntent::Link { user_path, .. }) = intents.first() {
assert!(
!user_path.to_string_lossy().contains(&format!("/{prefix}/")),
"prefix={prefix} leaked into deploy path: {}",
user_path.display()
);
}
}
}
#[test]
fn dir_with_targets_override_falls_back_to_per_file() {
let env = crate::testing::TempEnvironment::builder()
.pack("app")
.file("config/main.toml", "x")
.file("config/aux.toml", "y")
.done()
.build();
let m = build_dir_match(&env, "app", "config");
let handler = SymlinkHandler;
let mut targets = std::collections::HashMap::new();
targets.insert("config/main.toml".into(), "/etc/main.toml".into());
let config = HandlerConfig {
targets,
..HandlerConfig::default()
};
let paths = crate::paths::XdgPather::builder()
.home(&env.home)
.dotfiles_root(&env.dotfiles_root)
.build()
.unwrap();
let intents = handler
.to_intents(&[m], &config, &paths, env.fs.as_ref())
.unwrap();
assert_eq!(intents.len(), 2, "intents: {intents:?}");
let main = intents
.iter()
.find(|i| matches!(i, HandlerIntent::Link { source, .. } if source.ends_with("config/main.toml")))
.expect("main.toml intent");
if let HandlerIntent::Link { user_path, .. } = main {
assert_eq!(user_path, &PathBuf::from("/etc/main.toml"));
}
}
}