mod infer;
use std::collections::BTreeSet;
use std::path::{Path, PathBuf};
use crate::commands::status;
use crate::commands::{DisplayFile, DisplayNote, PackStatusResult};
use crate::conflicts;
use crate::fs::Fs;
use crate::packs;
use crate::packs::orchestration::{self, ExecutionContext};
use crate::rules;
use crate::{DodotError, Result};
use self::infer::{infer_target, InferredTarget};
#[cfg(test)]
pub(crate) use self::infer::derive_home_in_pack as derive_pack_filename;
struct AdoptPlan {
source: PathBuf,
pack_dest: PathBuf,
is_dir: bool,
destructive_overwrite: bool,
}
pub fn adopt(
pack_override: Option<&str>,
sources: &[PathBuf],
force: bool,
no_follow: bool,
dry_run: bool,
only_os: Option<&str>,
ctx: &ExecutionContext,
) -> Result<PackStatusResult> {
if let Some(label) = only_os {
let root_config = ctx.config_manager.root_config()?;
let mut gates = crate::gates::GateTable::with_builtins();
if !root_config.gates.is_empty() {
gates.merge_user(&root_config.gates)?;
}
if !gates.contains(label) {
return Err(DodotError::Config(format!(
"unknown gate label `{label}` for --only-os: \
not in the built-in seed and not defined in [gates]. \
Built-ins: darwin, linux, macos, arm64, aarch64, x86_64."
)));
}
}
if sources.is_empty() {
return Err(DodotError::Other("no files specified".into()));
}
let resolved = resolve_pack_for_sources(pack_override, sources, ctx)?;
let pack_dir = resolved.pack_dir.clone();
let pack_display = resolved.display_name.clone();
let pack_path = ctx.paths.pack_path(&pack_dir);
if !ctx.fs.exists(&pack_path) {
ctx.fs.mkdir_all(&pack_path)?;
}
if ctx.fs.exists(&pack_path.join(".dodotignore")) {
return Err(DodotError::PackInvalid {
name: pack_display.clone(),
reason: "pack is marked ignored via .dodotignore".into(),
});
}
let (plans, skipped_already_adopted) = preflight(
&pack_dir,
&pack_path,
sources,
pack_override,
force,
no_follow,
only_os,
ctx,
)?;
if plans.is_empty() {
let mut result = status::status(Some(std::slice::from_ref(&pack_display)), ctx)?;
result.dry_run = dry_run;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
return Ok(result);
}
if let Err(e) = copy_all(&plans, ctx.fs.as_ref()) {
cleanup_pack_copies(&plans, ctx.fs.as_ref());
return Err(e);
}
if let Err(e) = check_deploy_conflicts(ctx) {
cleanup_pack_copies(&plans, ctx.fs.as_ref());
return Err(e);
}
if dry_run {
cleanup_pack_copies(&plans, ctx.fs.as_ref());
let mut result = status::status(Some(std::slice::from_ref(&pack_display)), ctx)?;
result.dry_run = true;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
return Ok(result);
}
let failures = swap_all(&plans, ctx.fs.as_ref());
let mut result = status::status(Some(std::slice::from_ref(&pack_display)), ctx)?;
result.dry_run = false;
for msg in skipped_already_adopted {
result.warnings.push(msg);
}
let force_home = ctx.config_manager.root_config()?.symlink.force_home.clone();
let any_app_support = sources.iter().any(|s| {
absolutize(s)
.ok()
.and_then(|abs| {
let is_dir = ctx.fs.stat(&abs).map(|m| m.is_dir).unwrap_or(false);
infer::infer_target(&abs, is_dir, ctx.paths.as_ref(), &force_home).ok()
})
.map(|t| t.source_root == infer::SourceRoot::AppSupport)
.unwrap_or(false)
});
let cache_dir = ctx.paths.probes_brew_cache_dir();
let now = crate::probe::brew::now_secs_unix();
let cask_matches = if any_app_support {
crate::probe::brew::match_folders_to_installed_casks(
std::slice::from_ref(&pack_display),
ctx.command_runner.as_ref(),
&cache_dir,
now,
ctx.fs.as_ref(),
false,
)
} else {
crate::probe::brew::InstalledCaskMatches::default()
};
let cask_token: Option<&str> = cask_matches
.folder_to_token
.get(&pack_display)
.map(String::as_str);
if pack_override.is_none() && infer::is_gui_app_folder(&pack_display) && any_app_support {
let lowercase_fallback: String = pack_display
.chars()
.filter(|c| !c.is_whitespace())
.flat_map(char::to_lowercase)
.collect();
let suggested_alias = cask_token.unwrap_or(lowercase_fallback.as_str());
if !suggested_alias.is_empty() && suggested_alias != pack_display {
let cask_credit = match cask_token {
Some(token) => format!(" (matches homebrew cask `{token}`)"),
None => String::new(),
};
result.warnings.push(format!(
"tip: pack `{pack_display}` looks like a macOS GUI-app folder{cask_credit}. \
Consider renaming the pack to `{suggested_alias}` and adding\n \
[symlink.app_aliases]\n {suggested_alias} = \"{pack_display}\"\n\
to your .dodot.toml so future files can use bare paths instead \
of `_app/{pack_display}/...`."
));
}
}
if let Some(token) = cask_token {
result.warnings.push(format!(
"homebrew cask `{token}` confirms this is the app-support directory \
for pack `{pack_display}`."
));
if let Ok(Some(info)) = crate::probe::brew::info_cask(
token,
&cache_dir,
now,
ctx.fs.as_ref(),
ctx.command_runner.as_ref(),
) {
let plists = info.preferences_plists();
let candidates: Vec<&str> = plists
.iter()
.filter_map(|p| {
let leaf = p.split('/').next_back()?;
if leaf.is_empty() {
None
} else {
Some(leaf)
}
})
.collect();
if !candidates.is_empty() {
let list = candidates.join(", ");
result.warnings.push(format!(
"homebrew also reports preferences for cask `{token}`: {list}. \
Adopt them too with `dodot adopt ~/Library/Preferences/<file> --into {pack_display}`."
));
}
}
}
let adopted_any_plist = plans.iter().any(|p| {
p.source
.extension()
.and_then(|e| e.to_str())
.map(|s| s.eq_ignore_ascii_case("plist"))
.unwrap_or(false)
});
if adopted_any_plist && !crate::commands::git_filters::is_installed(ctx).unwrap_or(true) {
result.warnings.push(
"tip: pack now contains a .plist file. Run `dodot git-install-filters` to enable \
canonical XML diffs (binary plists become diffable in `git status`/`git diff`)."
.into(),
);
}
for f in &failures {
let src_name = f
.source
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_else(|| f.source.display().to_string());
result.notes.push(DisplayNote {
body: format!("adopt failed: {}: {}", f.source.display(), f.reason),
hint: None,
});
let note_ref = Some(result.notes.len() as u32);
if let Some(pack) = result.packs.iter_mut().find(|p| p.name == pack_display) {
pack.files.push(DisplayFile {
name: src_name,
symbol: "×".into(),
description: "adopt failed".into(),
status: "error".into(),
status_label: "error".into(),
handler: String::new(),
note_ref,
});
pack.recompute_summary();
}
}
Ok(result)
}
struct ResolvedPack {
pack_dir: String,
display_name: String,
}
fn resolve_pack_for_sources(
pack_override: Option<&str>,
sources: &[PathBuf],
ctx: &ExecutionContext,
) -> Result<ResolvedPack> {
if let Some(name) = pack_override {
let pack_dir = orchestration::resolve_pack_dir_name(name, ctx)?;
let display_name = packs::display_name_for(&pack_dir).to_string();
return Ok(ResolvedPack {
pack_dir,
display_name,
});
}
let force_home = ctx.config_manager.root_config()?.symlink.force_home.clone();
let fs = ctx.fs.as_ref();
let mut candidates: BTreeSet<String> = BTreeSet::new();
let mut declined: Vec<PathBuf> = Vec::new();
for raw in sources {
let abs = absolutize(raw)?;
if !fs.exists(&abs) && !fs.is_symlink(&abs) {
return Err(DodotError::Fs {
path: abs,
source: std::io::Error::new(std::io::ErrorKind::NotFound, "source does not exist"),
});
}
let is_dir = fs.stat(&abs).map(|m| m.is_dir).unwrap_or(false);
match infer_target(&abs, is_dir, ctx.paths.as_ref(), &force_home) {
Ok(t) => match t.natural_pack {
Some(name) => {
candidates.insert(name);
}
None => declined.push(abs),
},
Err(e) => {
return Err(DodotError::Other(format!(
"refusing to adopt {}: {e}",
abs.display()
)))
}
}
}
match candidates.len() {
0 => Err(DodotError::Other(format!(
"could not infer a pack name for {} source(s); pass --into <pack>",
declined.len()
))),
1 => {
let inferred = candidates.into_iter().next().unwrap();
let pack_dir = orchestration::resolve_pack_dir_name(&inferred, ctx)
.unwrap_or_else(|_| inferred.clone());
let display_name = packs::display_name_for(&pack_dir).to_string();
let _ = declined;
Ok(ResolvedPack {
pack_dir,
display_name,
})
}
_ => {
let names: Vec<String> = candidates.into_iter().collect();
Err(DodotError::Other(format!(
"sources infer different packs ({}); split into separate adopt \
invocations or pass --into <pack> to force a single destination",
names.join(", ")
)))
}
}
}
#[allow(clippy::too_many_arguments)]
fn preflight(
pack_name: &str,
pack_path: &Path,
sources: &[PathBuf],
pack_override: Option<&str>,
force: bool,
no_follow: bool,
only_os: Option<&str>,
ctx: &ExecutionContext,
) -> Result<(Vec<AdoptPlan>, Vec<String>)> {
let fs = ctx.fs.as_ref();
let dotfiles_root = ctx.paths.dotfiles_root().to_path_buf();
let data_dir = ctx.paths.data_dir().to_path_buf();
let root_config = ctx.config_manager.root_config()?;
let pack_config = ctx.config_manager.config_for_pack(pack_path)?;
let ignore_patterns = {
let mut combined = root_config.pack.ignore.clone();
combined.extend(pack_config.pack.ignore.iter().cloned());
combined
};
let force_home = {
let mut combined = root_config.symlink.force_home.clone();
combined.extend(pack_config.symlink.force_home.iter().cloned());
combined
};
let mut plans: Vec<AdoptPlan> = Vec::new();
let mut skipped: Vec<String> = Vec::new();
for raw_source in sources {
let abs = absolutize(raw_source)?;
if !fs.exists(&abs) && !fs.is_symlink(&abs) {
return Err(DodotError::Fs {
path: abs,
source: std::io::Error::new(std::io::ErrorKind::NotFound, "source does not exist"),
});
}
if fs.is_symlink(&abs) {
if let Ok(raw_target) = fs.readlink(&abs) {
let resolved = crate::equivalence::resolve_symlink_target(&abs, &raw_target);
if resolved.starts_with(&data_dir) {
skipped.push(format!(
"skipped: {} is already managed by dodot (-> {})",
abs.display(),
raw_target.display()
));
continue;
}
if resolved.starts_with(&dotfiles_root) {
skipped.push(format!(
"skipped: {} is a direct symlink to pack source (-> {}); \
run `dodot up {}` to upgrade it to dodot's full chain",
abs.display(),
raw_target.display(),
pack_name,
));
continue;
}
}
}
let lmeta = fs.lstat(&abs)?;
let is_source_symlink = lmeta.is_symlink;
let treat_as_link = is_source_symlink && no_follow;
let is_dir = if treat_as_link {
false
} else {
let smeta = fs.stat(&abs)?;
smeta.is_dir
};
let inferred =
infer_target(&abs, is_dir, ctx.paths.as_ref(), &force_home).map_err(|reason| {
DodotError::Other(format!("refusing to adopt {}: {reason}", abs.display()))
})?;
let in_pack = match (&inferred.natural_pack, pack_override) {
(Some(natural), Some(over)) if natural != over => inferred.in_pack_override.clone(),
_ => inferred.in_pack_natural.clone(),
};
let in_pack = if let Some(label) = only_os {
std::path::PathBuf::from(format!("_{label}")).join(&in_pack)
} else {
in_pack
};
if inferred.expand_children {
let override_differs = matches!(
(&inferred.natural_pack, pack_override),
(Some(natural), Some(over)) if natural != over
);
let entries = fs.read_dir(&abs)?;
for entry in entries {
let child_in_pack = expand_child_in_pack(&inferred, &entry.name, override_differs);
let child_in_pack = if let Some(label) = only_os {
std::path::PathBuf::from(format!("_{label}")).join(&child_in_pack)
} else {
child_in_pack
};
push_plan(
&mut plans,
fs,
&abs.join(&entry.name),
pack_path,
&child_in_pack,
no_follow,
force,
&ignore_patterns,
)?;
}
} else {
push_plan(
&mut plans,
fs,
&abs,
pack_path,
&in_pack,
no_follow,
force,
&ignore_patterns,
)?;
}
}
let _ = pack_name; check_writable(fs, pack_path)?;
for plan in &plans {
check_readable(fs, &plan.source, plan.is_dir)?;
if let Some(src_parent) = plan.source.parent() {
check_writable(fs, src_parent)?;
}
}
Ok((plans, skipped))
}
fn expand_child_in_pack(
parent: &InferredTarget,
child_name: &str,
override_differs: bool,
) -> PathBuf {
use self::infer::SourceRoot;
match parent.source_root {
SourceRoot::XdgConfig => {
if override_differs {
parent.in_pack_override.join(child_name)
} else {
PathBuf::from(child_name)
}
}
SourceRoot::AppSupport => {
parent.in_pack_override.join(child_name)
}
SourceRoot::Home => {
PathBuf::from(child_name)
}
SourceRoot::Library => {
parent.in_pack_override.join(child_name)
}
}
}
#[allow(clippy::too_many_arguments)]
fn push_plan(
plans: &mut Vec<AdoptPlan>,
fs: &dyn Fs,
source: &Path,
pack_path: &Path,
in_pack: &Path,
no_follow: bool,
force: bool,
ignore_patterns: &[String],
) -> Result<()> {
let lmeta = fs.lstat(source)?;
let is_source_symlink = lmeta.is_symlink;
let treat_as_link = is_source_symlink && no_follow;
let is_dir = if treat_as_link {
false
} else {
fs.stat(source)?.is_dir
};
use std::path::Component;
let top_level_name = in_pack
.components()
.find_map(|c| match c {
Component::Normal(s) => Some(s.to_string_lossy().into_owned()),
_ => None,
})
.unwrap_or_else(|| in_pack.display().to_string());
if rules::should_skip_entry(&top_level_name, ignore_patterns) {
return Err(DodotError::Other(format!(
"refusing to adopt {}: top-level entry '{}' matches an ignore pattern or is reserved",
source.display(),
top_level_name
)));
}
let pack_dest = pack_path.join(in_pack);
let dest_exists = fs.exists(&pack_dest) || fs.is_symlink(&pack_dest);
if dest_exists && !force {
return Err(DodotError::SymlinkConflict { path: pack_dest });
}
if plans.iter().any(|p| p.pack_dest == pack_dest) {
return Err(DodotError::Other(format!(
"two sources produce the same pack path '{}'; adopt them separately",
in_pack.display()
)));
}
plans.push(AdoptPlan {
source: source.to_path_buf(),
pack_dest,
is_dir,
destructive_overwrite: dest_exists,
});
Ok(())
}
fn absolutize(raw: &Path) -> Result<PathBuf> {
let abs = if raw.is_absolute() {
raw.to_path_buf()
} else {
std::env::current_dir()
.map_err(|e| DodotError::Fs {
path: raw.to_path_buf(),
source: e,
})?
.join(raw)
};
Ok(crate::equivalence::normalize_path(&abs))
}
fn check_writable(fs: &dyn Fs, dir: &Path) -> Result<()> {
let probe = dir.join(format!(".dodot-adopt-probe-{}", nonce()));
fs.write_file(&probe, b"").map_err(|e| {
DodotError::Other(format!("not writable: {}: {}", dir.display(), err_msg(&e)))
})?;
let _ = fs.remove_file(&probe);
Ok(())
}
fn check_readable(fs: &dyn Fs, path: &Path, is_dir: bool) -> Result<()> {
if is_dir {
fs.read_dir(path).map(|_| ())
} else {
fs.lstat(path).map(|_| ())
}
}
fn copy_all(plans: &[AdoptPlan], fs: &dyn Fs) -> Result<()> {
for plan in plans {
let had_existing_dest = fs.exists(&plan.pack_dest) || fs.is_symlink(&plan.pack_dest);
if let Some(parent) = plan.pack_dest.parent() {
if !parent.as_os_str().is_empty() && !fs.exists(parent) {
fs.mkdir_all(parent)?;
}
}
if had_existing_dest {
let stage = temp_sibling(&plan.pack_dest, "stage");
if let Err(e) = copy_tree(&plan.source, &stage, fs) {
remove_best_effort(fs, &stage);
return Err(e);
}
remove_path(&plan.pack_dest, fs)?;
if let Err(e) = fs.rename(&stage, &plan.pack_dest) {
remove_best_effort(fs, &stage);
return Err(e);
}
} else {
copy_tree(&plan.source, &plan.pack_dest, fs)?;
}
}
Ok(())
}
fn remove_path(path: &Path, fs: &dyn Fs) -> Result<()> {
if fs.is_symlink(path) {
fs.remove_file(path)
} else if fs.is_dir(path) {
fs.remove_dir_all(path)
} else {
fs.remove_file(path)
}
}
fn copy_tree(src: &Path, dst: &Path, fs: &dyn Fs) -> Result<()> {
let meta = fs.lstat(src)?;
if meta.is_symlink {
let target = fs.readlink(src)?;
fs.symlink(&target, dst)?;
return Ok(());
}
if meta.is_dir {
fs.mkdir_all(dst)?;
let _ = fs.set_permissions(dst, meta.mode);
for entry in fs.read_dir(src)? {
copy_tree(&entry.path, &dst.join(&entry.name), fs)?;
}
return Ok(());
}
if meta.is_file {
fs.copy_file(src, dst)?;
let _ = fs.set_permissions(dst, meta.mode);
return Ok(());
}
Err(DodotError::Other(format!(
"unsupported file type: {}",
src.display()
)))
}
fn cleanup_pack_copies(plans: &[AdoptPlan], fs: &dyn Fs) {
for plan in plans {
if plan.destructive_overwrite {
continue;
}
remove_best_effort(fs, &plan.pack_dest);
}
}
fn remove_best_effort(fs: &dyn Fs, path: &Path) {
if fs.is_symlink(path) {
let _ = fs.remove_file(path);
} else if fs.is_dir(path) {
let _ = fs.remove_dir_all(path);
} else if fs.exists(path) {
let _ = fs.remove_file(path);
}
}
fn check_deploy_conflicts(ctx: &ExecutionContext) -> Result<()> {
let root_config = ctx.config_manager.root_config()?;
let packs::DiscoveredPacks { packs: all, .. } = packs::scan_packs(
ctx.fs.as_ref(),
ctx.paths.dotfiles_root(),
&root_config.pack.ignore,
)?;
let mut pack_intents = Vec::new();
for mut pack in all {
let pack_config = ctx.config_manager.config_for_pack(&pack.path)?;
pack.config = pack_config.to_handler_config();
let intents = orchestration::collect_pack_intents(&pack, ctx)?;
pack_intents.push((pack.display_name.clone(), intents));
}
let conflicts = conflicts::detect_cross_pack_conflicts(&pack_intents, ctx.fs.as_ref());
if !conflicts.is_empty() {
return Err(DodotError::CrossPackConflict { conflicts });
}
Ok(())
}
struct AdoptFailure {
source: PathBuf,
reason: String,
}
fn swap_all(plans: &[AdoptPlan], fs: &dyn Fs) -> Vec<AdoptFailure> {
let mut failures = Vec::new();
for plan in plans {
let result = if plan.is_dir {
swap_dir(&plan.source, &plan.pack_dest, fs)
} else {
swap_file_atomic(&plan.source, &plan.pack_dest, fs)
};
if let Err(e) = result {
remove_best_effort(fs, &plan.pack_dest);
failures.push(AdoptFailure {
source: plan.source.clone(),
reason: format!("{}", e),
});
}
}
failures
}
fn swap_file_atomic(source: &Path, pack_dest: &Path, fs: &dyn Fs) -> Result<()> {
let tmp = temp_sibling(source, "tmp");
fs.symlink(pack_dest, &tmp)?;
if let Err(e) = fs.rename(&tmp, source) {
let _ = fs.remove_file(&tmp);
return Err(e);
}
Ok(())
}
fn swap_dir(source: &Path, pack_dest: &Path, fs: &dyn Fs) -> Result<()> {
let backup = temp_sibling(source, "old");
fs.rename(source, &backup)?;
match fs.symlink(pack_dest, source) {
Ok(()) => {
let _ = fs.remove_dir_all(&backup);
Ok(())
}
Err(e) => {
let _ = fs.rename(&backup, source);
Err(e)
}
}
}
fn temp_sibling(path: &Path, tag: &str) -> PathBuf {
let parent = path.parent().unwrap_or(Path::new("."));
let name = path
.file_name()
.map(|n| n.to_string_lossy().into_owned())
.unwrap_or_default();
parent.join(format!(".dodot-adopt-{}-{}-{}", tag, name, nonce()))
}
fn nonce() -> String {
use std::time::{SystemTime, UNIX_EPOCH};
let n = SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_nanos())
.unwrap_or(0);
format!("{:x}", n)
}
fn err_msg(e: &DodotError) -> String {
format!("{e}")
}