"""Tests for DotfilesService.""" from pathlib import Path import yaml from flow.core.config import AppConfig, FlowContext from flow.core.console import Console from flow.core.platform import PlatformInfo from flow.core.runtime import SystemRuntime from flow.core import paths from flow.services.dotfiles import DotfilesService from tests.fakes import FakeRunner def _make_ctx(tmp_path, console=None): """Build a FlowContext for testing.""" return FlowContext( config=AppConfig(), manifest={}, platform=PlatformInfo(), console=console or Console(color=False), runtime=SystemRuntime(), ) def _setup_dotfiles(tmp_path, packages_files): """Set up a fake dotfiles directory structure. packages_files: dict of {package_name: {relative_path: content}} """ dotfiles = tmp_path / "dotfiles" shared = dotfiles / "_shared" for pkg_name, files in packages_files.items(): pkg_dir = shared / pkg_name for rel_path, content in files.items(): file_path = pkg_dir / rel_path file_path.parent.mkdir(parents=True, exist_ok=True) file_path.write_text(content) return dotfiles class TestDotfilesServiceLink: def test_link_creates_symlinks(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh config"}, "git": {".config/git/config": "[user]\n name = test"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.link() assert (home / ".zshrc").is_symlink() assert (home / ".config" / "git" / "config").is_symlink() def test_link_with_module(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = tmp_path / "dotfiles" modules = tmp_path / "modules" # Set up package with _module.yaml pkg_dir = dotfiles / "_shared" / "nvim" config_dir = pkg_dir / ".config" / "nvim" config_dir.mkdir(parents=True) (config_dir / "_module.yaml").write_text(yaml.dump({ "source": "github:test/nvim-config", "ref": {"branch": "main"}, })) # Set up local file outside mount path (pkg_dir / ".local" / "bin").mkdir(parents=True) (pkg_dir / ".local" / "bin" / "nvim-wrapper").write_text("#!/bin/sh") # Set up cloned module module_dir = modules / "_shared--nvim" module_dir.mkdir(parents=True) (module_dir / "init.lua").write_text("-- init") (module_dir / "lua").mkdir() (module_dir / "lua" / "plugins.lua").write_text("-- plugins") monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", modules) monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.link() # Module files should be linked under .config/nvim/ assert (home / ".config" / "nvim" / "init.lua").is_symlink() assert (home / ".config" / "nvim" / "lua" / "plugins.lua").is_symlink() # Local file outside mount path should be linked assert (home / ".local" / "bin" / "nvim-wrapper").is_symlink() def test_unlink_removes_symlinks(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) # Link first svc.link() assert (home / ".zshrc").is_symlink() # Then unlink svc.unlink() assert not (home / ".zshrc").exists() def test_link_dry_run_no_changes(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.link(dry_run=True) # No symlinks should exist assert not (home / ".zshrc").exists() def test_status_shows_packages(self, tmp_path, monkeypatch, capsys): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) # Link first to populate state svc.link() # Check status svc.status() output = capsys.readouterr().out assert "zsh" in output def test_relink_does_not_remove_unmanaged_file(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.link() target = home / ".zshrc" target.unlink() target.write_text("user managed file") svc.link() assert target.read_text() == "user managed file" assert not target.is_symlink() def test_status_shows_module_info(self, tmp_path, monkeypatch, capsys): home = tmp_path / "home" home.mkdir() dotfiles = tmp_path / "dotfiles" modules = tmp_path / "modules" # Set up package with _module.yaml pkg_dir = dotfiles / "_shared" / "nvim" config_dir = pkg_dir / ".config" / "nvim" config_dir.mkdir(parents=True) (config_dir / "_module.yaml").write_text(yaml.dump({ "source": "github:test/nvim-config", "ref": {"branch": "main"}, })) # Set up cloned module module_dir = modules / "_shared--nvim" module_dir.mkdir(parents=True) (module_dir / "init.lua").write_text("-- init") monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", modules) monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.link() svc.status() output = capsys.readouterr().out assert "nvim" in output assert "branch:main" in output def test_repos_list_shows_dotfiles_and_modules(self, tmp_path, monkeypatch, capsys): home = tmp_path / "home" home.mkdir() dotfiles = tmp_path / "dotfiles" modules = tmp_path / "modules" pkg_dir = dotfiles / "_shared" / "nvim" config_dir = pkg_dir / ".config" / "nvim" config_dir.mkdir(parents=True) (config_dir / "_module.yaml").write_text(yaml.dump({ "source": "github:test/nvim-config", "ref": {"branch": "main"}, })) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", modules) monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.repos_list() output = capsys.readouterr().out assert "dotfiles" in output assert "nvim" in output assert "module" in output def test_repos_pull_includes_profile_module_repos(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = tmp_path / "dotfiles" profile_pkg = dotfiles / "linux-work" / "nvim" / ".config" / "nvim" profile_pkg.mkdir(parents=True) (profile_pkg / "_module.yaml").write_text(yaml.dump({ "source": "github:test/nvim-config", "ref": {"branch": "main"}, })) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") runtime = SystemRuntime() runner = FakeRunner() runtime.runner = runner runtime.git.runner = runner ctx = FlowContext( config=AppConfig(), manifest={}, platform=PlatformInfo(), console=Console(color=False), runtime=runtime, ) DotfilesService(ctx).repos_pull() assert any("linux-work--nvim" in " ".join(call) for call in runner.calls) def test_repos_status_shows_repo_names(self, tmp_path, monkeypatch, capsys): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") # Make dotfiles dir look like a git repo for status (dotfiles / ".git").mkdir() runtime = SystemRuntime() runner = FakeRunner() runtime.runner = runner runtime.git.runner = runner ctx = FlowContext( config=AppConfig(), manifest={}, platform=PlatformInfo(), console=Console(color=False), runtime=runtime, ) DotfilesService(ctx).repos_status() output = capsys.readouterr().out assert "dotfiles" in output def test_repos_push_calls_git_push(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") runtime = SystemRuntime() runner = FakeRunner() runtime.runner = runner runtime.git.runner = runner ctx = FlowContext( config=AppConfig(), manifest={}, platform=PlatformInfo(), console=Console(color=False), runtime=runtime, ) DotfilesService(ctx).repos_push() assert any("push" in " ".join(call) for call in runner.calls) def test_repos_pull_dry_run_no_calls(self, tmp_path, monkeypatch, capsys): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") runtime = SystemRuntime() runner = FakeRunner() runtime.runner = runner runtime.git.runner = runner ctx = FlowContext( config=AppConfig(), manifest={}, platform=PlatformInfo(), console=Console(color=False), runtime=runtime, ) DotfilesService(ctx).repos_pull(dry_run=True) output = capsys.readouterr().out assert "Would" in output # No git calls should be made in dry run assert not runner.calls def test_status_filter_by_package(self, tmp_path, monkeypatch, capsys): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh"}, "git": {".gitconfig": "[user]"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) svc.link() capsys.readouterr() # discard link output svc.status(package_filter=["zsh"]) output = capsys.readouterr().out assert "zsh" in output # Only zsh should appear, not git assert "_shared/git" not in output def test_link_repairs_broken_symlinks(self, tmp_path, monkeypatch): home = tmp_path / "home" home.mkdir() dotfiles = _setup_dotfiles(tmp_path, { "zsh": {".zshrc": "# zsh config"}, }) monkeypatch.setattr(paths, "HOME", home) monkeypatch.setattr(paths, "DOTFILES_DIR", dotfiles) monkeypatch.setattr(paths, "MODULES_DIR", tmp_path / "modules") monkeypatch.setattr(paths, "LINKED_STATE", tmp_path / "state" / "linked.json") ctx = _make_ctx(tmp_path) svc = DotfilesService(ctx) # Link normally svc.link() assert (home / ".zshrc").is_symlink() # Break the symlink by removing its target real_target = (home / ".zshrc").resolve() (home / ".zshrc").unlink() (home / ".zshrc").symlink_to("/nonexistent/path") # Re-link should repair the broken symlink svc.link() assert (home / ".zshrc").is_symlink() assert (home / ".zshrc").resolve() == real_target.resolve()