"""Tests for DotfilesService.""" import subprocess 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 CommandRunner, SystemRuntime from flow.core import paths from flow.services.dotfiles import DotfilesService class FakeRunner(CommandRunner): def __init__(self): self.calls: list[list[str]] = [] def run(self, argv, *, cwd=None, env=None, capture_output=True, check=False, timeout=None): command = [str(part) for part in argv] self.calls.append(command) return subprocess.CompletedProcess(command, 0, stdout="", stderr="") 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_sync_modules_includes_profile_layers(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).sync_modules() assert any("linux-work--nvim" in " ".join(call) for call in runner.calls)