From 1038826d2571b4e85bc23425d5a102769fa4f883 Mon Sep 17 00:00:00 2001 From: piny4man Date: Sun, 5 Apr 2026 23:40:19 +0200 Subject: [PATCH 1/5] feat(interaction): apply links from existing repo --- plans/phase1-interactive-mvp.md | 251 +++++++++++++++++++++++ prd/001-phase1-interactive-mvp.md | 225 ++++++++++++++++++++ src/cli/apply.rs | 193 ++++++++++++++++- src/config/local.rs | 207 +++++++++++++++++++ src/config/mod.rs | 2 + src/lib.rs | 1 + src/linker/mod.rs | 299 +++++++++++++++++++++++++++ src/ui/mod.rs | 5 +- tests/apply_test.rs | 330 ++++++++++++++++++++++++++++++ 9 files changed, 1510 insertions(+), 3 deletions(-) create mode 100644 plans/phase1-interactive-mvp.md create mode 100644 prd/001-phase1-interactive-mvp.md create mode 100644 src/config/local.rs create mode 100644 src/linker/mod.rs create mode 100644 tests/apply_test.rs diff --git a/plans/phase1-interactive-mvp.md b/plans/phase1-interactive-mvp.md new file mode 100644 index 0000000..57173d9 --- /dev/null +++ b/plans/phase1-interactive-mvp.md @@ -0,0 +1,251 @@ +# Plan: Phase 1 Interactive MVP + +> Source PRD: `prd/001-phase1-interactive-mvp.md` + +## Architectural decisions + +Durable decisions that apply across all phases: + +- **Local config path**: `~/.config/anvil.toml` stores `clone_dir` and `profiles`. Written by `init`, read by `sync`/`apply`. +- **Repo discovery order**: read local config -> fall back to `~/.dotfiles` -> error with "run `anvil init`" +- **Git backend**: `GitBackend` trait with `ShellGit` impl. Clone uses `--depth=1`. Pull respects user's git config (no `--rebase`). +- **Linker model**: `ResolvedLink` struct with absolute `src`, absolute `dest`, `copy: bool`. Path expansion via `dirs::home_dir()`. +- **Conflict strategy**: Overwrite (with `.bak` backup) / Skip / Show diff. `--yes` defaults to Skip. +- **Hook execution**: Shell commands from manifest, working dir = repo root, streamed output with `| ` prefix. +- **UiContext threading**: All commands receive `&UiContext`. Flags (`--yes`, `--quiet`, `--dry-run`) are handled centrally. +- **New dependency**: `similar` crate for inline diff rendering (no external `diff` binary). +- **Testing**: Unit tests per module, integration tests via `assert_cmd` + `tempfile::tempdir()`. Mock git via `GitBackend` trait. +- **ROADMAP sync**: When a phase is completed, check off the corresponding `ROADMAP.md` step(s). Mapping below. + +| Plan Phase | ROADMAP Step(s) | +|------------|-----------------| +| Phase 1 (Apply) | Step 5 (Linker) | +| Phase 2 (Init) | Step 4 (Git Backend), Step 7 (init command) | +| Phase 3 (Sync) | Step 8 (sync command) | +| Phase 4 (Conflict Resolution) | Step 6 (apply command) — partial | +| Phase 5 (Hooks) | Step 6 (apply command) — completes it | +| Phase 6 (Copy/dry-run/scripting) | — (no direct ROADMAP step) | +| Phase 7 (Signal/errors) | Step 9 (Polish & edge cases) — partial | +| Phase 8 (Integration tests) | Step 9 (Polish & edge cases) — completes it | + +--- + +## Phase 1: Apply — Link files from an existing repo + +**User stories**: 9, 10, 25, 26 + +### What to build + +The narrowest useful vertical slice: given a dotfiles repo already on disk, anvil reads the manifest, resolves a profile's links, and creates symlinks. This requires three new pieces wired together end-to-end: + +1. **`config/local.rs`** — Read and write `~/.config/anvil.toml` (the local state file that tells anvil where the dotfiles repo lives and which profiles are active). Serde for reads, `toml_edit` for writes. +2. **`linker/`** — Resolve links from a profile into absolute paths, create symlinks with parent directory creation, and detect when a symlink already points to the correct target (idempotent no-op). No conflict handling yet — if a non-symlink file exists at the destination, report it as a conflict and skip. +3. **`cli/apply.rs`** — Wire it together: discover repo via local config or `~/.dotfiles` fallback, parse manifest, resolve the target profile (from `--profile` flag, local config, or manifest default), create symlinks, print summary via `ApplySummary`. + +After this phase, a user who manually clones their dotfiles repo and writes a local config can run `anvil apply` and get symlinks created. + +### Acceptance criteria + +- [x] `config/local.rs` can write and round-trip read `clone_dir` and `profiles` +- [x] `config/local.rs` returns `None` when the local config file doesn't exist +- [x] Linker creates a symlink from `dest -> src` in a temp directory +- [x] Linker detects an existing correct symlink and skips it (idempotent) +- [x] Linker creates parent directories for dest paths that don't exist yet +- [x] Linker reports conflict when dest exists and is not the expected symlink +- [x] `anvil apply` discovers repo from local config, falls back to `~/.dotfiles`, errors with guidance if neither exists +- [x] `anvil apply --profile base` applies only links from the named profile +- [x] `ApplySummary` correctly counts linked/skipped/failed and prints via `UiContext` +- [x] Unit tests for local config read/write, linker symlink creation, idempotency, and conflict detection +- [x] `--quiet` suppresses non-error output from apply + +--- + +## Phase 2: Init — Clone a repo and apply + +**User stories**: 1, 2, 3, 4, 5, 6 + +### What to build + +The first-run experience: `anvil init ` clones a dotfiles repo, runs the apply flow from Phase 1, and writes local config so subsequent commands know where to find things. + +1. **`git/`** — `GitBackend` trait with `clone_repo(url, dest)` method. `ShellGit` impl shells out to `git clone --depth=1`. Checks for git binary existence first. +2. **`cli/init.rs`** — Print ASCII header, prompt for repo URL if not provided as arg, prompt for clone directory (default `~/.dotfiles`), clone via git backend with spinner, parse `anvil.toml` from cloned repo (error clearly if missing), resolve profile (from `--profile` flag or manifest default), run the apply flow, write local config. + +After this phase, a new user can run `anvil init https://github.com/me/dotfiles` and get a fully linked setup. + +### Acceptance criteria + +- [ ] `GitBackend` trait defined with `clone_repo` method +- [ ] `ShellGit` constructs correct `git clone --depth=1` command +- [ ] Missing git binary produces `AnvilError::GitNotFound` with a clear message +- [ ] Failed clone produces `AnvilError::GitCloneFailed` with the URL +- [ ] `anvil init ` clones repo, applies links, writes local config +- [ ] `anvil init` without URL prompts interactively; errors in `--yes` mode (no URL to default to) +- [ ] Clone directory prompt defaults to `~/.dotfiles` +- [ ] Spinner shown during clone, replaced by success/failure message +- [ ] Missing `anvil.toml` in cloned repo produces `AnvilError::ManifestNotFound` +- [ ] After successful init, `~/.config/anvil.toml` exists with correct `clone_dir` and `profiles` +- [ ] Unit tests for git command construction, error mapping +- [ ] Integration test: `anvil init` with a local bare git repo fixture + +--- + +## Phase 3: Sync — Pull and re-apply + +**User stories**: 7, 8 + +### What to build + +The daily workflow: `anvil sync` pulls the latest changes from the remote and re-applies links. + +1. **`git/`** — Add `pull(repo_dir)` method to `GitBackend`. `ShellGit` runs `git pull` (no `--rebase`). Returns `PullResult` with `was_updated: bool` and `summary: String`. +2. **`cli/sync.rs`** — Read local config to find repo dir (fall back to `~/.dotfiles`), pull via git backend with spinner, show result ("Already up to date" or change summary), re-run apply flow. + +After this phase, the core daily loop works: init once, sync whenever. + +### Acceptance criteria + +- [ ] `GitBackend` trait extended with `pull` method returning `PullResult` +- [ ] `ShellGit::pull` runs `git pull` in the repo directory +- [ ] Pull result correctly reports whether changes were fetched +- [ ] Failed pull produces `AnvilError::GitPullFailed` +- [ ] `anvil sync` reads local config, pulls, and re-applies +- [ ] `anvil sync` with no local config and no `~/.dotfiles` produces a helpful error +- [ ] Spinner shown during pull, replaced by result message +- [ ] Unit tests for pull command construction, result parsing +- [ ] Integration test: sync pulls and re-applies links + +--- + +## Phase 4: Conflict resolution + +**User stories**: 11, 12, 13 + +### What to build + +When `apply` encounters an existing file at a destination that isn't managed by anvil, it now prompts the user instead of just skipping. The interactive flow offers three choices: Overwrite, Skip, or Show diff. + +1. **Linker conflict handling** — When a conflict is detected, return it to the command layer for UI-driven resolution instead of auto-skipping. +2. **Diff rendering** — Add the `similar` crate. When the user chooses "Show diff", render an inline text diff between the existing file and the repo version, then re-prompt. +3. **Backup on overwrite** — When the user chooses "Overwrite", copy the existing file to `.bak` before creating the symlink/copy. +4. **`--yes` behavior** — Default to Skip (safe, non-destructive) when prompts are suppressed. + +The `conflict_resolution` prompt method already exists on `UiContext` — this phase wires it into the apply loop. + +### Acceptance criteria + +- [ ] Existing non-managed file at dest triggers Overwrite/Skip/Show diff prompt +- [ ] "Show diff" displays inline diff using `similar` and re-prompts +- [ ] "Overwrite" backs up existing file to `.bak` then creates link +- [ ] "Skip" logs the file as skipped and continues +- [ ] `--yes` mode defaults to Skip without prompting +- [ ] `ApplySummary` reflects overwritten and skipped counts accurately +- [ ] Unit tests for backup creation, diff output, conflict flow with each choice +- [ ] Integration test: apply with conflicting files exercises prompt paths + +--- + +## Phase 5: Hook execution + +**User stories**: 14, 15, 16, 17 + +### What to build + +Shell hooks from the manifest's `before_apply` and `after_apply` arrays run at the right points in the apply flow. + +1. **`hooks/`** — Execute shell commands with working directory set to the dotfiles repo root. Stream stdout/stderr live, line-by-line via `BufReader`, prefixed with `| ` for visual separation. On non-zero exit, stop execution and return an error with the failing command and exit code. +2. **Wire into apply** — Run `before_apply` hooks before any links are created, `after_apply` hooks after all links are created (in `apply`, `init`, and `sync` flows). +3. **`--dry-run` support** — Print which hooks would run without executing them. + +### Acceptance criteria + +- [ ] `before_apply` hooks execute before link creation +- [ ] `after_apply` hooks execute after link creation +- [ ] Hook output streams live with `| ` prefix +- [ ] Hook failure (non-zero exit) stops execution and reports command + exit code +- [ ] Hook working directory is the dotfiles repo root +- [ ] `--dry-run` prints hook names without executing them +- [ ] Hooks still stream output even in `--quiet` mode (they are user scripts, not anvil UI) +- [ ] Unit tests for hook execution, failure reporting, working directory +- [ ] Integration test: apply with hooks verifies execution order + +--- + +## Phase 6: Copy mode, dry-run, and scripting hardening + +**User stories**: 18, 19, 20, 21, 22 + +### What to build + +Polish the apply flow for edge cases and automation use. + +1. **Copy mode** — When a link entry has `copy: true`, copy the file instead of creating a symlink. Conflict resolution applies the same way. +2. **`--dry-run` threading** — Ensure dry-run is respected throughout: linker reports what it would do without filesystem changes, hooks print but don't execute (Phase 5), init/sync still clone/pull but apply is dry. +3. **Non-TTY auto-quiet** — Already partially implemented in `main.rs`. Verify prompts return defaults and spinners are suppressed when piped. +4. **`--yes` completeness** — Verify all prompts in init (URL, dir) and apply (conflicts) short-circuit correctly. + +### Acceptance criteria + +- [ ] `copy: true` link entries produce file copies instead of symlinks +- [ ] Copied files are byte-identical to source +- [ ] `--dry-run` on apply shows what would happen without filesystem changes +- [ ] `--dry-run` on init/sync still clones/pulls but apply portion is dry +- [ ] `--quiet` suppresses all non-error output (spinners, summaries, success messages) +- [ ] Non-TTY detection auto-sets quiet behavior +- [ ] `--yes` skips all prompts with sensible defaults +- [ ] Unit tests for copy mode, dry-run link planning +- [ ] Integration test: piped output produces no interactive elements + +--- + +## Phase 7: Signal handling and error hardening + +**User stories**: 23, 24 + +### What to build + +Make anvil resilient to interruption and clear about failures. + +1. **Ctrl-C handling** — Register a signal handler that sets an atomic flag. Check the flag between link operations; abort cleanly with a partial summary. During clone, remove the partially cloned directory on interrupt. +2. **Error messages** — Ensure clear, actionable messages for: git not installed, invalid repo URL, clone directory already exists, `anvil.toml` missing from repo, permission errors on dest directories, broken symlinks. + +### Acceptance criteria + +- [ ] Ctrl-C during apply aborts cleanly with partial summary (no half-created state) +- [ ] Ctrl-C during clone removes the partially cloned directory +- [ ] Missing git binary error tells the user to install git +- [ ] Invalid repo URL error includes the URL that failed +- [ ] Clone into existing directory error suggests a different path or `anvil sync` +- [ ] Missing `anvil.toml` error tells the user what file is expected and where +- [ ] Permission errors on dest directory are reported with the path +- [ ] Unit tests for signal flag checking, error message content + +--- + +## Phase 8: Integration test hardening + +**User stories**: 27 + +### What to build + +A comprehensive integration test suite that exercises all flows end-to-end and covers edge cases not tested in earlier phases. Earlier phases include happy-path integration tests; this phase adds cross-cutting and adversarial scenarios. + +1. **Init edge cases** — Init into existing directory, init without URL in `--yes` mode, init with repo missing `anvil.toml`. +2. **Apply edge cases** — Broken symlinks (target deleted), empty profiles (zero links), re-apply idempotency (run twice, same result). +3. **Sync edge cases** — Sync with no local config and no `~/.dotfiles`. +4. **Cross-cutting** — Non-TTY mode (piped output, no prompts), `--dry-run` produces no filesystem side effects, `--quiet` suppresses output. +5. **Error paths** — Missing git binary, permission errors on read-only directories. + +### Acceptance criteria + +- [ ] Init into existing directory produces appropriate error +- [ ] Init without URL in `--yes` mode errors cleanly +- [ ] Init with repo missing `anvil.toml` errors clearly +- [ ] Apply with broken symlinks handles gracefully +- [ ] Apply with empty profile succeeds with no-op summary +- [ ] Apply is idempotent (running twice produces same filesystem state) +- [ ] Sync with no config and no `~/.dotfiles` produces helpful error +- [ ] Non-TTY mode suppresses prompts and spinners +- [ ] `--dry-run` across all commands produces no filesystem changes +- [ ] Missing git binary produces clear error message +- [ ] All tests use `tempfile::tempdir()` — no real home directory touched diff --git a/prd/001-phase1-interactive-mvp.md b/prd/001-phase1-interactive-mvp.md new file mode 100644 index 0000000..47b16db --- /dev/null +++ b/prd/001-phase1-interactive-mvp.md @@ -0,0 +1,225 @@ +# PRD-001: Phase 1 Interactive MVP — Complete Implementation + +## Problem Statement + +anvil is a dotfiles manager CLI that currently has its foundation in place (error types, config parsing, CLI skeleton, UI module) but cannot actually do anything yet. The six command stubs (`init`, `sync`, `apply`, `add`, `status`, `doctor`) are empty shells. There is no git integration, no symlink management, no hook execution, and no local state persistence. A user who installs anvil today gets a binary that parses flags and exits silently. + +The core value proposition — clone a dotfiles repo, link config files to their correct locations, and keep them in sync — requires implementing the remaining Phase 1 steps (4–9) from the roadmap. + +## Solution + +Implement the complete Phase 1 MVP: the `init`, `sync`, and `apply` commands with full interactive flows. This means building four new modules (git backend, linker, hooks, local config), wiring them into the three command stubs, and hardening the result with comprehensive tests. + +After this work, a user can: +1. Run `anvil init ` to clone their dotfiles repo and apply links +2. Run `anvil sync` to pull changes and re-apply +3. Run `anvil apply` to re-link manually at any time +4. Get interactive conflict resolution when files already exist +5. Have hooks run automatically before/after applying + +## User Stories + +1. As a new user, I want to run `anvil init https://github.com/me/dotfiles` so that my dotfiles repo is cloned and all my config files are symlinked into place on a fresh machine. +2. As a new user, I want anvil to prompt me for a repo URL if I run `anvil init` without arguments, so that I don't have to memorize the CLI syntax. +3. As a new user, I want anvil to prompt me for a clone directory with a sensible default (`~/.dotfiles`), so that I can customize it or just press Enter. +4. As a new user, I want to see a spinner while the repo is cloning, so that I know something is happening. +5. As a new user, I want anvil to show a clear summary after init (e.g., "4 linked, 1 skipped"), so that I know what happened. +6. As a new user, I want anvil to write `~/.config/anvil.toml` after a successful init, so that subsequent commands know where my dotfiles live and which profiles are active. +7. As a returning user, I want to run `anvil sync` from anywhere and have it pull the latest changes and re-apply links, so that I stay up to date without manually navigating to my dotfiles directory. +8. As a returning user, I want `anvil sync` to show me what changed (e.g., "3 files changed" or "Already up to date"), so that I have visibility into updates. +9. As a user, I want to run `anvil apply` to re-apply all links without pulling, so that I can fix broken links or re-apply after manual edits to `anvil.toml`. +10. As a user, I want anvil to skip symlinks that already point to the correct target, so that re-running apply is fast and idempotent. +11. As a user, I want anvil to prompt me when a destination file already exists and is not managed by anvil, offering Overwrite / Skip / Show diff, so that I don't lose local changes. +12. As a user choosing "Show diff", I want to see an inline diff between my existing file and the repo version, so that I can make an informed decision about overwriting. +13. As a user choosing "Overwrite", I want anvil to back up my existing file to `.bak` before replacing it, so that I have a safety net. +14. As a user, I want `before_apply` hooks from `anvil.toml` to run before any links are created, so that prerequisites (e.g., creating directories, installing packages) are handled first. +15. As a user, I want `after_apply` hooks to run after all links are created, so that post-setup tasks (e.g., reloading shell config) happen automatically. +16. As a user, I want to see hook output streamed live with a `│ ` prefix, so that I can distinguish hook output from anvil's own messages. +17. As a user, I want hook failures to be reported clearly with the exit code and command that failed, so that I can debug issues. +18. As a user with `copy: true` on a link, I want anvil to copy the file instead of symlinking, so that I can handle files that don't work as symlinks (e.g., some apps rewrite the file in place). +19. As a scripter, I want `--yes` to accept all defaults without prompting (skip conflicts, accept default clone dir), so that I can use anvil in CI or automation. +20. As a scripter, I want `--quiet` to suppress all non-error output, so that anvil works cleanly in pipelines. +21. As a scripter, I want `--dry-run` to show what would happen without making changes, so that I can preview the effect of apply/init/sync safely. +22. As a user piping anvil output, I want anvil to auto-detect non-TTY and behave as if `--quiet` was passed, so that spinners and prompts don't corrupt my pipeline. +23. As a user, I want Ctrl-C to cleanly abort any operation without leaving partial state (half-created symlinks, incomplete clones), so that I can safely cancel at any point. +24. As a user, I want clear error messages when git is not installed, the repo URL is invalid, the clone directory already exists, or `anvil.toml` is missing from the repo. +25. As a user, I want `anvil apply --profile base` to apply only links from a specific profile, so that I can selectively apply parts of my config. +26. As a user on a machine where `~/.config/anvil.toml` doesn't exist and no `--dir` is provided, I want anvil to check `~/.dotfiles` as a default before erroring, so that the common case works without configuration. +27. As a contributor, I want comprehensive integration tests covering init/apply/sync flows and edge cases, so that regressions are caught automatically. + +## Implementation Decisions + +### New Modules + +**git/ — Git Backend** +- `GitBackend` trait with three methods: `clone_repo(url, dest)`, `pull(repo_dir)`, `status(repo_dir)` +- `ShellGit` implementation that shells out to the `git` binary +- Clone always uses `--depth=1` (shallow clone) for fast setup +- Pull does NOT pass `--rebase` — respects the user's own git config for pull strategy +- `PullResult` struct contains `was_updated: bool` and `summary: String` +- Error variants already exist in `AnvilError`: `GitNotFound`, `GitCloneFailed`, `GitPullFailed` + +**linker/ — Symlink & Copy Management** +- `ResolvedLink` struct: absolute `src` (inside dotfiles repo), absolute `dest` (on the system), `copy: bool` +- Path expansion always via `dirs::home_dir()`, never string replacement on `~` +- Symlink creation: check if dest is already a correct symlink before touching it (idempotent) +- Copy mode triggered only by explicit `copy: true` in the manifest link entry +- Conflict detection: if dest exists and is not our symlink, return a conflict status for the UI layer to handle +- Parent directory creation: `create_dir_all` for dest parent path before linking +- `--dry-run` support: return what would happen without making filesystem changes + +**hooks/ — Hook Execution** +- Executes shell commands listed in `before_apply` and `after_apply` arrays from the manifest +- Working directory is the dotfiles repo root +- stdout/stderr are streamed live, prefixed with `│ ` for visual separation +- Hook failure (non-zero exit) stops execution and reports the failing command + exit code +- `--dry-run` prints which hooks would run without executing them + +**config/local.rs — Local State** +- Manages `~/.config/anvil.toml` (not to be confused with the manifest `anvil.toml` inside the dotfiles repo) +- Stores `clone_dir` (path to dotfiles repo) and `profiles` (list of active profile names) +- Written after successful `init`, read by `sync` and `apply` to discover the repo +- Uses `toml_edit` for writes to stay consistent with the project's write strategy +- Uses `toml`/`serde` for reads + +**Repo discovery order:** read `~/.config/anvil.toml` → fall back to `~/.dotfiles` → error with "run `anvil init`" + +### Modified Modules + +**cli/apply.rs — Apply Command** +1. Discover dotfiles repo (local config → `~/.dotfiles` fallback) +2. Parse `anvil.toml` manifest from repo +3. Resolve profile to use (from `--profile` flag, local config, or manifest default) +4. Run `before_apply` hooks +5. For each link: expand paths → check dest → create symlink/copy or handle conflict +6. Run `after_apply` hooks +7. Print summary via `ApplySummary` + +**cli/init.rs — Init Command** +1. Print ASCII header +2. Prompt for repo URL if not provided as arg +3. Prompt for clone directory (default: `~/.dotfiles`) +4. Clone repo via `GitBackend` (shallow) +5. Parse `anvil.toml` from cloned repo +6. If `--profile` not specified, use manifest's `default_profile` or prompt +7. Run the full apply flow +8. Write `~/.config/anvil.toml` with clone_dir + selected profiles + +**cli/sync.rs — Sync Command** +1. Read `~/.config/anvil.toml` to find repo dir (fall back to `~/.dotfiles`) +2. Pull via `GitBackend` +3. Show pull result (spinner → success/fail message) +4. Re-run apply flow + +### Conflict Resolution Flow +- When dest exists and is not managed by anvil: prompt Overwrite / Skip / Show diff +- "Show diff": use the `similar` crate for inline diff rendering (built-in, no external `diff` binary dependency) +- "Overwrite": backup existing file to `.bak`, then create symlink/copy +- "Skip": log as skipped, continue to next link +- With `--yes`: default to Skip (safe, non-destructive) + +### Ctrl-C / Signal Handling +- Register a handler that sets an atomic flag +- Check the flag between link operations — abort cleanly with partial summary +- Clone operation: if interrupted, remove the partially cloned directory + +### Non-TTY Detection +- Already implemented in `main.rs`: auto-sets `quiet = true` when `!Term::stdout().is_term()` +- Prompts in `UiContext` already return defaults when `yes = true` +- Ensure hooks still stream output even in quiet mode (they represent user scripts, not anvil UI) + +## Testing Decisions + +### Testing philosophy +- Test external behavior, not implementation details +- Integration tests exercise the CLI binary end-to-end via `assert_cmd` +- Unit tests verify module contracts in isolation +- All tests use `tempfile::tempdir()` — never touch the real home directory or filesystem +- Mock the git backend for tests that don't need real clones (use the `GitBackend` trait) +- Prior art: 14 existing unit tests in `config/manifest.rs` establish the pattern + +### Modules with unit tests + +**git/ (git backend)** +- `ShellGit` constructs correct command arguments +- `PullResult` parsing from git output +- Error mapping for missing git binary, failed clone, failed pull + +**linker/ (symlink & copy)** +- Creates symlink correctly in a temp directory +- Detects existing correct symlink (idempotent — no-op) +- Detects conflict (dest exists, not our symlink) +- Copy mode creates a file copy instead of symlink +- Parent directory creation +- Tilde expansion resolves correctly +- `--dry-run` returns plan without filesystem side effects + +**hooks/ (hook execution)** +- Successful hook execution returns ok +- Failed hook (non-zero exit) returns error with exit code +- Hook working directory is set to dotfiles repo +- Hook stdout/stderr captured and prefixed +- `--dry-run` does not execute hooks + +**config/local.rs (local state)** +- Write and read round-trip for `~/.config/anvil.toml` +- Missing config file returns None/default +- Tilde expansion in clone_dir + +**cli/ commands (command logic)** +- `apply`: links created for a simple manifest, conflicts detected, hooks called in order, summary counts correct +- `init`: clone triggered, manifest parsed, local config written, apply called +- `sync`: local config read, pull triggered, apply called +- All commands respect `--dry-run`, `--yes`, `--quiet` flags + +### Integration tests + +**init flow** +- `anvil init ` with a local bare git repo as fixture → clones, applies links, writes local config +- `anvil init` without URL in `--yes` mode → errors cleanly (no URL to default to) +- Init into existing directory → appropriate error + +**apply flow** +- Apply with valid manifest → all symlinks created +- Apply with conflicts → prompts shown (or skipped with `--yes`) +- Apply with `copy: true` links → files copied +- Apply with hooks → hooks executed in correct order +- Apply idempotent — running twice produces same result +- Apply with `--dry-run` → no filesystem changes + +**sync flow** +- Sync pulls and re-applies +- Sync with no local config and no `~/.dotfiles` → helpful error + +**Edge cases** +- Broken symlinks (target deleted after linking) +- Permission errors (read-only dest directory) +- Non-TTY mode (piped output, no prompts) +- Ctrl-C during apply (partial state cleanup) +- Missing git binary → clear error message +- Missing `anvil.toml` in cloned repo → clear error +- Empty profiles (zero links) → no-op with success message + +## Out of Scope + +- **Profile inheritance (`extends`)** — deferred to Phase 2. MVP profiles are flat (no resolution chain). +- **Multi-select profile picker** — deferred to Phase 2. MVP uses `--profile` flag or manifest default. +- **Machine detection via hostname** — deferred to Phase 2. No automatic profile selection by machine. +- **`add` command** — deferred to Phase 2. Requires `toml_edit` manifest writes. +- **`status` command** — deferred to Phase 2. Requires iterating links and checking state. +- **`doctor` command** — deferred to Phase 3. +- **`--dry-run` on init/sync** — partially supported (apply respects it), but clone/pull still execute. Full dry-run for all write operations is Phase 3. +- **`libgit2` backend** — Phase 3. MVP shells out to git. +- **Shell completions** — Phase 3. +- **Secrets / encryption** — Phase 4. +- **Cross-platform release binaries** — Phase 3. +- **crates.io publishing** — Phase 3. + +## Further Notes + +- The `similar` crate will be added as a new dependency for built-in diff rendering in conflict resolution. This avoids requiring `diff` to be installed on the system. +- The `GitBackend` trait enables swapping in `libgit2` later (Phase 3) without modifying any command code. +- Local config at `~/.config/anvil.toml` is intentionally simple and separate from the dotfiles manifest. It's anvil's own state, not the user's config. +- Hook output streaming with `│ ` prefix should use `BufReader` line-by-line reading on the child process's stdout/stderr to avoid buffering the entire output. +- The `ApplySummary` struct already exists in `ui/summary.rs` — command implementations will use it directly. +- Estimated scope: ~930 new lines of Rust across the new modules, plus ~500-700 lines of tests. diff --git a/src/cli/apply.rs b/src/cli/apply.rs index a7bc409..5f1e1e7 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -1,5 +1,196 @@ +use std::path::Path; + +use console::style; + +use crate::config::manifest::Manifest; +use crate::config::{LocalConfig, discover_repo}; +use crate::error::{AnvilError, Result}; +use crate::linker::{LinkResult, ResolvedLink, process_link}; use crate::ui::UiContext; +use crate::ui::prompt::ConflictAction; +use crate::ui::summary::ApplySummary; +use crate::ui::theme::{INDENT, SYMBOL_ARROW}; + +pub fn run(profiles: Vec, ctx: &UiContext) -> Result<()> { + let repo_dir = discover_repo()?; + let manifest_path = repo_dir.join("anvil.toml"); + let manifest = Manifest::from_path(&manifest_path)?; + + let profile_names = resolve_profiles(&profiles, &manifest)?; + + apply_profiles(&repo_dir, &manifest, &profile_names, ctx) +} + +/// Runs the apply flow for a given repo directory and manifest. +/// Used by `init` and `sync` as well. +pub fn apply_profiles( + repo_dir: &Path, + manifest: &Manifest, + profile_names: &[String], + ctx: &UiContext, +) -> Result<()> { + let mut summary = ApplySummary::new(); + + for profile_name in profile_names { + let profile = manifest.get_profile(profile_name)?; + + if profile.links.is_empty() { + continue; + } + + // Resolve all links to absolute paths + let links: Vec = profile + .links + .iter() + .map(|l| ResolvedLink::resolve(repo_dir, &l.src, &l.dest, l.copy.unwrap_or(false))) + .collect::>>()?; + + for link in &links { + let result = process_link(link, ctx.dry_run); + + match result { + LinkResult::Linked => { + summary.linked += 1; + if !ctx.quiet { + println!( + "{INDENT}{} {} {} {}", + style("linked").green(), + link.dest.display(), + SYMBOL_ARROW, + link.src.display() + ); + } + } + LinkResult::AlreadyCorrect => { + summary.skipped += 1; + if !ctx.quiet { + println!( + "{INDENT}{} {} (already correct)", + style("skip").dim(), + link.dest.display() + ); + } + } + LinkResult::Conflict => { + handle_conflict(link, &mut summary, ctx)?; + } + LinkResult::Failed(msg) => { + summary.failed += 1; + ctx.warn(&msg); + } + } + } + } -pub fn run(_profiles: Vec, _ctx: &UiContext) -> crate::error::Result<()> { + summary.print(ctx); Ok(()) } + +/// Handles a conflict: file exists at dest and is not managed by anvil. +fn handle_conflict(link: &ResolvedLink, summary: &mut ApplySummary, ctx: &UiContext) -> Result<()> { + let action = ctx.conflict_resolution(&link.dest)?; + + match action { + ConflictAction::Skip => { + summary.skipped += 1; + if !ctx.quiet { + println!( + "{INDENT}{} {} (skipped, file exists)", + style("skip").yellow(), + link.dest.display() + ); + } + } + ConflictAction::Overwrite => { + if !ctx.dry_run { + // Backup existing file + let backup = link.dest.with_extension( + link.dest + .extension() + .map(|e| format!("{}.bak", e.to_string_lossy())) + .unwrap_or_else(|| "bak".to_string()), + ); + std::fs::rename(&link.dest, &backup).map_err(|e| AnvilError::SymlinkFailed { + path: link.dest.clone(), + source: e, + })?; + + let result = link.apply(); + match result { + LinkResult::Linked => { + summary.linked += 1; + if !ctx.quiet { + println!( + "{INDENT}{} {} {} {} (overwrote, backup at {})", + style("linked").green(), + link.dest.display(), + SYMBOL_ARROW, + link.src.display(), + backup.display() + ); + } + } + LinkResult::Failed(msg) => { + summary.failed += 1; + ctx.warn(&msg); + } + _ => {} + } + } else { + summary.linked += 1; + if !ctx.quiet { + println!( + "{INDENT}{} {} (would overwrite)", + style("linked").cyan(), + link.dest.display() + ); + } + } + } + ConflictAction::ShowDiff => { + // Phase 4 will add actual diff rendering. + // For now, skip and note it. + summary.skipped += 1; + if !ctx.quiet { + println!( + "{INDENT}{} {} (diff not yet implemented, skipping)", + style("skip").yellow(), + link.dest.display() + ); + } + } + } + + Ok(()) +} + +/// Determines which profiles to apply. +/// +/// Priority: `--profile` flags > local config > manifest default. +pub fn resolve_profiles(explicit: &[String], manifest: &Manifest) -> Result> { + if !explicit.is_empty() { + // Validate all requested profiles exist + for name in explicit { + manifest.get_profile(name)?; + } + return Ok(explicit.to_vec()); + } + + // Try local config + if let Some(config) = LocalConfig::load()? + && !config.profiles.is_empty() + { + return Ok(config.profiles); + } + + // Fall back to manifest default + if let Some(default) = manifest.default_profile_name() { + manifest.get_profile(default)?; + return Ok(vec![default.to_string()]); + } + + Err(AnvilError::Other( + "no profile specified. Use --profile or set default_profile in anvil.toml" + .to_string(), + )) +} diff --git a/src/config/local.rs b/src/config/local.rs new file mode 100644 index 0000000..eccb107 --- /dev/null +++ b/src/config/local.rs @@ -0,0 +1,207 @@ +use std::path::{Path, PathBuf}; + +use serde::Deserialize; + +use crate::error::{AnvilError, Result}; + +/// Local anvil state stored at `~/.config/anvil.toml`. +/// +/// This is anvil's own config — not the manifest inside the dotfiles repo. +/// Written by `init`, read by `sync` and `apply` to discover the repo. +#[derive(Debug, Deserialize)] +pub struct LocalConfig { + pub clone_dir: String, + #[serde(default)] + pub profiles: Vec, +} + +impl LocalConfig { + /// Returns the platform-specific path for the local config file. + /// Uses `dirs::config_dir()` (e.g. `~/.config` on Linux). + pub fn path() -> Result { + let config_dir = dirs::config_dir().ok_or(AnvilError::HomeDirNotFound)?; + Ok(config_dir.join("anvil.toml")) + } + + /// Reads the local config from disk. Returns `None` if the file doesn't exist. + pub fn load() -> Result> { + let path = Self::path()?; + Self::load_from(&path) + } + + /// Reads from a specific path. Returns `None` if the file doesn't exist. + pub fn load_from(path: &Path) -> Result> { + if !path.exists() { + return Ok(None); + } + let contents = std::fs::read_to_string(path).map_err(|e| AnvilError::ConfigRead { + path: path.to_path_buf(), + source: e, + })?; + let config: Self = + toml::from_str(&contents).map_err(|e| AnvilError::ConfigParse(e.to_string()))?; + Ok(Some(config)) + } + + /// Resolves `clone_dir` to an absolute path with tilde expansion. + pub fn clone_dir_expanded(&self) -> Result { + expand_tilde(&self.clone_dir) + } + + /// Writes a local config to disk at the standard path. + pub fn save(clone_dir: &str, profiles: &[String]) -> Result<()> { + let path = Self::path()?; + Self::save_to(&path, clone_dir, profiles) + } + + /// Writes a local config to a specific path using `toml_edit` for clean output. + pub fn save_to(path: &Path, clone_dir: &str, profiles: &[String]) -> Result<()> { + use toml_edit::{Array, DocumentMut, value}; + + let mut doc = DocumentMut::new(); + doc["clone_dir"] = value(clone_dir); + + let mut arr = Array::new(); + for p in profiles { + arr.push(p.as_str()); + } + doc["profiles"] = value(arr); + + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| AnvilError::ConfigRead { + path: parent.to_path_buf(), + source: e, + })?; + } + + std::fs::write(path, doc.to_string()).map_err(|e| AnvilError::ConfigRead { + path: path.to_path_buf(), + source: e, + })?; + + Ok(()) + } +} + +/// Expands a leading `~/` to the user's home directory. +pub fn expand_tilde(path: &str) -> Result { + if let Some(rest) = path.strip_prefix("~/") { + let home = dirs::home_dir().ok_or(AnvilError::HomeDirNotFound)?; + Ok(home.join(rest)) + } else { + Ok(PathBuf::from(path)) + } +} + +/// Discovers the dotfiles repo directory. +/// +/// Order: local config `clone_dir` -> `~/.dotfiles` fallback -> error. +pub fn discover_repo() -> Result { + discover_repo_with(LocalConfig::load()?) +} + +/// Discovers repo from an already-loaded optional config. +pub fn discover_repo_with(config: Option) -> Result { + if let Some(cfg) = config { + let dir = cfg.clone_dir_expanded()?; + if dir.is_dir() { + return Ok(dir); + } + } + + // Fallback: ~/.dotfiles + let home = dirs::home_dir().ok_or(AnvilError::HomeDirNotFound)?; + let fallback = home.join(".dotfiles"); + if fallback.is_dir() { + return Ok(fallback); + } + + Err(AnvilError::Other( + "no dotfiles repo found. Run `anvil init ` to get started.".to_string(), + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_save_and_load_roundtrip() { + let dir = tempdir().unwrap(); + let path = dir.path().join("anvil.toml"); + + LocalConfig::save_to(&path, "~/.dotfiles", &["base".into(), "work".into()]).unwrap(); + + let config = LocalConfig::load_from(&path).unwrap().unwrap(); + assert_eq!(config.clone_dir, "~/.dotfiles"); + assert_eq!(config.profiles, vec!["base", "work"]); + } + + #[test] + fn test_load_missing_file_returns_none() { + let dir = tempdir().unwrap(); + let path = dir.path().join("nonexistent.toml"); + let config = LocalConfig::load_from(&path).unwrap(); + assert!(config.is_none()); + } + + #[test] + fn test_save_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let path = dir.path().join("nested").join("dir").join("anvil.toml"); + + LocalConfig::save_to(&path, "/opt/dots", &[]).unwrap(); + assert!(path.exists()); + } + + #[test] + fn test_expand_tilde() { + let expanded = expand_tilde("~/.dotfiles").unwrap(); + assert!(!expanded.to_string_lossy().contains('~')); + assert!(expanded.ends_with(".dotfiles")); + } + + #[test] + fn test_expand_absolute_path() { + let expanded = expand_tilde("/opt/dotfiles").unwrap(); + assert_eq!(expanded, PathBuf::from("/opt/dotfiles")); + } + + #[test] + fn test_discover_repo_from_config() { + let dir = tempdir().unwrap(); + let repo_dir = dir.path().join("my-dots"); + std::fs::create_dir(&repo_dir).unwrap(); + + let config = LocalConfig { + clone_dir: repo_dir.to_string_lossy().to_string(), + profiles: vec![], + }; + let result = discover_repo_with(Some(config)).unwrap(); + assert_eq!(result, repo_dir); + } + + #[test] + fn test_discover_repo_no_config_no_fallback() { + let result = discover_repo_with(None); + // This may succeed if ~/.dotfiles exists on the system, or fail. + // We test the error path specifically: + if result.is_err() { + let err = result.unwrap_err().to_string(); + assert!(err.contains("anvil init")); + } + } + + #[test] + fn test_save_empty_profiles() { + let dir = tempdir().unwrap(); + let path = dir.path().join("anvil.toml"); + + LocalConfig::save_to(&path, "~/.dotfiles", &[]).unwrap(); + + let config = LocalConfig::load_from(&path).unwrap().unwrap(); + assert_eq!(config.clone_dir, "~/.dotfiles"); + assert!(config.profiles.is_empty()); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 32378e2..0cb2e88 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,5 @@ +pub mod local; pub mod manifest; +pub use local::{LocalConfig, discover_repo, expand_tilde}; pub use manifest::{AnvilMeta, Hooks, Link, Manifest, Profile}; diff --git a/src/lib.rs b/src/lib.rs index 4c804ba..2197290 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,4 +1,5 @@ pub mod cli; pub mod config; pub mod error; +pub mod linker; pub mod ui; diff --git a/src/linker/mod.rs b/src/linker/mod.rs new file mode 100644 index 0000000..54944dd --- /dev/null +++ b/src/linker/mod.rs @@ -0,0 +1,299 @@ +use std::path::{Path, PathBuf}; + +use crate::config::expand_tilde; +use crate::error::Result; + +/// A fully resolved link with absolute paths, ready for filesystem operations. +#[derive(Debug, Clone)] +pub struct ResolvedLink { + /// Absolute path to the source file inside the dotfiles repo. + pub src: PathBuf, + /// Absolute path to the destination on the system. + pub dest: PathBuf, + /// If true, copy the file instead of creating a symlink. + pub copy: bool, +} + +/// The outcome of processing a single link. +#[derive(Debug, PartialEq)] +pub enum LinkResult { + /// Symlink/copy was created successfully. + Linked, + /// Destination already points to the correct source — no-op. + AlreadyCorrect, + /// Destination exists and is not managed by anvil. + Conflict, + /// An error occurred. + Failed(String), +} + +impl ResolvedLink { + /// Resolves a link entry from the manifest into absolute paths. + /// + /// `src` is relative to `repo_dir`, `dest` gets tilde expansion. + pub fn resolve(repo_dir: &Path, src: &str, dest: &str, copy: bool) -> Result { + let abs_src = repo_dir.join(src); + let abs_dest = expand_tilde(dest)?; + Ok(Self { + src: abs_src, + dest: abs_dest, + copy, + }) + } + + /// Checks the current state of this link on the filesystem. + pub fn check(&self) -> LinkResult { + if !self.dest.exists() && self.dest.symlink_metadata().is_err() { + // Dest doesn't exist at all — ready to link + return LinkResult::Linked; + } + + // Check if it's already a symlink pointing to the right place + if let Ok(meta) = self.dest.symlink_metadata() + && meta.is_symlink() + && let Ok(target) = std::fs::read_link(&self.dest) + && target == self.src + { + return LinkResult::AlreadyCorrect; + } + + // Something else exists at dest + LinkResult::Conflict + } + + /// Creates the symlink or copy. Returns the outcome. + /// + /// Assumes conflict resolution has already been handled if needed. + pub fn apply(&self) -> LinkResult { + // Create parent directories + if let Some(parent) = self.dest.parent() + && !parent.exists() + && let Err(e) = std::fs::create_dir_all(parent) + { + return LinkResult::Failed(format!( + "failed to create directory {}: {e}", + parent.display() + )); + } + + if self.copy { + self.apply_copy() + } else { + self.apply_symlink() + } + } + + fn apply_symlink(&self) -> LinkResult { + #[cfg(unix)] + { + if let Err(e) = std::os::unix::fs::symlink(&self.src, &self.dest) { + return LinkResult::Failed(format!( + "failed to symlink {} -> {}: {e}", + self.dest.display(), + self.src.display() + )); + } + } + + #[cfg(not(unix))] + { + return LinkResult::Failed("symlinks not supported on this platform".to_string()); + } + + LinkResult::Linked + } + + fn apply_copy(&self) -> LinkResult { + match std::fs::copy(&self.src, &self.dest) { + Ok(_) => LinkResult::Linked, + Err(e) => LinkResult::Failed(format!( + "failed to copy {} -> {}: {e}", + self.src.display(), + self.dest.display() + )), + } + } +} + +/// Processes a single link: check state, apply if needed. +/// +/// Returns `LinkResult` indicating what happened. Does NOT handle conflicts — +/// caller must check for `Conflict` and resolve before calling `apply`. +pub fn process_link(link: &ResolvedLink, dry_run: bool) -> LinkResult { + let state = link.check(); + + match state { + LinkResult::AlreadyCorrect => LinkResult::AlreadyCorrect, + LinkResult::Conflict => LinkResult::Conflict, + LinkResult::Linked => { + // Dest doesn't exist — create it + if dry_run { + LinkResult::Linked + } else { + link.apply() + } + } + LinkResult::Failed(msg) => LinkResult::Failed(msg), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use tempfile::tempdir; + + #[test] + fn test_resolve_link() { + let repo = PathBuf::from("/home/user/.dotfiles"); + let link = ResolvedLink::resolve(&repo, ".zshrc", "/home/user/.zshrc", false).unwrap(); + assert_eq!(link.src, PathBuf::from("/home/user/.dotfiles/.zshrc")); + assert_eq!(link.dest, PathBuf::from("/home/user/.zshrc")); + assert!(!link.copy); + } + + #[test] + fn test_resolve_link_with_tilde() { + let repo = PathBuf::from("/tmp/repo"); + let link = ResolvedLink::resolve(&repo, "config", "~/.config/app", false).unwrap(); + assert!(!link.dest.to_string_lossy().contains('~')); + assert!(link.dest.ends_with(".config/app")); + } + + #[test] + fn test_creates_symlink() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("link.txt"); + + std::fs::write(&src, "hello").unwrap(); + + let link = ResolvedLink { + src: src.clone(), + dest: dest.clone(), + copy: false, + }; + + let result = link.apply(); + assert_eq!(result, LinkResult::Linked); + assert!(dest.symlink_metadata().unwrap().is_symlink()); + assert_eq!(std::fs::read_link(&dest).unwrap(), src); + } + + #[test] + fn test_detects_correct_symlink() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("link.txt"); + + std::fs::write(&src, "hello").unwrap(); + #[cfg(unix)] + std::os::unix::fs::symlink(&src, &dest).unwrap(); + + let link = ResolvedLink { + src: src.clone(), + dest: dest.clone(), + copy: false, + }; + + assert_eq!(link.check(), LinkResult::AlreadyCorrect); + } + + #[test] + fn test_detects_conflict() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("existing.txt"); + + std::fs::write(&src, "repo version").unwrap(); + std::fs::write(&dest, "local version").unwrap(); + + let link = ResolvedLink { + src, + dest, + copy: false, + }; + + assert_eq!(link.check(), LinkResult::Conflict); + } + + #[test] + fn test_creates_parent_directories() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("nested").join("deep").join("link.txt"); + + std::fs::write(&src, "hello").unwrap(); + + let link = ResolvedLink { + src: src.clone(), + dest: dest.clone(), + copy: false, + }; + + let result = link.apply(); + assert_eq!(result, LinkResult::Linked); + assert!(dest.exists()); + } + + #[test] + fn test_copy_mode() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("copy.txt"); + + std::fs::write(&src, "hello world").unwrap(); + + let link = ResolvedLink { + src: src.clone(), + dest: dest.clone(), + copy: true, + }; + + let result = link.apply(); + assert_eq!(result, LinkResult::Linked); + assert!(!dest.symlink_metadata().unwrap().is_symlink()); + assert_eq!(std::fs::read_to_string(&dest).unwrap(), "hello world"); + } + + #[test] + fn test_dry_run_no_filesystem_changes() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("link.txt"); + + std::fs::write(&src, "hello").unwrap(); + + let link = ResolvedLink { + src, + dest: dest.clone(), + copy: false, + }; + + let result = process_link(&link, true); + assert_eq!(result, LinkResult::Linked); + assert!(!dest.exists()); + } + + #[test] + fn test_idempotent_apply() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("link.txt"); + + std::fs::write(&src, "hello").unwrap(); + + let link = ResolvedLink { + src: src.clone(), + dest: dest.clone(), + copy: false, + }; + + // First apply + let result1 = link.apply(); + assert_eq!(result1, LinkResult::Linked); + + // Second check should be AlreadyCorrect + let result2 = process_link(&link, false); + assert_eq!(result2, LinkResult::AlreadyCorrect); + } +} diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 20d5dfa..dc65def 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -82,10 +82,11 @@ impl UiContext { prompt::multi_select(msg, options) } - /// Asks how to handle a conflicting file. Under `--yes`, returns `Overwrite`. + /// Asks how to handle a conflicting file. Under `--yes`, returns `Skip` + /// (safe, non-destructive default). pub fn conflict_resolution(&self, path: &Path) -> Result { if self.yes { - return Ok(ConflictAction::Overwrite); + return Ok(ConflictAction::Skip); } prompt::conflict_resolution(path) } diff --git a/tests/apply_test.rs b/tests/apply_test.rs new file mode 100644 index 0000000..30d6450 --- /dev/null +++ b/tests/apply_test.rs @@ -0,0 +1,330 @@ +use std::fs; +use std::path::Path; + +use tempfile::tempdir; + +/// Creates a minimal dotfiles repo fixture with an anvil.toml manifest +/// and source files. Returns the repo directory path. +fn setup_repo(dir: &Path, manifest: &str, files: &[(&str, &str)]) { + fs::create_dir_all(dir).unwrap(); + fs::write(dir.join("anvil.toml"), manifest).unwrap(); + for (name, content) in files { + if let Some(parent) = Path::new(name).parent() { + fs::create_dir_all(dir.join(parent)).unwrap(); + } + fs::write(dir.join(name), content).unwrap(); + } +} + +#[test] +fn test_apply_creates_symlinks() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + let dest_dir = dir.path().join("home"); + fs::create_dir_all(&dest_dir).unwrap(); + + let dest_zshrc = dest_dir.join(".zshrc"); + let dest_gitconfig = dest_dir.join(".gitconfig"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, + {{ src = ".gitconfig", dest = "{}" }}, +] +"#, + dest_zshrc.display(), + dest_gitconfig.display() + ); + + setup_repo( + &repo, + &manifest, + &[(".zshrc", "# zshrc"), (".gitconfig", "[user]\nname = me")], + ); + + // Use the library to apply + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let ctx = anvil::ui::UiContext::new(true, true, false); + let profiles = vec!["base".to_string()]; + + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + + // Verify symlinks were created + assert!(dest_zshrc.exists()); + assert!(dest_gitconfig.exists()); + assert!(dest_zshrc.symlink_metadata().unwrap().is_symlink()); + assert_eq!(fs::read_link(&dest_zshrc).unwrap(), repo.join(".zshrc")); + assert_eq!(fs::read_to_string(&dest_zshrc).unwrap(), "# zshrc"); +} + +#[test] +fn test_apply_idempotent() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + let dest = dir.path().join("home").join(".zshrc"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, +] +"#, + dest.display() + ); + + setup_repo(&repo, &manifest, &[(".zshrc", "# zshrc")]); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let ctx = anvil::ui::UiContext::new(true, true, false); + let profiles = vec!["base".to_string()]; + + // Apply twice + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + + // Still a valid symlink + assert!(dest.symlink_metadata().unwrap().is_symlink()); + assert_eq!(fs::read_to_string(&dest).unwrap(), "# zshrc"); +} + +#[test] +fn test_apply_with_conflict_yes_skips() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + let dest = dir.path().join("home").join(".zshrc"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, +] +"#, + dest.display() + ); + + setup_repo(&repo, &manifest, &[(".zshrc", "repo version")]); + + // Create a conflicting file at dest + fs::create_dir_all(dest.parent().unwrap()).unwrap(); + fs::write(&dest, "local version").unwrap(); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + // --yes mode should skip conflicts (non-destructive) + let ctx = anvil::ui::UiContext::new(true, true, false); + let profiles = vec!["base".to_string()]; + + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + + // File should be unchanged (skipped) + assert!(!dest.symlink_metadata().unwrap().is_symlink()); + assert_eq!(fs::read_to_string(&dest).unwrap(), "local version"); +} + +#[test] +fn test_apply_dry_run_no_changes() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + let dest = dir.path().join("home").join(".zshrc"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, +] +"#, + dest.display() + ); + + setup_repo(&repo, &manifest, &[(".zshrc", "# zshrc")]); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let ctx = anvil::ui::UiContext::new(true, true, true); // dry_run = true + let profiles = vec!["base".to_string()]; + + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + + // Dest should NOT exist + assert!(!dest.exists()); +} + +#[test] +fn test_apply_creates_parent_dirs() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + let dest = dir + .path() + .join("home") + .join("deep") + .join("nested") + .join(".config"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = "config", dest = "{}" }}, +] +"#, + dest.display() + ); + + setup_repo(&repo, &manifest, &[("config", "contents")]); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let ctx = anvil::ui::UiContext::new(true, true, false); + let profiles = vec!["base".to_string()]; + + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + + assert!(dest.exists()); + assert!(dest.symlink_metadata().unwrap().is_symlink()); +} + +#[test] +fn test_apply_empty_profile_succeeds() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + + let manifest = r#" +[anvil] +version = "1" +default_profile = "empty" + +[profiles.empty] +links = [] +"#; + + setup_repo(&repo, manifest, &[]); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let ctx = anvil::ui::UiContext::new(true, true, false); + let profiles = vec!["empty".to_string()]; + + // Should succeed without errors + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); +} + +#[test] +fn test_apply_nonexistent_profile_errors() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + + let manifest = r#" +[anvil] +version = "1" + +[profiles.base] +links = [] +"#; + + setup_repo(&repo, manifest, &[]); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let result = + anvil::cli::apply::resolve_profiles(&["nonexistent".to_string()], &manifest_parsed); + assert!(result.is_err()); +} + +#[test] +fn test_resolve_profiles_uses_default() { + let manifest = anvil::config::Manifest::parse_toml( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [] +"#, + ) + .unwrap(); + + let profiles = anvil::cli::apply::resolve_profiles(&[], &manifest).unwrap(); + assert_eq!(profiles, vec!["base"]); +} + +#[test] +fn test_resolve_profiles_no_default_errors() { + let manifest = anvil::config::Manifest::parse_toml( + r#" +[anvil] +version = "1" + +[profiles.base] +links = [] +"#, + ) + .unwrap(); + + let result = anvil::cli::apply::resolve_profiles(&[], &manifest); + assert!(result.is_err()); +} + +#[test] +fn test_apply_specific_profile() { + let dir = tempdir().unwrap(); + let repo = dir.path().join("dotfiles"); + let dest_base = dir.path().join("home").join(".zshrc"); + let dest_work = dir.path().join("home").join(".work_config"); + + let manifest = format!( + r#" +[anvil] +version = "1" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, +] + +[profiles.work] +links = [ + {{ src = ".work_config", dest = "{}" }}, +] +"#, + dest_base.display(), + dest_work.display() + ); + + setup_repo( + &repo, + &manifest, + &[(".zshrc", "# zshrc"), (".work_config", "# work")], + ); + + let manifest_parsed = anvil::config::Manifest::from_path(&repo.join("anvil.toml")).unwrap(); + let ctx = anvil::ui::UiContext::new(true, true, false); + + // Apply only "work" profile + let profiles = vec!["work".to_string()]; + anvil::cli::apply::apply_profiles(&repo, &manifest_parsed, &profiles, &ctx).unwrap(); + + // work config should be linked, base should not + assert!(dest_work.exists()); + assert!(!dest_base.exists()); +} From 170bfbbdfaea8a52b0b8fd9334cba157cb02f393 Mon Sep 17 00:00:00 2001 From: piny4man Date: Sun, 5 Apr 2026 23:52:05 +0200 Subject: [PATCH 2/5] fix: rust patterns and code improvements --- ROADMAP.md | 5 +- plans/phase1-interactive-mvp.md | 3 + src/cli/apply.rs | 106 +++++++++++++++----------------- src/config/local.rs | 27 ++++++-- src/error.rs | 6 ++ src/linker/mod.rs | 62 ++++++++++++++++--- src/ui/mod.rs | 7 +++ 7 files changed, 144 insertions(+), 72 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 70729ed..90efce4 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -52,14 +52,15 @@ Steps 2–5 can proceed in parallel after Step 1. Step 6 unifies them into the f - Depends on: Step 1 - ~150 lines -- [ ] **Step 5: Linker** — Symlink creation/verification, copy-mode fallback, and path expansion via `dirs::home_dir()`. - - Delivers: `src/linker/mod.rs`, `src/linker/symlink.rs`, `src/linker/copy.rs`, `ResolvedLink` struct +- [x] **Step 5: Linker** — Symlink creation/verification, copy-mode fallback, and path expansion via `dirs::home_dir()`. + - Delivers: `src/linker/mod.rs` (`ResolvedLink` struct, symlink + copy in one module), `src/config/local.rs` (local state + repo discovery) - Depends on: Step 1 - ~250 lines - [ ] **Step 6: apply command** — The core user-facing flow: parse manifest, resolve links, run linker, handle conflicts with interactive prompts, print summary. - Delivers: full `src/cli/apply.rs`, hooks execution (`src/hooks/mod.rs`) - Depends on: Steps 2, 3, 4, 5 + - Status: apply command implemented (repo discovery, profile resolution, linking, conflict handling, summary). Hooks deferred to plan Phase 5. - ~200 lines - [ ] **Step 7: init command** — Prompt for repo URL, clone, parse manifest, run apply. diff --git a/plans/phase1-interactive-mvp.md b/plans/phase1-interactive-mvp.md index 57173d9..9857728 100644 --- a/plans/phase1-interactive-mvp.md +++ b/plans/phase1-interactive-mvp.md @@ -57,6 +57,9 @@ After this phase, a user who manually clones their dotfiles repo and writes a lo - [x] `ApplySummary` correctly counts linked/skipped/failed and prints via `UiContext` - [x] Unit tests for local config read/write, linker symlink creation, idempotency, and conflict detection - [x] `--quiet` suppresses non-error output from apply +- [x] ROADMAP.md updated (Step 5 checked off, Step 6 noted as partial) +- [x] `rust-pro` agent review +- [x] `documentation-expert` agent review --- diff --git a/src/cli/apply.rs b/src/cli/apply.rs index 5f1e1e7..3a3144d 100644 --- a/src/cli/apply.rs +++ b/src/cli/apply.rs @@ -1,4 +1,11 @@ -use std::path::Path; +//! The `apply` subcommand. +//! +//! Reads the dotfiles manifest, resolves which profiles to apply, and +//! creates symlinks (or copies) for every link entry. Handles conflict +//! resolution interactively and prints a coloured summary when finished. +//! The core [`apply_profiles`] function is also called by `init` and `sync`. + +use std::path::{Path, PathBuf}; use console::style; @@ -9,8 +16,10 @@ use crate::linker::{LinkResult, ResolvedLink, process_link}; use crate::ui::UiContext; use crate::ui::prompt::ConflictAction; use crate::ui::summary::ApplySummary; -use crate::ui::theme::{INDENT, SYMBOL_ARROW}; +use crate::ui::theme::SYMBOL_ARROW; +/// Entry point for `anvil apply`. Discovers the repo, loads the manifest, +/// resolves profiles, and applies all links. pub fn run(profiles: Vec, ctx: &UiContext) -> Result<()> { let repo_dir = discover_repo()?; let manifest_path = repo_dir.join("anvil.toml"); @@ -51,25 +60,21 @@ pub fn apply_profiles( match result { LinkResult::Linked => { summary.linked += 1; - if !ctx.quiet { - println!( - "{INDENT}{} {} {} {}", - style("linked").green(), - link.dest.display(), - SYMBOL_ARROW, - link.src.display() - ); - } + ctx.info(&format!( + "{} {} {} {}", + style("linked").green(), + link.dest.display(), + SYMBOL_ARROW, + link.src.display() + )); } LinkResult::AlreadyCorrect => { summary.skipped += 1; - if !ctx.quiet { - println!( - "{INDENT}{} {} (already correct)", - style("skip").dim(), - link.dest.display() - ); - } + ctx.info(&format!( + "{} {} (already correct)", + style("skip").dim(), + link.dest.display() + )); } LinkResult::Conflict => { handle_conflict(link, &mut summary, ctx)?; @@ -78,6 +83,7 @@ pub fn apply_profiles( summary.failed += 1; ctx.warn(&msg); } + LinkResult::Ready => unreachable!("process_link never returns Ready"), } } } @@ -93,23 +99,16 @@ fn handle_conflict(link: &ResolvedLink, summary: &mut ApplySummary, ctx: &UiCont match action { ConflictAction::Skip => { summary.skipped += 1; - if !ctx.quiet { - println!( - "{INDENT}{} {} (skipped, file exists)", - style("skip").yellow(), - link.dest.display() - ); - } + ctx.info(&format!( + "{} {} (skipped, file exists)", + style("skip").yellow(), + link.dest.display() + )); } ConflictAction::Overwrite => { if !ctx.dry_run { - // Backup existing file - let backup = link.dest.with_extension( - link.dest - .extension() - .map(|e| format!("{}.bak", e.to_string_lossy())) - .unwrap_or_else(|| "bak".to_string()), - ); + // Backup existing file by appending .bak to the full path + let backup = PathBuf::from(format!("{}.bak", link.dest.display())); std::fs::rename(&link.dest, &backup).map_err(|e| AnvilError::SymlinkFailed { path: link.dest.clone(), source: e, @@ -119,16 +118,14 @@ fn handle_conflict(link: &ResolvedLink, summary: &mut ApplySummary, ctx: &UiCont match result { LinkResult::Linked => { summary.linked += 1; - if !ctx.quiet { - println!( - "{INDENT}{} {} {} {} (overwrote, backup at {})", - style("linked").green(), - link.dest.display(), - SYMBOL_ARROW, - link.src.display(), - backup.display() - ); - } + ctx.info(&format!( + "{} {} {} {} (overwrote, backup at {})", + style("linked").green(), + link.dest.display(), + SYMBOL_ARROW, + link.src.display(), + backup.display() + )); } LinkResult::Failed(msg) => { summary.failed += 1; @@ -138,26 +135,21 @@ fn handle_conflict(link: &ResolvedLink, summary: &mut ApplySummary, ctx: &UiCont } } else { summary.linked += 1; - if !ctx.quiet { - println!( - "{INDENT}{} {} (would overwrite)", - style("linked").cyan(), - link.dest.display() - ); - } + ctx.info(&format!( + "{} {} (would overwrite)", + style("linked").cyan(), + link.dest.display() + )); } } ConflictAction::ShowDiff => { // Phase 4 will add actual diff rendering. - // For now, skip and note it. summary.skipped += 1; - if !ctx.quiet { - println!( - "{INDENT}{} {} (diff not yet implemented, skipping)", - style("skip").yellow(), - link.dest.display() - ); - } + ctx.info(&format!( + "{} {} (diff not yet implemented, skipping)", + style("skip").yellow(), + link.dest.display() + )); } } diff --git a/src/config/local.rs b/src/config/local.rs index eccb107..0eefcc5 100644 --- a/src/config/local.rs +++ b/src/config/local.rs @@ -1,3 +1,11 @@ +//! Local anvil state management. +//! +//! Handles the per-machine config file (`~/.config/anvil.toml`) that records +//! which dotfiles repo to use and which profiles are active. This is anvil's +//! own bookkeeping -- distinct from the `anvil.toml` manifest inside the +//! dotfiles repo itself. Also provides repo discovery logic used by most +//! subcommands. + use std::path::{Path, PathBuf}; use serde::Deserialize; @@ -10,7 +18,9 @@ use crate::error::{AnvilError, Result}; /// Written by `init`, read by `sync` and `apply` to discover the repo. #[derive(Debug, Deserialize)] pub struct LocalConfig { + /// Path to the cloned dotfiles repo (may contain `~/`). pub clone_dir: String, + /// Active profile names. Empty means "use manifest default". #[serde(default)] pub profiles: Vec, } @@ -68,13 +78,13 @@ impl LocalConfig { doc["profiles"] = value(arr); if let Some(parent) = path.parent() { - std::fs::create_dir_all(parent).map_err(|e| AnvilError::ConfigRead { + std::fs::create_dir_all(parent).map_err(|e| AnvilError::ConfigWrite { path: parent.to_path_buf(), source: e, })?; } - std::fs::write(path, doc.to_string()).map_err(|e| AnvilError::ConfigRead { + std::fs::write(path, doc.to_string()).map_err(|e| AnvilError::ConfigWrite { path: path.to_path_buf(), source: e, })?; @@ -83,9 +93,11 @@ impl LocalConfig { } } -/// Expands a leading `~/` to the user's home directory. +/// Expands a leading `~` or `~/` to the user's home directory. pub fn expand_tilde(path: &str) -> Result { - if let Some(rest) = path.strip_prefix("~/") { + if path == "~" { + dirs::home_dir().ok_or(AnvilError::HomeDirNotFound) + } else if let Some(rest) = path.strip_prefix("~/") { let home = dirs::home_dir().ok_or(AnvilError::HomeDirNotFound)?; Ok(home.join(rest)) } else { @@ -204,4 +216,11 @@ mod tests { assert_eq!(config.clone_dir, "~/.dotfiles"); assert!(config.profiles.is_empty()); } + + #[test] + fn test_expand_bare_tilde() { + let expanded = expand_tilde("~").unwrap(); + let home = dirs::home_dir().unwrap(); + assert_eq!(expanded, home); + } } diff --git a/src/error.rs b/src/error.rs index 89517bf..4887f78 100644 --- a/src/error.rs +++ b/src/error.rs @@ -10,6 +10,12 @@ pub enum AnvilError { source: std::io::Error, }, + #[error("failed to write {path}: {source}")] + ConfigWrite { + path: PathBuf, + source: std::io::Error, + }, + #[error("failed to parse config: {0}")] ConfigParse(String), diff --git a/src/linker/mod.rs b/src/linker/mod.rs index 54944dd..9a8c1e8 100644 --- a/src/linker/mod.rs +++ b/src/linker/mod.rs @@ -1,3 +1,10 @@ +//! Symlink and copy engine for anvil. +//! +//! Resolves manifest link entries into absolute paths, checks the filesystem +//! state of each destination, and creates symlinks (or copies in copy-mode). +//! All conflict detection lives here; conflict *resolution* is handled by the +//! caller (typically [`crate::cli::apply`]). + use std::path::{Path, PathBuf}; use crate::config::expand_tilde; @@ -14,9 +21,11 @@ pub struct ResolvedLink { pub copy: bool, } -/// The outcome of processing a single link. -#[derive(Debug, PartialEq)] +/// The outcome of checking or processing a single link. +#[derive(Debug, PartialEq, Eq)] pub enum LinkResult { + /// Destination absent — ready to be linked. + Ready, /// Symlink/copy was created successfully. Linked, /// Destination already points to the correct source — no-op. @@ -44,8 +53,7 @@ impl ResolvedLink { /// Checks the current state of this link on the filesystem. pub fn check(&self) -> LinkResult { if !self.dest.exists() && self.dest.symlink_metadata().is_err() { - // Dest doesn't exist at all — ready to link - return LinkResult::Linked; + return LinkResult::Ready; } // Check if it's already a symlink pointing to the right place @@ -65,6 +73,10 @@ impl ResolvedLink { /// /// Assumes conflict resolution has already been handled if needed. pub fn apply(&self) -> LinkResult { + if !self.src.exists() { + return LinkResult::Failed(format!("source file not found: {}", self.src.display())); + } + // Create parent directories if let Some(parent) = self.dest.parent() && !parent.exists() @@ -123,17 +135,16 @@ pub fn process_link(link: &ResolvedLink, dry_run: bool) -> LinkResult { let state = link.check(); match state { - LinkResult::AlreadyCorrect => LinkResult::AlreadyCorrect, - LinkResult::Conflict => LinkResult::Conflict, - LinkResult::Linked => { - // Dest doesn't exist — create it + LinkResult::Ready => { if dry_run { LinkResult::Linked } else { link.apply() } } - LinkResult::Failed(msg) => LinkResult::Failed(msg), + LinkResult::AlreadyCorrect => LinkResult::AlreadyCorrect, + LinkResult::Conflict => LinkResult::Conflict, + LinkResult::Linked | LinkResult::Failed(_) => state, } } @@ -296,4 +307,37 @@ mod tests { let result2 = process_link(&link, false); assert_eq!(result2, LinkResult::AlreadyCorrect); } + + #[test] + fn test_missing_source_file() { + let dir = tempdir().unwrap(); + let src = dir.path().join("nonexistent.txt"); + let dest = dir.path().join("link.txt"); + + let link = ResolvedLink { + src, + dest, + copy: false, + }; + + let result = link.apply(); + assert!(matches!(result, LinkResult::Failed(msg) if msg.contains("source file not found"))); + } + + #[test] + fn test_check_returns_ready_for_absent_dest() { + let dir = tempdir().unwrap(); + let src = dir.path().join("source.txt"); + let dest = dir.path().join("absent.txt"); + + std::fs::write(&src, "hello").unwrap(); + + let link = ResolvedLink { + src, + dest, + copy: false, + }; + + assert_eq!(link.check(), LinkResult::Ready); + } } diff --git a/src/ui/mod.rs b/src/ui/mod.rs index dc65def..5e4e334 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -101,6 +101,13 @@ impl UiContext { Some(spinner::start(msg)) } + /// Prints an informational message to stdout. Suppressed by `--quiet`. + pub fn info(&self, msg: &str) { + if !self.quiet { + println!("{}{msg}", theme::INDENT); + } + } + /// Prints a green success message. Suppressed by `--quiet`. pub fn success(&self, msg: &str) { if !self.quiet { From 4fab6ffaad91d35986149dd9bfc8728f6b751513 Mon Sep 17 00:00:00 2001 From: piny4man Date: Mon, 6 Apr 2026 11:08:26 +0200 Subject: [PATCH 3/5] fix: unwrapp error after is_error --- src/config/local.rs | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/src/config/local.rs b/src/config/local.rs index 0eefcc5..81acb52 100644 --- a/src/config/local.rs +++ b/src/config/local.rs @@ -199,9 +199,8 @@ mod tests { let result = discover_repo_with(None); // This may succeed if ~/.dotfiles exists on the system, or fail. // We test the error path specifically: - if result.is_err() { - let err = result.unwrap_err().to_string(); - assert!(err.contains("anvil init")); + if let Err(e) = result { + assert!(e.to_string().contains("anvil init")); } } From 503cde95a4694cc88045612ced553346c6e486d4 Mon Sep 17 00:00:00 2001 From: piny4man Date: Mon, 6 Apr 2026 22:30:07 +0200 Subject: [PATCH 4/5] feat(interaction): clone repo main logic --- ROADMAP.md | 6 +- plans/phase1-interactive-mvp.md | 24 +-- src/cli/init.rs | 111 ++++++++++++- src/git/mod.rs | 189 ++++++++++++++++++++++ src/lib.rs | 1 + tests/init_test.rs | 278 ++++++++++++++++++++++++++++++++ 6 files changed, 589 insertions(+), 20 deletions(-) create mode 100644 src/git/mod.rs create mode 100644 tests/init_test.rs diff --git a/ROADMAP.md b/ROADMAP.md index 90efce4..956b7d9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -47,8 +47,8 @@ Steps 2–5 can proceed in parallel after Step 1. Step 6 unifies them into the f - Depends on: Step 1 - ~300 lines -- [ ] **Step 4: Git Backend** — `GitBackend` trait abstracting git operations, with a `ShellGit` implementation that shells out to the git binary. - - Delivers: `src/git/backend.rs` (trait + PullResult), `src/git/shell.rs` (ShellGit) +- [x] **Step 4: Git Backend** — `GitBackend` trait abstracting git operations, with a `ShellGit` implementation that shells out to the git binary. + - Delivers: `src/git/mod.rs` (trait + ShellGit impl with `clone_repo`) - Depends on: Step 1 - ~150 lines @@ -63,7 +63,7 @@ Steps 2–5 can proceed in parallel after Step 1. Step 6 unifies them into the f - Status: apply command implemented (repo discovery, profile resolution, linking, conflict handling, summary). Hooks deferred to plan Phase 5. - ~200 lines -- [ ] **Step 7: init command** — Prompt for repo URL, clone, parse manifest, run apply. +- [x] **Step 7: init command** — Prompt for repo URL, clone, parse manifest, run apply. - Delivers: full `src/cli/init.rs` - Depends on: Step 6 - ~150 lines diff --git a/plans/phase1-interactive-mvp.md b/plans/phase1-interactive-mvp.md index 9857728..b524dc5 100644 --- a/plans/phase1-interactive-mvp.md +++ b/plans/phase1-interactive-mvp.md @@ -78,18 +78,18 @@ After this phase, a new user can run `anvil init https://github.com/me/dotfiles` ### Acceptance criteria -- [ ] `GitBackend` trait defined with `clone_repo` method -- [ ] `ShellGit` constructs correct `git clone --depth=1` command -- [ ] Missing git binary produces `AnvilError::GitNotFound` with a clear message -- [ ] Failed clone produces `AnvilError::GitCloneFailed` with the URL -- [ ] `anvil init ` clones repo, applies links, writes local config -- [ ] `anvil init` without URL prompts interactively; errors in `--yes` mode (no URL to default to) -- [ ] Clone directory prompt defaults to `~/.dotfiles` -- [ ] Spinner shown during clone, replaced by success/failure message -- [ ] Missing `anvil.toml` in cloned repo produces `AnvilError::ManifestNotFound` -- [ ] After successful init, `~/.config/anvil.toml` exists with correct `clone_dir` and `profiles` -- [ ] Unit tests for git command construction, error mapping -- [ ] Integration test: `anvil init` with a local bare git repo fixture +- [x] `GitBackend` trait defined with `clone_repo` method +- [x] `ShellGit` constructs correct `git clone --depth=1` command +- [x] Missing git binary produces `AnvilError::GitNotFound` with a clear message +- [x] Failed clone produces `AnvilError::GitCloneFailed` with the URL +- [x] `anvil init ` clones repo, applies links, writes local config +- [x] `anvil init` without URL prompts interactively; errors in `--yes` mode (no URL to default to) +- [x] Clone directory prompt defaults to `~/.dotfiles` +- [x] Spinner shown during clone, replaced by success/failure message +- [x] Missing `anvil.toml` in cloned repo produces `AnvilError::ManifestNotFound` +- [x] After successful init, `~/.config/anvil.toml` exists with correct `clone_dir` and `profiles` +- [x] Unit tests for git command construction, error mapping +- [x] Integration test: `anvil init` with a local git repo fixture --- diff --git a/src/cli/init.rs b/src/cli/init.rs index 25b39a3..00644ef 100644 --- a/src/cli/init.rs +++ b/src/cli/init.rs @@ -1,9 +1,110 @@ +//! The `init` subcommand. +//! +//! Bootstraps anvil on a new machine: clones a dotfiles repo, parses its +//! manifest, applies links via the [`super::apply`] flow, and writes local +//! config so subsequent commands know where the repo lives. + +use std::path::Path; + +use crate::config::expand_tilde; +use crate::config::local::LocalConfig; +use crate::config::manifest::Manifest; +use crate::error::{AnvilError, Result}; +use crate::git::{GitBackend, ShellGit}; use crate::ui::UiContext; -pub fn run( - _url: Option, - _profiles: Vec, - _ctx: &UiContext, -) -> crate::error::Result<()> { +/// Entry point for `anvil init`. Prompts for URL and clone directory, +/// clones the repo, applies links, and writes local config. +pub fn run(url: Option, profiles: Vec, ctx: &UiContext) -> Result<()> { + ctx.header(); + + ShellGit::ensure_available()?; + + let url = match url { + Some(u) => u, + None => ctx.text("Repository URL:", None)?, + }; + + let default_dir = "~/.dotfiles"; + let clone_dir_raw = ctx.text("Clone to:", Some(default_dir))?; + let clone_dir = expand_tilde(&clone_dir_raw)?; + + let resolved_profiles = run_init(&url, &clone_dir, &profiles, &ShellGit, ctx)?; + + LocalConfig::save(&clone_dir_raw, &resolved_profiles)?; + ctx.success("Local config saved"); + Ok(()) } + +/// Core init logic, factored out for testability. Accepts a git backend +/// and resolved paths so tests can inject mocks and temp directories. +/// +/// Callers passing [`ShellGit`] should call [`ShellGit::ensure_available`] +/// first — this function does not check for git binary availability. +pub fn run_init( + url: &str, + clone_dir: &Path, + explicit_profiles: &[String], + git: &dyn GitBackend, + ctx: &UiContext, +) -> Result> { + // Refuse to clone into an existing directory + if clone_dir.is_dir() { + return Err(AnvilError::Other(format!( + "directory already exists: {}. Use `anvil sync` to update, or choose a different path.", + clone_dir.display() + ))); + } + + // Clone with spinner + let spinner = ctx.spinner("Cloning repository\u{2026}"); + match git.clone_repo(url, clone_dir) { + Ok(()) => { + if let Some(s) = spinner { + s.success("Repository cloned"); + } + } + Err(e) => { + if let Some(s) = spinner { + s.fail("Clone failed"); + } + // Clean up partial clone + let _ = std::fs::remove_dir_all(clone_dir); + return Err(e); + } + } + + // Parse manifest + let manifest_path = clone_dir.join("anvil.toml"); + let manifest = Manifest::from_path(&manifest_path)?; + + // Resolve profiles: --profile flags > manifest default + let profile_names = resolve_init_profiles(explicit_profiles, &manifest)?; + + // Apply links + super::apply::apply_profiles(clone_dir, &manifest, &profile_names, ctx)?; + + Ok(profile_names) +} + +/// Resolves profiles for init. Unlike apply's resolver, init cannot +/// consult the local config (it doesn't exist yet). +fn resolve_init_profiles(explicit: &[String], manifest: &Manifest) -> Result> { + if !explicit.is_empty() { + for name in explicit { + manifest.get_profile(name)?; + } + return Ok(explicit.to_vec()); + } + + if let Some(default) = manifest.default_profile_name() { + manifest.get_profile(default)?; + return Ok(vec![default.to_string()]); + } + + Err(AnvilError::Other( + "no profile specified and no default_profile in anvil.toml. Use --profile " + .to_string(), + )) +} diff --git a/src/git/mod.rs b/src/git/mod.rs new file mode 100644 index 0000000..5c937f2 --- /dev/null +++ b/src/git/mod.rs @@ -0,0 +1,189 @@ +//! Git backend for anvil. +//! +//! Abstracts git operations behind the [`GitBackend`] trait so the rest of +//! the codebase never shells out to git directly. The default [`ShellGit`] +//! implementation calls the system `git` binary; a future `libgit2` backend +//! can be swapped in without touching command code. + +use std::path::Path; +use std::process::Command; + +use crate::error::{AnvilError, Result}; + +/// Abstracts git operations. Implementations must be able to clone a repo. +pub trait GitBackend { + /// Clones a repository from `url` into `dest`. + fn clone_repo(&self, url: &str, dest: &Path) -> Result<()>; +} + +/// Git backend that shells out to the system `git` binary. +pub struct ShellGit; + +impl ShellGit { + /// Checks that the `git` binary is available on `$PATH`. + pub fn ensure_available() -> Result<()> { + Command::new("git") + .arg("--version") + .output() + .map_err(AnvilError::GitNotFound)?; + Ok(()) + } +} + +impl GitBackend for ShellGit { + fn clone_repo(&self, url: &str, dest: &Path) -> Result<()> { + let output = Command::new("git") + .args(["clone", "--depth=1", url]) + .arg(dest) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .output() + .map_err(AnvilError::GitNotFound)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + if detail.is_empty() { + return Err(AnvilError::GitCloneFailed(url.to_string())); + } + return Err(AnvilError::GitCloneFailed(format!("{url}\n {detail}"))); + } + + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::process::Command as StdCommand; + use tempfile::tempdir; + + #[test] + fn test_ensure_git_available() { + // Should succeed on any dev machine with git installed + ShellGit::ensure_available().unwrap(); + } + + #[test] + fn test_clone_local_repo() { + let dir = tempdir().unwrap(); + + // Create a source repo with a file + let source = dir.path().join("source"); + std::fs::create_dir_all(&source).unwrap(); + StdCommand::new("git") + .args(["init"]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&source) + .output() + .unwrap(); + std::fs::write(source.join("hello.txt"), "world").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&source) + .output() + .unwrap(); + + // Clone it via ShellGit + let dest = dir.path().join("cloned"); + let git = ShellGit; + git.clone_repo(source.to_str().unwrap(), &dest).unwrap(); + + assert!(dest.join("hello.txt").exists()); + assert_eq!( + std::fs::read_to_string(dest.join("hello.txt")).unwrap(), + "world" + ); + } + + #[test] + fn test_clone_invalid_url_fails() { + let dir = tempdir().unwrap(); + let dest = dir.path().join("cloned"); + + let git = ShellGit; + let err = git + .clone_repo("https://invalid.example.com/no-such-repo.git", &dest) + .unwrap_err(); + + assert!(matches!(err, AnvilError::GitCloneFailed(_))); + } + + #[test] + fn test_clone_uses_depth_one() { + let dir = tempdir().unwrap(); + + // Create a source repo with multiple commits + let source = dir.path().join("source"); + std::fs::create_dir_all(&source).unwrap(); + StdCommand::new("git") + .args(["init"]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&source) + .output() + .unwrap(); + std::fs::write(source.join("a.txt"), "first").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "first"]) + .current_dir(&source) + .output() + .unwrap(); + std::fs::write(source.join("b.txt"), "second").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "second"]) + .current_dir(&source) + .output() + .unwrap(); + + // Clone with ShellGit (uses --depth=1) + // Use file:// protocol to force pack transport (local clones use + // hardlinks and may ignore --depth) + let dest = dir.path().join("cloned"); + let git = ShellGit; + let url = format!("file://{}", source.display()); + git.clone_repo(&url, &dest).unwrap(); + + // Verify shallow clone: only 1 commit in history + let log = StdCommand::new("git") + .args(["rev-list", "--count", "HEAD"]) + .current_dir(&dest) + .output() + .unwrap(); + let count: usize = String::from_utf8_lossy(&log.stdout).trim().parse().unwrap(); + assert_eq!(count, 1, "shallow clone should have exactly 1 commit"); + } +} diff --git a/src/lib.rs b/src/lib.rs index 2197290..6c0aae1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,5 +1,6 @@ pub mod cli; pub mod config; pub mod error; +pub mod git; pub mod linker; pub mod ui; diff --git a/tests/init_test.rs b/tests/init_test.rs new file mode 100644 index 0000000..17a4468 --- /dev/null +++ b/tests/init_test.rs @@ -0,0 +1,278 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use tempfile::tempdir; + +/// Creates a local git repo with an anvil.toml and source files, +/// suitable as a clone source for init tests. +fn create_source_repo(dir: &Path, manifest: &str, files: &[(&str, &str)]) { + fs::create_dir_all(dir).unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .unwrap(); + + fs::write(dir.join("anvil.toml"), manifest).unwrap(); + for (name, content) in files { + if let Some(parent) = Path::new(name).parent() { + fs::create_dir_all(dir.join(parent)).unwrap(); + } + fs::write(dir.join(name), content).unwrap(); + } + + Command::new("git") + .args(["add", "."]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(dir) + .output() + .unwrap(); +} + +#[test] +fn test_init_clones_applies_and_writes_config() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone_dest = dir.path().join("dotfiles"); + let config_path = dir.path().join("config").join("anvil.toml"); + let dest_zshrc = dir.path().join("home").join(".zshrc"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, +] +"#, + dest_zshrc.display() + ); + + create_source_repo(&source, &manifest, &[(".zshrc", "# zshrc content")]); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + let profiles = + anvil::cli::init::run_init(source.to_str().unwrap(), &clone_dest, &[], &git, &ctx).unwrap(); + + // Verify clone happened + assert!(clone_dest.join("anvil.toml").exists()); + assert!(clone_dest.join(".zshrc").exists()); + + // Verify symlinks were created + assert!(dest_zshrc.exists()); + assert!(dest_zshrc.symlink_metadata().unwrap().is_symlink()); + assert_eq!(fs::read_to_string(&dest_zshrc).unwrap(), "# zshrc content"); + + // Verify correct profiles resolved + assert_eq!(profiles, vec!["base"]); + + // Verify local config can be written with the returned values + anvil::config::local::LocalConfig::save_to( + &config_path, + clone_dest.to_str().unwrap(), + &profiles, + ) + .unwrap(); + let config = anvil::config::local::LocalConfig::load_from(&config_path) + .unwrap() + .unwrap(); + assert_eq!(config.clone_dir, clone_dest.to_str().unwrap()); + assert_eq!(config.profiles, vec!["base"]); +} + +#[test] +fn test_init_without_url_in_yes_mode_errors() { + // In --yes mode, text prompt with no default returns PromptCancelled + let ctx = anvil::ui::UiContext::new(true, true, false); + let result = ctx.text("Repository URL:", None); + assert!(result.is_err()); +} + +#[test] +fn test_init_into_existing_directory_errors() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone_dest = dir.path().join("dotfiles"); + + create_source_repo(&source, "[anvil]\nversion = \"1\"\n", &[]); + + // Pre-create the clone destination + fs::create_dir_all(&clone_dest).unwrap(); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + let err = anvil::cli::init::run_init(source.to_str().unwrap(), &clone_dest, &[], &git, &ctx) + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("already exists"), + "expected 'already exists' in: {msg}" + ); +} + +#[test] +fn test_init_missing_manifest_errors() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone_dest = dir.path().join("dotfiles"); + + // Create repo WITHOUT anvil.toml + fs::create_dir_all(&source).unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(&source) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(&source) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(&source) + .output() + .unwrap(); + fs::write(source.join("README.md"), "# my dots").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&source) + .output() + .unwrap(); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + let err = anvil::cli::init::run_init(source.to_str().unwrap(), &clone_dest, &[], &git, &ctx) + .unwrap_err(); + + let msg = err.to_string(); + assert!( + msg.contains("anvil.toml"), + "expected mention of anvil.toml in: {msg}" + ); +} + +#[test] +fn test_init_with_explicit_profile() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone_dest = dir.path().join("dotfiles"); + let dest_work = dir.path().join("home").join(".work"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [] + +[profiles.work] +links = [ + {{ src = ".work", dest = "{}" }}, +] +"#, + dest_work.display() + ); + + create_source_repo(&source, &manifest, &[(".work", "# work config")]); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + let profiles = anvil::cli::init::run_init( + source.to_str().unwrap(), + &clone_dest, + &["work".to_string()], + &git, + &ctx, + ) + .unwrap(); + + assert_eq!(profiles, vec!["work"]); + assert!(dest_work.exists()); + assert!(dest_work.symlink_metadata().unwrap().is_symlink()); +} + +#[test] +fn test_init_invalid_clone_url_errors() { + let dir = tempdir().unwrap(); + let clone_dest = dir.path().join("dotfiles"); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + let err = anvil::cli::init::run_init("/nonexistent/path/to/repo", &clone_dest, &[], &git, &ctx) + .unwrap_err(); + + assert!( + matches!(err, anvil::error::AnvilError::GitCloneFailed(_)), + "expected GitCloneFailed, got: {err}" + ); + + // Verify partial clone was cleaned up + assert!(!clone_dest.exists()); +} + +#[test] +fn test_init_nonexistent_profile_errors() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone_dest = dir.path().join("dotfiles"); + + let manifest = r#" +[anvil] +version = "1" + +[profiles.base] +links = [] +"#; + + create_source_repo(&source, manifest, &[]); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + let err = anvil::cli::init::run_init( + source.to_str().unwrap(), + &clone_dest, + &["nonexistent".to_string()], + &git, + &ctx, + ) + .unwrap_err(); + + assert!( + matches!(err, anvil::error::AnvilError::ProfileNotFound(_)), + "expected ProfileNotFound, got: {err}" + ); +} From 39dfc5e0eeb6a8669fb62d6caf37fddb6413bd4a Mon Sep 17 00:00:00 2001 From: piny4man Date: Mon, 6 Apr 2026 22:41:15 +0200 Subject: [PATCH 5/5] feat: sync and pull changes --- plans/phase1-interactive-mvp.md | 18 +-- src/cli/sync.rs | 50 ++++++++- src/git/mod.rs | 185 ++++++++++++++++++++++++++++++- tests/sync_test.rs | 188 ++++++++++++++++++++++++++++++++ 4 files changed, 427 insertions(+), 14 deletions(-) create mode 100644 tests/sync_test.rs diff --git a/plans/phase1-interactive-mvp.md b/plans/phase1-interactive-mvp.md index b524dc5..ef08660 100644 --- a/plans/phase1-interactive-mvp.md +++ b/plans/phase1-interactive-mvp.md @@ -108,15 +108,15 @@ After this phase, the core daily loop works: init once, sync whenever. ### Acceptance criteria -- [ ] `GitBackend` trait extended with `pull` method returning `PullResult` -- [ ] `ShellGit::pull` runs `git pull` in the repo directory -- [ ] Pull result correctly reports whether changes were fetched -- [ ] Failed pull produces `AnvilError::GitPullFailed` -- [ ] `anvil sync` reads local config, pulls, and re-applies -- [ ] `anvil sync` with no local config and no `~/.dotfiles` produces a helpful error -- [ ] Spinner shown during pull, replaced by result message -- [ ] Unit tests for pull command construction, result parsing -- [ ] Integration test: sync pulls and re-applies links +- [x] `GitBackend` trait extended with `pull` method returning `PullResult` +- [x] `ShellGit::pull` runs `git pull` in the repo directory +- [x] Pull result correctly reports whether changes were fetched +- [x] Failed pull produces `AnvilError::GitPullFailed` +- [x] `anvil sync` reads local config, pulls, and re-applies +- [x] `anvil sync` with no local config and no `~/.dotfiles` produces a helpful error +- [x] Spinner shown during pull, replaced by result message +- [x] Unit tests for pull command construction, result parsing +- [x] Integration test: sync pulls and re-applies links --- diff --git a/src/cli/sync.rs b/src/cli/sync.rs index ad3ce20..6d3c7bf 100644 --- a/src/cli/sync.rs +++ b/src/cli/sync.rs @@ -1,5 +1,53 @@ +//! The `sync` subcommand. +//! +//! Pulls the latest changes from the remote and re-applies links. +//! This is the daily-driver command: run it from anywhere to stay up to date. + +use std::path::Path; + +use crate::config::discover_repo; +use crate::config::manifest::Manifest; +use crate::error::Result; +use crate::git::{GitBackend, ShellGit}; use crate::ui::UiContext; -pub fn run(_ctx: &UiContext) -> crate::error::Result<()> { +/// Entry point for `anvil sync`. Discovers the repo, pulls changes, +/// and re-applies all links. +pub fn run(ctx: &UiContext) -> Result<()> { + ShellGit::ensure_available()?; + let repo_dir = discover_repo()?; + run_sync(&repo_dir, &ShellGit, ctx) +} + +/// Core sync logic, factored out for testability. Accepts a git backend, +/// resolved repo path, and [`UiContext`] so tests can inject mocks, temp +/// directories, and UI overrides (quiet, dry-run, etc.). +pub fn run_sync(repo_dir: &Path, git: &dyn GitBackend, ctx: &UiContext) -> Result<()> { + // Pull with spinner + let spinner = ctx.spinner("Pulling latest changes\u{2026}"); + match git.pull(repo_dir) { + Ok(result) => { + if let Some(s) = spinner { + if result.was_updated { + s.success(&result.summary); + } else { + s.success("Already up to date"); + } + } + } + Err(e) => { + if let Some(s) = spinner { + s.fail("Pull failed"); + } + return Err(e); + } + } + + // Always re-apply links (even when up-to-date — links may have been broken) + let manifest_path = repo_dir.join("anvil.toml"); + let manifest = Manifest::from_path(&manifest_path)?; + let profile_names = super::apply::resolve_profiles(&[], &manifest)?; + super::apply::apply_profiles(repo_dir, &manifest, &profile_names, ctx)?; + Ok(()) } diff --git a/src/git/mod.rs b/src/git/mod.rs index 5c937f2..983fd94 100644 --- a/src/git/mod.rs +++ b/src/git/mod.rs @@ -6,14 +6,28 @@ //! can be swapped in without touching command code. use std::path::Path; -use std::process::Command; +use std::process::{Command, Stdio}; use crate::error::{AnvilError, Result}; -/// Abstracts git operations. Implementations must be able to clone a repo. +/// Result of a `git pull` operation. +#[derive(Debug)] +pub struct PullResult { + /// Whether new changes were fetched from the remote. + pub was_updated: bool, + /// Human-readable summary. When changes were pulled this contains the + /// diffstat line (e.g. "2 files changed, 3 insertions(+)"); when the repo + /// is already up to date it is `"Already up to date"`. + pub summary: String, +} + +/// Abstracts git operations so command code never shells out directly. pub trait GitBackend { /// Clones a repository from `url` into `dest`. fn clone_repo(&self, url: &str, dest: &Path) -> Result<()>; + + /// Pulls the latest changes in `repo_dir` from its configured remote. + fn pull(&self, repo_dir: &Path) -> Result; } /// Git backend that shells out to the system `git` binary. @@ -35,8 +49,8 @@ impl GitBackend for ShellGit { let output = Command::new("git") .args(["clone", "--depth=1", url]) .arg(dest) - .stdout(std::process::Stdio::piped()) - .stderr(std::process::Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) .output() .map_err(AnvilError::GitNotFound)?; @@ -51,6 +65,49 @@ impl GitBackend for ShellGit { Ok(()) } + + fn pull(&self, repo_dir: &Path) -> Result { + let output = Command::new("git") + .arg("-C") + .arg(repo_dir) + .args(["pull", "--ff-only"]) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .output() + .map_err(AnvilError::GitNotFound)?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let detail = stderr.trim(); + if detail.is_empty() { + return Err(AnvilError::GitPullFailed("unknown error".to_string())); + } + return Err(AnvilError::GitPullFailed(detail.to_string())); + } + + let stdout = String::from_utf8_lossy(&output.stdout); + // TODO: This is locale-dependent. A more robust approach would be to + // compare HEAD before and after the pull. + let was_updated = !stdout.contains("Already up"); + + let summary = if was_updated { + // Extract the stat summary line (e.g. "2 files changed, 3 insertions(+)") + stdout + .lines() + .rev() + .find(|line| line.contains("changed")) + .unwrap_or("Changes pulled") + .trim() + .to_string() + } else { + "Already up to date".to_string() + }; + + Ok(PullResult { + was_updated, + summary, + }) + } } #[cfg(test)] @@ -124,6 +181,126 @@ mod tests { assert!(matches!(err, AnvilError::GitCloneFailed(_))); } + /// Helper: creates a git repo at `dir` with user config and an initial commit. + fn init_repo(dir: &std::path::Path) { + std::fs::create_dir_all(dir).unwrap(); + StdCommand::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .unwrap(); + StdCommand::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .unwrap(); + } + + #[test] + fn test_pull_no_changes() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + init_repo(&source); + + std::fs::write(source.join("file.txt"), "hello").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&source) + .output() + .unwrap(); + + // Clone it + let clone = dir.path().join("clone"); + StdCommand::new("git") + .args(["clone"]) + .arg(&source) + .arg(&clone) + .output() + .unwrap(); + + // Pull with no upstream changes + let git = ShellGit; + let result = git.pull(&clone).unwrap(); + assert!(!result.was_updated); + assert_eq!(result.summary, "Already up to date"); + } + + #[test] + fn test_pull_with_changes() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source"); + init_repo(&source); + + std::fs::write(source.join("file.txt"), "hello").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "init"]) + .current_dir(&source) + .output() + .unwrap(); + + // Clone it + let clone = dir.path().join("clone"); + StdCommand::new("git") + .args(["clone"]) + .arg(&source) + .arg(&clone) + .output() + .unwrap(); + + // Add a new commit to source + std::fs::write(source.join("new.txt"), "new content").unwrap(); + StdCommand::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + StdCommand::new("git") + .args(["commit", "-m", "add new file"]) + .current_dir(&source) + .output() + .unwrap(); + + // Pull — should detect changes + let git = ShellGit; + let result = git.pull(&clone).unwrap(); + assert!(result.was_updated); + assert!( + result.summary.contains("changed"), + "expected 'changed' in summary: {}", + result.summary + ); + assert!(clone.join("new.txt").exists()); + } + + #[test] + fn test_pull_not_a_repo_fails() { + let dir = tempdir().unwrap(); + let not_repo = dir.path().join("not-a-repo"); + std::fs::create_dir(¬_repo).unwrap(); + + let git = ShellGit; + let err = git.pull(¬_repo).unwrap_err(); + assert!( + matches!(err, AnvilError::GitPullFailed(_)), + "expected GitPullFailed, got: {err}" + ); + } + #[test] fn test_clone_uses_depth_one() { let dir = tempdir().unwrap(); diff --git a/tests/sync_test.rs b/tests/sync_test.rs new file mode 100644 index 0000000..574e607 --- /dev/null +++ b/tests/sync_test.rs @@ -0,0 +1,188 @@ +use std::fs; +use std::path::Path; +use std::process::Command; + +use tempfile::tempdir; + +/// Creates a local git repo with an anvil.toml and source files, +/// suitable as a clone source for sync tests. +fn create_source_repo(dir: &Path, manifest: &str, files: &[(&str, &str)]) { + fs::create_dir_all(dir).unwrap(); + Command::new("git") + .args(["init"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.email", "test@test.com"]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["config", "user.name", "Test"]) + .current_dir(dir) + .output() + .unwrap(); + + fs::write(dir.join("anvil.toml"), manifest).unwrap(); + for (name, content) in files { + if let Some(parent) = Path::new(name).parent() { + fs::create_dir_all(dir.join(parent)).unwrap(); + } + fs::write(dir.join(name), content).unwrap(); + } + + Command::new("git") + .args(["add", "."]) + .current_dir(dir) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "initial"]) + .current_dir(dir) + .output() + .unwrap(); +} + +#[test] +fn test_sync_pulls_and_reapplies() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone = dir.path().join("dotfiles"); + let dest_zshrc = dir.path().join("home").join(".zshrc"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, +] +"#, + dest_zshrc.display() + ); + + create_source_repo(&source, &manifest, &[(".zshrc", "# zshrc v1")]); + + // Clone the source (simulating what init would have done) + Command::new("git") + .args(["clone"]) + .arg(&source) + .arg(&clone) + .output() + .unwrap(); + + // First apply via sync — links the initial files + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + anvil::cli::sync::run_sync(&clone, &git, &ctx).unwrap(); + + assert!(dest_zshrc.exists()); + assert!(dest_zshrc.symlink_metadata().unwrap().is_symlink()); + assert_eq!(fs::read_to_string(&dest_zshrc).unwrap(), "# zshrc v1"); + + // Now update the source with a new file and updated manifest + let dest_vimrc = dir.path().join("home").join(".vimrc"); + let updated_manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = ".zshrc", dest = "{}" }}, + {{ src = ".vimrc", dest = "{}" }}, +] +"#, + dest_zshrc.display(), + dest_vimrc.display() + ); + fs::write(source.join("anvil.toml"), &updated_manifest).unwrap(); + fs::write(source.join(".vimrc"), "\" vimrc content").unwrap(); + Command::new("git") + .args(["add", "."]) + .current_dir(&source) + .output() + .unwrap(); + Command::new("git") + .args(["commit", "-m", "add vimrc"]) + .current_dir(&source) + .output() + .unwrap(); + + // Sync again — should pull the new commit and create the new link + anvil::cli::sync::run_sync(&clone, &git, &ctx).unwrap(); + + assert!(dest_vimrc.exists()); + assert!(dest_vimrc.symlink_metadata().unwrap().is_symlink()); + assert_eq!(fs::read_to_string(&dest_vimrc).unwrap(), "\" vimrc content"); + + // Original link still correct + assert!(dest_zshrc.symlink_metadata().unwrap().is_symlink()); +} + +#[test] +fn test_sync_already_up_to_date_still_applies() { + let dir = tempdir().unwrap(); + let source = dir.path().join("source-repo"); + let clone = dir.path().join("dotfiles"); + let dest_file = dir.path().join("home").join(".config"); + + let manifest = format!( + r#" +[anvil] +version = "1" +default_profile = "base" + +[profiles.base] +links = [ + {{ src = "config", dest = "{}" }}, +] +"#, + dest_file.display() + ); + + create_source_repo(&source, &manifest, &[("config", "content")]); + + Command::new("git") + .args(["clone"]) + .arg(&source) + .arg(&clone) + .output() + .unwrap(); + + let ctx = anvil::ui::UiContext::new(true, true, false); + let git = anvil::git::ShellGit; + + // First sync creates the link + anvil::cli::sync::run_sync(&clone, &git, &ctx).unwrap(); + assert!(dest_file.exists()); + + // Remove the symlink manually (simulating a broken link scenario) + fs::remove_file(&dest_file).unwrap(); + assert!(!dest_file.exists()); + + // Sync again with no upstream changes — should still re-apply the link + anvil::cli::sync::run_sync(&clone, &git, &ctx).unwrap(); + assert!(dest_file.exists()); + assert!(dest_file.symlink_metadata().unwrap().is_symlink()); +} + +#[test] +fn test_sync_no_repo_found_errors() { + // discover_repo_with(None) + no ~/.dotfiles should produce a helpful error + let result = anvil::config::local::discover_repo_with(None); + if let Err(e) = result { + let msg = e.to_string(); + assert!( + msg.contains("anvil init"), + "expected guidance to run 'anvil init' in: {msg}" + ); + } + // If ~/.dotfiles happens to exist on the test machine, the fallback + // succeeds — that's fine, the error path is still tested in local.rs +}