use std::path::{Component, Path, PathBuf};
use crate::paths::Pather;
#[derive(Debug, Clone)]
pub(crate) struct InferredTarget {
pub natural_pack: Option<String>,
pub in_pack_natural: PathBuf,
pub in_pack_override: PathBuf,
pub source_root: SourceRoot,
pub expand_children: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum SourceRoot {
Home,
XdgConfig,
AppSupport,
Library,
}
#[derive(Debug)]
pub(crate) enum InferenceError {
UnrecognizedRoot { hint_roots: Vec<PathBuf> },
NonDottedHome { stripped: String },
LooseXdgFile,
XdgRootItself,
HomeRootItself,
SandboxedContainer,
LibraryRootItself,
}
impl std::fmt::Display for InferenceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
InferenceError::UnrecognizedRoot { hint_roots } => {
let roots: Vec<String> =
hint_roots.iter().map(|p| p.display().to_string()).collect();
write!(
f,
"source is outside any recognized adopt root (expected under one of: {}). \
Move the file under one of these locations first, or copy it into a \
pack manually and use `[symlink.targets]` for an absolute deploy path.",
roots.join(", ")
)
}
InferenceError::NonDottedHome { stripped } => write!(
f,
"a non-dotted entry in $HOME has no automatic round-trip path \
under the post-#48 XDG default. Either rename to a dotted name \
(e.g. .{stripped}) before adopting, or copy into the pack \
manually and add a [symlink.targets] override pinning the \
deploy path."
),
InferenceError::LooseXdgFile => write!(
f,
"loose file directly under $XDG_CONFIG_HOME has no pack structure \
to infer. Move it into a subdirectory (recommended) or pass \
--into <pack> and place the file manually as `_xdg/<name>` in \
that pack."
),
InferenceError::XdgRootItself => write!(
f,
"$XDG_CONFIG_HOME itself is too broad to adopt — adopt individual \
application subdirectories (e.g. `~/.config/nvim/`) instead."
),
InferenceError::HomeRootItself => {
write!(f, "$HOME itself is too broad to adopt.")
}
InferenceError::SandboxedContainer => write!(
f,
"this is a sandboxed app's container; its config is not \
intended to be edited externally. dodot does not support \
adopting from ~/Library/Containers/."
),
InferenceError::LibraryRootItself => write!(
f,
"$HOME/Library itself is too broad to adopt — pick a \
subdirectory like ~/Library/Preferences/ or \
~/Library/LaunchAgents/."
),
}
}
}
pub(crate) fn infer_target(
abs_source: &Path,
is_dir: bool,
pather: &dyn Pather,
force_home: &[String],
) -> Result<InferredTarget, InferenceError> {
let canon_source = canonicalize_parent_keep_basename(abs_source);
let canon_home = canonicalize_for_match(pather.home_dir());
let canon_xdg = canonicalize_for_match(pather.xdg_config_home());
let canon_app = canonicalize_for_match(pather.app_support_dir());
let containers_root = canon_home.join("Library").join("Containers");
if canon_source.starts_with(&containers_root) {
return Err(InferenceError::SandboxedContainer);
}
if canon_app != canon_xdg {
if canon_source == canon_app {
return Err(InferenceError::XdgRootItself);
}
if let Ok(rel) = canon_source.strip_prefix(&canon_app) {
return resolve_app_support_relative(rel, is_dir);
}
}
if cfg!(target_os = "macos") {
let library_root = canon_home.join("Library");
if canon_source == library_root {
return Err(InferenceError::LibraryRootItself);
}
if let Ok(rel) = canon_source.strip_prefix(&library_root) {
return resolve_library_relative(rel, is_dir);
}
}
if canon_source == canon_xdg {
return Err(InferenceError::XdgRootItself);
}
if let Ok(rel) = canon_source.strip_prefix(&canon_xdg) {
return resolve_xdg_relative(rel, is_dir);
}
if canon_source == canon_home {
return Err(InferenceError::HomeRootItself);
}
if let Ok(rel) = canon_source.strip_prefix(&canon_home) {
return resolve_home_relative(rel, is_dir, force_home);
}
let mut hint_roots = vec![canon_xdg, canon_home];
if canon_app != hint_roots[0] && canon_app != hint_roots[1] {
hint_roots.push(canon_app);
}
Err(InferenceError::UnrecognizedRoot { hint_roots })
}
fn resolve_xdg_relative(rel: &Path, is_dir: bool) -> Result<InferredTarget, InferenceError> {
let mut comps = rel.components();
let first = match comps.next() {
Some(Component::Normal(s)) => s.to_string_lossy().into_owned(),
_ => return Err(InferenceError::XdgRootItself),
};
let rest_path: PathBuf = comps.as_path().to_path_buf();
if rest_path.as_os_str().is_empty() {
if is_dir {
return Ok(InferredTarget {
natural_pack: Some(first.clone()),
in_pack_natural: PathBuf::new(),
in_pack_override: PathBuf::from("_xdg").join(&first),
source_root: SourceRoot::XdgConfig,
expand_children: true,
});
} else {
return Err(InferenceError::LooseXdgFile);
}
}
Ok(InferredTarget {
natural_pack: Some(first.clone()),
in_pack_natural: rest_path.clone(),
in_pack_override: PathBuf::from("_xdg").join(&first).join(&rest_path),
source_root: SourceRoot::XdgConfig,
expand_children: false,
})
}
fn resolve_app_support_relative(
rel: &Path,
is_dir: bool,
) -> Result<InferredTarget, InferenceError> {
let mut comps = rel.components();
let first = match comps.next() {
Some(Component::Normal(s)) => s.to_string_lossy().into_owned(),
_ => return Err(InferenceError::XdgRootItself),
};
let rest_path: PathBuf = comps.as_path().to_path_buf();
if rest_path.as_os_str().is_empty() {
if is_dir {
return Ok(InferredTarget {
natural_pack: Some(first.clone()),
in_pack_natural: PathBuf::from("_app").join(&first),
in_pack_override: PathBuf::from("_app").join(&first),
source_root: SourceRoot::AppSupport,
expand_children: true,
});
} else {
return Err(InferenceError::LooseXdgFile);
}
}
let in_pack = PathBuf::from("_app").join(&first).join(&rest_path);
Ok(InferredTarget {
natural_pack: Some(first.clone()),
in_pack_natural: in_pack.clone(),
in_pack_override: in_pack,
source_root: SourceRoot::AppSupport,
expand_children: false,
})
}
fn resolve_library_relative(rel: &Path, is_dir: bool) -> Result<InferredTarget, InferenceError> {
if rel.as_os_str().is_empty() {
return Err(InferenceError::LibraryRootItself);
}
if rel.starts_with("Containers") {
return Err(InferenceError::SandboxedContainer);
}
let in_pack = PathBuf::from("_lib").join(rel);
Ok(InferredTarget {
natural_pack: None,
in_pack_natural: in_pack.clone(),
in_pack_override: in_pack,
source_root: SourceRoot::Library,
expand_children: is_dir
&& rel
.components()
.next()
.is_some_and(|c| matches!(c, Component::Normal(_)))
&& rel.components().count() == 1,
})
}
fn resolve_home_relative(
rel: &Path,
is_dir: bool,
force_home: &[String],
) -> Result<InferredTarget, InferenceError> {
let mut comps = rel.components();
let first = match comps.next() {
Some(Component::Normal(s)) => s.to_string_lossy().into_owned(),
_ => return Err(InferenceError::HomeRootItself),
};
if comps.next().is_some() {
return Err(InferenceError::UnrecognizedRoot {
hint_roots: vec![PathBuf::from("$HOME"), PathBuf::from("$XDG_CONFIG_HOME")],
});
}
let stripped = first.strip_prefix('.').unwrap_or(&first);
let in_pack_str = derive_home_in_pack(&first, is_dir, force_home).map_err(|_| {
InferenceError::NonDottedHome {
stripped: stripped.to_string(),
}
})?;
let in_pack = PathBuf::from(in_pack_str);
Ok(InferredTarget {
natural_pack: None,
in_pack_natural: in_pack.clone(),
in_pack_override: in_pack,
source_root: SourceRoot::Home,
expand_children: false,
})
}
fn canonicalize_for_match(p: &Path) -> PathBuf {
std::fs::canonicalize(p).unwrap_or_else(|_| p.to_path_buf())
}
fn canonicalize_parent_keep_basename(source: &Path) -> PathBuf {
let parent = match source.parent() {
Some(p) if !p.as_os_str().is_empty() => p,
_ => return source.to_path_buf(),
};
let canon_parent = match std::fs::canonicalize(parent) {
Ok(p) => p,
Err(_) => return source.to_path_buf(),
};
match source.file_name() {
Some(name) => canon_parent.join(name),
None => canon_parent,
}
}
pub(crate) fn is_gui_app_folder(name: &str) -> bool {
if name.is_empty() {
return false;
}
if name.chars().any(|c| c.is_ascii_uppercase()) {
return true;
}
if name.contains(' ') {
return true;
}
let segments: Vec<&str> = name.split('.').collect();
if segments.len() >= 2 && segments.iter().all(|s| !s.is_empty()) {
return true;
}
false
}
pub(crate) fn derive_home_in_pack(
file_name: &str,
is_dir: bool,
force_home: &[String],
) -> std::result::Result<String, String> {
let stripped = file_name.strip_prefix('.').unwrap_or(file_name);
let in_force_home = force_home
.iter()
.any(|entry| entry.strip_prefix('.').unwrap_or(entry) == stripped);
if in_force_home {
Ok(stripped.to_string())
} else if file_name.starts_with('.') {
if is_dir {
Ok(format!("_home/{stripped}"))
} else {
Ok(format!("home.{stripped}"))
}
} else {
Err(format!(
"a non-dotted entry in $HOME has no automatic round-trip path \
under the post-#48 XDG default. Either rename to a dotted name \
(e.g. .{stripped}) before adopting, or copy into the pack \
manually and add a [symlink.targets] override pinning the \
deploy path."
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::paths::XdgPather;
fn pather(home: &str, xdg: &str) -> XdgPather {
XdgPather::builder()
.home(home)
.dotfiles_root(format!("{home}/dotfiles"))
.data_dir(format!("{home}/.local/share/dodot"))
.config_dir(format!("{home}/.config/dodot"))
.cache_dir(format!("{home}/.cache/dodot"))
.xdg_config_home(xdg)
.app_support_dir(format!("{home}/Library/Application Support"))
.build()
.unwrap()
}
fn pather_app_collapsed(home: &str, xdg: &str) -> XdgPather {
XdgPather::builder()
.home(home)
.dotfiles_root(format!("{home}/dotfiles"))
.data_dir(format!("{home}/.local/share/dodot"))
.config_dir(format!("{home}/.config/dodot"))
.cache_dir(format!("{home}/.cache/dodot"))
.xdg_config_home(xdg)
.app_support_dir(xdg)
.build()
.unwrap()
}
#[test]
fn xdg_nested_file_infers_pack_and_relative_path() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/x/nvim/init.lua"),
false,
&p,
&[],
)
.unwrap();
assert_eq!(t.natural_pack.as_deref(), Some("nvim"));
assert_eq!(t.in_pack_natural, PathBuf::from("init.lua"));
assert_eq!(t.in_pack_override, PathBuf::from("_xdg/nvim/init.lua"));
assert_eq!(t.source_root, SourceRoot::XdgConfig);
assert!(!t.expand_children);
}
#[test]
fn xdg_deeply_nested_file_keeps_full_subpath() {
let p = pather("/u", "/x");
let t = infer_target(Path::new("/x/nvim/lua/plugins/foo.lua"), false, &p, &[]).unwrap();
assert_eq!(t.natural_pack.as_deref(), Some("nvim"));
assert_eq!(t.in_pack_natural, PathBuf::from("lua/plugins/foo.lua"));
assert_eq!(
t.in_pack_override,
PathBuf::from("_xdg/nvim/lua/plugins/foo.lua")
);
}
#[test]
fn xdg_pack_root_directory_triggers_expansion() {
let p = pather("/u", "/x");
let t = infer_target(Path::new("/x/nvim"), true, &p, &[]).unwrap();
assert_eq!(t.natural_pack.as_deref(), Some("nvim"));
assert!(t.expand_children);
assert_eq!(t.in_pack_natural, PathBuf::new());
}
#[test]
fn xdg_loose_file_at_root_is_refused() {
let p = pather("/u", "/x");
let err = infer_target(
Path::new("/x/standalone.toml"),
false,
&p,
&[],
)
.unwrap_err();
assert!(matches!(err, InferenceError::LooseXdgFile));
}
#[test]
fn xdg_root_itself_is_refused() {
let p = pather("/u", "/x");
let err = infer_target(Path::new("/x"), true, &p, &[]).unwrap_err();
assert!(matches!(err, InferenceError::XdgRootItself));
}
#[test]
fn home_dotted_file_uses_home_prefix_no_pack_inference() {
let p = pather("/u", "/x");
let t = infer_target(Path::new("/u/.vimrc"), false, &p, &[]).unwrap();
assert_eq!(t.natural_pack, None);
assert_eq!(t.in_pack_natural, PathBuf::from("home.vimrc"));
assert_eq!(t.in_pack_override, PathBuf::from("home.vimrc"));
assert_eq!(t.source_root, SourceRoot::Home);
}
#[test]
fn home_dotted_dir_uses_home_subtree_prefix() {
let p = pather("/u", "/x");
let t = infer_target(Path::new("/u/.weechat"), true, &p, &[]).unwrap();
assert_eq!(t.natural_pack, None);
assert_eq!(t.in_pack_natural, PathBuf::from("_home/weechat"));
}
#[test]
fn home_force_home_uses_bare_name() {
let force = vec!["bashrc".to_string(), "ssh".to_string()];
let p = pather("/u", "/x");
let t = infer_target(Path::new("/u/.bashrc"), false, &p, &force).unwrap();
assert_eq!(t.in_pack_natural, PathBuf::from("bashrc"));
let t = infer_target(Path::new("/u/.ssh"), true, &p, &force).unwrap();
assert_eq!(t.in_pack_natural, PathBuf::from("ssh"));
}
#[test]
fn home_non_dotted_is_refused() {
let p = pather("/u", "/x");
let err = infer_target(Path::new("/u/myscript.sh"), false, &p, &[]).unwrap_err();
match err {
InferenceError::NonDottedHome { stripped } => assert_eq!(stripped, "myscript.sh"),
other => panic!("expected NonDottedHome, got {other:?}"),
}
}
#[test]
fn home_nested_outside_xdg_is_unrecognized() {
let p = pather("/u", "/x");
let err = infer_target(Path::new("/u/Documents/notes.txt"), false, &p, &[]).unwrap_err();
assert!(matches!(err, InferenceError::UnrecognizedRoot { .. }));
}
#[test]
fn home_root_itself_is_refused() {
let p = pather("/u", "/x");
let err = infer_target(Path::new("/u"), true, &p, &[]).unwrap_err();
assert!(matches!(err, InferenceError::HomeRootItself));
}
#[test]
fn xdg_inside_home_prefers_xdg_root() {
let p = pather("/u", "/u/.config");
let t = infer_target(Path::new("/u/.config/nvim/init.lua"), false, &p, &[]).unwrap();
assert_eq!(t.source_root, SourceRoot::XdgConfig);
assert_eq!(t.natural_pack.as_deref(), Some("nvim"));
assert_eq!(t.in_pack_natural, PathBuf::from("init.lua"));
}
#[test]
fn unrecognized_root_lists_known_roots() {
let p = pather("/u", "/x");
let err = infer_target(Path::new("/etc/passwd"), false, &p, &[]).unwrap_err();
match err {
InferenceError::UnrecognizedRoot { hint_roots } => {
assert!(hint_roots.iter().any(|r| r.starts_with("/x")));
assert!(hint_roots.iter().any(|r| r.starts_with("/u")));
}
other => panic!("expected UnrecognizedRoot, got {other:?}"),
}
}
#[test]
fn app_support_nested_file_uses_app_prefix() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/u/Library/Application Support/Code/User/settings.json"),
false,
&p,
&[],
)
.unwrap();
assert_eq!(t.natural_pack.as_deref(), Some("Code"));
assert_eq!(
t.in_pack_natural,
PathBuf::from("_app/Code/User/settings.json")
);
assert_eq!(
t.in_pack_override,
PathBuf::from("_app/Code/User/settings.json")
);
assert_eq!(t.source_root, SourceRoot::AppSupport);
assert!(!t.expand_children);
}
#[test]
fn app_support_pack_root_directory_triggers_expansion() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/u/Library/Application Support/Cursor"),
true,
&p,
&[],
)
.unwrap();
assert_eq!(t.natural_pack.as_deref(), Some("Cursor"));
assert!(t.expand_children);
assert_eq!(t.in_pack_override, PathBuf::from("_app/Cursor"));
}
#[test]
fn app_support_outranks_home_when_distinct_root() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/u/Library/Application Support/Zed/settings.json"),
false,
&p,
&[],
)
.unwrap();
assert_eq!(t.source_root, SourceRoot::AppSupport);
assert_eq!(t.natural_pack.as_deref(), Some("Zed"));
}
#[test]
fn app_support_collapsed_falls_back_to_lib_or_unrecognized() {
let p = pather_app_collapsed("/u", "/x");
let result = infer_target(
Path::new("/u/Library/Application Support/Code/User/settings.json"),
false,
&p,
&[],
);
if cfg!(target_os = "macos") {
let t = result.expect("inference");
assert_eq!(t.source_root, SourceRoot::Library);
assert_eq!(
t.in_pack_natural,
Path::new("_lib/Application Support/Code/User/settings.json")
);
} else {
let err = result.unwrap_err();
assert!(matches!(err, InferenceError::UnrecognizedRoot { .. }));
}
}
#[test]
fn gui_app_heuristic_uppercase_yes() {
assert!(is_gui_app_folder("Code"));
assert!(is_gui_app_folder("Cursor"));
assert!(is_gui_app_folder("IntelliJ"));
assert!(is_gui_app_folder("Visual Studio Code"));
}
#[test]
fn gui_app_heuristic_space_yes() {
assert!(is_gui_app_folder("sublime text"));
assert!(is_gui_app_folder("smart code ltd"));
}
#[test]
fn gui_app_heuristic_reverse_dns_yes() {
assert!(is_gui_app_folder("dev.warp.warp-stable"));
assert!(is_gui_app_folder("com.apple.dt.xcode"));
assert!(is_gui_app_folder("org.videolan.vlc"));
}
#[test]
fn gui_app_heuristic_lowercase_cli_tool_no() {
assert!(!is_gui_app_folder("nvim"));
assert!(!is_gui_app_folder("helix"));
assert!(!is_gui_app_folder("ghostty"));
assert!(!is_gui_app_folder("lazygit"));
assert!(!is_gui_app_folder("starship"));
}
#[test]
fn gui_app_heuristic_empty_name_no() {
assert!(!is_gui_app_folder(""));
}
#[test]
fn gui_app_heuristic_dotted_but_empty_segment_no() {
assert!(!is_gui_app_folder(".bashrc"));
assert!(!is_gui_app_folder("foo..bar"));
assert!(!is_gui_app_folder("trailing."));
}
#[cfg(target_os = "macos")]
#[test]
fn library_preferences_plist_uses_lib_prefix_and_requires_into() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/u/Library/Preferences/com.colliderli.iina.plist"),
false,
&p,
&[],
)
.expect("inference");
assert_eq!(t.source_root, SourceRoot::Library);
assert_eq!(t.natural_pack, None, "Library sources require --into");
assert_eq!(
t.in_pack_natural,
Path::new("_lib/Preferences/com.colliderli.iina.plist")
);
assert_eq!(t.in_pack_natural, t.in_pack_override);
assert!(!t.expand_children);
}
#[cfg(target_os = "macos")]
#[test]
fn library_launch_agents_routes_through_lib_prefix() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/u/Library/LaunchAgents/com.example.foo.plist"),
false,
&p,
&[],
)
.expect("inference");
assert_eq!(t.source_root, SourceRoot::Library);
assert_eq!(
t.in_pack_natural,
Path::new("_lib/LaunchAgents/com.example.foo.plist")
);
}
#[cfg(target_os = "macos")]
#[test]
fn library_subdirectory_top_level_expands_children() {
let p = pather("/u", "/x");
let t =
infer_target(Path::new("/u/Library/LaunchAgents"), true, &p, &[]).expect("inference");
assert!(t.expand_children);
assert_eq!(t.in_pack_natural, Path::new("_lib/LaunchAgents"));
}
#[cfg(target_os = "macos")]
#[test]
fn library_root_itself_is_refused() {
let p = pather("/u", "/x");
let err = infer_target(Path::new("/u/Library"), true, &p, &[]).unwrap_err();
assert!(matches!(err, InferenceError::LibraryRootItself));
}
#[cfg(target_os = "macos")]
#[test]
fn library_application_support_still_routes_through_app_support() {
let p = pather("/u", "/x");
let t = infer_target(
Path::new("/u/Library/Application Support/Code/User/settings.json"),
false,
&p,
&[],
)
.expect("inference");
assert_eq!(t.source_root, SourceRoot::AppSupport);
assert!(t.in_pack_natural.starts_with("_app"));
}
#[test]
fn macos_containers_path_is_refused() {
let p = pather("/u", "/x");
let err = infer_target(
Path::new("/u/Library/Containers/com.example.app/Data/Library/Preferences/foo.plist"),
false,
&p,
&[],
)
.unwrap_err();
assert!(matches!(err, InferenceError::SandboxedContainer));
}
}