#![allow(unused_imports)]
use std::sync::Arc;
use crate::commands;
use crate::config::ConfigManager;
use crate::datastore::{CommandOutput, CommandRunner, FilesystemDataStore};
use crate::fs::Fs;
use crate::packs::orchestration::ExecutionContext;
use crate::paths::Pather;
use crate::render;
use crate::testing::TempEnvironment;
use crate::Result;
use standout_render::OutputMode;
use super::support::{make_ctx, make_ctx_with_runner, CannedRunner};
#[test]
fn adopt_moves_file_and_creates_symlink() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.home_file(".vimrc", "set nocompatible")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let result = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
env.assert_regular_file(
&env.dotfiles_root.join("vim/home.vimrc"),
"set nocompatible",
);
assert!(env.fs.is_symlink(&source));
assert!(result.packs.iter().any(|p| p.name == "vim"));
let vim = result.packs.iter().find(|p| p.name == "vim").unwrap();
assert!(vim.files.iter().any(|f| f.name == "home.vimrc"));
}
#[test]
fn adopt_preserves_executable_permissions() {
use std::os::unix::fs::PermissionsExt;
let env = TempEnvironment::builder()
.pack("tools")
.file("placeholder", "")
.done()
.home_file(".script.sh", "#!/bin/sh\necho hi")
.build();
let source = env.home.join(".script.sh");
let perms = std::fs::Permissions::from_mode(0o755);
std::fs::set_permissions(&source, perms).unwrap();
let ctx = make_ctx(&env);
commands::adopt::adopt(
Some("tools"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let dest = env.dotfiles_root.join("tools/home.script.sh");
let meta = std::fs::metadata(&dest).unwrap();
assert_eq!(
meta.permissions().mode() & 0o777,
0o755,
"executable bit should be preserved on adopted file"
);
}
#[test]
fn adopt_refuses_non_dotted_home_entry() {
let env = TempEnvironment::builder()
.pack("tools")
.file("placeholder", "")
.done()
.home_file("script.sh", "#!/bin/sh\necho hi")
.build();
let ctx = make_ctx(&env);
let source = env.home.join("script.sh");
let err = commands::adopt::adopt(
Some("tools"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("non-dotted entry in $HOME"),
"expected refusal message, got: {msg}"
);
assert!(
msg.contains("[symlink.targets]"),
"refusal should point at [symlink.targets] escape hatch, got: {msg}"
);
env.assert_regular_file(&source, "#!/bin/sh\necho hi");
env.assert_not_exists(&env.dotfiles_root.join("tools/script.sh"));
}
#[test]
fn adopt_destination_conflict_refused_without_force() {
let env = TempEnvironment::builder()
.pack("vim")
.file("home.vimrc", "existing content")
.done()
.home_file(".vimrc", "new content")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let err = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::SymlinkConflict { .. }),
"expected SymlinkConflict, got: {err}"
);
env.assert_regular_file(&source, "new content");
env.assert_regular_file(
&env.dotfiles_root.join("vim/home.vimrc"),
"existing content",
);
}
#[test]
fn adopt_destination_conflict_resolved_with_force() {
let env = TempEnvironment::builder()
.pack("vim")
.file("home.vimrc", "OLD")
.done()
.home_file(".vimrc", "NEW")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
true, false,
false,
None,
&ctx,
)
.unwrap();
env.assert_regular_file(&env.dotfiles_root.join("vim/home.vimrc"), "NEW");
assert!(env.fs.is_symlink(&source));
}
#[test]
fn adopt_directory_creates_symlink_and_preserves_contents() {
let env = TempEnvironment::builder()
.pack("editor")
.file("placeholder", "")
.done()
.home_file(".vim/vimrc", "set nocompatible")
.home_file(".vim/colors/scheme.vim", "\" colors")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vim");
commands::adopt::adopt(
Some("editor"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_dir = env.dotfiles_root.join("editor/_home/vim");
env.assert_dir_exists(&pack_dir);
env.assert_regular_file(&pack_dir.join("vimrc"), "set nocompatible");
env.assert_regular_file(&pack_dir.join("colors/scheme.vim"), "\" colors");
assert!(env.fs.is_symlink(&source));
let target = env.fs.readlink(&source).unwrap();
assert_eq!(target, pack_dir);
}
#[test]
fn adopt_dotted_dir_from_home_round_trips_via_home_escape() {
let env = TempEnvironment::builder()
.pack("chats")
.file("placeholder", "")
.done()
.home_file(".weechat/weechat.conf", "[server]")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".weechat");
commands::adopt::adopt(
Some("chats"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_dir = env.dotfiles_root.join("chats/_home/weechat");
env.assert_dir_exists(&pack_dir);
env.assert_regular_file(&pack_dir.join("weechat.conf"), "[server]");
commands::up::up(Some(&["chats".into()]), &ctx).unwrap();
let user_path = env.home.join(".weechat");
assert!(
env.fs.is_symlink(&user_path),
"~/.weechat should be a symlink after re-deploy"
);
}
#[test]
fn pack_filename_round_trips_through_resolve_target() {
use crate::commands::adopt::derive_pack_filename;
use crate::handlers::symlink::resolve_target;
let force_home: Vec<String> = vec![
"ssh".into(),
"gnupg".into(),
"aws".into(),
"kube".into(),
"bashrc".into(),
"zshrc".into(),
"profile".into(),
"inputrc".into(),
];
let config = crate::handlers::HandlerConfig {
force_home: force_home.clone(),
..crate::handlers::HandlerConfig::default()
};
let paths = crate::paths::XdgPather::builder()
.home("/home/alice")
.dotfiles_root("/home/alice/dotfiles")
.xdg_config_home("/home/alice/.config")
.build()
.unwrap();
struct Case {
pack: &'static str,
home_name: &'static str,
is_dir: bool,
expected_pack_filename: &'static str,
}
let cases = [
Case {
pack: "shell",
home_name: ".bashrc",
is_dir: false,
expected_pack_filename: "bashrc",
},
Case {
pack: "net",
home_name: ".ssh",
is_dir: true,
expected_pack_filename: "ssh",
},
Case {
pack: "vim",
home_name: ".vimrc",
is_dir: false,
expected_pack_filename: "home.vimrc",
},
Case {
pack: "chats",
home_name: ".weechat",
is_dir: true,
expected_pack_filename: "_home/weechat",
},
];
for c in &cases {
let derived =
derive_pack_filename(c.home_name, c.is_dir, &force_home).unwrap_or_else(|e| {
panic!(
"derive_pack_filename refused accepted case {:?}: {e}",
c.home_name
)
});
assert_eq!(
derived, c.expected_pack_filename,
"documentation-expected pack filename drifted for {}",
c.home_name
);
let target = resolve_target(c.pack, &derived, &config, &paths);
let expected_source = std::path::PathBuf::from(format!("/home/alice/{}", c.home_name));
assert_eq!(
target,
expected_source,
"round-trip broke for {}: derive_pack_filename → {} → resolve_target → {} \
(expected back at {})",
c.home_name,
derived,
target.display(),
expected_source.display(),
);
}
let refused = derive_pack_filename("my_script.sh", false, &force_home);
assert!(
refused.is_err(),
"non-dotted $HOME entry must be refused, got: {refused:?}"
);
}
#[test]
fn adopt_preserves_inner_symlinks_as_symlinks() {
let env = TempEnvironment::builder()
.pack("shell")
.file("placeholder", "")
.done()
.home_file(".mydir/real.txt", "hello")
.build();
let inner_target = env.home.join(".mydir/real.txt");
let inner_link = env.home.join(".mydir/alias");
env.fs.symlink(&inner_target, &inner_link).unwrap();
let ctx = make_ctx(&env);
let source = env.home.join(".mydir");
commands::adopt::adopt(
Some("shell"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let copied_link = env.dotfiles_root.join("shell/_home/mydir/alias");
assert!(
env.fs.is_symlink(&copied_link),
"inner symlink should be preserved as a symlink, not followed"
);
}
#[test]
fn adopt_xdg_nested_file_lands_at_pack_root() {
let env = TempEnvironment::builder()
.pack("nvim")
.file("placeholder", "")
.done()
.home_file(".config/nvim/init.lua", "-- config")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".config/nvim/init.lua");
commands::adopt::adopt(
Some("nvim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_file = env.dotfiles_root.join("nvim/init.lua");
env.assert_regular_file(&pack_file, "-- config");
assert!(env.fs.is_symlink(&source));
let target = env.fs.readlink(&source).unwrap();
assert_eq!(target, pack_file);
}
#[test]
fn adopt_xdg_source_infers_pack_and_auto_creates() {
let env = TempEnvironment::builder()
.home_file(".config/ghostty/config", "theme = dark")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".config/ghostty/config");
commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_dir = env.dotfiles_root.join("ghostty");
env.assert_dir_exists(&pack_dir);
env.assert_regular_file(&pack_dir.join("config"), "theme = dark");
assert!(env.fs.is_symlink(&source));
}
#[test]
fn adopt_xdg_pack_root_directory_expands_to_children() {
let env = TempEnvironment::builder()
.home_file(".config/helix/config.toml", "theme = \"onedark\"")
.home_file(".config/helix/themes/extra.toml", "fg = \"white\"")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".config/helix");
commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_dir = env.dotfiles_root.join("helix");
env.assert_regular_file(&pack_dir.join("config.toml"), "theme = \"onedark\"");
env.assert_regular_file(&pack_dir.join("themes/extra.toml"), "fg = \"white\"");
assert!(env
.fs
.is_symlink(&env.home.join(".config/helix/config.toml")));
assert!(env.fs.is_symlink(&env.home.join(".config/helix/themes")));
assert!(!env.fs.is_symlink(&source));
}
#[test]
fn adopt_xdg_root_itself_refused() {
let env = TempEnvironment::builder()
.home_file(".config/nvim/init.lua", "-- config")
.build();
let ctx = make_ctx(&env);
let source = env.config_home.clone();
let err = commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("$XDG_CONFIG_HOME"),
"expected XDG-root refusal, got: {msg}"
);
}
#[test]
fn adopt_xdg_pack_root_expansion_with_override_uses_xdg_prefix() {
let env = TempEnvironment::builder()
.pack("toolbox")
.file("placeholder", "")
.done()
.home_file(".config/lazygit/config.yml", "gui:\n theme: dark")
.home_file(".config/lazygit/themes/x.yml", "fg: white")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".config/lazygit");
commands::adopt::adopt(
Some("toolbox"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
env.assert_regular_file(
&env.dotfiles_root.join("toolbox/_xdg/lazygit/config.yml"),
"gui:\n theme: dark",
);
env.assert_regular_file(
&env.dotfiles_root.join("toolbox/_xdg/lazygit/themes/x.yml"),
"fg: white",
);
assert!(env
.fs
.is_symlink(&env.home.join(".config/lazygit/config.yml")));
assert!(env.fs.is_symlink(&env.home.join(".config/lazygit/themes")));
assert!(!env.fs.is_symlink(&source));
}
#[test]
fn adopt_xdg_with_into_override_uses_xdg_prefix() {
let env = TempEnvironment::builder()
.pack("toolbox")
.file("placeholder", "")
.done()
.home_file(".config/lazygit/config.yml", "gui:\n theme: dark")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".config/lazygit/config.yml");
commands::adopt::adopt(
Some("toolbox"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_file = env.dotfiles_root.join("toolbox/_xdg/lazygit/config.yml");
env.assert_regular_file(&pack_file, "gui:\n theme: dark");
assert!(env.fs.is_symlink(&source));
}
#[test]
fn adopt_app_support_source_round_trips_through_app_prefix() {
let env = TempEnvironment::builder()
.home_file(
"Library/Application Support/Code/User/settings.json",
"{\"editor.fontSize\": 14}",
)
.build();
let ctx = make_ctx(&env);
let source = env.app_support.join("Code/User/settings.json");
commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_file = env.dotfiles_root.join("Code/_app/Code/User/settings.json");
env.assert_regular_file(&pack_file, "{\"editor.fontSize\": 14}");
assert!(env.fs.is_symlink(&source));
use crate::handlers::symlink::{resolve_target_full, Resolution};
let resolution = resolve_target_full(
"Code",
"_app/Code/User/settings.json",
&Default::default(),
env.paths.as_ref(),
);
match resolution {
Resolution::Path(p) => assert_eq!(p, source),
Resolution::Skip { reason } => panic!("expected Path, got Skip({reason})"),
}
}
#[test]
fn adopt_app_support_pack_root_directory_expands_to_children() {
let env = TempEnvironment::builder()
.home_file(
"Library/Application Support/Cursor/User/settings.json",
"{}",
)
.home_file(
"Library/Application Support/Cursor/User/keybindings.json",
"[]",
)
.build();
let ctx = make_ctx(&env);
let source = env.app_support.join("Cursor");
commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let pack_dir = env.dotfiles_root.join("Cursor");
env.assert_dir_exists(&pack_dir);
env.assert_regular_file(&pack_dir.join("_app/Cursor/User/settings.json"), "{}");
env.assert_regular_file(&pack_dir.join("_app/Cursor/User/keybindings.json"), "[]");
assert!(env.fs.is_symlink(&env.app_support.join("Cursor/User")));
assert!(!env.fs.is_symlink(&source));
}
#[test]
fn adopt_app_support_emits_capitalization_hint() {
let env = TempEnvironment::builder()
.home_file("Library/Application Support/Code/User/settings.json", "{}")
.build();
let ctx = make_ctx(&env);
let source = env.app_support.join("Code/User/settings.json");
let result = commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
assert!(
result
.warnings
.iter()
.any(|w| w.contains("app_aliases") && w.contains("Code")),
"expected an `app_aliases` tip in warnings, got: {:?}",
result.warnings
);
}
#[test]
#[cfg_attr(not(target_os = "macos"), ignore = "macOS-only enrichment paths")]
fn adopt_app_support_reverse_dns_uses_cask_token_in_tip() {
let env = TempEnvironment::builder()
.home_file(
"Library/Application Support/com.colliderli.iina/input_conf/mine.conf",
"x",
)
.build();
let runner = Arc::new(CannedRunner::new());
runner.respond(&["brew", "list", "--cask", "--versions"], "iina 1.4.0\n", 0);
runner.respond(
&["brew", "info", "--json=v2", "--cask", "iina"],
r#"{"casks": [{
"token": "iina",
"artifacts": [
{"app": ["IINA.app"]},
{"zap": [{"trash": ["~/Library/Application Support/com.colliderli.iina"]}]}
]
}]}"#,
0,
);
let ctx = make_ctx_with_runner(&env, runner);
let source = env
.app_support
.join("com.colliderli.iina/input_conf/mine.conf");
let result = commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let tip = result
.warnings
.iter()
.find(|w| w.contains("app_aliases"))
.unwrap_or_else(|| panic!("expected an app_aliases tip, got: {:?}", result.warnings));
assert!(
tip.contains("renaming the pack to `iina`"),
"expected cask-token-based rename suggestion (`iina`), got: {tip}"
);
assert!(
!tip.contains("comcolliderliiina"),
"rename suggestion fell back to lowercase mangling instead of cask token: {tip}"
);
assert!(
tip.contains("matches homebrew cask"),
"tip should credit the cask source, got: {tip}"
);
}
#[test]
#[cfg_attr(not(target_os = "macos"), ignore = "macOS-only enrichment paths")]
fn adopt_app_support_falls_back_to_lowercase_when_no_cask_match() {
let env = TempEnvironment::builder()
.home_file("Library/Application Support/Tinkerbell/settings.json", "{}")
.build();
let runner = Arc::new(CannedRunner::new());
runner.respond(&["brew", "list", "--cask", "--versions"], "", 0);
let ctx = make_ctx_with_runner(&env, runner);
let source = env.app_support.join("Tinkerbell/settings.json");
let result = commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let tip = result
.warnings
.iter()
.find(|w| w.contains("app_aliases"))
.unwrap_or_else(|| panic!("expected an app_aliases tip, got: {:?}", result.warnings));
assert!(
tip.contains("renaming the pack to `tinkerbell`"),
"expected fallback rename suggestion, got: {tip}"
);
assert!(
!tip.contains("matches homebrew cask"),
"tip should not claim a cask match when none exists: {tip}"
);
}
#[test]
fn adopt_app_support_into_override_suppresses_hint() {
let env = TempEnvironment::builder()
.pack("Code")
.file("placeholder", "")
.done()
.home_file("Library/Application Support/Code/User/settings.json", "{}")
.build();
let ctx = make_ctx(&env);
let source = env.app_support.join("Code/User/settings.json");
let result = commands::adopt::adopt(
Some("Code"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
assert!(
!result.warnings.iter().any(|w| w.contains("app_aliases")),
"expected no app_aliases tip with --into, got: {:?}",
result.warnings
);
}
#[test]
fn adopt_xdg_lowercase_pack_emits_no_hint() {
let env = TempEnvironment::builder()
.home_file(".config/nvim/init.lua", "-- nvim")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".config/nvim/init.lua");
let result = commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
assert!(
!result.warnings.iter().any(|w| w.contains("app_aliases")),
"expected no app_aliases tip for plain XDG adopt, got: {:?}",
result.warnings
);
}
#[test]
fn adopt_disagreeing_inferred_packs_refused() {
let env = TempEnvironment::builder()
.home_file(".config/nvim/init.lua", "-- nvim")
.home_file(".config/helix/config.toml", "# helix")
.build();
let ctx = make_ctx(&env);
let sources = vec![
env.home.join(".config/nvim/init.lua"),
env.home.join(".config/helix/config.toml"),
];
let err = commands::adopt::adopt(None, &sources, false, false, false, None, &ctx).unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("different packs"),
"expected disagreement message, got: {msg}"
);
assert!(msg.contains("nvim") && msg.contains("helix"));
}
#[test]
fn adopt_home_source_without_into_requires_pack() {
let env = TempEnvironment::builder()
.home_file(".vimrc", "set nocompatible")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let err = commands::adopt::adopt(
None,
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("--into"), "expected '--into' hint, got: {msg}");
}
#[test]
fn adopt_already_adopted_source_is_skipped() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "content")
.done()
.build();
let source = env.home.join(".vimrc");
let pack_file = env.dotfiles_root.join("vim/vimrc");
env.fs.symlink(&pack_file, &source).unwrap();
let ctx = make_ctx(&env);
let result = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let warning = result
.warnings
.iter()
.find(|w| w.contains("skipped"))
.unwrap_or_else(|| panic!("expected a skipped warning, got: {:?}", result.warnings));
assert!(
warning.contains("direct symlink to pack source"),
"expected #44 'direct symlink' wording, got: {warning}"
);
assert!(
warning.contains("dodot up vim"),
"warning should point user at `dodot up vim`, got: {warning}"
);
assert!(env.fs.is_symlink(&source));
env.assert_regular_file(&pack_file, "content");
}
#[test]
fn adopt_fully_managed_source_keeps_original_skip_message() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "content")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(Some(&["vim".into()]), &ctx).unwrap();
let source = env.home.join(".config/vim/vimrc");
assert!(env.fs.is_symlink(&source));
let result = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap();
let warning = result
.warnings
.iter()
.find(|w| w.contains("skipped"))
.unwrap_or_else(|| panic!("expected a skipped warning, got: {:?}", result.warnings));
assert!(
warning.contains("already managed by dodot"),
"fully-managed case should keep original wording, got: {warning}"
);
assert!(
!warning.contains("direct symlink"),
"fully-managed case should NOT use the #44 'direct symlink' wording, got: {warning}"
);
}
#[test]
fn up_auto_replaces_content_equivalent_pre_existing_file() {
let env = TempEnvironment::builder()
.pack("git")
.file("home.gitconfig", "[user]\n name = test")
.done()
.home_file(".gitconfig", "[user]\n name = test")
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(
result.message.as_deref(),
Some("Packs deployed."),
"no errors expected for content-equivalent file, got: {:?}",
result.message
);
let user_path = env.home.join(".gitconfig");
assert!(
env.fs.is_symlink(&user_path),
"user file should now be a symlink"
);
assert_eq!(
env.fs.read_to_string(&user_path).unwrap(),
"[user]\n name = test"
);
let status = commands::status::status(None, &ctx).unwrap();
let file = &status.packs[0].files[0];
assert_eq!(file.status, "deployed");
}
#[test]
fn up_still_refuses_content_different_pre_existing_file() {
let env = TempEnvironment::builder()
.pack("git")
.file("home.gitconfig", "[user]\n name = new")
.done()
.home_file(".gitconfig", "[user]\n name = old")
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(
result.message.as_deref(),
Some("Packs deployed with errors."),
"different content should still conflict, got: {:?}",
result.message
);
env.assert_file_contents(&env.home.join(".gitconfig"), "[user]\n name = old");
}
#[test]
fn status_does_not_flag_content_equivalent_file_as_conflict() {
let env = TempEnvironment::builder()
.pack("git")
.file("home.gitconfig", "[user]\n name = test")
.done()
.home_file(".gitconfig", "[user]\n name = test")
.build();
let ctx = make_ctx(&env);
let status = commands::status::status(None, &ctx).unwrap();
let file = &status.packs[0].files[0];
assert_eq!(
file.status, "pending",
"content-equivalent file should be plain pending (auto-replaceable), got: {}",
file.status
);
assert!(
file.note_ref.is_none(),
"no note_ref for auto-replaceable case"
);
assert!(
status.notes.is_empty(),
"no notes for auto-replaceable case, got: {:?}",
status.notes
);
}
#[test]
fn adopt_relative_path_with_curdir_normalizes() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.home_file(".vimrc", "content")
.build();
let prev_cwd = std::env::current_dir().unwrap();
std::env::set_current_dir(&env.home).unwrap();
let ctx = make_ctx(&env);
let result = commands::adopt::adopt(
Some("vim"),
&[std::path::PathBuf::from("./.vimrc")],
false,
false,
false,
None,
&ctx,
);
std::env::set_current_dir(prev_cwd).unwrap();
result.expect("adopt should accept ./.vimrc when CWD is HOME");
env.assert_regular_file(&env.dotfiles_root.join("vim/home.vimrc"), "content");
assert!(env.fs.is_symlink(&env.home.join(".vimrc")));
}
#[test]
fn adopt_ignored_pack_refused() {
let env = TempEnvironment::builder()
.pack("disabled")
.file("placeholder", "")
.ignored()
.done()
.home_file(".vimrc", "x")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let err = commands::adopt::adopt(
Some("disabled"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::PackInvalid { .. }),
"expected PackInvalid, got: {err}"
);
}
#[test]
fn adopt_filename_matching_pack_ignore_refused() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.config("[pack]\nignore = [\"*.bak\"]")
.done()
.home_file(".vimrc.bak", "old")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc.bak");
let err = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
let msg = format!("{err}");
assert!(
msg.contains("ignore"),
"expected ignore-pattern message, got: {msg}"
);
}
#[test]
fn adopt_broken_pack_blocks_conflict_check() {
let env = TempEnvironment::builder()
.pack("broken")
.file("config.toml.tmpl", "{{ missing_var }}")
.done()
.pack("target")
.file("placeholder", "")
.done()
.home_file(".vimrc", "content")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let err = commands::adopt::adopt(
Some("target"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::TemplateRender { .. }),
"expected the broken pack's error to surface, got: {err}"
);
env.assert_regular_file(&source, "content");
env.assert_not_exists(&env.dotfiles_root.join("target/vimrc"));
}
#[test]
fn adopt_deploy_conflict_refused() {
let env = TempEnvironment::builder()
.pack("unix")
.file("bashrc", "existing")
.done()
.pack("work")
.file("placeholder", "")
.done()
.home_file(".bashrc", "new")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".bashrc");
let err = commands::adopt::adopt(
Some("work"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"expected CrossPackConflict, got: {err}"
);
env.assert_regular_file(&source, "new");
env.assert_not_exists(&env.dotfiles_root.join("work/bashrc"));
}
#[test]
fn adopt_deploy_conflict_not_bypassed_by_force() {
let env = TempEnvironment::builder()
.pack("unix")
.file("bashrc", "existing")
.done()
.pack("work")
.file("placeholder", "")
.done()
.home_file(".bashrc", "new")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".bashrc");
let err = commands::adopt::adopt(
Some("work"),
std::slice::from_ref(&source),
true, false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"--force must not bypass deploy conflicts, got: {err}"
);
}
#[test]
fn adopt_dry_run_makes_no_changes() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.home_file(".vimrc", "content")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let result = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
true, None,
&ctx,
)
.unwrap();
assert!(result.dry_run);
env.assert_regular_file(&source, "content");
assert!(!env.fs.is_symlink(&source));
env.assert_not_exists(&env.dotfiles_root.join("vim/home.vimrc"));
}
#[test]
fn adopt_no_follow_keeps_source_symlink_as_symlink() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.home_file("real_vimrc", "real content")
.build();
let real = env.home.join("real_vimrc");
let source = env.home.join(".vimrc");
env.fs.symlink(&real, &source).unwrap();
let ctx = make_ctx(&env);
commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
true, false,
None,
&ctx,
)
.unwrap();
let pack_copy = env.dotfiles_root.join("vim/home.vimrc");
assert!(
env.fs.is_symlink(&pack_copy),
"--no-follow should preserve source symlink as a symlink in the pack"
);
assert!(env.fs.is_symlink(&source));
}
#[cfg(unix)]
#[test]
fn adopt_force_preserves_old_content_when_copy_fails() {
use std::os::unix::fs::PermissionsExt;
let env = TempEnvironment::builder()
.pack("vim")
.file("home.vimrc", "OLD")
.done()
.home_file(".vimrc", "NEW")
.build();
let source = env.home.join(".vimrc");
std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o000)).unwrap();
let ctx = make_ctx(&env);
let result = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
true, false,
false,
None,
&ctx,
);
let _ = std::fs::set_permissions(&source, std::fs::Permissions::from_mode(0o644));
assert!(
result.is_err(),
"adopt should fail when the source is unreadable"
);
env.assert_regular_file(&env.dotfiles_root.join("vim/home.vimrc"), "OLD");
env.assert_regular_file(&source, "NEW");
let leftover = env.fs.read_dir(&env.dotfiles_root.join("vim")).unwrap();
for entry in leftover {
assert!(
!entry.name.contains("dodot-adopt-stage"),
"stage file leaked into pack: {}",
entry.name
);
}
}
#[test]
fn adopt_no_follow_on_dangling_symlink_succeeds() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.build();
let source = env.home.join(".dangling");
env.fs
.symlink(std::path::Path::new("/does/not/exist"), &source)
.unwrap();
let ctx = make_ctx(&env);
commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
true, false,
None,
&ctx,
)
.expect("adopt with --no-follow on a dangling symlink should succeed");
let pack_copy = env.dotfiles_root.join("vim/home.dangling");
assert!(env.fs.is_symlink(&pack_copy));
let target = env.fs.readlink(&pack_copy).unwrap();
assert_eq!(target, std::path::PathBuf::from("/does/not/exist"));
}
#[test]
fn adopt_nonexistent_source_errors() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".does-not-exist");
let err = commands::adopt::adopt(
Some("vim"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(matches!(err, crate::DodotError::Fs { .. }), "got: {err}");
}
#[test]
fn adopt_empty_sources_errors() {
let env = TempEnvironment::builder()
.pack("vim")
.file("placeholder", "")
.done()
.build();
let ctx = make_ctx(&env);
let err =
commands::adopt::adopt(Some("vim"), &[], false, false, false, None, &ctx).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("no files"), "got: {msg}");
}
#[test]
fn adopt_nonexistent_pack_returns_pack_not_found() {
let env = TempEnvironment::builder()
.home_file(".vimrc", "set nocompatible")
.build();
let ctx = make_ctx(&env);
let source = env.home.join(".vimrc");
let err = commands::adopt::adopt(
Some("newpack"),
std::slice::from_ref(&source),
false,
false,
false,
None,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::PackNotFound { .. }),
"expected PackNotFound, got: {err}"
);
}