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;
struct MockCommandRunner;
impl CommandRunner for MockCommandRunner {
fn run(&self, _: &str, _: &[String]) -> Result<CommandOutput> {
Ok(CommandOutput {
exit_code: 0,
stdout: String::new(),
stderr: String::new(),
})
}
}
struct CannedRunner {
responses: std::sync::Mutex<std::collections::HashMap<Vec<String>, CommandOutput>>,
}
impl CannedRunner {
fn new() -> Self {
Self {
responses: std::sync::Mutex::new(std::collections::HashMap::new()),
}
}
fn respond(&self, args: &[&str], stdout: &str, exit_code: i32) {
let key: Vec<String> = args.iter().map(|s| s.to_string()).collect();
self.responses.lock().unwrap().insert(
key,
CommandOutput {
exit_code,
stdout: stdout.into(),
stderr: String::new(),
},
);
}
}
impl CommandRunner for CannedRunner {
fn run(&self, exe: &str, args: &[String]) -> Result<CommandOutput> {
let mut full = vec![exe.to_string()];
full.extend(args.iter().cloned());
self.responses
.lock()
.unwrap()
.get(&full)
.cloned()
.ok_or_else(|| {
crate::DodotError::Other(format!("CannedRunner: no canned response for {full:?}"))
})
}
}
fn make_ctx(env: &TempEnvironment) -> ExecutionContext {
let runner: Arc<dyn CommandRunner> = Arc::new(MockCommandRunner);
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
}
}
fn make_ctx_with_runner(env: &TempEnvironment, runner: Arc<dyn CommandRunner>) -> ExecutionContext {
let datastore = Arc::new(FilesystemDataStore::new(
env.fs.clone(),
env.paths.clone(),
runner.clone(),
));
let config_manager = Arc::new(ConfigManager::new(&env.dotfiles_root).unwrap());
ExecutionContext {
fs: env.fs.clone() as Arc<dyn Fs>,
datastore,
paths: env.paths.clone() as Arc<dyn Pather>,
config_manager,
syntax_checker: Arc::new(crate::shell::NoopSyntaxChecker),
command_runner: runner,
dry_run: false,
no_provision: true,
provision_rerun: false,
force: false,
view_mode: crate::commands::ViewMode::Full,
group_mode: crate::commands::GroupMode::Name,
verbose: false,
}
}
#[test]
fn status_shows_pending_before_up() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
assert_eq!(result.packs.len(), 1);
assert_eq!(result.packs[0].name, "vim");
assert!(!result.packs[0].files.is_empty());
for file in &result.packs[0].files {
assert_eq!(
file.status, "pending",
"file {} should be pending",
file.name
);
}
}
#[test]
fn status_suppresses_lib_prefix_rows_when_skipped() {
let env = TempEnvironment::builder()
.pack("macapps")
.file("_lib/LaunchAgents/com.example.foo.plist", "x")
.file("regular.toml", "y")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let pack = result
.packs
.iter()
.find(|p| p.name == "macapps")
.expect("macapps pack must appear");
let lib_row = pack
.files
.iter()
.find(|f| f.name.starts_with("_lib/") || f.name == "_lib");
let regular_row = pack.files.iter().find(|f| f.name == "regular.toml");
assert!(
regular_row.is_some(),
"non-_lib entry must always render; got files {:?}",
pack.files.iter().map(|f| &f.name).collect::<Vec<_>>()
);
if cfg!(target_os = "macos") {
assert!(
lib_row.is_some(),
"on macOS `_lib/` entries should render normally; got files {:?}",
pack.files.iter().map(|f| &f.name).collect::<Vec<_>>()
);
} else {
assert!(
lib_row.is_none(),
"on non-macOS `_lib/` rows must be suppressed; got files {:?}",
pack.files.iter().map(|f| &f.name).collect::<Vec<_>>()
);
assert!(
result
.warnings
.iter()
.any(|w| w.contains("_lib") && w.contains("macOS-only")),
"expected a `_lib` macOS-only warning; got {:?}",
result.warnings
);
}
}
#[test]
fn status_renders_with_standout() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let output = render::render("pack-status", &result, OutputMode::Text).unwrap();
assert!(output.contains("vim"), "output: {output}");
assert!(output.contains("vimrc"), "output: {output}");
assert!(output.contains("pending"), "output: {output}");
let json = render::render("pack-status", &result, OutputMode::Json).unwrap();
assert!(json.contains("\"packs\""), "json: {json}");
}
#[test]
fn status_lists_ignored_packs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("stuff", "x")
.ignored()
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
assert_eq!(
result
.packs
.iter()
.map(|p| p.name.as_str())
.collect::<Vec<_>>(),
vec!["vim"]
);
assert_eq!(result.ignored_packs, vec!["disabled".to_string()]);
let output = render::render("pack-status", &result, OutputMode::Text).unwrap();
assert!(output.contains("Ignored Packs"), "output: {output}");
assert!(output.contains("disabled"), "output: {output}");
}
#[test]
fn status_pack_filter_applies_to_ignored_packs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("stuff", "x")
.ignored()
.done()
.pack("old")
.file("thing", "x")
.ignored()
.done()
.build();
let ctx = make_ctx(&env);
let filter = vec!["disabled".to_string()];
let result = commands::status::status(Some(&filter), &ctx).unwrap();
assert!(result.packs.is_empty(), "filter should exclude vim");
assert_eq!(result.ignored_packs, vec!["disabled".to_string()]);
}
#[test]
fn status_shows_xdg_target_for_subdirectory() {
let env = TempEnvironment::builder()
.pack("nvim")
.file("nvim/init.lua", "-- nvim config")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let nvim_pack = &result.packs[0];
let nvim_entry = nvim_pack
.files
.iter()
.find(|f| f.name == "nvim")
.expect("should have nvim dir entry");
assert!(
nvim_entry.description.contains(".config/nvim"),
"expected XDG path for wholesale dir, got: {}",
nvim_entry.description
);
}
#[test]
fn status_lists_top_level_dirs_wholesale() {
let env = TempEnvironment::builder()
.pack("nvim")
.file("nvim/init.lua", "-- nvim config")
.file("nvim/lua/plugins.lua", "return {}")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let nvim_pack = &result.packs[0];
let names: Vec<&str> = nvim_pack.files.iter().map(|f| f.name.as_str()).collect();
assert_eq!(
names,
vec!["nvim"],
"expected single wholesale dir entry, got {names:?}"
);
}
#[test]
fn up_deploys_packs() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert!(!result.packs.is_empty());
assert!(result.message.is_some());
let status = commands::status::status(None, &ctx).unwrap();
let deployed_count = status.packs[0]
.files
.iter()
.filter(|f| f.status == "deployed")
.count();
assert!(deployed_count > 0, "some files should be deployed after up");
}
#[test]
fn up_and_status_produce_matching_labels() {
let env = TempEnvironment::builder()
.pack("multi")
.file("vimrc", "set nocompat") .file("aliases.sh", "alias x=y") .done()
.pack("withbin")
.file("bin/tool", "#!/bin/sh\necho hi")
.done()
.build();
let ctx = make_ctx(&env);
let up_result = commands::up::up(None, &ctx).unwrap();
let status_result = commands::status::status(None, &ctx).unwrap();
let to_map = |packs: &[commands::DisplayPack]| {
let mut map = std::collections::HashMap::new();
for p in packs {
for f in &p.files {
if f.status == "error" || f.name.is_empty() {
continue; }
map.insert((p.name.clone(), f.name.clone()), f.status_label.clone());
}
}
map
};
let up_labels = to_map(&up_result.packs);
let status_labels = to_map(&status_result.packs);
assert_eq!(
up_labels, status_labels,
"up and status should report identical status_labels for the same files"
);
let labels: Vec<&str> = up_labels.values().map(String::as_str).collect();
assert!(
labels.contains(&"in PATH"),
"expected path handler to render as 'in PATH', got: {labels:?}"
);
assert!(
labels.contains(&"sourced"),
"expected shell handler to render as 'sourced', got: {labels:?}"
);
assert!(
labels.contains(&"deployed"),
"expected symlink handler to render as 'deployed', got: {labels:?}"
);
assert!(
labels.iter().all(|l| !l.starts_with("staged ")),
"no label should use the executor's 'staged X' vocabulary, got: {labels:?}"
);
}
#[test]
fn down_and_status_produce_matching_labels() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.file("aliases.sh", "alias v=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let down_result = commands::down::down(None, &ctx).unwrap();
let status_result = commands::status::status(None, &ctx).unwrap();
let to_map = |packs: &[commands::DisplayPack]| {
let mut map = std::collections::HashMap::new();
for p in packs {
for f in &p.files {
if f.status == "error" || f.name.is_empty() {
continue;
}
map.insert((p.name.clone(), f.name.clone()), f.status_label.clone());
}
}
map
};
let down_labels = to_map(&down_result.packs);
let status_labels = to_map(&status_result.packs);
assert_eq!(
down_labels, status_labels,
"down and status should report identical status_labels for the same files"
);
let labels: Vec<&str> = down_labels.values().map(String::as_str).collect();
assert!(
labels.iter().all(|l| !l.contains("removed")),
"down output should use status vocabulary, not 'removed', got: {labels:?}"
);
}
#[test]
fn up_generates_shell_init() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
env.assert_exists(&env.paths.init_script_path());
let init_content = env
.fs
.read_to_string(&env.paths.init_script_path())
.unwrap();
assert!(
init_content.contains("aliases.sh"),
"init script: {init_content}"
);
}
#[test]
fn status_surfaces_syntax_error_sidecar_for_deployed_shell_file() {
use crate::shell::{SyntaxCheckResult, SyntaxChecker};
use std::path::Path;
struct FlagAliases;
impl SyntaxChecker for FlagAliases {
fn check(&self, _interpreter: &str, file: &Path) -> SyntaxCheckResult {
if file.file_name().and_then(|s| s.to_str()) == Some("aliases.sh") {
SyntaxCheckResult::SyntaxError {
stderr: "/path/aliases.sh: line 47: bad substitution\n".into(),
}
} else {
SyntaxCheckResult::Ok
}
}
}
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "echo ${broken")
.file("env.sh", "export FOO=bar")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.syntax_checker = Arc::new(FlagAliases);
commands::up::up(None, &ctx).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let pack = &result.packs[0];
let aliases = pack
.files
.iter()
.find(|f| f.name == "aliases.sh")
.expect("aliases.sh row missing");
assert_eq!(aliases.status, "broken", "row: {aliases:?}");
assert_eq!(aliases.status_label, "syntax error");
let note_idx = aliases
.note_ref
.expect("aliases.sh should carry a note ref") as usize;
assert!(
result.notes[note_idx - 1].body.contains("bad substitution"),
"note: {:?}",
result.notes[note_idx - 1]
);
let env_row = pack
.files
.iter()
.find(|f| f.name == "env.sh")
.expect("env.sh row missing");
assert_eq!(env_row.status, "deployed");
assert_eq!(env_row.status_label, "sourced");
}
#[test]
fn status_surfaces_runtime_failures_from_recent_profiles() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let source_path = env.dotfiles_root.join("vim/aliases.sh");
let target = source_path.display().to_string();
let probes_dir = env.paths.probes_shell_init_dir();
env.fs.mkdir_all(&probes_dir).unwrap();
let make_profile = |t0: u64, exit: i32| {
let body = format!(
"# dodot shell-init profile v1\n\
# shell\tbash 5.0\n\
# start_t\t{t0}.000000\n\
source\tvim\tshell\t{target}\t{t0}.000100\t{t0}.000900\t{exit}\n\
# end_t\t{t0}.001000\n",
);
env.fs
.write_file(
&probes_dir.join(format!("profile-{t0:010}-100-1.tsv")),
body.as_bytes(),
)
.unwrap();
};
make_profile(1714000001, 2); make_profile(1714000002, 0); make_profile(1714000003, 1);
let result = commands::status::status(None, &ctx).unwrap();
let row = result.packs[0]
.files
.iter()
.find(|f| f.name == "aliases.sh")
.expect("aliases.sh row missing");
assert_eq!(row.status, "broken", "row: {row:?}");
assert!(
row.status_label.contains("exited 1") && row.status_label.contains("2/3"),
"status_label was: {}",
row.status_label
);
assert!(
!row.status_label.contains("exited 2"),
"status_label should report newest failure, not older: {}",
row.status_label
);
let note_idx = row.note_ref.expect("expected note ref") as usize;
let note = &result.notes[note_idx - 1];
assert!(
note.body.contains("2 of 3 recent shell startups"),
"note body: {}",
note.body
);
assert!(
note.body.contains("last failure: exit 1"),
"note should mention most recent failure exit code: {}",
note.body
);
assert!(
note.body.contains("dodot probe shell-init vim/aliases.sh"),
"note should point at the filtered probe view: {}",
note.body
);
}
#[test]
fn status_inlines_captured_stderr_into_runtime_failure_footnote() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let source_path = env.dotfiles_root.join("vim/aliases.sh");
let target = source_path.display().to_string();
let probes_dir = env.paths.probes_shell_init_dir();
env.fs.mkdir_all(&probes_dir).unwrap();
let prof_name = "profile-1714000003-100-1.tsv";
let body = format!(
"# dodot shell-init profile v1\n\
# shell\tbash 5.0\n\
# start_t\t1714000003.000000\n\
source\tvim\tshell\t{target}\t1714000003.000100\t1714000003.000900\t1\n\
# end_t\t1714000003.001000\n",
);
env.fs
.write_file(&probes_dir.join(prof_name), body.as_bytes())
.unwrap();
let err_log = format!(
"# dodot shell-init errors v1\n@@\t{target}\t1\nzsh: command not found: gpg-agent\n"
);
env.fs
.write_file(
&probes_dir.join("profile-1714000003-100-1.errors.log"),
err_log.as_bytes(),
)
.unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let row = result.packs[0]
.files
.iter()
.find(|f| f.name == "aliases.sh")
.expect("aliases.sh row missing");
let note_idx = row.note_ref.expect("expected note ref") as usize;
let note = &result.notes[note_idx - 1];
assert!(
note.body.contains("stderr:"),
"footnote should label the stderr excerpt: {}",
note.body
);
assert!(
note.body.contains("zsh: command not found: gpg-agent"),
"footnote should inline the captured stderr: {}",
note.body
);
assert!(
note.body.contains("dodot probe shell-init vim/aliases.sh"),
"footnote should point at the per-file probe view: {}",
note.body
);
}
#[test]
fn up_writes_syntax_error_sidecar_when_check_fails() {
use crate::shell::{SyntaxCheckResult, SyntaxChecker};
use std::path::Path;
struct FlagAliases;
impl SyntaxChecker for FlagAliases {
fn check(&self, _interpreter: &str, file: &Path) -> SyntaxCheckResult {
if file.file_name().and_then(|s| s.to_str()) == Some("aliases.sh") {
SyntaxCheckResult::SyntaxError {
stderr: "aliases.sh: line 1: unexpected token\n".into(),
}
} else {
SyntaxCheckResult::Ok
}
}
}
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "if [ x = y\nfi")
.file("env.sh", "export FOO=bar")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.syntax_checker = Arc::new(FlagAliases);
commands::up::up(None, &ctx).unwrap();
let bad = crate::shell::error_sidecar_path(env.paths.as_ref(), "vim", "aliases.sh");
assert!(env.fs.exists(&bad), "expected sidecar at {}", bad.display());
let body = env.fs.read_to_string(&bad).unwrap();
assert!(body.contains("unexpected token"), "sidecar:\n{body}");
let good = crate::shell::error_sidecar_path(env.paths.as_ref(), "vim", "env.sh");
assert!(!env.fs.exists(&good));
}
#[test]
fn up_dry_run_no_changes() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.dry_run = true;
let result = commands::up::up(None, &ctx).unwrap();
assert!(result.dry_run);
let status_ctx = make_ctx(&env); let status = commands::status::status(None, &status_ctx).unwrap();
for file in &status.packs[0].files {
assert_eq!(file.status, "pending", "dry run should not deploy");
}
}
#[test]
fn up_dry_run_does_not_write_preprocessing_baselines() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_ctx(&env);
let baseline_path = ctx
.paths
.preprocessor_baseline_path("app", "preprocessed", "config.toml");
assert!(
!ctx.fs.exists(&baseline_path),
"test precondition: baseline should not exist before any up runs"
);
let mut dry_ctx = make_ctx(&env);
dry_ctx.dry_run = true;
let _ = commands::up::up(None, &dry_ctx).unwrap();
assert!(
!ctx.fs.exists(&baseline_path),
"dry-run must NOT write a baseline; the cache must remain untouched"
);
}
#[cfg(target_os = "macos")]
#[test]
fn up_writes_cfprefsd_marker_on_first_run_with_plists() {
let env = TempEnvironment::builder()
.pack("mac-defaults")
.file("com.example.app.plist", "<?xml?><plist></plist>")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let marker = ctx.paths.data_dir().join("cfprefsd-needs-invalidation");
assert!(
ctx.fs.exists(&marker),
"marker should land on the first up that deploys a plist"
);
}
#[cfg(target_os = "macos")]
#[test]
fn up_does_not_write_cfprefsd_marker_when_pack_has_no_plists() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let marker = ctx.paths.data_dir().join("cfprefsd-needs-invalidation");
assert!(
!ctx.fs.exists(&marker),
"marker must not appear when no plists are present"
);
}
#[cfg(target_os = "macos")]
#[test]
fn up_with_pack_filter_does_not_write_cfprefsd_marker_for_unrelated_pack_plists() {
let env = TempEnvironment::builder()
.pack("mac-defaults")
.file("com.example.app.plist", "<?xml?><plist></plist>")
.done()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
let filter = vec!["vim".to_string()];
commands::up::up(Some(&filter), &ctx).unwrap();
let marker = ctx.paths.data_dir().join("cfprefsd-needs-invalidation");
assert!(
!ctx.fs.exists(&marker),
"drift detection must respect the pack filter — \
a plist in an unrelated pack should not trigger the marker"
);
}
#[test]
fn status_does_not_write_to_datastore() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let snapshot = snapshot_dir_contents(&env, env.paths.data_dir());
commands::status::status(None, &ctx).unwrap();
commands::status::status(None, &ctx).unwrap();
let after = snapshot_dir_contents(&env, env.paths.data_dir());
assert_eq!(
snapshot, after,
"status must be byte-identical to the post-up snapshot — \
no datastore writes allowed"
);
}
#[test]
fn up_dry_run_does_not_write_to_datastore() {
let env = TempEnvironment::builder()
.pack("app")
.file("config.toml.tmpl", "name = {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let snapshot = snapshot_dir_contents(&env, env.paths.data_dir());
let mut dry_ctx = make_ctx(&env);
dry_ctx.dry_run = true;
commands::up::up(None, &dry_ctx).unwrap();
let after = snapshot_dir_contents(&env, env.paths.data_dir());
assert_eq!(
snapshot, after,
"dry-run must be byte-identical to the post-up snapshot"
);
}
#[test]
fn install_template_dry_run_emits_correct_sentinel_without_writing_rendered_file() {
let env = TempEnvironment::builder()
.pack("app")
.file("install.sh.tmpl", "#!/bin/sh\necho hello {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.no_provision = false;
commands::up::up(None, &ctx).unwrap();
let active_intents = crate::packs::orchestration::collect_pack_intents(
&crate::packs::Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
ctx.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
),
&ctx,
)
.unwrap();
let active_sentinel = active_intents
.iter()
.find_map(|i| match i {
crate::operations::HandlerIntent::Run { sentinel, .. } => Some(sentinel.clone()),
_ => None,
})
.expect("active path must produce a Run intent for install.sh");
let rendered_path = env
.paths
.handler_data_dir("app", "preprocessed")
.join("install.sh");
assert!(
ctx.fs.exists(&rendered_path),
"active up must have written the rendered file"
);
let rendered_before = ctx.fs.read_file(&rendered_path).unwrap();
let mut dry_ctx = make_ctx(&env);
dry_ctx.no_provision = false;
dry_ctx.dry_run = true;
let plan = crate::packs::orchestration::plan_pack(
&crate::packs::Pack::new(
"app".into(),
env.dotfiles_root.join("app"),
dry_ctx
.config_manager
.config_for_pack(&env.dotfiles_root.join("app"))
.unwrap()
.to_handler_config(),
),
&dry_ctx,
crate::preprocessing::PreprocessMode::Passive,
)
.unwrap();
let dry_sentinel = plan
.intents
.iter()
.find_map(|i| match i {
crate::operations::HandlerIntent::Run { sentinel, .. } => Some(sentinel.clone()),
_ => None,
})
.expect("passive path must produce a Run intent for install.sh");
assert_eq!(
active_sentinel, dry_sentinel,
"passive sentinel must match active — same rendered bytes either way"
);
let rendered_after = ctx.fs.read_file(&rendered_path).unwrap();
assert_eq!(
rendered_before, rendered_after,
"passive must not rewrite the rendered file"
);
}
#[test]
fn up_dry_run_first_time_pack_with_install_template_does_not_error() {
let env = TempEnvironment::builder()
.pack("setup")
.file("install.sh.tmpl", "#!/bin/sh\necho hello {{ name }}")
.file("Brewfile.tmpl", "brew '{{ pkg }}'")
.config("[preprocessor.template.vars]\nname = \"Alice\"\npkg = \"jq\"\n")
.done()
.build();
let mut dry_ctx = make_ctx(&env);
dry_ctx.no_provision = false;
dry_ctx.dry_run = true;
let result = commands::up::up(None, &dry_ctx).unwrap();
assert!(result.dry_run);
}
#[test]
fn passive_first_time_pack_surfaces_pending_placeholder() {
let env = TempEnvironment::builder()
.pack("app")
.file("greet.tmpl", "hello {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let files = &result.packs[0].files;
assert_eq!(files.len(), 1, "should surface the templated entry");
assert_eq!(
files[0].name, "greet",
"stripped name (not source filename)"
);
assert_eq!(
files[0].status, "pending",
"first-time template before any up: pending"
);
}
fn snapshot_dir_contents(
env: &crate::testing::TempEnvironment,
root: &std::path::Path,
) -> std::collections::BTreeMap<std::path::PathBuf, Option<Vec<u8>>> {
use std::collections::BTreeMap;
let mut out = BTreeMap::new();
if !env.fs.exists(root) {
return out;
}
let mut stack: Vec<std::path::PathBuf> = vec![root.to_path_buf()];
while let Some(dir) = stack.pop() {
let entries = env
.fs
.read_dir(&dir)
.unwrap_or_else(|e| panic!("snapshot_dir_contents: read_dir({dir:?}): {e}"));
for entry in entries {
if entry.is_dir {
out.insert(entry.path.clone(), None);
stack.push(entry.path);
} else {
let bytes = env.fs.read_file(&entry.path).unwrap_or_else(|e| {
panic!("snapshot_dir_contents: read_file({:?}): {e}", entry.path)
});
out.insert(entry.path, Some(bytes));
}
}
}
out
}
#[test]
fn up_reports_conflict_when_file_exists() {
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!(
result.message.as_deref() == Some("Packs deployed with errors."),
"msg: {:?}",
result.message
);
let error_files: Vec<&commands::DisplayFile> = result.packs[0]
.files
.iter()
.filter(|f| f.status == "error")
.collect();
assert!(
!error_files.is_empty(),
"should have error files for conflicts"
);
let note_idx = error_files[0]
.note_ref
.expect("error row should carry a note_ref") as usize
- 1;
assert!(
result.notes[note_idx].body.contains("conflict"),
"note should mention conflict: {}",
result.notes[note_idx].body
);
assert!(
!error_files[0].name.is_empty(),
"error row should name the failing file, got empty name"
);
assert!(
error_files[0].name.contains("gitconfig"),
"error row name should reference gitconfig, got: {}",
error_files[0].name
);
env.assert_file_contents(&env.home.join(".gitconfig"), "[user]\n name = old");
let status = commands::status::status(None, &ctx).unwrap();
for file in &status.packs[0].files {
assert!(
matches!(file.status.as_str(), "pending" | "warning"),
"conflicted file {} should be pending or warning, got {}",
file.name,
file.status
);
}
let conflicted = status.packs[0]
.files
.iter()
.find(|f| f.status == "warning")
.expect("the conflicted file should surface as warning (PendingConflict)");
assert_eq!(
conflicted.status_label, "pending",
"warning label should be plain 'pending' (the [N] marker is a separate column now), got: {}",
conflicted.status_label
);
assert!(
conflicted.note_ref.is_some(),
"conflicted row should carry a note_ref into the command-wide notes list"
);
assert!(
!status.notes.is_empty(),
"status should have at least one note describing the pre-existing file"
);
let note_idx = conflicted.note_ref.unwrap() as usize - 1;
assert!(
status.notes[note_idx].body.contains(".gitconfig"),
"note should mention the conflicting path, got: {}",
status.notes[note_idx].body
);
}
#[test]
fn up_force_overwrites_existing_files() {
let env = TempEnvironment::builder()
.pack("git")
.file("home.gitconfig", "[user]\n name = new")
.done()
.home_file(".gitconfig", "[user]\n name = old")
.build();
let mut ctx = make_ctx(&env);
ctx.force = true;
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
let content = env.fs.read_to_string(&env.home.join(".gitconfig")).unwrap();
assert_eq!(content, "[user]\n name = new");
}
#[test]
fn up_reconciles_deleted_shell_source() {
let env = TempEnvironment::builder()
.pack("gh")
.file("aliases.sh", "alias g=git")
.file("profile.sh", "export GH=true")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let shell_dir = env.paths.handler_data_dir("gh", "shell");
let mut before = env.list_dir_names(&shell_dir);
before.sort();
assert_eq!(before, vec!["aliases.sh", "profile.sh"]);
env.fs
.remove_file(&env.dotfiles_root.join("gh/profile.sh"))
.unwrap();
commands::up::up(None, &ctx).unwrap();
let after = env.list_dir_names(&shell_dir);
assert_eq!(
after,
vec!["aliases.sh"],
"orphan datastore entry persisted after re-up"
);
let init = env
.fs
.read_to_string(&env.paths.init_script_path())
.unwrap();
assert!(
!init.contains("profile.sh"),
"regenerated init still references deleted file:\n{init}"
);
assert!(init.contains("aliases.sh"), "init: {init}");
}
#[test]
fn up_reconciles_deleted_symlink_source() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.file("gvimrc", "set guifont=Mono")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let symlink_dir = env.paths.handler_data_dir("vim", "symlink");
let mut before = env.list_dir_names(&symlink_dir);
before.sort();
assert_eq!(before, vec!["gvimrc", "vimrc"]);
env.fs
.remove_file(&env.dotfiles_root.join("vim/gvimrc"))
.unwrap();
commands::up::up(None, &ctx).unwrap();
let after = env.list_dir_names(&symlink_dir);
assert_eq!(
after,
vec!["vimrc"],
"orphan datastore symlink persisted after re-up"
);
}
#[test]
fn up_reconciles_deleted_path_dir() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/foo", "#!/bin/sh\necho foo")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let path_dir = env.paths.handler_data_dir("tools", "path");
assert_eq!(env.list_dir_names(&path_dir), vec!["bin"]);
env.fs
.remove_dir_all(&env.dotfiles_root.join("tools/bin"))
.unwrap();
commands::up::up(None, &ctx).unwrap();
let after = env.list_dir_names(&path_dir);
assert!(
after.is_empty(),
"path datastore should be empty after source dir removed, got: {after:?}"
);
let init = env
.fs
.read_to_string(&env.paths.init_script_path())
.unwrap();
assert!(
!init.contains("tools/bin"),
"init script still exports deleted PATH entry:\n{init}"
);
}
#[test]
fn up_preserves_install_sentinel_when_source_persists() {
let env = TempEnvironment::builder()
.pack("setup")
.file("install.sh", "#!/bin/sh\necho hi")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.no_provision = false;
commands::up::up(None, &ctx).unwrap();
let install_dir = env.paths.handler_data_dir("setup", "install");
let sentinels_before = env.list_dir_names(&install_dir);
assert_eq!(
sentinels_before.len(),
1,
"expected one sentinel, got {sentinels_before:?}"
);
let original = sentinels_before.into_iter().next().unwrap();
commands::up::up(None, &ctx).unwrap();
let sentinels_after = env.list_dir_names(&install_dir);
assert_eq!(
sentinels_after,
vec![original],
"install sentinel should persist across re-up"
);
}
#[test]
fn up_preserves_install_sentinel_when_source_deleted() {
let env = TempEnvironment::builder()
.pack("setup")
.file("install.sh", "#!/bin/sh\necho hi")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.no_provision = false;
commands::up::up(None, &ctx).unwrap();
let install_dir = env.paths.handler_data_dir("setup", "install");
let sentinels_before = env.list_dir_names(&install_dir);
assert_eq!(sentinels_before.len(), 1);
env.fs
.remove_file(&env.dotfiles_root.join("setup/install.sh"))
.unwrap();
commands::up::up(None, &ctx).unwrap();
let sentinels_after = env.list_dir_names(&install_dir);
assert_eq!(
sentinels_after, sentinels_before,
"deleting an install source must not wipe its sentinel"
);
}
#[test]
fn down_removes_deployed_state() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let status = commands::status::status(None, &ctx).unwrap();
let has_deployed = status.packs[0].files.iter().any(|f| f.status == "deployed");
assert!(has_deployed, "should have deployed files after up");
let down_result = commands::down::down(None, &ctx).unwrap();
assert!(down_result.message.is_some());
let status = commands::status::status(None, &ctx).unwrap();
for file in &status.packs[0].files {
assert_eq!(
file.status, "pending",
"file {} should be pending after down (dangling symlinks are not conflicts), got {}",
file.name, file.status
);
}
}
#[test]
fn list_shows_all_packs() {
let env = TempEnvironment::builder()
.pack("git")
.file("gitconfig", "x")
.done()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("x", "x")
.ignored()
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::list::list(&ctx).unwrap();
let names: Vec<&str> = result.packs.iter().map(|p| p.name.as_str()).collect();
assert!(names.contains(&"git"));
assert!(names.contains(&"vim"));
assert!(names.contains(&"disabled"));
let disabled = result.packs.iter().find(|p| p.name == "disabled").unwrap();
assert!(disabled.ignored);
let output = render::render("list", &result, OutputMode::Text).unwrap();
assert!(output.contains("vim"), "output: {output}");
assert!(output.contains("(ignored)"), "output: {output}");
}
#[test]
fn init_creates_pack_directory() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::init::init("newpack", &ctx).unwrap();
assert!(result.message.contains("newpack"));
env.assert_dir_exists(&env.dotfiles_root.join("newpack"));
env.assert_exists(&env.dotfiles_root.join("newpack/.dodot.toml"));
}
#[test]
fn init_fails_if_exists() {
let env = TempEnvironment::builder()
.pack("existing")
.file("f", "x")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::init::init("existing", &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::PackInvalid { .. }),
"expected PackInvalid, got: {err}"
);
}
#[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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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, &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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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,
&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, &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,
&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,
&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,
&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,
&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, &ctx).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("no files"), "got: {msg}");
}
#[test]
fn addignore_creates_file() {
let env = TempEnvironment::builder()
.pack("scratch")
.file("notes", "x")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::addignore::addignore("scratch", &ctx).unwrap();
assert!(result.message.contains("ignored"));
env.assert_exists(&env.dotfiles_root.join("scratch/.dodotignore"));
}
#[test]
fn addignore_idempotent() {
let env = TempEnvironment::builder()
.pack("scratch")
.file("notes", "x")
.ignored()
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::addignore::addignore("scratch", &ctx).unwrap();
assert!(result.message.contains("already ignored"));
}
#[test]
fn status_on_nonexistent_pack_returns_error() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_ctx(&env);
let filter = vec!["nonexistent".into()];
let err = commands::status::status(Some(&filter), &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::PackNotFound { .. }),
"expected PackNotFound, got: {err}"
);
}
#[test]
fn up_on_nonexistent_pack_returns_error() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_ctx(&env);
let filter = vec!["typo".into()];
let err = commands::up::up(Some(&filter), &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::PackNotFound { .. }),
"expected PackNotFound, got: {err}"
);
}
#[test]
fn down_on_already_down_pack_says_nothing_to_do() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::down::down(None, &ctx).unwrap();
assert_eq!(
result.message.as_deref(),
Some("Nothing to deactivate."),
"should say nothing to deactivate"
);
assert!(result.packs.is_empty(), "should have no pack entries");
}
#[test]
fn addignore_on_deployed_pack_warns() {
let env = TempEnvironment::builder()
.pack("git")
.file("gitconfig", "[user]\n name = test")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::addignore::addignore("git", &ctx).unwrap();
assert!(result.message.contains("ignored"));
let has_warning = result
.details
.iter()
.any(|d| d.contains("currently deployed"));
assert!(
has_warning,
"should warn about deployed pack: {:?}",
result.details
);
}
#[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,
&ctx,
)
.unwrap_err();
assert!(
matches!(err, crate::DodotError::PackNotFound { .. }),
"expected PackNotFound, got: {err}"
);
}
#[test]
fn full_lifecycle_up_status_down_status() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.pack("git")
.file("gitconfig", "[user]\n name = test")
.done()
.build();
let ctx = make_ctx(&env);
let s1 = commands::status::status(None, &ctx).unwrap();
assert_eq!(s1.packs.len(), 2);
for pack in &s1.packs {
for file in &pack.files {
assert_eq!(file.status, "pending");
}
}
let up = commands::up::up(None, &ctx).unwrap();
assert!(!up.packs.is_empty());
let s2 = commands::status::status(None, &ctx).unwrap();
let total_deployed: usize = s2
.packs
.iter()
.flat_map(|p| &p.files)
.filter(|f| f.status == "deployed")
.count();
assert!(total_deployed > 0);
commands::down::down(None, &ctx).unwrap();
let s3 = commands::status::status(None, &ctx).unwrap();
for pack in &s3.packs {
for file in &pack.files {
assert_eq!(
file.status, "pending",
"{} should be pending after down, got {}",
file.name, file.status
);
}
}
commands::up::up(None, &ctx).unwrap();
let s4 = commands::status::status(None, &ctx).unwrap();
let deployed_again: usize = s4
.packs
.iter()
.flat_map(|p| &p.files)
.filter(|f| f.status == "deployed")
.count();
assert_eq!(total_deployed, deployed_again, "idempotent re-deploy");
}
#[test]
fn status_surfaces_pre_existing_conflict_as_warning_with_footnote() {
let env = TempEnvironment::builder()
.pack("ghostty")
.file("home.ghostrc", "theme=dark")
.done()
.home_file(".ghostrc", "theme=light")
.pack("vim")
.file("vimrc", "set nocompat")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let ghostty = result
.packs
.iter()
.find(|p| p.name == "ghostty")
.expect("ghostty pack should appear");
let vim = result
.packs
.iter()
.find(|p| p.name == "vim")
.expect("vim pack should appear");
let ghostty_file = &ghostty.files[0];
assert_eq!(
ghostty_file.status, "warning",
"ghostty/ghostrc collides with ~/.ghostrc — should surface as warning"
);
assert_eq!(
ghostty_file.status_label, "pending",
"label should be plain 'pending'; the [N] marker lives in a separate column, got: {}",
ghostty_file.status_label
);
let ghostty_note = ghostty_file
.note_ref
.expect("ghostty row should carry a note_ref") as usize
- 1;
assert_eq!(
result.notes.len(),
1,
"status should have exactly one note, got: {:?}",
result.notes
);
assert!(
result.notes[ghostty_note].body.contains(".ghostrc"),
"note should mention the conflicting path, got: {}",
result.notes[ghostty_note].body
);
assert!(
result.notes[ghostty_note].body.contains("existing file"),
"note should classify the target (existing file), got: {}",
result.notes[ghostty_note].body
);
let vim_file = &vim.files[0];
assert_eq!(
vim_file.status, "pending",
"vim/vimrc has no conflict — should be plain pending"
);
assert_eq!(vim_file.status_label, "pending");
assert!(
vim_file.note_ref.is_none(),
"vim row should carry no note_ref"
);
}
#[test]
fn status_does_not_flag_pre_existing_symlinks_as_conflict() {
let env = TempEnvironment::builder()
.pack("kitty")
.file("kittyrc", "font_size 14")
.done()
.pack("ghostty")
.file("ghostrc", "x")
.done()
.build();
let source = env.dotfiles_root.join("kitty/kittyrc");
let kitty_target = env.home.join(".kittyrc");
env.fs.symlink(&source, &kitty_target).unwrap();
let ghostty_target = env.home.join(".ghostrc");
env.fs
.symlink(std::path::Path::new("/tmp/elsewhere"), &ghostty_target)
.unwrap();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let kitty = result.packs.iter().find(|p| p.name == "kitty").unwrap();
assert_eq!(
kitty.files[0].status, "pending",
"equivalent symlink should be plain pending, not a conflict (executor handles it)"
);
assert!(
kitty.files[0].note_ref.is_none(),
"no note_ref for non-conflict"
);
let ghostty = result.packs.iter().find(|p| p.name == "ghostty").unwrap();
assert_eq!(
ghostty.files[0].status, "pending",
"non-equivalent symlink should also be plain pending — executor will replace it"
);
assert!(
ghostty.files[0].note_ref.is_none(),
"no note_ref for non-conflict"
);
assert!(
result.notes.is_empty(),
"no notes for non-conflict case, got: {:?}",
result.notes
);
}
#[test]
fn status_verified_deployed_after_up() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let file = &result.packs[0].files[0];
assert_eq!(file.status, "deployed", "should be verified deployed");
assert_eq!(file.status_label, "deployed");
}
#[test]
fn status_detects_broken_source_deleted() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let source = env.dotfiles_root.join("vim/vimrc");
env.fs.remove_file(&source).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
assert!(
result.packs[0].files.is_empty(),
"deleted source should produce no scanner matches"
);
assert!(
env.fs
.is_symlink(&env.paths.handler_data_dir("vim", "symlink").join("vimrc")),
"data link should still exist after source deletion"
);
}
#[test]
fn status_detects_broken_user_link_removed() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let user_path = env.home.join(".config/vim/vimrc");
env.fs.remove_file(&user_path).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let file = &result.packs[0].files[0];
assert_eq!(
file.status, "stale",
"should detect missing user link, got: {} ({})",
file.status, file.status_label
);
assert!(
file.status_label.contains("user link missing"),
"label: {}",
file.status_label
);
}
#[test]
fn status_detects_conflict_at_user_path() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let user_path = env.home.join(".config/vim/vimrc");
env.fs.remove_file(&user_path).unwrap();
env.fs.write_file(&user_path, b"manual file").unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let file = &result.packs[0].files[0];
assert_eq!(
file.status, "broken",
"should detect conflict, got: {} ({})",
file.status, file.status_label
);
assert!(
file.status_label.contains("conflict"),
"label: {}",
file.status_label
);
}
#[test]
fn status_shell_handler_verified_deployed() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let file = result.packs[0]
.files
.iter()
.find(|f| f.handler == "shell")
.expect("should have shell file");
assert_eq!(
file.status, "deployed",
"shell handler should be verified deployed"
);
assert_eq!(file.status_label, "sourced");
}
#[test]
fn status_shell_handler_detects_broken_source() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let source = env.dotfiles_root.join("vim/aliases.sh");
env.fs.remove_file(&source).unwrap();
env.fs.write_file(&source, b"alias vi=vim").unwrap();
let data_link = env
.paths
.handler_data_dir("vim", "shell")
.join("aliases.sh");
env.fs.remove_file(&data_link).unwrap();
let bogus = env.dotfiles_root.join("vim/nonexistent");
env.fs.symlink(&bogus, &data_link).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let file = result.packs[0]
.files
.iter()
.find(|f| f.handler == "shell")
.expect("should have shell file");
assert_eq!(
file.status, "broken",
"should detect broken data link, got: {} ({})",
file.status, file.status_label
);
}
#[test]
fn status_path_handler_verified_deployed() {
let env = TempEnvironment::builder()
.pack("vim")
.file("bin/myscript", "#!/bin/sh")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
let file = result.packs[0]
.files
.iter()
.find(|f| f.handler == "path")
.expect("should have path file");
assert_eq!(
file.status, "deployed",
"path handler should be verified deployed"
);
assert_eq!(file.status_label, "in PATH");
}
#[test]
fn up_halts_on_cross_pack_symlink_conflict() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "alias a=1")
.done()
.pack("pack-b")
.file("home.aliases", "alias b=2")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"expected CrossPackConflict, got: {err}"
);
let msg = err.to_string();
assert!(msg.contains("pack-a"), "msg: {msg}");
assert!(msg.contains("pack-b"), "msg: {msg}");
assert!(msg.contains(".aliases"), "msg: {msg}");
}
#[test]
fn up_halts_no_partial_deployment_on_conflict() {
let env = TempEnvironment::builder()
.pack("conflict-a")
.file("home.aliases", "a")
.done()
.pack("conflict-b")
.file("home.aliases", "b")
.done()
.pack("innocent")
.file("vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
let _err = commands::up::up(None, &ctx).unwrap_err();
env.assert_no_handler_state("innocent", "symlink");
env.assert_no_handler_state("conflict-a", "symlink");
env.assert_no_handler_state("conflict-b", "symlink");
}
#[test]
fn up_force_does_not_override_cross_pack_conflict() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "a")
.done()
.pack("pack-b")
.file("home.aliases", "b")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.force = true;
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"force should NOT override cross-pack conflict, got: {err}"
);
assert!(
err.to_string().contains("--force does not override"),
"msg: {}",
err
);
}
#[test]
fn up_dry_run_still_detects_cross_pack_conflict() {
let env = TempEnvironment::builder()
.pack("a")
.file("bashrc", "a")
.done()
.pack("b")
.file("bashrc", "b")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.dry_run = true;
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"dry-run should still detect conflicts, got: {err}"
);
}
#[test]
fn up_with_cross_pack_conflict_renders_full_status_view() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "alias a=1")
.done()
.pack("pack-b")
.file("home.aliases", "alias b=2")
.done()
.pack("innocent")
.file("home.vimrc", "set nocompatible")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up_or_status_for_conflict(None, &ctx)
.expect("status fallback should produce Ok on cross-pack conflict");
assert_eq!(
result.message.as_deref(),
Some("Cross-pack conflicts prevent deployment."),
"got message: {:?}",
result.message
);
assert!(
!result.packs.is_empty(),
"up-with-conflict must render pack rows, not a bare conflicts dump"
);
let pack_names: Vec<&str> = result.packs.iter().map(|p| p.name.as_str()).collect();
assert!(
pack_names.contains(&"pack-a"),
"expected pack-a in listing, got: {:?}",
pack_names
);
assert!(
pack_names.contains(&"pack-b"),
"expected pack-b in listing, got: {:?}",
pack_names
);
assert!(
pack_names.contains(&"innocent"),
"expected innocent pack in listing, got: {:?}",
pack_names
);
assert!(
!result.conflicts.is_empty(),
"expected conflicts section to be populated"
);
let conflict = &result.conflicts[0];
assert!(
conflict.target.contains(".aliases"),
"conflict target should reference .aliases, got: {}",
conflict.target
);
for pack in &result.packs {
for file in &pack.files {
assert_ne!(
file.status, "deployed",
"{}::{} should not be deployed when conflict blocks up, got: {} ({})",
pack.name, file.name, file.status, file.status_label
);
}
}
}
#[test]
fn up_no_conflict_when_different_target_files() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.pack("git")
.file("gitconfig", "[user]\n name = test")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
}
#[test]
fn up_no_conflict_within_same_pack() {
let env = TempEnvironment::builder()
.pack("shell")
.file("bashrc", "# bash")
.file("zshrc", "# zsh")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
}
#[test]
fn up_conflict_via_config_mapping() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("settings", "a")
.config("[symlink]\ntargets = { settings = \"myapp/settings.toml\" }")
.done()
.pack("pack-b")
.file("config", "b")
.config("[symlink]\ntargets = { config = \"myapp/settings.toml\" }")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"expected CrossPackConflict for config mapping collision, got: {err}"
);
let msg = err.to_string();
assert!(
msg.contains("myapp/settings.toml"),
"should mention the conflicting target: {msg}"
);
}
#[test]
fn up_conflict_via_home_prefix() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("_home/vim/vimrc", "a")
.done()
.pack("pack-b")
.file("vim/vimrc", "b")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(
result.message.as_deref(),
Some("Packs deployed."),
"different targets should not conflict"
);
}
#[test]
fn up_conflict_two_packs_same_home_prefix_target() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.bashrc", "# a")
.done()
.pack("pack-b")
.file("home.bashrc", "# b")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"both targeting ~/.bashrc should conflict, got: {err}"
);
}
#[test]
fn up_filtered_packs_only_checks_filtered_subset() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "a")
.done()
.pack("pack-b")
.file("home.aliases", "b")
.done()
.build();
let ctx = make_ctx(&env);
let filter = vec!["pack-a".into()];
let result = commands::up::up(Some(&filter), &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
assert_eq!(result.packs.len(), 1);
assert_eq!(result.packs[0].name, "pack-a");
}
#[test]
fn up_same_name_shell_scripts_are_not_conflicts() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.pack("git")
.file("aliases.sh", "alias g=git")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
}
#[test]
fn up_path_dirs_with_different_executables_ok() {
let env = TempEnvironment::builder()
.pack("tools-a")
.file("bin/tool-a", "#!/bin/sh")
.done()
.pack("tools-b")
.file("bin/tool-b", "#!/bin/sh")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
}
#[test]
fn up_path_dirs_with_same_executable_name_conflicts() {
let env = TempEnvironment::builder()
.pack("tools-a")
.file("bin/tool", "#!/bin/sh\necho a")
.done()
.pack("tools-b")
.file("bin/tool", "#!/bin/sh\necho b")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"same-name executables across packs should conflict: {err}"
);
let msg = err.to_string();
assert!(msg.contains("tool"), "should mention the executable: {msg}");
assert!(msg.contains("tools-a"), "should mention pack a: {msg}");
assert!(msg.contains("tools-b"), "should mention pack b: {msg}");
}
#[test]
fn up_no_cross_handler_conflict() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.pack("git")
.file("gitconfig", "[user]\n name = test")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
}
#[test]
fn up_three_packs_partial_conflict() {
let env = TempEnvironment::builder()
.pack("a")
.file("home.aliases", "a")
.done()
.pack("b")
.file("home.aliases", "b")
.done()
.pack("c")
.file("gitconfig", "c")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"should detect the conflict even if not all packs are involved"
);
env.assert_no_handler_state("a", "symlink");
env.assert_no_handler_state("b", "symlink");
env.assert_no_handler_state("c", "symlink");
}
#[test]
fn up_error_message_includes_all_conflict_details() {
let env = TempEnvironment::builder()
.pack("alpha")
.file("home.aliases", "a")
.done()
.pack("beta")
.file("home.aliases", "b")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("alpha"), "msg: {msg}");
assert!(msg.contains("beta"), "msg: {msg}");
assert!(msg.contains("symlink"), "msg: {msg}");
assert!(msg.contains(".aliases"), "msg: {msg}");
}
#[test]
fn status_warns_on_potential_cross_pack_conflict() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "a")
.done()
.pack("pack-b")
.file("home.aliases", "b")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
assert_eq!(result.conflicts.len(), 1, "should detect one conflict");
let c = &result.conflicts[0];
assert_eq!(c.kind, "symlink");
let packs: Vec<&str> = c.claimants.iter().map(|cl| cl.pack.as_str()).collect();
assert!(packs.contains(&"pack-a"), "claimants: {:?}", c.claimants);
assert!(packs.contains(&"pack-b"), "claimants: {:?}", c.claimants);
}
#[test]
fn status_no_warnings_without_conflicts() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "set nocompatible")
.done()
.pack("git")
.file("gitconfig", "[user]\n name = test")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
assert!(
result.warnings.is_empty(),
"no warnings expected, got: {:?}",
result.warnings
);
assert!(
result.conflicts.is_empty(),
"no conflicts expected, got: {:?}",
result.conflicts
);
}
#[test]
fn status_shows_conflict_even_when_not_deployed() {
let env = TempEnvironment::builder()
.pack("a")
.file("bashrc", "a")
.done()
.pack("b")
.file("bashrc", "b")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
for pack in &result.packs {
for file in &pack.files {
assert_eq!(file.status, "pending");
}
}
assert!(
!result.conflicts.is_empty(),
"should flag potential conflict even when undeployed"
);
}
#[test]
fn status_filtered_to_one_pack_no_conflict_warning() {
let env = TempEnvironment::builder()
.pack("a")
.file("home.aliases", "a")
.done()
.pack("b")
.file("home.aliases", "b")
.done()
.build();
let ctx = make_ctx(&env);
let filter = vec!["a".into()];
let result = commands::status::status(Some(&filter), &ctx).unwrap();
assert!(
result.conflicts.is_empty(),
"single-pack filter should not produce cross-pack conflicts"
);
}
#[test]
fn status_conflict_with_config_mapping() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("settings", "a")
.config("[symlink]\ntargets = { settings = \"myapp/settings.toml\" }")
.done()
.pack("pack-b")
.file("config", "b")
.config("[symlink]\ntargets = { config = \"myapp/settings.toml\" }")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
assert_eq!(
result.conflicts.len(),
1,
"config mapping collision should surface one conflict"
);
assert!(
result.conflicts[0].target.contains("settings.toml"),
"should mention the conflicting target: {:?}",
result.conflicts[0]
);
}
#[test]
fn up_succeeds_after_resolving_conflict() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "a")
.done()
.pack("pack-b")
.file("home.aliases", "b")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(matches!(err, crate::DodotError::CrossPackConflict { .. }));
let filter = vec!["pack-a".into()];
let result = commands::up::up(Some(&filter), &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
let status = commands::status::status(Some(&filter), &ctx).unwrap();
assert!(status.packs[0].files.iter().any(|f| f.status == "deployed"));
}
#[test]
fn up_conflict_with_home_prefix_convention() {
let env = TempEnvironment::builder()
.pack("a")
.file("home.bashrc", "# pack a")
.done()
.pack("b")
.file("bashrc", "# pack b")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"home.bashrc and bashrc both resolve to ~/.bashrc: {err}"
);
}
#[test]
fn up_multiple_simultaneous_conflicts() {
let env = TempEnvironment::builder()
.pack("a")
.file("home.aliases", "a-aliases")
.file("bashrc", "a-bash")
.done()
.pack("b")
.file("home.aliases", "b-aliases")
.file("bashrc", "b-bash")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
if let crate::DodotError::CrossPackConflict { conflicts } = &err {
assert!(
conflicts.len() >= 2,
"should detect at least 2 conflict groups, got {}",
conflicts.len()
);
} else {
panic!("expected CrossPackConflict, got: {err}");
}
}
#[test]
fn up_ignored_pack_does_not_cause_conflict() {
let env = TempEnvironment::builder()
.pack("pack-a")
.file("home.aliases", "a")
.done()
.pack("pack-b")
.file("home.aliases", "b")
.ignored()
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::up::up(None, &ctx).unwrap();
assert_eq!(result.message.as_deref(), Some("Packs deployed."));
}
#[test]
fn status_no_warning_for_same_name_shell_scripts() {
let env = TempEnvironment::builder()
.pack("a")
.file("aliases.sh", "alias a=1")
.done()
.pack("b")
.file("aliases.sh", "alias b=2")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
assert!(
result.warnings.is_empty(),
"same-name shell scripts should not produce warnings, got: {:?}",
result.warnings
);
}
#[test]
fn up_conflict_xdg_path_both_packs_subdir() {
let env = TempEnvironment::builder()
.pack("nvim-base")
.file("_xdg/nvim/init.lua", "-- base config")
.done()
.pack("nvim-custom")
.file("_xdg/nvim/init.lua", "-- custom config")
.done()
.build();
let ctx = make_ctx(&env);
let err = commands::up::up(None, &ctx).unwrap_err();
assert!(
matches!(err, crate::DodotError::CrossPackConflict { .. }),
"both targeting ~/.config/nvim/init.lua should conflict: {err}"
);
}
#[test]
fn up_auto_chmod_makes_bin_files_executable() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/deploy", "#!/bin/sh\necho deploying")
.done()
.build();
let ctx = make_ctx(&env);
let tool_path = env.dotfiles_root.join("tools/bin/deploy");
let meta_before = env.fs.stat(&tool_path).unwrap();
assert_eq!(meta_before.mode & 0o111, 0, "should start non-executable");
commands::up::up(None, &ctx).unwrap();
let meta_after = env.fs.stat(&tool_path).unwrap();
assert_ne!(
meta_after.mode & 0o111,
0,
"bin/ file should be executable after up"
);
}
#[test]
fn up_auto_chmod_disabled_via_config() {
let env = TempEnvironment::builder()
.pack("tools")
.file("bin/deploy", "#!/bin/sh\necho deploying")
.done()
.build();
env.fs
.write_file(
&env.dotfiles_root.join(".dodot.toml"),
b"[path]\nauto_chmod_exec = false",
)
.unwrap();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let tool_path = env.dotfiles_root.join("tools/bin/deploy");
let meta = env.fs.stat(&tool_path).unwrap();
assert_eq!(
meta.mode & 0o111,
0,
"auto_chmod_exec=false should leave file non-executable"
);
}
#[test]
fn status_reports_template_under_stripped_name() {
let env = TempEnvironment::builder()
.pack("app")
.file("greet.tmpl", "hello {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::status::status(None, &ctx).unwrap();
assert_eq!(result.packs.len(), 1);
let files = &result.packs[0].files;
assert_eq!(files.len(), 1, "files: {files:?}");
assert_eq!(files[0].name, "greet", "file name: {}", files[0].name);
assert_eq!(
files[0].status, "deployed",
"template should report as deployed after up, not pending"
);
}
#[test]
fn status_reports_template_pending_before_up() {
let env = TempEnvironment::builder()
.pack("app")
.file("greet.tmpl", "hello {{ name }}")
.config("[preprocessor.template.vars]\nname = \"Alice\"\n")
.done()
.build();
let ctx = make_ctx(&env);
let result = commands::status::status(None, &ctx).unwrap();
let files = &result.packs[0].files;
assert_eq!(files.len(), 1);
assert_eq!(files[0].name, "greet");
assert_eq!(files[0].status, "pending");
}
#[test]
fn summary_aggregates_all_deployed_as_deployed() {
use crate::commands::{DisplayFile, DisplayPack};
let files = vec![
DisplayFile {
name: "a".into(),
symbol: "➞".into(),
description: "".into(),
status: "deployed".into(),
status_label: "deployed".into(),
handler: "symlink".into(),
note_ref: None,
},
DisplayFile {
name: "b".into(),
symbol: "➞".into(),
description: "".into(),
status: "deployed".into(),
status_label: "deployed".into(),
handler: "symlink".into(),
note_ref: None,
},
];
let pack = DisplayPack::new("vim".into(), files);
assert_eq!(pack.summary_status, "deployed");
assert_eq!(pack.summary_count, 2);
}
#[test]
fn summary_rolls_up_error_over_pending_over_deployed() {
use crate::commands::{DisplayFile, DisplayPack};
let mk = |status: &str| DisplayFile {
name: status.into(),
symbol: "➞".into(),
description: "".into(),
status: status.into(),
status_label: status.into(),
handler: "symlink".into(),
note_ref: None,
};
let pack = DisplayPack::new(
"mixed".into(),
vec![mk("error"), mk("pending"), mk("deployed")],
);
assert_eq!(pack.summary_status, "error");
assert_eq!(pack.summary_count, 1);
let pack = DisplayPack::new("b".into(), vec![mk("broken"), mk("deployed")]);
assert_eq!(pack.summary_status, "error");
let pack = DisplayPack::new("s".into(), vec![mk("stale"), mk("deployed")]);
assert_eq!(pack.summary_status, "pending");
let pack = DisplayPack::new("w".into(), vec![mk("warning"), mk("deployed")]);
assert_eq!(pack.summary_status, "pending");
let pack = DisplayPack::new(
"counts".into(),
vec![
mk("error"),
mk("broken"),
mk("pending"),
mk("pending"),
mk("deployed"),
],
);
assert_eq!(pack.summary_status, "error");
assert_eq!(pack.summary_count, 2);
}
#[test]
fn short_mode_renders_one_line_per_pack_with_count() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("nvim")
.file("init.lua", "x")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.view_mode = crate::commands::ViewMode::Short;
let result = commands::status::status(None, &ctx).unwrap();
let output = render::render("pack-status", &result, OutputMode::Text).unwrap();
assert!(output.contains("vim"), "output: {output}");
assert!(output.contains("nvim"), "output: {output}");
assert!(output.contains("(1) pending"), "output: {output}");
assert!(
!output.contains("vimrc"),
"short mode should not render individual files: {output}"
);
assert!(
!output.contains("init.lua"),
"short mode should not render individual files: {output}"
);
}
#[test]
fn by_status_groups_packs_under_banners() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("nvim")
.file("init.lua", "x")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.group_mode = crate::commands::GroupMode::Status;
let result = commands::status::status(None, &ctx).unwrap();
let output = render::render("pack-status", &result, OutputMode::Text).unwrap();
assert!(output.contains("Pending Packs"), "output: {output}");
assert!(
!output.contains("Deployed Packs"),
"no deployed packs — deployed banner should be hidden: {output}"
);
assert!(
!output.contains("Error Packs"),
"no error packs — error banner should be hidden: {output}"
);
assert!(output.contains("vim"), "output: {output}");
assert!(output.contains("nvim"), "output: {output}");
}
#[test]
fn probe_summary_lists_available_subcommands() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::summary(&ctx).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(output.contains("deployment-map"), "output:\n{output}");
assert!(output.contains("show-data-dir"), "output:\n{output}");
}
#[test]
fn probe_deployment_map_renders_rows_after_up() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::probe::deployment_map(&ctx).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(output.contains("vim"), "output:\n{output}");
assert!(output.contains("shell"), "output:\n{output}");
assert!(output.contains("aliases.sh"), "output:\n{output}");
}
#[test]
fn probe_deployment_map_empty_state_shows_hint() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::deployment_map(&ctx).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("nothing deployed"),
"empty probe should point the user at `dodot up`; got:\n{output}"
);
}
#[test]
fn probe_show_data_dir_renders_tree_with_sizes() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let result = commands::probe::show_data_dir(&ctx, 4).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(output.contains("packs"), "output:\n{output}");
assert!(output.contains("vim"), "output:\n{output}");
assert!(output.contains("shell"), "output:\n{output}");
assert!(
output.contains("├") || output.contains("└"),
"expected branch glyphs in tree; got:\n{output}"
);
}
#[test]
fn probe_deployment_map_json_mode_is_kind_tagged() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::deployment_map(&ctx).unwrap();
let output = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["kind"], "deployment-map");
assert!(parsed["entries"].is_array());
}
fn write_fake_profile(env: &TempEnvironment, name: &str, lines: &[&str]) {
let dir = env.paths.probes_shell_init_dir();
env.fs.mkdir_all(&dir).unwrap();
let mut content =
String::from("# columns\tphase\tpack\thandler\ttarget\tstart_t\tend_t\texit_status\n");
for l in lines {
content.push_str(l);
content.push('\n');
}
env.fs
.write_file(&dir.join(name), content.as_bytes())
.unwrap();
}
#[test]
fn probe_shell_init_aggregate_renders_percentile_table() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tvim\tshell\t/x/aliases.sh\t1.000000\t1.000100\t0"],
);
write_fake_profile(
&env,
"profile-1714000002-1-1.tsv",
&["source\tvim\tshell\t/x/aliases.sh\t1.000000\t1.000200\t0"],
);
write_fake_profile(
&env,
"profile-1714000003-1-1.tsv",
&["source\tvim\tshell\t/x/aliases.sh\t1.000000\t1.000300\t0"],
);
let result = commands::probe::shell_init_aggregate(&ctx, 5).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("aggregate"),
"header missing; got:\n{output}"
);
assert!(output.contains("aliases.sh"), "row missing; got:\n{output}");
assert!(output.contains("3/3"), "seen-label missing; got:\n{output}");
}
#[test]
fn probe_shell_init_aggregate_warns_when_fewer_runs_than_requested() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tvim\tshell\t/x.sh\t1.000000\t1.000100\t0"],
);
let result = commands::probe::shell_init_aggregate(&ctx, 10).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("requested 10"),
"expected mismatch warning; got:\n{output}"
);
}
#[test]
fn probe_shell_init_aggregate_empty_state_shows_hint() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::shell_init_aggregate(&ctx, 5).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("no profiles yet"),
"expected empty hint; got:\n{output}"
);
}
#[test]
fn probe_shell_init_history_renders_one_row_per_run_newest_first() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000000-1-1.tsv",
&["source\tvim\tshell\t/a.sh\t1.000000\t1.000100\t0"],
);
write_fake_profile(
&env,
"profile-1714003600-1-1.tsv",
&["source\tvim\tshell\t/a.sh\t1.000000\t1.000200\t1"],
);
write_fake_profile(
&env,
"profile-1714007200-1-1.tsv",
&["source\tvim\tshell\t/a.sh\t1.000000\t1.000300\t0"],
);
let result = commands::probe::shell_init_history(&ctx, 50).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(output.contains("history"), "header missing; got:\n{output}");
assert!(
output.contains("2024-04-24"),
"date missing; got:\n{output}"
);
let json = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 3);
let timestamps: Vec<u64> = rows
.iter()
.map(|r| r["unix_ts"].as_u64().unwrap_or(0))
.collect();
assert_eq!(timestamps, vec![1714007200, 1714003600, 1714000000]);
assert_eq!(rows[1]["failed_entries"].as_u64().unwrap(), 1);
assert_eq!(rows[0]["failed_entries"].as_u64().unwrap(), 0);
assert_eq!(rows[2]["failed_entries"].as_u64().unwrap(), 0);
}
#[test]
fn probe_shell_init_history_empty_state_shows_hint() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::shell_init_history(&ctx, 50).unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("no profiles yet"),
"expected empty hint; got:\n{output}"
);
}
#[test]
fn probe_shell_init_aggregate_json_is_kind_tagged() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::shell_init_aggregate(&ctx, 1).unwrap();
let output = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["kind"], "shell-init-aggregate");
assert!(parsed["rows"].is_array());
assert!(parsed["requested_runs"].is_number());
}
#[test]
fn probe_shell_init_history_json_is_kind_tagged() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::shell_init_history(&ctx, 1).unwrap();
let output = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["kind"], "shell-init-history");
assert!(parsed["rows"].is_array());
}
#[test]
fn probe_shell_init_filter_json_is_kind_tagged() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::shell_init_filter(&ctx, "vim", 5).unwrap();
let output = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["kind"], "shell-init-filter");
assert!(parsed["targets"].is_array());
assert_eq!(parsed["filter_pack"], "vim");
}
#[test]
fn probe_shell_init_errors_json_is_kind_tagged() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
let result = commands::probe::shell_init_errors(&ctx, 5).unwrap();
let output = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&output).unwrap();
assert_eq!(parsed["kind"], "shell-init-errors");
assert!(parsed["targets"].is_array());
}
fn write_last_up_marker_at(env: &TempEnvironment, ts: u64) {
env.fs.mkdir_all(env.paths.data_dir()).unwrap();
env.fs
.write_file(&env.paths.last_up_path(), ts.to_string().as_bytes())
.unwrap();
}
#[test]
fn probe_shell_init_banner_when_profile_predates_last_up() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000000-1-1.tsv",
&["source\tvim\tshell\t/x/aliases.sh\t1.000000\t1.000100\t0"],
);
write_last_up_marker_at(&env, 1714003600);
let result = commands::probe::shell_init(&ctx).unwrap();
let json = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["stale"], true);
let text = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
text.contains("warning:"),
"expected staleness banner, got:\n{text}"
);
assert!(
text.contains("2024-04-24") && text.contains("2024-04-25"),
"banner should reference both capture and up timestamps, got:\n{text}"
);
assert!(
text.contains("capture a fresh profile"),
"banner should explain the remediation, got:\n{text}"
);
}
#[test]
fn probe_shell_init_no_banner_when_profile_postdates_last_up() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_last_up_marker_at(&env, 1714000000);
write_fake_profile(
&env,
"profile-1714003600-1-1.tsv",
&["source\tvim\tshell\t/x/aliases.sh\t1.000000\t1.000100\t0"],
);
let result = commands::probe::shell_init(&ctx).unwrap();
let json = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["stale"], false);
let text = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
!text.contains("warning:"),
"no banner expected when profile is fresh, got:\n{text}"
);
}
#[test]
fn probe_shell_init_no_banner_when_no_last_up_marker() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000000-1-1.tsv",
&["source\tvim\tshell\t/x/aliases.sh\t1.000000\t1.000100\t0"],
);
let result = commands::probe::shell_init(&ctx).unwrap();
let text = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
!text.contains("warning:"),
"no banner without an up marker, got:\n{text}"
);
}
#[test]
fn probe_shell_init_no_banner_when_no_profile() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_last_up_marker_at(&env, 1714000000);
let result = commands::probe::shell_init(&ctx).unwrap();
let text = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
!text.contains("warning:"),
"no banner when there's no profile to flag, got:\n{text}"
);
}
#[test]
fn probe_shell_init_aggregate_banner_when_newest_predates_last_up() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tvim\tshell\t/x.sh\t1.000000\t1.000100\t0"],
);
write_fake_profile(
&env,
"profile-1714000002-1-1.tsv",
&["source\tvim\tshell\t/x.sh\t1.000000\t1.000200\t0"],
);
write_last_up_marker_at(&env, 1714000003);
let result = commands::probe::shell_init_aggregate(&ctx, 5).unwrap();
let json = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["stale"], true);
let text = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
text.contains("warning:"),
"aggregate view should show banner, got:\n{text}"
);
}
#[test]
fn probe_shell_init_history_banner_when_newest_predates_last_up() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000000-1-1.tsv",
&["source\tvim\tshell\t/x.sh\t1.000000\t1.000100\t0"],
);
write_fake_profile(
&env,
"profile-1714003600-1-1.tsv",
&["source\tvim\tshell\t/x.sh\t1.000000\t1.000200\t0"],
);
write_last_up_marker_at(&env, 1714007200);
let result = commands::probe::shell_init_history(&ctx, 50).unwrap();
let json = render::render("probe", &result, OutputMode::Json).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(parsed["stale"], true);
let text = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
text.contains("warning:"),
"history view should show banner, got:\n{text}"
);
}
fn write_fake_errors_log(env: &TempEnvironment, profile_name: &str, body: &str) {
let dir = env.paths.probes_shell_init_dir();
env.fs.mkdir_all(&dir).unwrap();
let stem = profile_name.trim_end_matches(".tsv");
let path = dir.join(format!("{stem}.errors.log"));
let mut content = String::from("# dodot shell-init errors v1\n");
content.push_str(body);
env.fs.write_file(&path, content.as_bytes()).unwrap();
}
#[test]
fn probe_shell_init_filter_pack_only_lists_each_target_in_pack() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&[
"source\tgpg\tshell\t/p/gpg/env.sh\t1.0\t1.001\t1",
"source\tgpg\tshell\t/p/gpg/aliases.sh\t1.0\t1.001\t0",
"source\tvim\tshell\t/p/vim/aliases.sh\t1.0\t1.001\t0",
],
);
let result =
commands::probe::shell_init_filter(&ctx, "gpg", commands::probe::DEFAULT_FILTER_RUNS)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert_eq!(view.filter_pack, "gpg");
assert!(view.filter_filename.is_none());
assert_eq!(view.targets.len(), 2, "expected both gpg targets");
let names: Vec<&str> = view
.targets
.iter()
.map(|t| t.display_target.as_str())
.collect();
assert!(names.contains(&"env.sh"));
assert!(names.contains(&"aliases.sh"));
}
#[test]
fn probe_shell_init_filter_with_filename_narrows_to_single_target() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&[
"source\tgpg\tshell\t/p/gpg/env.sh\t1.0\t1.001\t1",
"source\tgpg\tshell\t/p/gpg/aliases.sh\t1.0\t1.001\t0",
],
);
let result = commands::probe::shell_init_filter(
&ctx,
"gpg/env.sh",
commands::probe::DEFAULT_FILTER_RUNS,
)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert_eq!(view.filter_pack, "gpg");
assert_eq!(view.filter_filename.as_deref(), Some("env.sh"));
assert_eq!(view.targets.len(), 1);
assert_eq!(view.targets[0].display_target, "env.sh");
assert_eq!(view.targets[0].failure_count, 1);
}
#[test]
fn probe_shell_init_filter_attaches_captured_stderr_to_matching_run() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tgpg\tshell\t/p/gpg/env.sh\t1.0\t1.001\t1"],
);
write_fake_errors_log(
&env,
"profile-1714000001-1-1.tsv",
"@@\t/p/gpg/env.sh\t1\nfirst error line\nsecond error line\n",
);
let result = commands::probe::shell_init_filter(
&ctx,
"gpg/env.sh",
commands::probe::DEFAULT_FILTER_RUNS,
)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert_eq!(view.targets.len(), 1);
assert_eq!(view.targets[0].runs.len(), 1);
assert_eq!(
view.targets[0].runs[0].stderr_lines,
vec!["first error line", "second error line"]
);
}
#[test]
fn probe_shell_init_filter_runs_are_newest_first() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
for ts in [1714000001u64, 1714000002, 1714000003] {
write_fake_profile(
&env,
&format!("profile-{ts}-1-1.tsv"),
&["source\tgpg\tshell\t/p/gpg/env.sh\t1.0\t1.001\t0"],
);
}
let result = commands::probe::shell_init_filter(
&ctx,
"gpg/env.sh",
commands::probe::DEFAULT_FILTER_RUNS,
)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
let runs = &view.targets[0].runs;
assert_eq!(runs.len(), 3);
assert_eq!(runs[0].profile_filename, "profile-1714000003-1-1.tsv");
assert_eq!(runs[2].profile_filename, "profile-1714000001-1-1.tsv");
}
#[test]
fn probe_shell_init_filter_renders_with_template() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tgpg\tshell\t/p/gpg/env.sh\t1.0\t1.001\t1"],
);
write_fake_errors_log(
&env,
"profile-1714000001-1-1.tsv",
"@@\t/p/gpg/env.sh\t1\nboom\n",
);
let result = commands::probe::shell_init_filter(
&ctx,
"gpg/env.sh",
commands::probe::DEFAULT_FILTER_RUNS,
)
.unwrap();
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("Shell-init filter"),
"header missing:\n{output}"
);
assert!(output.contains("env.sh"), "target missing:\n{output}");
assert!(output.contains("exit 1"), "exit code missing:\n{output}");
assert!(
output.contains("boom"),
"captured stderr missing:\n{output}"
);
}
#[test]
fn probe_shell_init_filter_supports_nested_subpaths() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&[
"source\tgpg\tshell\t/p/gpg/sub/dir/env.sh\t1.0\t1.001\t1",
"source\tgpg\tshell\t/p/gpg/other/env.sh\t1.0\t1.001\t0",
],
);
let result = commands::probe::shell_init_filter(
&ctx,
"gpg/sub/dir/env.sh",
commands::probe::DEFAULT_FILTER_RUNS,
)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert_eq!(view.targets.len(), 1);
assert_eq!(view.targets[0].target, "/p/gpg/sub/dir/env.sh");
let result_basename = commands::probe::shell_init_filter(
&ctx,
"gpg/env.sh",
commands::probe::DEFAULT_FILTER_RUNS,
)
.unwrap();
let view_basename = match result_basename {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert_eq!(view_basename.targets.len(), 2);
}
#[test]
fn probe_shell_init_filter_basename_does_not_partial_match() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&[
"source\tnv\tshell\t/p/nv/nvenv.sh\t1.0\t1.001\t0",
"source\tnv\tshell\t/p/nv/env.sh\t1.0\t1.001\t0",
],
);
let result =
commands::probe::shell_init_filter(&ctx, "nv/env.sh", commands::probe::DEFAULT_FILTER_RUNS)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert_eq!(view.targets.len(), 1);
assert_eq!(view.targets[0].target, "/p/nv/env.sh");
}
#[test]
fn probe_shell_init_filter_empty_when_no_match() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tvim\tshell\t/p/vim/aliases.sh\t1.0\t1.001\t0"],
);
let result =
commands::probe::shell_init_filter(&ctx, "missing", commands::probe::DEFAULT_FILTER_RUNS)
.unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitFilter(v) => v,
other => panic!("expected ShellInitFilter, got {other:?}"),
};
assert!(view.targets.is_empty());
assert_eq!(view.runs_examined, 1);
}
#[test]
fn probe_shell_init_errors_only_keeps_only_failed_runs() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&[
"source\tgpg\tshell\t/p/gpg/env.sh\t1.0\t1.001\t1",
"source\tvim\tshell\t/p/vim/aliases.sh\t1.0\t1.001\t0",
],
);
let result =
commands::probe::shell_init_errors(&ctx, commands::probe::DEFAULT_FILTER_RUNS).unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitErrors(v) => v,
other => panic!("expected ShellInitErrors, got {other:?}"),
};
assert_eq!(view.targets.len(), 1);
assert_eq!(view.targets[0].display_target, "env.sh");
assert_eq!(view.targets[0].failure_count, 1);
}
#[test]
fn probe_shell_init_errors_only_sorts_by_failure_count_desc() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&[
"source\ta\tshell\t/p/a.sh\t1.0\t1.001\t1",
"source\tb\tshell\t/p/b.sh\t1.0\t1.001\t1",
],
);
write_fake_profile(
&env,
"profile-1714000002-1-1.tsv",
&["source\ta\tshell\t/p/a.sh\t1.0\t1.001\t1"],
);
let result =
commands::probe::shell_init_errors(&ctx, commands::probe::DEFAULT_FILTER_RUNS).unwrap();
let view = match result {
commands::probe::ProbeResult::ShellInitErrors(v) => v,
other => panic!("expected ShellInitErrors, got {other:?}"),
};
assert_eq!(view.targets.len(), 2);
assert_eq!(
view.targets[0].pack, "a",
"most-broken target must come first"
);
assert_eq!(view.targets[0].failure_count, 2);
assert_eq!(view.targets[1].pack, "b");
assert_eq!(view.targets[1].failure_count, 1);
}
#[test]
fn probe_shell_init_errors_only_clean_window_says_so() {
let env = TempEnvironment::builder().build();
let ctx = make_ctx(&env);
write_fake_profile(
&env,
"profile-1714000001-1-1.tsv",
&["source\tvim\tshell\t/p/aliases.sh\t1.0\t1.001\t0"],
);
let result =
commands::probe::shell_init_errors(&ctx, commands::probe::DEFAULT_FILTER_RUNS).unwrap();
match &result {
commands::probe::ProbeResult::ShellInitErrors(v) => {
assert!(v.targets.is_empty());
assert_eq!(v.runs_examined, 1);
}
other => panic!("expected ShellInitErrors, got {other:?}"),
}
let output = render::render("probe", &result, OutputMode::Text).unwrap();
assert!(
output.contains("no failed sources"),
"clean-window message missing:\n{output}"
);
}
#[test]
fn up_writes_last_up_marker() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.build();
let ctx = make_ctx(&env);
assert!(
!env.fs.exists(&env.paths.last_up_path()),
"marker should not exist before first up"
);
commands::up::up(None, &ctx).unwrap();
assert!(
env.fs.exists(&env.paths.last_up_path()),
"marker should be written by up"
);
let raw = env.fs.read_to_string(&env.paths.last_up_path()).unwrap();
let parsed: u64 = raw.trim().parse().expect("marker should be a unix ts");
assert!(parsed > 1_700_000_000, "ts should look recent: {parsed}");
}
#[test]
fn up_writes_deployment_map() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.file("bin/tool", "#!/bin/sh")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
env.assert_exists(&env.paths.deployment_map_path());
let content = env
.fs
.read_to_string(&env.paths.deployment_map_path())
.unwrap();
assert!(content.starts_with("# dodot deployment map v1"));
assert!(
content.contains("vim\tshell\tsymlink\t"),
"expected a vim/shell row; content:\n{content}"
);
assert!(
content.contains("vim\tpath\tsymlink\t"),
"expected a vim/path row; content:\n{content}"
);
}
#[test]
fn down_refreshes_deployment_map_to_empty() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let ctx = make_ctx(&env);
commands::up::up(None, &ctx).unwrap();
let content_before = env
.fs
.read_to_string(&env.paths.deployment_map_path())
.unwrap();
assert!(content_before.contains("aliases.sh"));
commands::down::down(None, &ctx).unwrap();
let content_after = env
.fs
.read_to_string(&env.paths.deployment_map_path())
.unwrap();
assert!(content_after.starts_with("# dodot deployment map v1"));
assert!(
!content_after.contains("aliases.sh"),
"map should be empty after down; got:\n{content_after}"
);
}
#[test]
fn up_dry_run_does_not_touch_deployment_map() {
let env = TempEnvironment::builder()
.pack("vim")
.file("aliases.sh", "alias vi=vim")
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.dry_run = true;
commands::up::up(None, &ctx).unwrap();
env.assert_not_exists(&env.paths.deployment_map_path());
}
#[test]
fn by_status_folds_ignored_packs_into_ignored_group() {
let env = TempEnvironment::builder()
.pack("vim")
.file("vimrc", "x")
.done()
.pack("disabled")
.file("stuff", "x")
.ignored()
.done()
.build();
let mut ctx = make_ctx(&env);
ctx.group_mode = crate::commands::GroupMode::Status;
let result = commands::status::status(None, &ctx).unwrap();
let output = render::render("pack-status", &result, OutputMode::Text).unwrap();
assert!(output.contains("Ignored Packs"), "output: {output}");
assert!(output.contains("disabled"), "output: {output}");
assert!(output.contains("Pending Packs"), "output: {output}");
}
#[test]
#[cfg_attr(not(target_os = "macos"), ignore = "macOS-only enrichment paths")]
fn probe_app_collects_alias_force_and_underscore_entries() {
let env = TempEnvironment::builder()
.pack("vscode")
.file("settings.json", "{}")
.file("_app/Cursor/User/keys.json", "[]")
.file("Code/User/extra.json", "{}")
.config("[symlink.app_aliases]\nvscode = \"VSCodeAliased\"\n")
.done()
.build();
env.fs.mkdir_all(&env.app_support.join("Cursor")).unwrap();
let runner = Arc::new(CannedRunner::new());
runner.respond(
&["brew", "list", "--cask", "--versions"],
"cursor 0.42.0\n",
0,
);
runner.respond(
&["brew", "info", "--json=v2", "--cask", "cursor"],
r#"{"casks": [{
"token": "cursor",
"installed": "0.42.0",
"artifacts": [
{"app": ["Cursor.app"]},
{"zap": [{"trash": [
"~/Library/Application Support/Cursor",
"~/Library/Preferences/com.todesktop.Cursor.plist"
]}]}
]
}]}"#,
0,
);
runner.respond(
&[
"mdls",
"-name",
"kMDItemCFBundleIdentifier",
"/Applications/Cursor.app",
],
"kMDItemCFBundleIdentifier = \"com.todesktop.Cursor\"\n",
0,
);
let ctx = make_ctx_with_runner(&env, runner);
let result = commands::probe::app("vscode", false, &ctx).unwrap();
let view = match result {
commands::probe::ProbeResult::App(v) => v,
other => panic!("expected App variant, got {other:?}"),
};
assert_eq!(view.pack, "vscode");
assert!(view.macos);
let folders: Vec<&str> = view.entries.iter().map(|e| e.folder.as_str()).collect();
assert!(folders.contains(&"VSCodeAliased"), "folders: {folders:?}");
assert!(folders.contains(&"Code"), "folders: {folders:?}");
assert!(folders.contains(&"Cursor"), "folders: {folders:?}");
let cursor_row = view.entries.iter().find(|e| e.folder == "Cursor").unwrap();
assert!(cursor_row.target_exists);
assert_eq!(cursor_row.cask.as_deref(), Some("cursor"));
assert_eq!(cursor_row.app_bundle.as_deref(), Some("Cursor.app"));
assert_eq!(
cursor_row.bundle_id.as_deref(),
Some("com.todesktop.Cursor")
);
assert!(
view.suggested_adoptions
.iter()
.any(|s| s.contains("Cursor.plist")),
"suggested adoptions: {:?}",
view.suggested_adoptions
);
}
#[test]
fn probe_app_rejects_path_traversal_input() {
let env = TempEnvironment::builder().build();
let runner = Arc::new(CannedRunner::new());
let ctx = make_ctx_with_runner(&env, runner);
for evil in ["..", "foo/../bar", "../sibling", "/abs/path"] {
let result = commands::probe::app(evil, false, &ctx).unwrap();
let view = match result {
commands::probe::ProbeResult::App(v) => v,
other => panic!("expected App variant, got {other:?}"),
};
assert_eq!(view.pack, evil, "input echoed back unchanged");
assert!(
view.entries.is_empty(),
"path-traversing input must not produce entries: got {:?}",
view.entries
);
}
}
#[test]
fn probe_app_non_macos_returns_minimal_view() {
if cfg!(target_os = "macos") {
return;
}
let env = TempEnvironment::builder()
.pack("vscode")
.file("Code/User/foo", "{}")
.done()
.build();
let runner = Arc::new(CannedRunner::new());
let ctx = make_ctx_with_runner(&env, runner);
let result = commands::probe::app("vscode", false, &ctx).unwrap();
let view = match result {
commands::probe::ProbeResult::App(v) => v,
other => panic!("expected App variant, got {other:?}"),
};
assert!(!view.macos);
for entry in &view.entries {
assert!(entry.cask.is_none(), "row: {entry:?}");
assert!(entry.app_bundle.is_none(), "row: {entry:?}");
assert!(entry.bundle_id.is_none(), "row: {entry:?}");
}
}
#[test]
#[cfg_attr(not(target_os = "macos"), ignore = "macOS-only behavior")]
fn plan_pack_emits_missing_target_hint_with_cask_enrichment() {
use crate::packs::orchestration;
use crate::packs::Pack;
let env = TempEnvironment::builder()
.pack("vscode")
.file("settings.json", "{}")
.config("[symlink.app_aliases]\nvscode = \"Code\"\n")
.done()
.build();
assert!(!env.app_support.join("Code").exists());
let runner = Arc::new(CannedRunner::new());
runner.respond(
&["brew", "list", "--cask", "--versions"],
"visual-studio-code 1.95.0\n",
0,
);
runner.respond(
&["brew", "info", "--json=v2", "--cask", "visual-studio-code"],
r#"{"casks": [{
"token": "visual-studio-code",
"artifacts": [
{"app": ["Visual Studio Code.app"]},
{"zap": [{"trash": ["~/Library/Application Support/Code"]}]}
]
}]}"#,
0,
);
let ctx = make_ctx_with_runner(&env, runner);
let cache_dir = ctx.paths.probes_brew_cache_dir();
let _ = crate::probe::brew::info_cask(
"visual-studio-code",
&cache_dir,
crate::probe::brew::now_secs_unix(),
ctx.fs.as_ref(),
ctx.command_runner.as_ref(),
);
let pack_path = env.dotfiles_root.join("vscode");
let pack_config = ctx.config_manager.config_for_pack(&pack_path).unwrap();
let pack = Pack {
name: "vscode".into(),
display_name: "vscode".into(),
path: pack_path,
config: pack_config.to_handler_config(),
};
let plan = orchestration::plan_pack(&pack, &ctx, crate::preprocessing::PreprocessMode::Active)
.unwrap();
let hint = plan.warnings.iter().find(|w| w.contains("Code"));
assert!(
hint.is_some(),
"expected missing-target hint mentioning `Code`; got {:?}",
plan.warnings
);
let hint_text = hint.unwrap();
assert!(
hint_text.contains("visual-studio-code"),
"expected cask-enriched hint, got: {hint_text}"
);
assert!(
!hint_text.contains("isn't installed"),
"hint should not falsely claim the cask is uninstalled, got: {hint_text}"
);
}