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,
#[allow(dead_code)]
AppSupport,
}
#[derive(Debug)]
pub(crate) enum InferenceError {
UnrecognizedRoot { hint_roots: Vec<PathBuf> },
NonDottedHome { stripped: String },
LooseXdgFile,
XdgRootItself,
HomeRootItself,
SandboxedContainer,
}
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/."
),
}
}
}
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 containers_root = canon_home.join("Library").join("Containers");
if canon_source.starts_with(&containers_root) {
return Err(InferenceError::SandboxedContainer);
}
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);
}
Err(InferenceError::UnrecognizedRoot {
hint_roots: vec![canon_xdg, canon_home],
})
}
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_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 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)
.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 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));
}
}