# osx-backup Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Construire `osx-backup`, un outil CLI interactif (checkboxes, barres de progression) qui sauvegarde un Mac de développeur vers un disque externe et le réinstalle sur un Mac vierge.

**Architecture:** Package Python `src/osx_backup/` avec un noyau (runner de commandes injectable, contexte, manifeste, UI) et douze modules indépendants qui implémentent `inventory / backup / restore`. Une map TOML déclare les dotfiles ; les modules à logique réelle (Postgres, Docker, secrets, rsync) sont en code. Toute commande externe passe par `ctx.runner`, toute écriture dans `$HOME` passe par `ctx.place`, ce qui rend chaque module testable sans toucher la machine.

**Tech Stack:** Python 3.13, uv, typer 0.27, rich 15, questionary 2.1, pyrage 1.4, pytest, ruff. Outils externes : brew, mise, uv, rsync GNU (Homebrew), pg_dump/pg_restore, docker, colima, code.

**Spec:** `docs/superpowers/specs/2026-09-03-osx-backup-design.md`

## Global Constraints

- Python `>=3.13`, exécution via `uv run osx-backup`. Aucun outil global : `uv run pytest`, `uv run ruff check src tests`. Avant chaque commit : `uv run ruff format src tests` puis `uv run ruff check src tests` (E501 est ignoré, le formateur gère les longueurs).
- Dépendances runtime exactes : `typer>=0.27`, `rich>=15`, `questionary>=2.1`, `pyrage>=1.4`. Dev : `pytest>=8`, `ruff>=0.6`.
- API pyrage : `pyrage.passphrase.encrypt(data: bytes, passphrase: str) -> bytes` et `pyrage.passphrase.decrypt(data: bytes, passphrase: str) -> bytes`.
- Le rsync système macOS est openrsync (protocole 29) : il ne supporte pas `--info=progress2`. On exige un rsync GNU >= 3 (`/opt/homebrew/bin/rsync` en priorité). Sans lui, github et documents sont indisponibles avec la raison `rsync GNU absent : brew install rsync`.
- Ordre de restauration fixe : brew(10) → mise(20) → uv(30) → dotfiles(40) → secrets(50) → claude(60) → editors(70) → github(80) → documents(90) → postgres(100) → docker(110) → apps(120).
- Chaque module écrit uniquement dans `<dest>/<module>/` (secrets : `<dest>/secrets.tar.age`).
- Toute commande externe passe par `ctx.runner.run(...)` ou `ctx.runner.stream(...)`. Les lectures d'inventaire passent `readonly=True` pour être exécutées même en dry-run.
- Toute écriture dans `$HOME` à la restauration passe par `ctx.place(src, dst)` qui déplace l'existant vers `~/.osx-backup-pre-restore/<timestamp>/`.
- Un module qui échoue ne bloque pas les suivants ; code de sortie 1 si au moins un échec.
- Messages utilisateur en français. Identifiants de code en anglais.
- Git : branche `dev` = intégration. Chaque tâche : `git checkout dev && git checkout -b feature/<nom>`, commits Conventional Commits en une ligne, puis `git checkout dev && git merge feature/<nom> --ff-only && git branch -d feature/<nom>`. Pas de remote pour l'instant : pas de push. Ne jamais committer sur `main` (hook bloquant).
- Les hooks git sont actifs : un message non conforme est rejeté, et **le hook scanne toute la commande Bash**, y compris les heredocs. Donc : jamais de `git commit` combiné avec une autre commande qui contient du texte libre ; toujours `git add …` puis, dans un appel Bash séparé, `git commit -m "type: description"`. Types autorisés : feat, fix, chore, docs, refactor, test, style, perf, build, ci, revert.

---

## Structure des fichiers

| Fichier | Responsabilité |
|---|---|
| `pyproject.toml` | métadonnées, deps, script `osx-backup`, config ruff/pytest |
| `map.toml` | liste blanche des dotfiles (secret ou non) |
| `src/osx_backup/__init__.py` | `__version__` |
| `src/osx_backup/runner.py` | `CommandRunner` (Real, Recording, DryRun), `CommandResult`, `CommandError` |
| `src/osx_backup/fsutil.py` | exclusions glob, `copy_tree`, `tree_size`, `move_aside`, `DEPENDENCY_DIRS`, `format_size` |
| `src/osx_backup/context.py` | `Context` : chemins, flags, runner, `place`, `log`, `module_dir` |
| `src/osx_backup/modules/base.py` | `Module`, `Inventory`, `ModuleResult`, `ProgressReporter`, registre |
| `src/osx_backup/manifest.py` | `Manifest`, `write_manifest`, `read_manifest` |
| `src/osx_backup/mapfile.py` | `DotfileEntry`, `load_map`, `GLOBAL_EXCLUDES`, `default_map_path` |
| `src/osx_backup/modules/dotfiles.py` | module dotfiles |
| `src/osx_backup/modules/secrets.py` | module secrets (tar + pyrage) |
| `src/osx_backup/modules/claude.py` | module claude |
| `src/osx_backup/modules/editors.py` | module editors (VS Code, Zed, Ghostty) |
| `src/osx_backup/rsync.py` | détection rsync GNU, construction de commande, parse progress2 et stats |
| `src/osx_backup/modules/rsyncmodule.py` | classe de base `RsyncModule` |
| `src/osx_backup/modules/github.py`, `documents.py` | modules miroirs |
| `src/osx_backup/modules/brew.py`, `mise.py`, `uv.py` | modules gestionnaires de paquets |
| `src/osx_backup/modules/postgres.py` | dumps et restauration Postgres |
| `src/osx_backup/modules/docker.py` | inventaire et volumes Docker |
| `src/osx_backup/modules/apps.py` | inventaire des apps hors casks |
| `src/osx_backup/ui.py` | checklist questionary, tables rich, `RichProgress` |
| `src/osx_backup/config.py` | config utilisateur `~/.config/osx-backup/config.toml` |
| `src/osx_backup/cli.py` | commandes typer `map`, `backup`, `restore`, orchestration |
| `src/osx_backup/modules/__init__.py` | import de tous les modules pour remplir le registre |
| `tests/conftest.py` | fixtures `fake_home`, `dest`, `ctx` |
| `tests/test_<unité>.py` | un fichier de test par unité |

---

### Task 1: Scaffolding du projet

**Files:**
- Create: `pyproject.toml`
- Create: `src/osx_backup/__init__.py`
- Create: `src/osx_backup/modules/__init__.py`
- Create: `tests/__init__.py`
- Create: `tests/conftest.py`
- Create: `tests/test_scaffold.py`

**Interfaces:**
- Produces: `osx_backup.__version__: str`; fixtures pytest `fake_home: Path` (HOME temporaire, variable d'environnement `HOME` positionnée) et `dest: Path` (dossier de destination temporaire).

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/scaffold
```

- [ ] **Step 2: Écrire `pyproject.toml`**

```toml
[project]
name = "osx-backup"
version = "0.1.0"
description = "Sauvegarde et réinstallation interactive d'un Mac de développeur"
requires-python = ">=3.13"
dependencies = [
    "typer>=0.27",
    "rich>=15",
    "questionary>=2.1",
    "pyrage>=1.4",
]

[project.scripts]
osx-backup = "osx_backup.cli:app"

[dependency-groups]
dev = ["pytest>=8", "ruff>=0.6"]

[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"

[tool.hatch.build.targets.wheel]
packages = ["src/osx_backup"]

[tool.ruff]
line-length = 100
target-version = "py313"

[tool.ruff.lint]
select = ["E", "F", "I", "UP", "B"]
ignore = ["E501"]

[tool.pytest.ini_options]
testpaths = ["tests"]
```

- [ ] **Step 3: Écrire les fichiers de package**

`src/osx_backup/__init__.py` :

```python
"""osx-backup : sauvegarde et réinstallation d'un Mac de développeur."""

__version__ = "0.1.0"
```

`src/osx_backup/modules/__init__.py` :

```python
"""Modules de sauvegarde. Chaque module s'enregistre à l'import (voir base.register)."""
```

`tests/__init__.py` : fichier vide.

- [ ] **Step 4: Écrire `tests/conftest.py`**

```python
from pathlib import Path

import pytest


@pytest.fixture
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setenv("HOME", str(home))
    return home


@pytest.fixture
def dest(tmp_path: Path) -> Path:
    d = tmp_path / "dest"
    d.mkdir()
    return d
```

- [ ] **Step 5: Écrire le test trivial `tests/test_scaffold.py`**

```python
import os
from pathlib import Path

import osx_backup


def test_version() -> None:
    assert osx_backup.__version__ == "0.1.0"


def test_fixtures(fake_home: Path, dest: Path) -> None:
    assert fake_home.is_dir()
    assert dest.is_dir()
    assert os.environ["HOME"] == str(fake_home)
```

- [ ] **Step 6: Installer et lancer les tests**

Run: `uv sync && uv run pytest -q`
Expected: `2 passed`. `uv sync` crée `.venv` et `uv.lock`.

- [ ] **Step 7: Lancer ruff**

Run: `uv run ruff check src tests && uv run ruff format src tests`
Expected: aucune erreur.

- [ ] **Step 8: Commit et merge**

```bash
git add pyproject.toml uv.lock src tests
```
Puis, appel Bash séparé : `git commit -m "build: scaffolding du package osx-backup"`. Puis :
```bash
git checkout dev && git merge feature/scaffold --ff-only && git branch -d feature/scaffold
```

---

### Task 2: Runner de commandes injectable

**Files:**
- Create: `src/osx_backup/runner.py`
- Test: `tests/test_runner.py`

**Interfaces:**
- Produces:
  - `CommandResult(returncode: int, stdout: str = "", stderr: str = "")` avec propriété `ok: bool`.
  - `CommandError(RuntimeError)` avec attributs `cmd: list[str]`, `result: CommandResult`.
  - `UnexpectedCommand(AssertionError)`.
  - `CommandRunner` (ABC) : `run(cmd: Sequence[str], *, check: bool = True, capture: bool = True, env: dict[str, str] | None = None, cwd: Path | None = None, input: str | None = None, readonly: bool = False) -> CommandResult` ; `stream(cmd: Sequence[str], on_line: Callable[[str], None], *, env=None, cwd=None) -> CommandResult` ; `which(name: str) -> str | None`.
  - `RealRunner(log: Callable[[str], None] | None = None)`.
  - `RecordingRunner(*, strict: bool = False, which_map: dict[str, str | None] | None = None)` avec `calls: list[list[str]]`, `mutations: list[list[str]]`, `add(prefix, stdout="", returncode=0, stderr="")`, `has_call(*prefix) -> bool`. Sans `which_map`, `which` renvoie `/opt/homebrew/bin/<name>` pour tout ; avec `which_map`, renvoie `which_map.get(name)`.
  - `DryRunRunner(real: CommandRunner, log: Callable[[str], None])` avec `planned: list[list[str]]`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/runner
```

- [ ] **Step 2: Écrire les tests `tests/test_runner.py`**

```python
import pytest

from osx_backup.runner import (
    CommandError,
    CommandResult,
    DryRunRunner,
    RealRunner,
    RecordingRunner,
    UnexpectedCommand,
)


def test_real_runner_runs_and_captures() -> None:
    logs: list[str] = []
    r = RealRunner(log=logs.append)
    res = r.run(["echo", "hello"])
    assert res.ok and res.stdout.strip() == "hello"
    assert logs[0] == "$ echo hello"


def test_real_runner_raises_on_failure() -> None:
    r = RealRunner()
    with pytest.raises(CommandError):
        r.run(["false"])
    assert r.run(["false"], check=False).returncode == 1


def test_real_runner_stream_splits_on_cr_and_lf() -> None:
    r = RealRunner()
    lines: list[str] = []
    res = r.stream(["printf", "a\\rb\\nc"], lines.append)
    assert lines == ["a", "b", "c"]
    assert res.ok


def test_real_runner_which() -> None:
    assert RealRunner().which("sh") is not None
    assert RealRunner().which("definitely-not-a-binary-xyz") is None


def test_recording_runner_matches_longest_prefix() -> None:
    r = RecordingRunner()
    r.add(("brew", "list"), stdout="generic")
    r.add(("brew", "list", "--cask"), stdout="casks")
    assert r.run(["brew", "list", "--cask"]).stdout == "casks"
    assert r.run(["brew", "list", "--formula"]).stdout == "generic"
    assert r.calls == [["brew", "list", "--cask"], ["brew", "list", "--formula"]]


def test_recording_runner_readonly_not_in_mutations() -> None:
    r = RecordingRunner()
    r.run(["docker", "info"], readonly=True)
    r.run(["docker", "stop", "x"])
    assert r.mutations == [["docker", "stop", "x"]]
    assert r.has_call("docker", "info")
    assert not r.has_call("docker", "start")


def test_recording_runner_strict_raises() -> None:
    r = RecordingRunner(strict=True)
    with pytest.raises(UnexpectedCommand):
        r.run(["unknown"])


def test_recording_runner_failure_and_check() -> None:
    r = RecordingRunner()
    r.add(("pg_isready",), returncode=2)
    with pytest.raises(CommandError):
        r.run(["pg_isready"])
    assert r.run(["pg_isready"], check=False).returncode == 2


def test_recording_runner_stream_feeds_lines() -> None:
    r = RecordingRunner()
    r.add(("rsync",), stdout="l1\nl2")
    got: list[str] = []
    r.stream(["rsync", "-a"], got.append)
    assert got == ["l1", "l2"]


def test_recording_runner_which() -> None:
    assert RecordingRunner().which("brew") == "/opt/homebrew/bin/brew"
    r = RecordingRunner(which_map={"brew": None})
    assert r.which("brew") is None
    assert r.which("docker") is None


def test_dry_run_runner_delegates_readonly_only() -> None:
    real = RecordingRunner()
    real.add(("docker", "info"), stdout="ok")
    logs: list[str] = []
    d = DryRunRunner(real, logs.append)
    assert d.run(["docker", "info"], readonly=True).stdout == "ok"
    res = d.run(["docker", "stop", "x"])
    assert res == CommandResult(0)
    assert d.planned == [["docker", "stop", "x"]]
    assert real.calls == [["docker", "info"]]
    assert logs == ["[dry-run] $ docker stop x"]
    d.stream(["rsync"], lambda _line: None)
    assert d.planned[-1] == ["rsync"]
```

- [ ] **Step 3: Vérifier que les tests échouent**

Run: `uv run pytest tests/test_runner.py -q`
Expected: erreur d'import `osx_backup.runner`.

- [ ] **Step 4: Écrire `src/osx_backup/runner.py`**

```python
"""Exécution de commandes externes, injectable pour les tests et le dry-run."""

from __future__ import annotations

import shutil
import subprocess
from abc import ABC, abstractmethod
from collections.abc import Callable, Sequence
from dataclasses import dataclass
from pathlib import Path


@dataclass(frozen=True)
class CommandResult:
    returncode: int
    stdout: str = ""
    stderr: str = ""

    @property
    def ok(self) -> bool:
        return self.returncode == 0


class CommandError(RuntimeError):
    def __init__(self, cmd: Sequence[str], result: CommandResult) -> None:
        self.cmd = list(cmd)
        self.result = result
        super().__init__(
            f"Commande échouée ({result.returncode}) : {' '.join(cmd)}\n{result.stderr.strip()}"
        )


class UnexpectedCommand(AssertionError):
    pass


class CommandRunner(ABC):
    @abstractmethod
    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult: ...

    @abstractmethod
    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult: ...

    @abstractmethod
    def which(self, name: str) -> str | None: ...


class RealRunner(CommandRunner):
    def __init__(self, log: Callable[[str], None] | None = None) -> None:
        self._log = log or (lambda _s: None)

    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        self._log("$ " + " ".join(cmd))
        proc = subprocess.run(
            list(cmd), capture_output=capture, text=True, env=env, cwd=cwd, input=input
        )
        result = CommandResult(proc.returncode, proc.stdout or "", proc.stderr or "")
        if result.stdout:
            self._log(result.stdout.rstrip())
        if result.stderr:
            self._log(result.stderr.rstrip())
        if check and not result.ok:
            raise CommandError(cmd, result)
        return result

    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        self._log("$ " + " ".join(cmd))
        proc = subprocess.Popen(
            list(cmd), stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env, cwd=cwd
        )
        assert proc.stdout is not None and proc.stderr is not None
        buf = bytearray()
        lines: list[str] = []

        def flush() -> None:
            if buf:
                line = buf.decode(errors="replace")
                lines.append(line)
                on_line(line)
                buf.clear()

        while True:
            ch = proc.stdout.read(1)
            if not ch:
                break
            if ch in (b"\r", b"\n"):
                flush()
            else:
                buf.extend(ch)
        flush()
        stderr = proc.stderr.read().decode(errors="replace")
        rc = proc.wait()
        if stderr:
            self._log(stderr.rstrip())
        return CommandResult(rc, "\n".join(lines), stderr)

    def which(self, name: str) -> str | None:
        return shutil.which(name)


class RecordingRunner(CommandRunner):
    """Runner de test : enregistre les commandes et renvoie des réponses préparées."""

    def __init__(
        self, *, strict: bool = False, which_map: dict[str, str | None] | None = None
    ) -> None:
        self.calls: list[list[str]] = []
        self.mutations: list[list[str]] = []
        self.strict = strict
        self._which = which_map
        self._responses: list[tuple[tuple[str, ...], CommandResult]] = []

    def add(
        self,
        prefix: str | Sequence[str],
        stdout: str = "",
        returncode: int = 0,
        stderr: str = "",
    ) -> None:
        key = (prefix,) if isinstance(prefix, str) else tuple(prefix)
        self._responses.append((key, CommandResult(returncode, stdout, stderr)))

    def _match(self, cmd: Sequence[str]) -> CommandResult | None:
        best: tuple[int, CommandResult] | None = None
        for prefix, result in self._responses:
            n = len(prefix)
            if tuple(cmd[:n]) == prefix and (best is None or n >= best[0]):
                best = (n, result)
        return best[1] if best else None

    def _record(self, cmd: Sequence[str], readonly: bool) -> CommandResult:
        self.calls.append(list(cmd))
        if not readonly:
            self.mutations.append(list(cmd))
        result = self._match(cmd)
        if result is None:
            if self.strict:
                raise UnexpectedCommand(f"Commande non préparée : {' '.join(cmd)}")
            result = CommandResult(0)
        return result

    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        result = self._record(cmd, readonly)
        if check and not result.ok:
            raise CommandError(cmd, result)
        return result

    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        result = self._record(cmd, readonly=False)
        for line in result.stdout.splitlines():
            on_line(line)
        return result

    def which(self, name: str) -> str | None:
        if self._which is None:
            return f"/opt/homebrew/bin/{name}"
        return self._which.get(name)

    def has_call(self, *prefix: str) -> bool:
        return any(tuple(c[: len(prefix)]) == prefix for c in self.calls)


class DryRunRunner(CommandRunner):
    """Exécute les lectures (readonly=True) sur le vrai runner, journalise le reste."""

    def __init__(self, real: CommandRunner, log: Callable[[str], None]) -> None:
        self.real = real
        self._log = log
        self.planned: list[list[str]] = []

    def run(
        self,
        cmd: Sequence[str],
        *,
        check: bool = True,
        capture: bool = True,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
        input: str | None = None,
        readonly: bool = False,
    ) -> CommandResult:
        if readonly:
            return self.real.run(
                cmd, check=check, capture=capture, env=env, cwd=cwd, input=input, readonly=True
            )
        self.planned.append(list(cmd))
        self._log("[dry-run] $ " + " ".join(cmd))
        return CommandResult(0)

    def stream(
        self,
        cmd: Sequence[str],
        on_line: Callable[[str], None],
        *,
        env: dict[str, str] | None = None,
        cwd: Path | None = None,
    ) -> CommandResult:
        self.planned.append(list(cmd))
        self._log("[dry-run] $ " + " ".join(cmd))
        return CommandResult(0)

    def which(self, name: str) -> str | None:
        return self.real.which(name)
```

- [ ] **Step 5: Vérifier que les tests passent**

Run: `uv run pytest tests/test_runner.py -q && uv run ruff check src tests`
Expected: `11 passed`, ruff propre.

- [ ] **Step 6: Commit et merge**

```bash
git add src/osx_backup/runner.py tests/test_runner.py
```
Appel séparé : `git commit -m "feat: runner de commandes injectable (real, recording, dry-run)"`. Puis :
```bash
git checkout dev && git merge feature/runner --ff-only && git branch -d feature/runner
```

---

### Task 3: Utilitaires fichiers et Context

**Files:**
- Create: `src/osx_backup/fsutil.py`
- Create: `src/osx_backup/context.py`
- Modify: `tests/conftest.py` (ajout fixture `ctx`)
- Test: `tests/test_fsutil.py`, `tests/test_context.py`

**Interfaces:**
- Consumes: `CommandRunner`, `RecordingRunner` (Task 2).
- Produces (fsutil) :
  - `DEPENDENCY_DIRS: list[str]` (node_modules, .venv, venv, __pycache__, .pytest_cache, .mypy_cache, .ruff_cache, .next, .nuxt, .svelte-kit, .output, .turbo, .cache, .parcel-cache, dist, build, target).
  - `is_excluded(rel: Path, patterns: Sequence[str]) -> bool`.
  - `copy_tree(src: Path, dst: Path, excludes: Sequence[str] = ()) -> int` (octets copiés ; accepte fichier ou dossier ; symlinks recréés).
  - `tree_size(src: Path, excludes: Sequence[str] = ()) -> tuple[int, int]` (fichiers, octets).
  - `move_aside(path: Path, aside_root: Path, home: Path) -> Path | None`.
  - `format_size(n: int) -> str` (`"1.5 KB"`, `"512 B"`).
- Produces (context) : `Context` dataclass avec champs `home: Path`, `dest: Path`, `runner: CommandRunner`, `console: Console`, `dry_run: bool`, `yes: bool`, `verbose: bool`, `force: bool`, `docker_no_stop: bool`, `docker_include_anonymous: bool`, `passphrase: str | None`, `timestamp: str`, `log_path: Path | None`, `applications_dir: Path`, `map_path: Path | None`, `old_home: Path | None` ; propriété `pre_restore_dir: Path` ; méthodes `module_dir(name: str) -> Path`, `log(message: str) -> None`, `warn(message: str) -> None`, `place(src: Path, dst: Path, excludes: Sequence[str] = ()) -> int`, `write_text(path: Path, text: str) -> None`, `write_json(path: Path, obj: Any) -> None`.
- Fixture `ctx(fake_home, dest) -> Context` avec `RecordingRunner()` et `Console(record=True, width=120)`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/context
```

- [ ] **Step 2: Écrire `tests/test_fsutil.py`**

```python
import os
from pathlib import Path

from osx_backup.fsutil import (
    DEPENDENCY_DIRS,
    copy_tree,
    format_size,
    is_excluded,
    move_aside,
    tree_size,
)


def test_is_excluded_by_part_and_glob() -> None:
    assert is_excluded(Path("a/node_modules/b"), ["**/node_modules"])
    assert is_excluded(Path("x/app.log"), ["**/*.log"])
    assert not is_excluded(Path("x/app.py"), ["**/*.log"])
    assert is_excluded(Path("plugins/cache/x"), ["cache"])
    assert is_excluded(Path(".env.local"), [".env.*"])
    assert not is_excluded(Path("src/main.py"), DEPENDENCY_DIRS)


def test_is_excluded_by_relative_path_prefix() -> None:
    assert is_excluded(Path("plugins/cache"), ["plugins/cache"])
    assert is_excluded(Path("plugins/cache/deep/file"), ["plugins/cache"])
    assert not is_excluded(Path("other/cache"), ["plugins/cache"])


def _make_tree(root: Path) -> None:
    (root / "src").mkdir(parents=True)
    (root / "src" / "main.py").write_text("print(1)")
    (root / "node_modules" / "pkg").mkdir(parents=True)
    (root / "node_modules" / "pkg" / "index.js").write_text("x")
    (root / "app.log").write_text("log")
    os.symlink("src/main.py", root / "link.py")


def test_copy_tree_applies_excludes_and_keeps_symlinks(tmp_path: Path) -> None:
    src, dst = tmp_path / "src", tmp_path / "dst"
    _make_tree(src)
    copied = copy_tree(src, dst, ["**/node_modules", "**/*.log"])
    assert (dst / "src" / "main.py").read_text() == "print(1)"
    assert not (dst / "node_modules").exists()
    assert not (dst / "app.log").exists()
    assert (dst / "link.py").is_symlink() and os.readlink(dst / "link.py") == "src/main.py"
    assert copied == len("print(1)")


def test_copy_tree_single_file(tmp_path: Path) -> None:
    f = tmp_path / "a.txt"
    f.write_text("abc")
    assert copy_tree(f, tmp_path / "out" / "b.txt") == 3
    assert (tmp_path / "out" / "b.txt").read_text() == "abc"


def test_tree_size(tmp_path: Path) -> None:
    _make_tree(tmp_path / "t")
    files, size = tree_size(tmp_path / "t", ["**/node_modules"])
    assert files == 3 and size == len("print(1)") + len("log")


def test_move_aside(tmp_path: Path) -> None:
    home = tmp_path / "home"
    target = home / ".config" / "fish"
    target.mkdir(parents=True)
    (target / "config.fish").write_text("x")
    aside = tmp_path / "aside"
    moved = move_aside(target, aside, home)
    assert moved == aside / ".config" / "fish"
    assert (moved / "config.fish").exists() and not target.exists()
    assert move_aside(target, aside, home) is None


def test_format_size() -> None:
    assert format_size(512) == "512 B"
    assert format_size(1536) == "1.5 KB"
    assert format_size(3 * 1024**3) == "3.0 GB"
```

- [ ] **Step 3: Écrire `tests/test_context.py`**

```python
import json
from pathlib import Path

from osx_backup.context import Context
from osx_backup.runner import RecordingRunner


def test_module_dir_created(ctx: Context) -> None:
    d = ctx.module_dir("brew")
    assert d == ctx.dest / "brew" and d.is_dir()


def test_module_dir_not_created_in_dry_run(ctx: Context) -> None:
    ctx.dry_run = True
    assert not ctx.module_dir("brew").exists()


def test_place_moves_existing_then_copies(ctx: Context) -> None:
    src = ctx.dest / "dotfiles" / ".zshrc"
    src.parent.mkdir(parents=True)
    src.write_text("new")
    target = ctx.home / ".zshrc"
    target.write_text("old")
    ctx.place(src, target)
    assert target.read_text() == "new"
    assert (ctx.pre_restore_dir / ".zshrc").read_text() == "old"


def test_place_dry_run_touches_nothing(ctx: Context) -> None:
    ctx.dry_run = True
    src = ctx.dest / "x"
    src.write_text("new")
    ctx.place(src, ctx.home / "x")
    assert not (ctx.home / "x").exists()


def test_log_writes_file(fake_home: Path, dest: Path) -> None:
    log = dest / "logs" / "run.log"
    ctx = Context(home=fake_home, dest=dest, runner=RecordingRunner(), log_path=log)
    ctx.log("hello")
    assert "hello" in log.read_text()


def test_write_json_and_text(ctx: Context) -> None:
    ctx.write_json(ctx.dest / "a" / "b.json", {"k": 1})
    assert json.loads((ctx.dest / "a" / "b.json").read_text()) == {"k": 1}
    ctx.dry_run = True
    ctx.write_text(ctx.dest / "nope.txt", "x")
    assert not (ctx.dest / "nope.txt").exists()
```

- [ ] **Step 4: Remplacer `tests/conftest.py`**

```python
from pathlib import Path

import pytest
from rich.console import Console

from osx_backup.context import Context
from osx_backup.runner import RecordingRunner


@pytest.fixture
def fake_home(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
    home = tmp_path / "home"
    home.mkdir()
    monkeypatch.setenv("HOME", str(home))
    return home


@pytest.fixture
def dest(tmp_path: Path) -> Path:
    d = tmp_path / "dest"
    d.mkdir()
    return d


@pytest.fixture
def ctx(fake_home: Path, dest: Path) -> Context:
    return Context(
        home=fake_home,
        dest=dest,
        runner=RecordingRunner(),
        console=Console(record=True, width=120),
    )
```

- [ ] **Step 5: Vérifier que les tests échouent**

Run: `uv run pytest tests/test_fsutil.py tests/test_context.py -q`
Expected: erreurs d'import.

- [ ] **Step 6: Écrire `src/osx_backup/fsutil.py`**

```python
"""Copie filtrée, tailles, exclusions glob, déplacement de l'existant."""

from __future__ import annotations

import os
import shutil
from collections.abc import Sequence
from fnmatch import fnmatch
from pathlib import Path

DEPENDENCY_DIRS: list[str] = [
    "node_modules",
    ".venv",
    "venv",
    "__pycache__",
    ".pytest_cache",
    ".mypy_cache",
    ".ruff_cache",
    ".next",
    ".nuxt",
    ".svelte-kit",
    ".output",
    ".turbo",
    ".cache",
    ".parcel-cache",
    "dist",
    "build",
    "target",
]


def is_excluded(rel: Path, patterns: Sequence[str]) -> bool:
    rel_posix = rel.as_posix()
    for pattern in patterns:
        p = pattern.removeprefix("**/")
        if "/" in p:
            if fnmatch(rel_posix, p) or fnmatch(rel_posix, p + "/*"):
                return True
        elif any(fnmatch(part, p) for part in rel.parts):
            return True
    return False


def _copy_symlink(src: Path, dst: Path) -> None:
    if dst.is_symlink() or dst.exists():
        if dst.is_dir() and not dst.is_symlink():
            shutil.rmtree(dst)
        else:
            dst.unlink()
    os.symlink(os.readlink(src), dst)


def copy_tree(src: Path, dst: Path, excludes: Sequence[str] = ()) -> int:
    """Copie src (fichier ou dossier) vers dst en appliquant les exclusions. Retourne les octets."""
    if src.is_symlink() and not src.is_dir():
        dst.parent.mkdir(parents=True, exist_ok=True)
        _copy_symlink(src, dst)
        return 0
    if src.is_file():
        dst.parent.mkdir(parents=True, exist_ok=True)
        shutil.copy2(src, dst)
        return src.stat().st_size
    total = 0
    for root, dirs, files in os.walk(src):
        root_p = Path(root)
        rel_root = root_p.relative_to(src)
        kept: list[str] = []
        for d in dirs:
            rel = rel_root / d
            if is_excluded(rel, excludes):
                continue
            if (root_p / d).is_symlink():
                (dst / rel_root).mkdir(parents=True, exist_ok=True)
                _copy_symlink(root_p / d, dst / rel)
                continue
            kept.append(d)
        dirs[:] = kept
        (dst / rel_root).mkdir(parents=True, exist_ok=True)
        for f in files:
            rel = rel_root / f
            if is_excluded(rel, excludes):
                continue
            sp, dp = root_p / f, dst / rel
            if sp.is_symlink():
                _copy_symlink(sp, dp)
                continue
            shutil.copy2(sp, dp)
            total += sp.stat().st_size
    return total


def tree_size(src: Path, excludes: Sequence[str] = ()) -> tuple[int, int]:
    """(nombre de fichiers, octets) de src après exclusions. Symlinks comptés 0 octet."""
    if src.is_file():
        return 1, src.stat().st_size
    files_count = total = 0
    for root, dirs, files in os.walk(src):
        rel_root = Path(root).relative_to(src)
        dirs[:] = [d for d in dirs if not is_excluded(rel_root / d, excludes)]
        for f in files:
            if is_excluded(rel_root / f, excludes):
                continue
            p = Path(root) / f
            files_count += 1
            if not p.is_symlink():
                total += p.stat().st_size
    return files_count, total


def move_aside(path: Path, aside_root: Path, home: Path) -> Path | None:
    """Déplace path (s'il existe) sous aside_root en conservant son chemin relatif à home."""
    if not path.exists() and not path.is_symlink():
        return None
    rel = path.relative_to(home) if path.is_relative_to(home) else Path(path.name)
    target = aside_root / rel
    target.parent.mkdir(parents=True, exist_ok=True)
    shutil.move(str(path), str(target))
    return target


def format_size(n: int) -> str:
    value = float(n)
    for unit in ("B", "KB", "MB", "GB", "TB"):
        if value < 1024 or unit == "TB":
            return f"{int(value)} {unit}" if unit == "B" else f"{value:.1f} {unit}"
        value /= 1024
    return f"{value:.1f} TB"
```

- [ ] **Step 7: Écrire `src/osx_backup/context.py`**

```python
"""Contexte d'exécution partagé par tous les modules."""

from __future__ import annotations

import json
from collections.abc import Sequence
from dataclasses import dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

from rich.console import Console

from osx_backup.fsutil import copy_tree, move_aside
from osx_backup.runner import CommandRunner


def _now() -> str:
    return datetime.now().strftime("%Y-%m-%d_%H-%M-%S")


@dataclass
class Context:
    home: Path
    dest: Path
    runner: CommandRunner
    console: Console = field(default_factory=Console)
    dry_run: bool = False
    yes: bool = False
    verbose: bool = False
    force: bool = False
    docker_no_stop: bool = False
    docker_include_anonymous: bool = False
    passphrase: str | None = None
    timestamp: str = field(default_factory=_now)
    log_path: Path | None = None
    applications_dir: Path = Path("/Applications")
    map_path: Path | None = None
    old_home: Path | None = None

    @property
    def pre_restore_dir(self) -> Path:
        return self.home / ".osx-backup-pre-restore" / self.timestamp

    def module_dir(self, name: str) -> Path:
        d = self.dest / name
        if not self.dry_run:
            d.mkdir(parents=True, exist_ok=True)
        return d

    def log(self, message: str) -> None:
        if self.log_path is not None:
            self.log_path.parent.mkdir(parents=True, exist_ok=True)
            stamp = datetime.now().strftime("%H:%M:%S")
            with self.log_path.open("a", encoding="utf-8") as fh:
                fh.write(f"[{stamp}] {message}\n")
        if self.verbose:
            self.console.print(message, markup=False, highlight=False)

    def warn(self, message: str) -> None:
        self.console.print(f"[yellow]⚠ {message}[/]")
        self.log("WARN " + message)

    def place(self, src: Path, dst: Path, excludes: Sequence[str] = ()) -> int:
        """Copie src vers dst dans $HOME en mettant l'existant de côté. Retourne les octets."""
        if self.dry_run:
            self.log(f"[dry-run] place {src} -> {dst}")
            return 0
        moved = move_aside(dst, self.pre_restore_dir, self.home)
        if moved is not None:
            self.log(f"existant déplacé : {dst} -> {moved}")
        return copy_tree(src, dst, excludes)

    def write_text(self, path: Path, text: str) -> None:
        if self.dry_run:
            self.log(f"[dry-run] write {path} ({len(text)} chars)")
            return
        path.parent.mkdir(parents=True, exist_ok=True)
        path.write_text(text, encoding="utf-8")

    def write_json(self, path: Path, obj: Any) -> None:
        self.write_text(path, json.dumps(obj, indent=2, ensure_ascii=False, sort_keys=True))
```

- [ ] **Step 8: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: tous verts.

- [ ] **Step 9: Commit et merge**

```bash
git add src/osx_backup/fsutil.py src/osx_backup/context.py tests/conftest.py tests/test_fsutil.py tests/test_context.py
```
Appel séparé : `git commit -m "feat: utilitaires fichiers et contexte d'exécution"`. Puis :
```bash
git checkout dev && git merge feature/context --ff-only && git branch -d feature/context
```

---

### Task 4: Modèle de module et registre

**Files:**
- Create: `src/osx_backup/modules/base.py`
- Test: `tests/test_base.py`

**Interfaces:**
- Consumes: `Context` (Task 3).
- Produces:
  - `Inventory(available: bool = True, reason: str = "", items: int = 0, size_bytes: int = 0, warnings: list[str] = [], details: dict[str, Any] = {})`.
  - `ModuleResult(status: Literal["ok", "failed", "skipped"], message: str = "", size_bytes: int = 0, duration_s: float = 0.0, items: int = 0, warnings: list[str] = [])` avec constructeurs `ModuleResult.ok(message="", **kw)`, `ModuleResult.failed(message, **kw)`, `ModuleResult.skipped(message)` et méthode `to_dict() -> dict`.
  - `TaskHandle` (ABC) : `advance(n: float = 1) -> None`, `set_total(total: float) -> None`, `set_completed(completed: float) -> None`, `finish() -> None`.
  - `ProgressReporter` (ABC) : `task(description: str, total: float | None = None) -> TaskHandle`.
  - `NullProgress(ProgressReporter)` : enregistre `tasks: list[NullTask]` ; `NullTask` a `description`, `total`, `completed`, `finished`.
  - `Module` (ABC) : attributs de classe `name: str`, `description: str`, `order: int` ; méthodes abstraites `inventory(ctx) -> Inventory`, `backup(ctx, progress) -> ModuleResult`, `restore(ctx, progress) -> ModuleResult`.
  - `register(cls: type[Module]) -> type[Module]`, `get_modules() -> list[Module]` (instances triées par `order`), `get_module(name: str) -> Module` (KeyError si inconnu), `clear_registry()` (tests), `_REGISTRY: dict[str, type[Module]]`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/module-base
```

- [ ] **Step 2: Écrire `tests/test_base.py`**

```python
import pytest

from osx_backup.context import Context
from osx_backup.modules import base
from osx_backup.modules.base import (
    Inventory,
    Module,
    ModuleResult,
    NullProgress,
    ProgressReporter,
    clear_registry,
    get_module,
    get_modules,
    register,
)


class Dummy(Module):
    name = "dummy"
    description = "test"
    order = 5

    def inventory(self, ctx: Context) -> Inventory:
        return Inventory(items=1)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        t = progress.task("dummy", total=2)
        t.advance()
        t.advance()
        t.finish()
        return ModuleResult.ok("done", items=1)

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.skipped("rien")


@pytest.fixture(autouse=True)
def _clean_registry():
    saved = dict(base._REGISTRY)
    clear_registry()
    yield
    clear_registry()
    base._REGISTRY.update(saved)


def test_register_and_sort() -> None:
    class Later(Dummy):
        name = "later"
        order = 50

    register(Later)
    register(Dummy)
    assert [m.name for m in get_modules()] == ["dummy", "later"]
    assert isinstance(get_module("later"), Later)
    with pytest.raises(KeyError):
        get_module("nope")


def test_result_helpers() -> None:
    ok = ModuleResult.ok("x", size_bytes=3)
    assert ok.status == "ok" and ok.size_bytes == 3
    failed = ModuleResult.failed("boom", warnings=["w"])
    assert failed.status == "failed" and failed.warnings == ["w"]
    assert ModuleResult.skipped("s").status == "skipped"
    assert ok.to_dict()["status"] == "ok"


def test_null_progress_records(ctx: Context) -> None:
    p = NullProgress()
    Dummy().backup(ctx, p)
    assert p.tasks[0].description == "dummy"
    assert p.tasks[0].completed == 2 and p.tasks[0].finished
```

- [ ] **Step 3: Vérifier que les tests échouent**

Run: `uv run pytest tests/test_base.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/modules/base.py`**

```python
"""Contrat des modules : inventaire, backup, restore, progression, registre."""

from __future__ import annotations

from abc import ABC, abstractmethod
from dataclasses import asdict, dataclass, field
from typing import Any, ClassVar, Literal

from osx_backup.context import Context

Status = Literal["ok", "failed", "skipped"]


@dataclass
class Inventory:
    available: bool = True
    reason: str = ""
    items: int = 0
    size_bytes: int = 0
    warnings: list[str] = field(default_factory=list)
    details: dict[str, Any] = field(default_factory=dict)


@dataclass
class ModuleResult:
    status: Status
    message: str = ""
    size_bytes: int = 0
    duration_s: float = 0.0
    items: int = 0
    warnings: list[str] = field(default_factory=list)

    @classmethod
    def ok(cls, message: str = "", **kw: Any) -> ModuleResult:
        return cls("ok", message, **kw)

    @classmethod
    def failed(cls, message: str, **kw: Any) -> ModuleResult:
        return cls("failed", message, **kw)

    @classmethod
    def skipped(cls, message: str = "") -> ModuleResult:
        return cls("skipped", message)

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)


class TaskHandle(ABC):
    @abstractmethod
    def advance(self, n: float = 1) -> None: ...

    @abstractmethod
    def set_total(self, total: float) -> None: ...

    @abstractmethod
    def set_completed(self, completed: float) -> None: ...

    @abstractmethod
    def finish(self) -> None: ...


class ProgressReporter(ABC):
    @abstractmethod
    def task(self, description: str, total: float | None = None) -> TaskHandle: ...


class NullTask(TaskHandle):
    def __init__(self, description: str, total: float | None) -> None:
        self.description = description
        self.total = total
        self.completed = 0.0
        self.finished = False

    def advance(self, n: float = 1) -> None:
        self.completed += n

    def set_total(self, total: float) -> None:
        self.total = total

    def set_completed(self, completed: float) -> None:
        self.completed = completed

    def finish(self) -> None:
        self.finished = True


class NullProgress(ProgressReporter):
    def __init__(self) -> None:
        self.tasks: list[NullTask] = []

    def task(self, description: str, total: float | None = None) -> TaskHandle:
        t = NullTask(description, total)
        self.tasks.append(t)
        return t


class Module(ABC):
    name: ClassVar[str]
    description: ClassVar[str]
    order: ClassVar[int]

    @abstractmethod
    def inventory(self, ctx: Context) -> Inventory: ...

    @abstractmethod
    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult: ...

    @abstractmethod
    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult: ...


_REGISTRY: dict[str, type[Module]] = {}


def register(cls: type[Module]) -> type[Module]:
    _REGISTRY[cls.name] = cls
    return cls


def clear_registry() -> None:
    _REGISTRY.clear()


def get_modules() -> list[Module]:
    return [cls() for cls in sorted(_REGISTRY.values(), key=lambda c: c.order)]


def get_module(name: str) -> Module:
    return _REGISTRY[name]()
```

- [ ] **Step 5: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 6: Commit et merge**

```bash
git add src/osx_backup/modules/base.py tests/test_base.py
```
Appel séparé : `git commit -m "feat: contrat des modules, progression et registre"`. Puis :
```bash
git checkout dev && git merge feature/module-base --ff-only && git branch -d feature/module-base
```

---

### Task 5: Manifeste

**Files:**
- Create: `src/osx_backup/manifest.py`
- Test: `tests/test_manifest.py`

**Interfaces:**
- Consumes: `ModuleResult` (Task 4), `Context` (Task 3), `__version__` (Task 1).
- Produces:
  - `Manifest(created_at: str, action: str, hostname: str, user: str, home: str, tool_version: str, modules: dict[str, dict[str, Any]])` avec `to_dict()`, `Manifest.from_dict(d)`, propriété `module_names: list[str]` (modules en statut ok).
  - `build_manifest(ctx: Context, action: str, results: dict[str, ModuleResult]) -> Manifest`.
  - `write_manifest(dest: Path, manifest: Manifest) -> Path` : écrit `<dest>/manifest.json` et `<dest>/manifests/<created_at>.json`.
  - `read_manifest(dest: Path) -> Manifest | None`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/manifest
```

- [ ] **Step 2: Écrire `tests/test_manifest.py`**

```python
import json
from pathlib import Path

from osx_backup.context import Context
from osx_backup.manifest import build_manifest, read_manifest, write_manifest
from osx_backup.modules.base import ModuleResult


def test_build_write_read(ctx: Context) -> None:
    results = {
        "brew": ModuleResult.ok("ok", size_bytes=10, items=3),
        "docker": ModuleResult.failed("boom"),
    }
    m = build_manifest(ctx, "backup", results)
    assert m.action == "backup" and m.home == str(ctx.home)
    assert m.module_names == ["brew"]
    path = write_manifest(ctx.dest, m)
    assert path == ctx.dest / "manifest.json"
    history = list((ctx.dest / "manifests").glob("*.json"))
    assert len(history) == 1
    loaded = read_manifest(ctx.dest)
    assert loaded is not None
    assert loaded.modules["docker"]["status"] == "failed"
    assert loaded.modules["brew"]["items"] == 3
    assert json.loads(path.read_text())["tool_version"] == loaded.tool_version


def test_read_missing(dest: Path) -> None:
    assert read_manifest(dest) is None
```

- [ ] **Step 3: Vérifier l'échec**

Run: `uv run pytest tests/test_manifest.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/manifest.py`**

```python
"""Manifeste d'un run : quels modules, quel statut, quelle machine."""

from __future__ import annotations

import getpass
import json
import socket
from dataclasses import asdict, dataclass, field
from datetime import datetime
from pathlib import Path
from typing import Any

from osx_backup import __version__
from osx_backup.context import Context
from osx_backup.modules.base import ModuleResult


@dataclass
class Manifest:
    created_at: str
    action: str
    hostname: str
    user: str
    home: str
    tool_version: str
    modules: dict[str, dict[str, Any]] = field(default_factory=dict)

    @property
    def module_names(self) -> list[str]:
        return [n for n, m in self.modules.items() if m.get("status") == "ok"]

    def to_dict(self) -> dict[str, Any]:
        return asdict(self)

    @classmethod
    def from_dict(cls, d: dict[str, Any]) -> Manifest:
        return cls(
            created_at=d["created_at"],
            action=d.get("action", "backup"),
            hostname=d.get("hostname", ""),
            user=d.get("user", ""),
            home=d.get("home", ""),
            tool_version=d.get("tool_version", ""),
            modules=d.get("modules", {}),
        )


def build_manifest(ctx: Context, action: str, results: dict[str, ModuleResult]) -> Manifest:
    return Manifest(
        created_at=datetime.now().isoformat(timespec="seconds"),
        action=action,
        hostname=socket.gethostname(),
        user=getpass.getuser(),
        home=str(ctx.home),
        tool_version=__version__,
        modules={name: r.to_dict() for name, r in results.items()},
    )


def write_manifest(dest: Path, manifest: Manifest) -> Path:
    text = json.dumps(manifest.to_dict(), indent=2, ensure_ascii=False)
    path = dest / "manifest.json"
    path.write_text(text, encoding="utf-8")
    history = dest / "manifests"
    history.mkdir(exist_ok=True)
    stamp = manifest.created_at.replace(":", "-")
    (history / f"{stamp}.json").write_text(text, encoding="utf-8")
    return path


def read_manifest(dest: Path) -> Manifest | None:
    path = dest / "manifest.json"
    if not path.exists():
        return None
    return Manifest.from_dict(json.loads(path.read_text(encoding="utf-8")))
```

- [ ] **Step 5: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 6: Commit et merge**

```bash
git add src/osx_backup/manifest.py tests/test_manifest.py
```
Appel séparé : `git commit -m "feat: manifeste de run avec historique"`. Puis :
```bash
git checkout dev && git merge feature/manifest --ff-only && git branch -d feature/manifest
```

---
### Task 6: Map TOML, ensembles de chemins et module dotfiles

**Files:**
- Create: `map.toml`
- Create: `src/osx_backup/mapfile.py`
- Create: `src/osx_backup/modules/pathset.py`
- Create: `src/osx_backup/modules/dotfiles.py`
- Modify: `src/osx_backup/fsutil.py` (ajout `remove_path`)
- Modify: `src/osx_backup/modules/__init__.py` (import du module)
- Modify: `tests/conftest.py` (fixture `map_path`)
- Test: `tests/test_mapfile.py`, `tests/test_pathset.py`, `tests/test_dotfiles.py`

**Interfaces:**
- Consumes: `Context`, `copy_tree`, `tree_size`, `Module`, `Inventory`, `ModuleResult`, `ProgressReporter`, `register`.
- Produces (fsutil) : `remove_path(path: Path) -> None` (supprime fichier, symlink ou dossier ; silencieux si absent).
- Produces (mapfile) :
  - `GLOBAL_EXCLUDES: list[str]` = `["**/cache", "**/Cache", "**/caches", "**/logs", "**/*.log", "**/node_modules", "**/.DS_Store", "**/*.sock"]`.
  - `DotfileEntry(path: str, secret: bool = False, exclude: tuple[str, ...] = (), description: str = "")` (frozen).
  - `default_map_path() -> Path` (variable `OSX_BACKUP_MAP` sinon `<repo>/map.toml`).
  - `load_map(path: Path | None = None) -> list[DotfileEntry]`.
- Produces (pathset) :
  - `PathItem(rel: str, excludes: tuple[str, ...] = ())` (frozen).
  - `inventory_paths(ctx: Context, items: Sequence[PathItem]) -> Inventory`.
  - `backup_paths(ctx: Context, module_name: str, items: Sequence[PathItem], progress: ProgressReporter) -> ModuleResult` : copie chaque item existant vers `<dest>/<module_name>/<rel>` et écrit `<dest>/<module_name>/_entries.json` (liste de `rel`).
  - `restore_paths(ctx: Context, module_name: str, progress: ProgressReporter) -> ModuleResult` : lit `_entries.json`, `ctx.place` chaque entrée vers `~/<rel>`.
- Produces (dotfiles) : `DotfilesModule` (`name="dotfiles"`, `order=40`).
- Fixture `map_path(tmp_path) -> Path` : écrit un `map.toml` de test avec `.zshrc`, `.config/fish` (exclude `fish_variables`), `.ssh` (secret), `.config/gh` (secret).

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/dotfiles
```

- [ ] **Step 2: Écrire `map.toml`**

```toml
# Liste blanche des dotfiles sauvegardés. path relatif à ~.
# secret = true : va dans l'archive chiffrée (module secrets) au lieu de dotfiles/.
# exclude : globs supplémentaires (en plus des exclusions globales cache/logs/node_modules).

[[dotfiles]]
path = ".zshrc"
description = "zsh"

[[dotfiles]]
path = ".zprofile"
description = "zsh profile"

[[dotfiles]]
path = ".gitconfig"
description = "git global"

[[dotfiles]]
path = ".tmux.conf"
description = "tmux"

[[dotfiles]]
path = ".vim"
description = "vim"

[[dotfiles]]
path = ".config/fish"
description = "fish shell"

[[dotfiles]]
path = ".config/git"
description = "git (xdg)"

[[dotfiles]]
path = ".config/starship.toml"
description = "starship prompt"

[[dotfiles]]
path = ".config/direnv"
description = "direnv"

[[dotfiles]]
path = ".config/beets"
description = "beets"

[[dotfiles]]
path = ".config/cmux"
description = "cmux"

[[dotfiles]]
path = ".config/herdr"
description = "herdr"

[[dotfiles]]
path = ".codex"
description = "Codex CLI"
exclude = ["sessions", "history.jsonl"]

[[dotfiles]]
path = ".gemini"
description = "Gemini CLI"
exclude = ["tmp", "history"]

[[dotfiles]]
path = ".agents"
description = "agents partagés"

[[dotfiles]]
path = ".cursor"
description = "Cursor"

[[dotfiles]]
path = ".continue"
description = "Continue"

[[dotfiles]]
path = ".serena"
description = "Serena"

[[dotfiles]]
path = ".claude-squad"
description = "claude-squad"

[[dotfiles]]
path = ".hermes"
description = "Hermes"

# --- secrets ---

[[dotfiles]]
path = ".ssh"
secret = true
description = "clés SSH"

[[dotfiles]]
path = ".gnupg"
secret = true
description = "GPG"
exclude = ["S.*", "*.lock", "random_seed"]

[[dotfiles]]
path = ".config/sops"
secret = true
description = "sops"

[[dotfiles]]
path = ".config/age"
secret = true
description = "clés age"

[[dotfiles]]
path = ".config/gh"
secret = true
description = "GitHub CLI"

[[dotfiles]]
path = ".config/gcloud"
secret = true
description = "gcloud"
exclude = ["logs", "*.db"]

[[dotfiles]]
path = ".claude.json"
secret = true
description = "état Claude Code (tokens MCP)"

[[dotfiles]]
path = ".docker/config.json"
secret = true
description = "registres Docker"

[[dotfiles]]
path = ".npmrc"
secret = true
description = "npm"

[[dotfiles]]
path = ".netrc"
secret = true
description = "netrc"

[[dotfiles]]
path = ".aws"
secret = true
description = "AWS"

[[dotfiles]]
path = ".boto"
secret = true
description = "boto"

[[dotfiles]]
path = ".railway"
secret = true
description = "Railway"

[[dotfiles]]
path = ".config/stripe"
secret = true
description = "Stripe CLI"

[[dotfiles]]
path = ".config/vercel-plugin"
secret = true
description = "Vercel"

[[dotfiles]]
path = ".snowflake"
secret = true
description = "Snowflake"

[[dotfiles]]
path = ".cloudflared"
secret = true
description = "cloudflared"
```

- [ ] **Step 3: Ajouter la fixture `map_path` à `tests/conftest.py`**

Ajouter à la fin du fichier :

```python
@pytest.fixture
def map_path(tmp_path: Path) -> Path:
    p = tmp_path / "map.toml"
    p.write_text(
        '[[dotfiles]]\npath = ".zshrc"\ndescription = "zsh"\n\n'
        '[[dotfiles]]\npath = ".config/fish"\nexclude = ["fish_variables"]\n\n'
        '[[dotfiles]]\npath = ".ssh"\nsecret = true\n\n'
        '[[dotfiles]]\npath = ".config/gh"\nsecret = true\n'
    )
    return p
```

- [ ] **Step 4: Écrire `tests/test_mapfile.py`**

```python
from pathlib import Path

import pytest

from osx_backup.mapfile import GLOBAL_EXCLUDES, DotfileEntry, default_map_path, load_map


def test_load_map(map_path: Path) -> None:
    entries = load_map(map_path)
    assert entries[0] == DotfileEntry(path=".zshrc", description="zsh")
    assert entries[1].exclude == ("fish_variables",)
    assert [e.path for e in entries if e.secret] == [".ssh", ".config/gh"]


def test_default_map_path_env(monkeypatch: pytest.MonkeyPatch, tmp_path: Path) -> None:
    monkeypatch.setenv("OSX_BACKUP_MAP", str(tmp_path / "m.toml"))
    assert default_map_path() == tmp_path / "m.toml"


def test_repo_map_loads() -> None:
    entries = load_map()
    assert any(e.path == ".ssh" and e.secret for e in entries)
    assert any(e.path == ".config/fish" and not e.secret for e in entries)


def test_global_excludes() -> None:
    assert "**/node_modules" in GLOBAL_EXCLUDES
```

- [ ] **Step 5: Écrire `tests/test_pathset.py`**

```python
import json

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.pathset import PathItem, backup_paths, inventory_paths, restore_paths


def _seed(ctx: Context) -> None:
    (ctx.home / ".zshrc").write_text("zsh")
    fish = ctx.home / ".config" / "fish"
    fish.mkdir(parents=True)
    (fish / "config.fish").write_text("fish")
    (fish / "fish_variables").write_text("vars")
    (fish / "cache").mkdir()
    (fish / "cache" / "x").write_text("cache")


ITEMS = [PathItem(".zshrc"), PathItem(".config/fish", ("fish_variables",)), PathItem(".missing")]


def test_inventory_paths(ctx: Context) -> None:
    _seed(ctx)
    inv = inventory_paths(ctx, ITEMS)
    assert inv.items == 2
    assert inv.size_bytes == len("zsh") + len("fish")
    assert inv.warnings == ["absent : ~/.missing"]


def test_backup_and_restore_roundtrip(ctx: Context, fake_home) -> None:
    _seed(ctx)
    res = backup_paths(ctx, "dotfiles", ITEMS, NullProgress())
    assert res.status == "ok" and res.items == 2
    out = ctx.dest / "dotfiles"
    assert (out / ".zshrc").read_text() == "zsh"
    assert (out / ".config" / "fish" / "config.fish").exists()
    assert not (out / ".config" / "fish" / "fish_variables").exists()
    assert not (out / ".config" / "fish" / "cache").exists()
    assert json.loads((out / "_entries.json").read_text()) == [".zshrc", ".config/fish"]

    (ctx.home / ".zshrc").write_text("old")
    res2 = restore_paths(ctx, "dotfiles", NullProgress())
    assert res2.status == "ok" and res2.items == 2
    assert (ctx.home / ".zshrc").read_text() == "zsh"
    assert (ctx.pre_restore_dir / ".zshrc").read_text() == "old"


def test_restore_without_backup_is_skipped(ctx: Context) -> None:
    assert restore_paths(ctx, "dotfiles", NullProgress()).status == "skipped"


def test_backup_dry_run_writes_nothing(ctx: Context) -> None:
    _seed(ctx)
    ctx.dry_run = True
    res = backup_paths(ctx, "dotfiles", ITEMS, NullProgress())
    assert res.status == "ok"
    assert not (ctx.dest / "dotfiles").exists()
```

- [ ] **Step 6: Écrire `tests/test_dotfiles.py`**

```python
from pathlib import Path

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress, get_module
from osx_backup.modules.dotfiles import DotfilesModule


def test_registered() -> None:
    assert isinstance(get_module("dotfiles"), DotfilesModule)


def test_dotfiles_ignores_secret_entries(ctx: Context, map_path: Path) -> None:
    ctx.map_path = map_path
    (ctx.home / ".zshrc").write_text("z")
    (ctx.home / ".ssh").mkdir()
    (ctx.home / ".ssh" / "id").write_text("k")
    m = DotfilesModule()
    inv = m.inventory(ctx)
    assert inv.items == 1 and inv.available
    m.backup(ctx, NullProgress())
    assert (ctx.dest / "dotfiles" / ".zshrc").exists()
    assert not (ctx.dest / "dotfiles" / ".ssh").exists()
```

- [ ] **Step 7: Vérifier l'échec**

Run: `uv run pytest tests/test_mapfile.py tests/test_pathset.py tests/test_dotfiles.py -q`
Expected: erreurs d'import.

- [ ] **Step 8: Ajouter `remove_path` à `src/osx_backup/fsutil.py`**

À la fin du fichier :

```python
def remove_path(path: Path) -> None:
    if path.is_symlink() or path.is_file():
        path.unlink()
    elif path.is_dir():
        shutil.rmtree(path)
```

- [ ] **Step 9: Écrire `src/osx_backup/mapfile.py`**

```python
"""Lecture de map.toml : la liste blanche des dotfiles."""

from __future__ import annotations

import os
import tomllib
from dataclasses import dataclass
from pathlib import Path

GLOBAL_EXCLUDES: list[str] = [
    "**/cache",
    "**/Cache",
    "**/caches",
    "**/logs",
    "**/*.log",
    "**/node_modules",
    "**/.DS_Store",
    "**/*.sock",
]


@dataclass(frozen=True)
class DotfileEntry:
    path: str
    secret: bool = False
    exclude: tuple[str, ...] = ()
    description: str = ""


def default_map_path() -> Path:
    env = os.environ.get("OSX_BACKUP_MAP")
    if env:
        return Path(env)
    return Path(__file__).resolve().parents[2] / "map.toml"


def load_map(path: Path | None = None) -> list[DotfileEntry]:
    p = path or default_map_path()
    data = tomllib.loads(p.read_text(encoding="utf-8"))
    return [
        DotfileEntry(
            path=raw["path"],
            secret=bool(raw.get("secret", False)),
            exclude=tuple(raw.get("exclude", [])),
            description=str(raw.get("description", "")),
        )
        for raw in data.get("dotfiles", [])
    ]
```

- [ ] **Step 10: Écrire `src/osx_backup/modules/pathset.py`**

```python
"""Sauvegarde et restauration d'une liste de chemins relatifs à $HOME."""

from __future__ import annotations

import json
from collections.abc import Sequence
from dataclasses import dataclass

from osx_backup.context import Context
from osx_backup.fsutil import copy_tree, remove_path, tree_size
from osx_backup.mapfile import GLOBAL_EXCLUDES
from osx_backup.modules.base import Inventory, ModuleResult, ProgressReporter

ENTRIES_FILE = "_entries.json"


@dataclass(frozen=True)
class PathItem:
    rel: str
    excludes: tuple[str, ...] = ()

    @property
    def all_excludes(self) -> list[str]:
        return GLOBAL_EXCLUDES + list(self.excludes)


def inventory_paths(ctx: Context, items: Sequence[PathItem]) -> Inventory:
    count = size = 0
    warnings: list[str] = []
    for item in items:
        src = ctx.home / item.rel
        if not src.exists():
            warnings.append(f"absent : ~/{item.rel}")
            continue
        _, s = tree_size(src, item.all_excludes)
        count += 1
        size += s
    return Inventory(items=count, size_bytes=size, warnings=warnings)


def backup_paths(
    ctx: Context, module_name: str, items: Sequence[PathItem], progress: ProgressReporter
) -> ModuleResult:
    present = [i for i in items if (ctx.home / i.rel).exists()]
    out = ctx.module_dir(module_name)
    task = progress.task(module_name, total=len(present))
    total = 0
    for item in present:
        src, dst = ctx.home / item.rel, out / item.rel
        if ctx.dry_run:
            ctx.log(f"[dry-run] copy {src} -> {dst}")
        else:
            remove_path(dst)
            total += copy_tree(src, dst, item.all_excludes)
        task.advance()
    task.finish()
    ctx.write_json(out / ENTRIES_FILE, [i.rel for i in present])
    return ModuleResult.ok(f"{len(present)} entrées copiées", items=len(present), size_bytes=total)


def restore_paths(ctx: Context, module_name: str, progress: ProgressReporter) -> ModuleResult:
    src_root = ctx.dest / module_name
    entries_file = src_root / ENTRIES_FILE
    if not entries_file.exists():
        return ModuleResult.skipped(f"aucune donnée {module_name} dans la sauvegarde")
    rels: list[str] = json.loads(entries_file.read_text(encoding="utf-8"))
    task = progress.task(module_name, total=len(rels))
    total = 0
    for rel in rels:
        total += ctx.place(src_root / rel, ctx.home / rel)
        task.advance()
    task.finish()
    return ModuleResult.ok(f"{len(rels)} entrées restaurées", items=len(rels), size_bytes=total)
```

- [ ] **Step 11: Écrire `src/osx_backup/modules/dotfiles.py`**

```python
"""Dotfiles non secrets déclarés dans map.toml."""

from __future__ import annotations

from osx_backup.context import Context
from osx_backup.mapfile import load_map
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register
from osx_backup.modules.pathset import PathItem, backup_paths, inventory_paths, restore_paths


def dotfile_items(ctx: Context) -> list[PathItem]:
    return [PathItem(e.path, e.exclude) for e in load_map(ctx.map_path) if not e.secret]


@register
class DotfilesModule(Module):
    name = "dotfiles"
    description = "Fichiers de config déclarés dans map.toml (hors secrets)"
    order = 40

    def inventory(self, ctx: Context) -> Inventory:
        return inventory_paths(ctx, dotfile_items(ctx))

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return backup_paths(ctx, self.name, dotfile_items(ctx), progress)

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return restore_paths(ctx, self.name, progress)
```

- [ ] **Step 12: Enregistrer le module dans `src/osx_backup/modules/__init__.py`**

```python
"""Modules de sauvegarde. Chaque module s'enregistre à l'import (voir base.register)."""

from osx_backup.modules import dotfiles  # noqa: F401
```

- [ ] **Step 13: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 14: Commit et merge**

```bash
git add map.toml src/osx_backup/fsutil.py src/osx_backup/mapfile.py src/osx_backup/modules tests/conftest.py tests/test_mapfile.py tests/test_pathset.py tests/test_dotfiles.py
```
Appel séparé : `git commit -m "feat: map.toml, ensembles de chemins et module dotfiles"`. Puis :
```bash
git checkout dev && git merge feature/dotfiles --ff-only && git branch -d feature/dotfiles
```

---

### Task 7: Module secrets (tar + age)

**Files:**
- Create: `src/osx_backup/modules/secrets.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_secrets.py`

**Interfaces:**
- Consumes: `load_map`, `GLOBAL_EXCLUDES`, `DEPENDENCY_DIRS`, `is_excluded`, `Context.place`, `pyrage.passphrase`.
- Produces:
  - `SECRETS_FILE = "secrets.tar.age"`.
  - `collect_secret_paths(ctx: Context) -> list[Path]` : chemins relatifs à `~` (entrées `secret` de la map existantes + tous les fichiers dont le nom commence par `.env` sous `~/Github`, en ignorant `DEPENDENCY_DIRS` et `.git`).
  - `get_passphrase(ctx: Context, confirm: bool) -> str` : `ctx.passphrase`, sinon `OSX_BACKUP_PASSPHRASE`, sinon prompt `getpass` (deux fois si `confirm`) ; `RuntimeError` si `ctx.yes` sans passphrase, `ValueError` si vide ou différente.
  - `build_archive(ctx: Context, rels: Sequence[Path], on_file: Callable[[], None] | None = None) -> bytes` : tar (gzip) avec `arcname` = chemin relatif posix, exclusions `GLOBAL_EXCLUDES` + `exclude` de l'entrée de map correspondante.
  - `extract_archive(ctx: Context, data: bytes) -> int` : extraction dans un dossier temporaire puis `ctx.place` fichier par fichier ; chmod `0600` fichiers, `0700` pour `~/.ssh` et `~/.gnupg` ; retourne le nombre de fichiers.
  - `SecretsModule` (`name="secrets"`, `order=50`).

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/secrets
```

- [ ] **Step 2: Écrire `tests/test_secrets.py`**

```python
import os
import stat
from pathlib import Path

import pytest
from rich.console import Console

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.secrets import (
    SECRETS_FILE,
    SecretsModule,
    collect_secret_paths,
    get_passphrase,
)
from osx_backup.runner import RecordingRunner


def _seed(home: Path) -> None:
    (home / ".ssh").mkdir()
    (home / ".ssh" / "id_ed25519").write_text("PRIVATE")
    (home / ".config" / "gh").mkdir(parents=True)
    (home / ".config" / "gh" / "hosts.yml").write_text("token")
    proj = home / "Github" / "proj"
    proj.mkdir(parents=True)
    (proj / ".env").write_text("A=1")
    (proj / ".env.local").write_text("B=2")
    (proj / "node_modules" / "x").mkdir(parents=True)
    (proj / "node_modules" / "x" / ".env").write_text("ignored")
    (proj / ".git").mkdir()
    (proj / ".git" / ".env").write_text("ignored")


def test_collect_secret_paths(ctx: Context, map_path: Path) -> None:
    ctx.map_path = map_path
    _seed(ctx.home)
    rels = sorted(p.as_posix() for p in collect_secret_paths(ctx))
    assert rels == [".config/gh", ".ssh", "Github/proj/.env", "Github/proj/.env.local"]


def test_get_passphrase_sources(ctx: Context, monkeypatch: pytest.MonkeyPatch) -> None:
    ctx.passphrase = "ctx"
    assert get_passphrase(ctx, confirm=True) == "ctx"
    ctx.passphrase = None
    monkeypatch.setenv("OSX_BACKUP_PASSPHRASE", "env")
    assert get_passphrase(ctx, confirm=False) == "env"
    monkeypatch.delenv("OSX_BACKUP_PASSPHRASE")
    ctx.yes = True
    with pytest.raises(RuntimeError):
        get_passphrase(ctx, confirm=False)


def test_backup_restore_roundtrip(ctx: Context, map_path: Path, tmp_path: Path) -> None:
    ctx.map_path = map_path
    ctx.passphrase = "pw"
    _seed(ctx.home)
    m = SecretsModule()
    inv = m.inventory(ctx)
    assert inv.items == 4 and inv.available
    res = m.backup(ctx, NullProgress())
    assert res.status == "ok", res.message
    archive = ctx.dest / SECRETS_FILE
    assert archive.exists()
    assert b"PRIVATE" not in archive.read_bytes()
    assert not list(ctx.dest.glob("*.tar"))

    home2 = tmp_path / "home2"
    home2.mkdir()
    (home2 / ".ssh").mkdir()
    (home2 / ".ssh" / "id_ed25519").write_text("OLD")
    ctx2 = Context(
        home=home2,
        dest=ctx.dest,
        runner=RecordingRunner(),
        console=Console(record=True),
        passphrase="pw",
        map_path=map_path,
    )
    res2 = m.restore(ctx2, NullProgress())
    assert res2.status == "ok", res2.message
    assert (home2 / ".ssh" / "id_ed25519").read_text() == "PRIVATE"
    assert (home2 / "Github" / "proj" / ".env.local").read_text() == "B=2"
    assert (ctx2.pre_restore_dir / ".ssh" / "id_ed25519").read_text() == "OLD"
    assert stat.S_IMODE(os.stat(home2 / ".ssh").st_mode) == 0o700
    assert stat.S_IMODE(os.stat(home2 / ".ssh" / "id_ed25519").st_mode) == 0o600


def test_wrong_passphrase_fails_module(ctx: Context, map_path: Path) -> None:
    ctx.map_path = map_path
    ctx.passphrase = "pw"
    _seed(ctx.home)
    SecretsModule().backup(ctx, NullProgress())
    ctx.passphrase = "nope"
    res = SecretsModule().restore(ctx, NullProgress())
    assert res.status == "failed" and "passphrase" in res.message


def test_restore_without_archive_skipped(ctx: Context) -> None:
    assert SecretsModule().restore(ctx, NullProgress()).status == "skipped"
```

- [ ] **Step 3: Vérifier l'échec**

Run: `uv run pytest tests/test_secrets.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/modules/secrets.py`**

```python
"""Secrets : tar chiffré par passphrase age (pyrage)."""

from __future__ import annotations

import getpass
import io
import os
import tarfile
import tempfile
from collections.abc import Callable, Sequence
from pathlib import Path

import pyrage

from osx_backup.context import Context
from osx_backup.fsutil import DEPENDENCY_DIRS, is_excluded, tree_size
from osx_backup.mapfile import GLOBAL_EXCLUDES, load_map
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register

SECRETS_FILE = "secrets.tar.age"
PRIVATE_DIRS = (".ssh", ".gnupg")


def _entry_excludes(ctx: Context) -> dict[str, list[str]]:
    return {e.path: GLOBAL_EXCLUDES + list(e.exclude) for e in load_map(ctx.map_path) if e.secret}


def collect_secret_paths(ctx: Context) -> list[Path]:
    rels: list[Path] = []
    for entry_path in _entry_excludes(ctx):
        if (ctx.home / entry_path).exists():
            rels.append(Path(entry_path))
    github = ctx.home / "Github"
    if github.is_dir():
        skip = set(DEPENDENCY_DIRS) | {".git"}
        for root, dirs, files in os.walk(github):
            dirs[:] = [d for d in dirs if d not in skip]
            for f in files:
                if f.startswith(".env"):
                    rels.append((Path(root) / f).relative_to(ctx.home))
    return rels


def get_passphrase(ctx: Context, confirm: bool) -> str:
    if ctx.passphrase:
        return ctx.passphrase
    env = os.environ.get("OSX_BACKUP_PASSPHRASE")
    if env:
        return env
    if ctx.yes:
        raise RuntimeError("passphrase requise : définir OSX_BACKUP_PASSPHRASE en mode --yes")
    first = getpass.getpass("Passphrase de l'archive secrets : ")
    if not first:
        raise ValueError("passphrase vide")
    if confirm and getpass.getpass("Confirmer la passphrase : ") != first:
        raise ValueError("les passphrases diffèrent")
    ctx.passphrase = first
    return first


def build_archive(
    ctx: Context, rels: Sequence[Path], on_file: Callable[[], None] | None = None
) -> bytes:
    excludes = _entry_excludes(ctx)
    with tempfile.TemporaryFile() as tmp:
        with tarfile.open(fileobj=tmp, mode="w:gz") as tar:
            for rel in rels:
                patterns = excludes.get(rel.as_posix(), GLOBAL_EXCLUDES)
                base = rel

                def _filter(info: tarfile.TarInfo, _base: Path = base, _p: list[str] = patterns):
                    inner = Path(info.name).relative_to(_base) if info.name != _base.as_posix() else Path()
                    return None if inner.parts and is_excluded(inner, _p) else info

                tar.add(ctx.home / rel, arcname=rel.as_posix(), filter=_filter)
                if on_file:
                    on_file()
        tmp.seek(0)
        return tmp.read()


def extract_archive(ctx: Context, data: bytes) -> int:
    count = 0
    with tempfile.TemporaryDirectory() as tmp, tarfile.open(fileobj=io.BytesIO(data)) as tar:
        tar.extractall(tmp, filter="data")
        for member in tar.getmembers():
            if not (member.isfile() or member.issym()):
                continue
            rel = Path(member.name)
            ctx.place(Path(tmp) / rel, ctx.home / rel)
            if not ctx.dry_run and not member.issym():
                os.chmod(ctx.home / rel, 0o600)
            count += 1
    if not ctx.dry_run:
        for d in PRIVATE_DIRS:
            p = ctx.home / d
            if p.is_dir():
                os.chmod(p, 0o700)
    return count


@register
class SecretsModule(Module):
    name = "secrets"
    description = "Clés SSH/GPG/age, tokens CLI et fichiers .env, archive chiffrée (age)"
    order = 50

    def inventory(self, ctx: Context) -> Inventory:
        rels = collect_secret_paths(ctx)
        excludes = _entry_excludes(ctx)
        size = sum(tree_size(ctx.home / r, excludes.get(r.as_posix(), GLOBAL_EXCLUDES))[1] for r in rels)
        missing = [p for p in excludes if not (ctx.home / p).exists()]
        return Inventory(
            items=len(rels), size_bytes=size, warnings=[f"absent : ~/{p}" for p in missing]
        )

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        rels = collect_secret_paths(ctx)
        if not rels:
            return ModuleResult.skipped("aucun secret trouvé")
        try:
            passphrase = get_passphrase(ctx, confirm=True)
        except (RuntimeError, ValueError) as exc:
            return ModuleResult.failed(str(exc))
        task = progress.task("secrets", total=len(rels))
        data = build_archive(ctx, rels, on_file=task.advance)
        encrypted = pyrage.passphrase.encrypt(data, passphrase)
        target = ctx.dest / SECRETS_FILE
        if ctx.dry_run:
            ctx.log(f"[dry-run] write {target} ({len(encrypted)} bytes)")
        else:
            target.write_bytes(encrypted)
        task.finish()
        return ModuleResult.ok(
            f"{len(rels)} entrées chiffrées", items=len(rels), size_bytes=len(encrypted)
        )

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        archive = ctx.dest / SECRETS_FILE
        if not archive.exists():
            return ModuleResult.skipped("pas d'archive secrets dans la sauvegarde")
        try:
            passphrase = get_passphrase(ctx, confirm=False)
        except (RuntimeError, ValueError) as exc:
            return ModuleResult.failed(str(exc))
        try:
            data = pyrage.passphrase.decrypt(archive.read_bytes(), passphrase)
        except Exception:  # noqa: BLE001 - pyrage lève DecryptError, on reste générique
            return ModuleResult.failed("passphrase incorrecte ou archive corrompue")
        task = progress.task("secrets", total=None)
        count = extract_archive(ctx, data)
        task.finish()
        return ModuleResult.ok(f"{count} fichiers restaurés", items=count)
```

- [ ] **Step 5: Enregistrer le module**

`src/osx_backup/modules/__init__.py` :

```python
"""Modules de sauvegarde. Chaque module s'enregistre à l'import (voir base.register)."""

from osx_backup.modules import dotfiles, secrets  # noqa: F401
```

- [ ] **Step 6: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: verts. Si ruff se plaint de la longueur de ligne dans `_filter`, découper l'expression sur deux lignes.

- [ ] **Step 7: Commit et merge**

```bash
git add src/osx_backup/modules/secrets.py src/osx_backup/modules/__init__.py tests/test_secrets.py
```
Appel séparé : `git commit -m "feat: module secrets avec archive tar chiffrée age"`. Puis :
```bash
git checkout dev && git merge feature/secrets --ff-only && git branch -d feature/secrets
```

---

### Task 8: Modules claude et editors

**Files:**
- Create: `src/osx_backup/modules/claude.py`
- Create: `src/osx_backup/modules/editors.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_claude.py`, `tests/test_editors.py`

**Interfaces:**
- Consumes: `PathItem`, `inventory_paths`, `backup_paths`, `restore_paths`, `Context.runner`.
- Produces:
  - `claude_items(ctx: Context) -> list[PathItem]` : `.claude/settings.json`, `.claude/CLAUDE.md`, `.claude/RTK.md`, `.claude/statusline.sh`, `.claude/hooks`, `.claude/skills`, `.claude/plugins` (exclude `cache`), plus chaque `.claude/projects/*/memory` existant.
  - `ClaudeModule` (`name="claude"`, `order=60`).
  - `EDITOR_ITEMS: list[PathItem]`, `EXTENSIONS_FILE = "vscode-extensions.txt"`, `EditorsModule` (`name="editors"`, `order=70`).

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/claude-editors
```

- [ ] **Step 2: Écrire `tests/test_claude.py`**

```python
from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.claude import ClaudeModule, claude_items


def _seed(ctx: Context) -> None:
    c = ctx.home / ".claude"
    (c / "hooks").mkdir(parents=True)
    (c / "settings.json").write_text("{}")
    (c / "hooks" / "h.sh").write_text("#!/bin/sh")
    (c / "plugins" / "cache" / "x").mkdir(parents=True)
    (c / "plugins" / "cache" / "x" / "big").write_text("cache")
    (c / "plugins" / "installed.json").write_text("[]")
    (c / "projects" / "p1" / "memory").mkdir(parents=True)
    (c / "projects" / "p1" / "memory" / "MEMORY.md").write_text("m")
    (c / "projects" / "p1" / "sessions").mkdir()
    (c / "projects" / "p1" / "sessions" / "s.jsonl").write_text("nope")
    (ctx.home / ".claude.json").write_text("secret")


def test_claude_items_include_memory_glob(ctx: Context) -> None:
    _seed(ctx)
    rels = [i.rel for i in claude_items(ctx)]
    assert ".claude/projects/p1/memory" in rels
    assert ".claude/plugins" in rels


def test_claude_backup_excludes_cache_and_sessions(ctx: Context) -> None:
    _seed(ctx)
    res = ClaudeModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    out = ctx.dest / "claude" / ".claude"
    assert (out / "settings.json").exists()
    assert (out / "plugins" / "installed.json").exists()
    assert not (out / "plugins" / "cache").exists()
    assert (out / "projects" / "p1" / "memory" / "MEMORY.md").exists()
    assert not (out / "projects" / "p1" / "sessions").exists()
    assert not (ctx.dest / "claude" / ".claude.json").exists()
```

- [ ] **Step 3: Écrire `tests/test_editors.py`**

```python
from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.editors import EXTENSIONS_FILE, EditorsModule
from osx_backup.runner import RecordingRunner


def _seed(ctx: Context) -> None:
    user = ctx.home / "Library" / "Application Support" / "Code" / "User"
    user.mkdir(parents=True)
    (user / "settings.json").write_text("{}")
    (ctx.home / ".config" / "zed").mkdir(parents=True)
    (ctx.home / ".config" / "zed" / "settings.json").write_text("{}")


def test_backup_lists_extensions(ctx: Context) -> None:
    _seed(ctx)
    runner = RecordingRunner()
    runner.add(("code", "--list-extensions"), stdout="ms-python.python\nesbenp.prettier-vscode\n")
    ctx.runner = runner
    res = EditorsModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    ext = (ctx.dest / "editors" / EXTENSIONS_FILE).read_text().splitlines()
    assert ext == ["ms-python.python", "esbenp.prettier-vscode"]
    assert (ctx.dest / "editors" / ".config" / "zed" / "settings.json").exists()


def test_backup_without_code_cli_warns(ctx: Context) -> None:
    _seed(ctx)
    ctx.runner = RecordingRunner(which_map={})
    res = EditorsModule().backup(ctx, NullProgress())
    assert res.status == "ok" and any("code" in w for w in res.warnings)


def test_restore_installs_extensions(ctx: Context) -> None:
    _seed(ctx)
    runner = RecordingRunner()
    runner.add(("code", "--list-extensions"), stdout="a.b\nc.d\n")
    runner.add(("code", "--install-extension", "c.d"), returncode=1, stderr="boom")
    ctx.runner = runner
    EditorsModule().backup(ctx, NullProgress())
    res = EditorsModule().restore(ctx, NullProgress())
    assert res.status == "ok"
    assert ["code", "--install-extension", "a.b"] in runner.mutations
    assert any("c.d" in w for w in res.warnings)
```

- [ ] **Step 4: Vérifier l'échec**

Run: `uv run pytest tests/test_claude.py tests/test_editors.py -q`
Expected: erreurs d'import.

- [ ] **Step 5: Écrire `src/osx_backup/modules/claude.py`**

```python
"""Configuration Claude Code (sans sessions ni caches)."""

from __future__ import annotations

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register
from osx_backup.modules.pathset import PathItem, backup_paths, inventory_paths, restore_paths

FIXED_ITEMS: list[PathItem] = [
    PathItem(".claude/settings.json"),
    PathItem(".claude/CLAUDE.md"),
    PathItem(".claude/RTK.md"),
    PathItem(".claude/statusline.sh"),
    PathItem(".claude/hooks"),
    PathItem(".claude/skills"),
    PathItem(".claude/plugins", ("cache",)),
]


def claude_items(ctx: Context) -> list[PathItem]:
    items = list(FIXED_ITEMS)
    projects = ctx.home / ".claude" / "projects"
    if projects.is_dir():
        for memory in sorted(projects.glob("*/memory")):
            if memory.is_dir():
                items.append(PathItem(memory.relative_to(ctx.home).as_posix()))
    return items


@register
class ClaudeModule(Module):
    name = "claude"
    description = "Claude Code : settings, hooks, skills, plugins, mémoires projet"
    order = 60

    def inventory(self, ctx: Context) -> Inventory:
        if not (ctx.home / ".claude").is_dir():
            return Inventory(available=False, reason="~/.claude absent")
        inv = inventory_paths(ctx, claude_items(ctx))
        inv.warnings = [w for w in inv.warnings if "memory" not in w]
        return inv

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return backup_paths(ctx, self.name, claude_items(ctx), progress)

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return restore_paths(ctx, self.name, progress)
```

- [ ] **Step 6: Écrire `src/osx_backup/modules/editors.py`**

```python
"""VS Code (settings + extensions), Zed, Ghostty."""

from __future__ import annotations

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register
from osx_backup.modules.pathset import PathItem, backup_paths, inventory_paths, restore_paths

VSCODE_USER = "Library/Application Support/Code/User"
EXTENSIONS_FILE = "vscode-extensions.txt"

EDITOR_ITEMS: list[PathItem] = [
    PathItem(f"{VSCODE_USER}/settings.json"),
    PathItem(f"{VSCODE_USER}/keybindings.json"),
    PathItem(f"{VSCODE_USER}/snippets"),
    PathItem(".config/zed"),
    PathItem("Library/Application Support/com.mitchellh.ghostty"),
    PathItem(".config/ghostty"),
]


@register
class EditorsModule(Module):
    name = "editors"
    description = "VS Code (settings, extensions), Zed, Ghostty"
    order = 70

    def inventory(self, ctx: Context) -> Inventory:
        inv = inventory_paths(ctx, EDITOR_ITEMS)
        if ctx.runner.which("code") is None:
            inv.warnings.append("CLI `code` introuvable : extensions VS Code non listées")
        return inv

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        result = backup_paths(ctx, self.name, EDITOR_ITEMS, progress)
        if ctx.runner.which("code") is None:
            result.warnings.append("CLI `code` introuvable : extensions VS Code non listées")
            return result
        res = ctx.runner.run(["code", "--list-extensions"], check=False, readonly=True)
        if not res.ok:
            result.warnings.append("`code --list-extensions` a échoué")
            return result
        ids = [line.strip() for line in res.stdout.splitlines() if line.strip()]
        ctx.write_text(ctx.module_dir(self.name) / EXTENSIONS_FILE, "\n".join(ids) + "\n")
        result.items += len(ids)
        return result

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        result = restore_paths(ctx, self.name, progress)
        ext_file = ctx.dest / self.name / EXTENSIONS_FILE
        if not ext_file.exists():
            return result
        if ctx.runner.which("code") is None:
            result.warnings.append("CLI `code` introuvable : extensions non installées")
            return result
        ids = [line.strip() for line in ext_file.read_text().splitlines() if line.strip()]
        task = progress.task("extensions VS Code", total=len(ids))
        for ext in ids:
            res = ctx.runner.run(["code", "--install-extension", ext], check=False)
            if not res.ok:
                result.warnings.append(f"extension {ext} : échec ({res.stderr.strip()})")
            task.advance()
        task.finish()
        return result
```

- [ ] **Step 7: Enregistrer les modules**

`src/osx_backup/modules/__init__.py` :

```python
"""Modules de sauvegarde. Chaque module s'enregistre à l'import (voir base.register)."""

from osx_backup.modules import claude, dotfiles, editors, secrets  # noqa: F401
```

- [ ] **Step 8: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 9: Commit et merge**

```bash
git add src/osx_backup/modules tests/test_claude.py tests/test_editors.py
```
Appel séparé : `git commit -m "feat: modules claude et editors"`. Puis :
```bash
git checkout dev && git merge feature/claude-editors --ff-only && git branch -d feature/claude-editors
```

---

### Task 9: rsync GNU, modules github et documents

**Files:**
- Create: `src/osx_backup/rsync.py`
- Create: `src/osx_backup/modules/rsyncmodule.py`
- Create: `src/osx_backup/modules/github.py`
- Create: `src/osx_backup/modules/documents.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_rsync.py`, `tests/test_rsync_modules.py`

**Interfaces:**
- Consumes: `CommandRunner.run/stream/which`, `DEPENDENCY_DIRS`, `Module`, `Inventory`, `ModuleResult`.
- Produces (rsync) :
  - `RSYNC_MISSING = "rsync GNU absent : brew install rsync"`.
  - `find_gnu_rsync(runner: CommandRunner) -> str | None`.
  - `build_command(rsync: str, src: Path, dst: Path, excludes: Sequence[str], *, delete: bool, dry_run: bool = False, stats: bool = False) -> list[str]`.
  - `parse_progress2(line: str) -> tuple[int, int] | None` (octets transférés, pourcentage).
  - `parse_stats(text: str) -> tuple[int, int]` (fichiers, octets « Total file size »).
  - `estimate(runner, rsync, src, dst, excludes) -> tuple[int, int]`.
- Produces (rsyncmodule) : `RsyncModule(Module)` avec attributs de classe `source_rel: str`, `excludes: list[str]`, `delete_on_backup: bool = True`.
- Produces : `GithubModule` (`name="github"`, `order=80`, `source_rel="Github"`, `excludes=DEPENDENCY_DIRS + [".DS_Store", ".env", ".env.*"]`), `DocumentsModule` (`name="documents"`, `order=90`, `source_rel="Documents"`, `excludes=[".DS_Store", "node_modules"]`).

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/rsync
```

- [ ] **Step 2: Écrire `tests/test_rsync.py`**

```python
from pathlib import Path

from osx_backup.rsync import (
    build_command,
    estimate,
    find_gnu_rsync,
    parse_progress2,
    parse_stats,
)
from osx_backup.runner import RecordingRunner

GNU = "rsync  version 3.4.1  protocol version 32\n"
OPENRSYNC = "openrsync: protocol version 29\n"


def test_find_gnu_rsync_prefers_homebrew() -> None:
    r = RecordingRunner()
    r.add(("/opt/homebrew/bin/rsync", "--version"), stdout=GNU)
    assert find_gnu_rsync(r) == "/opt/homebrew/bin/rsync"


def test_find_gnu_rsync_rejects_openrsync() -> None:
    r = RecordingRunner(which_map={"rsync": "/usr/bin/rsync"})
    r.add(("/opt/homebrew/bin/rsync", "--version"), returncode=127)
    r.add(("/usr/local/bin/rsync", "--version"), returncode=127)
    r.add(("/usr/bin/rsync", "--version"), stdout=OPENRSYNC)
    assert find_gnu_rsync(r) is None
    assert all(c[1] == "--version" for c in r.calls)
    assert r.mutations == []


def test_build_command() -> None:
    cmd = build_command("/r", Path("/src"), Path("/dst"), ["node_modules", ".env"], delete=True)
    assert cmd == [
        "/r", "-a", "--no-inc-recursive", "--info=progress2", "--delete",
        "--exclude", "node_modules", "--exclude", ".env", "/src/", "/dst/",
    ]
    cmd2 = build_command("/r", Path("/s"), Path("/d"), [], delete=False, dry_run=True, stats=True)
    assert "--dry-run" in cmd2 and "--stats" in cmd2 and "--delete" not in cmd2


def test_parse_progress2() -> None:
    assert parse_progress2("  1,234,567  45%   12.34MB/s    0:00:10") == (1234567, 45)
    assert parse_progress2("sending incremental file list") is None


def test_parse_stats() -> None:
    text = "Number of files: 1,234 (reg: 1,000, dir: 234)\nTotal file size: 9,876,543 bytes\n"
    assert parse_stats(text) == (1234, 9876543)
    assert parse_stats("") == (0, 0)


def test_estimate_is_readonly() -> None:
    r = RecordingRunner()
    r.add(("/r",), stdout="Number of files: 3\nTotal file size: 42 bytes\n")
    assert estimate(r, "/r", Path("/s"), Path("/d"), []) == (3, 42)
    assert r.mutations == []
    assert "--dry-run" in r.calls[0]
```

- [ ] **Step 3: Écrire `tests/test_rsync_modules.py`**

```python
from osx_backup.context import Context
from osx_backup.modules.base import NullProgress, get_module
from osx_backup.modules.documents import DocumentsModule
from osx_backup.modules.github import GithubModule
from osx_backup.rsync import RSYNC_MISSING
from osx_backup.runner import RecordingRunner

GNU = "rsync  version 3.4.1  protocol version 32\n"


def _runner() -> RecordingRunner:
    r = RecordingRunner()
    r.add(("/opt/homebrew/bin/rsync", "--version"), stdout=GNU)
    return r


def test_registered() -> None:
    assert isinstance(get_module("github"), GithubModule)
    assert isinstance(get_module("documents"), DocumentsModule)


def test_unavailable_without_source(ctx: Context) -> None:
    ctx.runner = _runner()
    inv = GithubModule().inventory(ctx)
    assert not inv.available and "Github" in inv.reason


def test_unavailable_without_gnu_rsync(ctx: Context) -> None:
    (ctx.home / "Github").mkdir()
    ctx.runner = RecordingRunner(which_map={})
    inv = GithubModule().inventory(ctx)
    assert not inv.available and inv.reason == RSYNC_MISSING


def test_inventory_counts_non_git_dirs(ctx: Context) -> None:
    gh = ctx.home / "Github"
    (gh / "a" / ".git").mkdir(parents=True)
    (gh / "b").mkdir()
    r = _runner()
    r.add(("/opt/homebrew/bin/rsync", "-a"), stdout="Number of files: 5\nTotal file size: 100 bytes\n")
    ctx.runner = r
    inv = GithubModule().inventory(ctx)
    assert inv.available and inv.items == 5 and inv.size_bytes == 100
    assert inv.warnings == ["1 dossier(s) sans dépôt git : b"]


def test_backup_streams_progress_and_uses_delete(ctx: Context) -> None:
    (ctx.home / "Github").mkdir()
    r = _runner()
    r.add(("/opt/homebrew/bin/rsync", "-a"), stdout="  500  50%  1MB/s\n  1,000  100%  1MB/s\n")
    ctx.runner = r
    p = NullProgress()
    res = GithubModule().backup(ctx, p)
    assert res.status == "ok" and res.size_bytes == 1000
    cmd = r.mutations[-1]
    assert "--delete" in cmd and "--exclude" in cmd and "node_modules" in cmd
    assert cmd[-2:] == [f"{ctx.home}/Github/", f"{ctx.dest}/github/"]
    assert p.tasks[0].completed == 100 and p.tasks[0].finished


def test_restore_never_deletes(ctx: Context) -> None:
    (ctx.dest / "documents").mkdir()
    r = _runner()
    ctx.runner = r
    res = DocumentsModule().restore(ctx, NullProgress())
    assert res.status == "ok"
    cmd = r.mutations[-1]
    assert "--delete" not in cmd
    assert cmd[-2:] == [f"{ctx.dest}/documents/", f"{ctx.home}/Documents/"]


def test_restore_without_backup_skipped(ctx: Context) -> None:
    ctx.runner = _runner()
    assert DocumentsModule().restore(ctx, NullProgress()).status == "skipped"


def test_backup_failure_reported(ctx: Context) -> None:
    (ctx.home / "Documents").mkdir()
    r = _runner()
    r.add(("/opt/homebrew/bin/rsync", "-a"), returncode=23)
    ctx.runner = r
    res = DocumentsModule().backup(ctx, NullProgress())
    assert res.status == "failed" and "23" in res.message
```

- [ ] **Step 4: Vérifier l'échec**

Run: `uv run pytest tests/test_rsync.py tests/test_rsync_modules.py -q`
Expected: erreurs d'import.

- [ ] **Step 5: Écrire `src/osx_backup/rsync.py`**

```python
"""Détection d'un rsync GNU et helpers de commande/progression."""

from __future__ import annotations

import re
from collections.abc import Sequence
from pathlib import Path

from osx_backup.runner import CommandRunner

RSYNC_MISSING = "rsync GNU absent : brew install rsync"
CANDIDATES = ["/opt/homebrew/bin/rsync", "/usr/local/bin/rsync"]
VERSION_RE = re.compile(r"rsync\s+version\s+(\d+)\.")
PROGRESS_RE = re.compile(r"^\s*([\d,]+)\s+(\d+)%")
STAT_FILES_RE = re.compile(r"Number of files:\s*([\d,]+)")
STAT_TOTAL_RE = re.compile(r"Total file size:\s*([\d,]+)")


def find_gnu_rsync(runner: CommandRunner) -> str | None:
    candidates = list(CANDIDATES)
    found = runner.which("rsync")
    if found and found not in candidates:
        candidates.append(found)
    for candidate in candidates:
        res = runner.run([candidate, "--version"], check=False, readonly=True)
        match = VERSION_RE.search(res.stdout)
        if res.ok and match and int(match.group(1)) >= 3:
            return candidate
    return None


def build_command(
    rsync: str,
    src: Path,
    dst: Path,
    excludes: Sequence[str],
    *,
    delete: bool,
    dry_run: bool = False,
    stats: bool = False,
) -> list[str]:
    cmd = [rsync, "-a", "--no-inc-recursive", "--info=progress2"]
    if delete:
        cmd.append("--delete")
    if dry_run:
        cmd.append("--dry-run")
    if stats:
        cmd.append("--stats")
    for pattern in excludes:
        cmd += ["--exclude", pattern]
    cmd += [f"{src}/", f"{dst}/"]
    return cmd


def parse_progress2(line: str) -> tuple[int, int] | None:
    match = PROGRESS_RE.match(line)
    if not match:
        return None
    return int(match.group(1).replace(",", "")), int(match.group(2))


def parse_stats(text: str) -> tuple[int, int]:
    files = STAT_FILES_RE.search(text)
    total = STAT_TOTAL_RE.search(text)
    to_int = lambda m: int(m.group(1).replace(",", "")) if m else 0  # noqa: E731
    return to_int(files), to_int(total)


def estimate(
    runner: CommandRunner, rsync: str, src: Path, dst: Path, excludes: Sequence[str]
) -> tuple[int, int]:
    cmd = build_command(rsync, src, dst, excludes, delete=False, dry_run=True, stats=True)
    res = runner.run(cmd, check=False, readonly=True)
    return parse_stats(res.stdout)
```

- [ ] **Step 6: Écrire `src/osx_backup/modules/rsyncmodule.py`**

```python
"""Base des modules miroir rsync (github, documents)."""

from __future__ import annotations

from pathlib import Path
from typing import ClassVar

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter
from osx_backup.rsync import RSYNC_MISSING, build_command, estimate, find_gnu_rsync, parse_progress2


class RsyncModule(Module):
    source_rel: ClassVar[str]
    excludes: ClassVar[list[str]]
    delete_on_backup: ClassVar[bool] = True

    def source(self, ctx: Context) -> Path:
        return ctx.home / self.source_rel

    def inventory(self, ctx: Context) -> Inventory:
        src = self.source(ctx)
        if not src.is_dir():
            return Inventory(available=False, reason=f"~/{self.source_rel} absent")
        rsync = find_gnu_rsync(ctx.runner)
        if rsync is None:
            return Inventory(available=False, reason=RSYNC_MISSING)
        files, size = estimate(ctx.runner, rsync, src, ctx.dest / self.name, self.excludes)
        return Inventory(items=files, size_bytes=size, details={"rsync": rsync})

    def _mirror(
        self, ctx: Context, progress: ProgressReporter, src: Path, dst: Path, delete: bool
    ) -> ModuleResult:
        rsync = find_gnu_rsync(ctx.runner)
        if rsync is None:
            return ModuleResult.failed(RSYNC_MISSING)
        if not ctx.dry_run:
            dst.mkdir(parents=True, exist_ok=True)
        cmd = build_command(rsync, src, dst, self.excludes, delete=delete)
        task = progress.task(self.name, total=100)
        transferred = 0

        def on_line(line: str) -> None:
            nonlocal transferred
            parsed = parse_progress2(line)
            if parsed:
                transferred, percent = parsed
                task.set_completed(percent)

        res = ctx.runner.stream(cmd, on_line)
        task.finish()
        if not res.ok:
            return ModuleResult.failed(
                f"rsync a échoué (code {res.returncode})", size_bytes=transferred
            )
        return ModuleResult.ok("miroir à jour", size_bytes=transferred)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return self._mirror(
            ctx, progress, self.source(ctx), ctx.module_dir(self.name), self.delete_on_backup
        )

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        src = ctx.dest / self.name
        if not src.is_dir():
            return ModuleResult.skipped(f"pas de dossier {self.name} dans la sauvegarde")
        return self._mirror(ctx, progress, src, self.source(ctx), delete=False)
```

- [ ] **Step 7: Écrire `src/osx_backup/modules/github.py` et `documents.py`**

`github.py` :

```python
"""Miroir de ~/Github sans dépendances, caches ni .env."""

from __future__ import annotations

from osx_backup.context import Context
from osx_backup.fsutil import DEPENDENCY_DIRS
from osx_backup.modules.base import Inventory, register
from osx_backup.modules.rsyncmodule import RsyncModule


@register
class GithubModule(RsyncModule):
    name = "github"
    description = "Projets ~/Github (sans node_modules, .venv, builds ; .env via secrets)"
    order = 80
    source_rel = "Github"
    excludes = DEPENDENCY_DIRS + [".DS_Store", ".env", ".env.*"]

    def inventory(self, ctx: Context) -> Inventory:
        inv = super().inventory(ctx)
        if not inv.available:
            return inv
        non_git = sorted(
            d.name for d in self.source(ctx).iterdir() if d.is_dir() and not (d / ".git").exists()
        )
        if non_git:
            inv.warnings.append(f"{len(non_git)} dossier(s) sans dépôt git : {', '.join(non_git)}")
        return inv
```

`documents.py` :

```python
"""Miroir de ~/Documents."""

from __future__ import annotations

from osx_backup.modules.base import register
from osx_backup.modules.rsyncmodule import RsyncModule


@register
class DocumentsModule(RsyncModule):
    name = "documents"
    description = "Dossier ~/Documents"
    order = 90
    source_rel = "Documents"
    excludes = [".DS_Store", "node_modules"]
```

- [ ] **Step 8: Enregistrer les modules**

`src/osx_backup/modules/__init__.py` :

```python
"""Modules de sauvegarde. Chaque module s'enregistre à l'import (voir base.register)."""

from osx_backup.modules import (  # noqa: F401
    claude,
    documents,
    dotfiles,
    editors,
    github,
    secrets,
)
```

- [ ] **Step 9: Vérifier que les tests passent**

Run: `uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 10: Commit et merge**

```bash
git add src/osx_backup/rsync.py src/osx_backup/modules tests/test_rsync.py tests/test_rsync_modules.py
```
Appel séparé : `git commit -m "feat: rsync GNU et modules miroir github/documents"`. Puis :
```bash
git checkout dev && git merge feature/rsync --ff-only && git branch -d feature/rsync
```

---
### Task 10: Modules brew, mise et uv

**Files:**
- Create: `src/osx_backup/modules/brew.py`
- Create: `src/osx_backup/modules/mise.py`
- Create: `src/osx_backup/modules/uv.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_brew.py`, `tests/test_mise.py`, `tests/test_uv.py`

**Interfaces:**
- Consumes: `Context`, `copy_tree`, `Module`, `Inventory`, `ModuleResult`, `register`.
- Produces (brew) : `Service(name: str, status: str)`, `parse_services(text: str) -> list[Service]`, `ensure_rsync_in_brewfile(text: str) -> str`, `BrewModule` (`name="brew"`, `order=10`). Fichiers : `<dest>/brew/Brewfile`, `<dest>/brew/services.json` (liste de `{"name", "status"}`).
- Produces (mise) : `MISE_CONFIG = ".config/mise/config.toml"`, `MiseModule` (`name="mise"`, `order=20`). Fichiers : `<dest>/mise/config.toml`, `<dest>/mise/ls.json`.
- Produces (uv) : `UvTool(name: str, version: str)`, `parse_uv_tool_list(text: str) -> list[UvTool]`, `UvModule` (`name="uv"`, `order=30`). Fichier : `<dest>/uv/tools.json` (liste de `{"name", "version"}`).

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/package-managers
```

- [ ] **Step 2: Écrire `tests/test_brew.py`**

```python
import json

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress, get_module
from osx_backup.modules.brew import BrewModule, Service, ensure_rsync_in_brewfile, parse_services
from osx_backup.runner import RecordingRunner

SERVICES_JSON = json.dumps(
    [
        {"name": "postgresql@18", "status": "started", "user": "me"},
        {"name": "redis", "status": "none", "user": None},
    ]
)


def _runner() -> RecordingRunner:
    r = RecordingRunner()
    r.add(("brew", "list", "--formula"), stdout="git\nrsync\n")
    r.add(("brew", "list", "--cask"), stdout="ghostty\n")
    r.add(("brew", "tap"), stdout="homebrew/core\nshopify/shopify\n")
    r.add(("brew", "services", "list", "--json"), stdout=SERVICES_JSON)
    return r


def test_registered() -> None:
    assert isinstance(get_module("brew"), BrewModule)


def test_parse_services() -> None:
    assert parse_services(SERVICES_JSON) == [
        Service("postgresql@18", "started"),
        Service("redis", "none"),
    ]


def test_ensure_rsync_in_brewfile() -> None:
    assert ensure_rsync_in_brewfile('brew "git"\n') == 'brew "git"\nbrew "rsync"\n'
    assert ensure_rsync_in_brewfile('brew "rsync"\n') == 'brew "rsync"\n'
    assert ensure_rsync_in_brewfile('brew "git"') == 'brew "git"\nbrew "rsync"\n'


def test_inventory(ctx: Context) -> None:
    ctx.runner = _runner()
    inv = BrewModule().inventory(ctx)
    assert inv.available and inv.items == 5
    assert inv.details == {"formulae": 2, "casks": 1, "taps": 2, "services_started": 1}


def test_inventory_without_brew(ctx: Context) -> None:
    ctx.runner = RecordingRunner(which_map={})
    inv = BrewModule().inventory(ctx)
    assert not inv.available and "Homebrew" in inv.reason


class DumpingRunner(RecordingRunner):
    """Simule l'écriture du Brewfile par `brew bundle dump`."""

    def __init__(self, brewfile) -> None:
        super().__init__()
        self.brewfile = brewfile

    def run(self, cmd, **kw):
        if list(cmd[:3]) == ["brew", "bundle", "dump"]:
            self.brewfile.parent.mkdir(parents=True, exist_ok=True)
            self.brewfile.write_text('brew "git"\n')
        return super().run(cmd, **kw)


def test_backup_dumps_and_adds_rsync(ctx: Context) -> None:
    brewfile = ctx.dest / "brew" / "Brewfile"
    r = DumpingRunner(brewfile)
    r.add(("brew", "services", "list", "--json"), stdout=SERVICES_JSON)
    ctx.runner = r
    res = BrewModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    assert r.has_call("brew", "bundle", "dump", "--file", str(brewfile), "--force", "--describe")
    assert brewfile.read_text() == 'brew "git"\nbrew "rsync"\n'
    services = json.loads((ctx.dest / "brew" / "services.json").read_text())
    assert services == [
        {"name": "postgresql@18", "status": "started"},
        {"name": "redis", "status": "none"},
    ]


def test_restore_installs_and_starts_services(ctx: Context) -> None:
    (ctx.dest / "brew").mkdir()
    (ctx.dest / "brew" / "Brewfile").write_text('brew "git"\n')
    (ctx.dest / "brew" / "services.json").write_text(
        json.dumps([{"name": "postgresql@18", "status": "started"}, {"name": "redis", "status": "none"}])
    )
    r = RecordingRunner()
    ctx.runner = r
    res = BrewModule().restore(ctx, NullProgress())
    assert res.status == "ok"
    assert r.mutations == [
        ["brew", "bundle", "install", "--file", str(ctx.dest / "brew" / "Brewfile")],
        ["brew", "services", "start", "postgresql@18"],
    ]


def test_restore_without_brewfile_skipped(ctx: Context) -> None:
    assert BrewModule().restore(ctx, NullProgress()).status == "skipped"
```

- [ ] **Step 3: Écrire `tests/test_mise.py`**

```python
import json

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.mise import MISE_CONFIG, MiseModule
from osx_backup.runner import RecordingRunner

LS_JSON = json.dumps({"node": [{"version": "26.8.1"}], "bun": [{"version": "1.4.0"}]})


def _seed(ctx: Context) -> None:
    cfg = ctx.home / MISE_CONFIG
    cfg.parent.mkdir(parents=True)
    cfg.write_text('[tools]\nnode = "latest"\n')


def test_inventory_and_backup(ctx: Context) -> None:
    _seed(ctx)
    r = RecordingRunner()
    r.add(("mise", "ls", "--json"), stdout=LS_JSON)
    ctx.runner = r
    m = MiseModule()
    inv = m.inventory(ctx)
    assert inv.available and inv.items == 2
    res = m.backup(ctx, NullProgress())
    assert res.status == "ok"
    assert (ctx.dest / "mise" / "config.toml").read_text().startswith("[tools]")
    assert json.loads((ctx.dest / "mise" / "ls.json").read_text())["bun"][0]["version"] == "1.4.0"


def test_inventory_without_config(ctx: Context) -> None:
    inv = MiseModule().inventory(ctx)
    assert not inv.available and "config.toml" in inv.reason


def test_restore_places_config_then_installs(ctx: Context) -> None:
    (ctx.dest / "mise").mkdir()
    (ctx.dest / "mise" / "config.toml").write_text("[tools]\n")
    r = RecordingRunner()
    ctx.runner = r
    res = MiseModule().restore(ctx, NullProgress())
    assert res.status == "ok"
    assert (ctx.home / MISE_CONFIG).read_text() == "[tools]\n"
    assert r.mutations == [
        ["mise", "trust", str(ctx.home / MISE_CONFIG)],
        ["mise", "install", "--yes"],
    ]


def test_restore_install_failure(ctx: Context) -> None:
    (ctx.dest / "mise").mkdir()
    (ctx.dest / "mise" / "config.toml").write_text("[tools]\n")
    r = RecordingRunner()
    r.add(("mise", "install"), returncode=1, stderr="boom")
    ctx.runner = r
    assert MiseModule().restore(ctx, NullProgress()).status == "failed"
```

- [ ] **Step 4: Écrire `tests/test_uv.py`**

```python
import json

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.uv import UvModule, UvTool, parse_uv_tool_list
from osx_backup.runner import RecordingRunner

TOOL_LIST = "beets v2.11.0\n- beet\nserena-agent v1.5.3\n- serena\n- serena-agent\n"


def test_parse_uv_tool_list() -> None:
    assert parse_uv_tool_list(TOOL_LIST) == [UvTool("beets", "2.11.0"), UvTool("serena-agent", "1.5.3")]
    assert parse_uv_tool_list("No tools installed\n") == []


def test_backup_writes_tools_json(ctx: Context) -> None:
    r = RecordingRunner()
    r.add(("uv", "tool", "list"), stdout=TOOL_LIST)
    ctx.runner = r
    m = UvModule()
    assert m.inventory(ctx).items == 2
    assert m.backup(ctx, NullProgress()).status == "ok"
    data = json.loads((ctx.dest / "uv" / "tools.json").read_text())
    assert data == [{"name": "beets", "version": "2.11.0"}, {"name": "serena-agent", "version": "1.5.3"}]


def test_restore_installs_each_tool_with_warnings(ctx: Context) -> None:
    (ctx.dest / "uv").mkdir()
    (ctx.dest / "uv" / "tools.json").write_text(json.dumps([{"name": "a", "version": "1"}, {"name": "b", "version": "2"}]))
    r = RecordingRunner()
    r.add(("uv", "tool", "install", "b"), returncode=1, stderr="nope")
    ctx.runner = r
    res = UvModule().restore(ctx, NullProgress())
    assert res.status == "ok" and res.items == 2
    assert ["uv", "tool", "install", "a"] in r.mutations
    assert any("b" in w for w in res.warnings)


def test_inventory_without_uv(ctx: Context) -> None:
    ctx.runner = RecordingRunner(which_map={})
    assert not UvModule().inventory(ctx).available
```

- [ ] **Step 5: Vérifier l'échec**

Run: `uv run pytest tests/test_brew.py tests/test_mise.py tests/test_uv.py -q`
Expected: erreurs d'import.

- [ ] **Step 6: Écrire `src/osx_backup/modules/brew.py`**

```python
"""Homebrew : Brewfile et services."""

from __future__ import annotations

import json
import re
from dataclasses import asdict, dataclass

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register

BREW_MISSING = "Homebrew absent : https://brew.sh"
RSYNC_LINE_RE = re.compile(r'^brew\s+"rsync"', re.MULTILINE)


@dataclass(frozen=True)
class Service:
    name: str
    status: str


def parse_services(text: str) -> list[Service]:
    try:
        raw = json.loads(text or "[]")
    except json.JSONDecodeError:
        return []
    return [Service(str(s["name"]), str(s.get("status") or "none")) for s in raw]


def ensure_rsync_in_brewfile(text: str) -> str:
    if RSYNC_LINE_RE.search(text):
        return text
    if text and not text.endswith("\n"):
        text += "\n"
    return text + 'brew "rsync"\n'


def _lines(ctx: Context, cmd: list[str]) -> list[str]:
    res = ctx.runner.run(cmd, check=False, readonly=True)
    return [line for line in res.stdout.splitlines() if line.strip()] if res.ok else []


@register
class BrewModule(Module):
    name = "brew"
    description = "Homebrew : formulae, casks, taps et services"
    order = 10

    def inventory(self, ctx: Context) -> Inventory:
        if ctx.runner.which("brew") is None:
            return Inventory(available=False, reason=BREW_MISSING)
        formulae = _lines(ctx, ["brew", "list", "--formula"])
        casks = _lines(ctx, ["brew", "list", "--cask"])
        taps = _lines(ctx, ["brew", "tap"])
        services = parse_services(
            ctx.runner.run(["brew", "services", "list", "--json"], check=False, readonly=True).stdout
        )
        started = sum(1 for s in services if s.status == "started")
        return Inventory(
            items=len(formulae) + len(casks) + len(taps),
            details={
                "formulae": len(formulae),
                "casks": len(casks),
                "taps": len(taps),
                "services_started": started,
            },
        )

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        out = ctx.module_dir(self.name)
        brewfile = out / "Brewfile"
        task = progress.task("brew", total=2)
        ctx.runner.run(
            ["brew", "bundle", "dump", "--file", str(brewfile), "--force", "--describe"]
        )
        if not ctx.dry_run and brewfile.exists():
            brewfile.write_text(ensure_rsync_in_brewfile(brewfile.read_text()), encoding="utf-8")
        task.advance()
        services = parse_services(
            ctx.runner.run(["brew", "services", "list", "--json"], check=False, readonly=True).stdout
        )
        ctx.write_json(out / "services.json", [asdict(s) for s in services])
        task.advance()
        task.finish()
        return ModuleResult.ok("Brewfile et services exportés", items=len(services))

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        brewfile = ctx.dest / self.name / "Brewfile"
        if not brewfile.exists():
            return ModuleResult.skipped("pas de Brewfile dans la sauvegarde")
        warnings: list[str] = []
        task = progress.task("brew bundle install", total=None)
        res = ctx.runner.run(["brew", "bundle", "install", "--file", str(brewfile)], check=False)
        if not res.ok:
            warnings.append("brew bundle a signalé des erreurs (voir le log)")
        task.finish()
        services_file = ctx.dest / self.name / "services.json"
        started = [
            s for s in parse_services(services_file.read_text()) if s.status == "started"
        ] if services_file.exists() else []
        task2 = progress.task("services brew", total=len(started))
        for service in started:
            r = ctx.runner.run(["brew", "services", "start", service.name], check=False)
            if not r.ok:
                warnings.append(f"service {service.name} : démarrage impossible")
            task2.advance()
        task2.finish()
        return ModuleResult.ok(
            f"Brewfile installé, {len(started)} service(s) démarré(s)",
            items=len(started),
            warnings=warnings,
        )
```

- [ ] **Step 7: Écrire `src/osx_backup/modules/mise.py`**

```python
"""mise : config.toml et installation des runtimes."""

from __future__ import annotations

import json

from osx_backup.context import Context
from osx_backup.fsutil import copy_tree
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register

MISE_CONFIG = ".config/mise/config.toml"


@register
class MiseModule(Module):
    name = "mise"
    description = "mise : runtimes (node, python, rust…) et outils npm globaux"
    order = 20

    def inventory(self, ctx: Context) -> Inventory:
        if ctx.runner.which("mise") is None:
            return Inventory(available=False, reason="mise absent : brew install mise")
        cfg = ctx.home / MISE_CONFIG
        if not cfg.exists():
            return Inventory(available=False, reason=f"~/{MISE_CONFIG} absent")
        res = ctx.runner.run(["mise", "ls", "--json"], check=False, readonly=True)
        try:
            tools = json.loads(res.stdout or "{}")
        except json.JSONDecodeError:
            tools = {}
        return Inventory(items=len(tools), size_bytes=cfg.stat().st_size)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        out = ctx.module_dir(self.name)
        cfg = ctx.home / MISE_CONFIG
        task = progress.task("mise", total=2)
        if ctx.dry_run:
            ctx.log(f"[dry-run] copy {cfg} -> {out / 'config.toml'}")
        else:
            copy_tree(cfg, out / "config.toml")
        task.advance()
        res = ctx.runner.run(["mise", "ls", "--json"], check=False, readonly=True)
        ctx.write_text(out / "ls.json", res.stdout if res.ok else "{}")
        task.advance()
        task.finish()
        return ModuleResult.ok("config.toml et liste des outils exportés", items=1)

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        src = ctx.dest / self.name / "config.toml"
        if not src.exists():
            return ModuleResult.skipped("pas de config mise dans la sauvegarde")
        target = ctx.home / MISE_CONFIG
        task = progress.task("mise install", total=None)
        ctx.place(src, target)
        ctx.runner.run(["mise", "trust", str(target)], check=False)
        res = ctx.runner.run(["mise", "install", "--yes"], check=False)
        task.finish()
        if not res.ok:
            return ModuleResult.failed(f"mise install a échoué : {res.stderr.strip()[:200]}")
        return ModuleResult.ok("config restaurée et runtimes installés", items=1)
```

- [ ] **Step 8: Écrire `src/osx_backup/modules/uv.py`**

```python
"""uv : outils installés globalement (uv tool)."""

from __future__ import annotations

import json
from dataclasses import asdict, dataclass

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register


@dataclass(frozen=True)
class UvTool:
    name: str
    version: str


def parse_uv_tool_list(text: str) -> list[UvTool]:
    tools: list[UvTool] = []
    for line in text.splitlines():
        if not line.strip() or line.startswith("-") or " " not in line.strip():
            continue
        name, version = line.strip().split(None, 1)
        if not version.startswith("v"):
            continue
        tools.append(UvTool(name, version[1:]))
    return tools


def _list_tools(ctx: Context) -> list[UvTool]:
    res = ctx.runner.run(["uv", "tool", "list"], check=False, readonly=True)
    return parse_uv_tool_list(res.stdout) if res.ok else []


@register
class UvModule(Module):
    name = "uv"
    description = "uv : outils Python globaux (uv tool)"
    order = 30

    def inventory(self, ctx: Context) -> Inventory:
        if ctx.runner.which("uv") is None:
            return Inventory(available=False, reason="uv absent : brew install uv")
        return Inventory(items=len(_list_tools(ctx)))

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        tools = _list_tools(ctx)
        task = progress.task("uv tools", total=1)
        ctx.write_json(ctx.module_dir(self.name) / "tools.json", [asdict(t) for t in tools])
        task.advance()
        task.finish()
        return ModuleResult.ok(f"{len(tools)} outil(s) listé(s)", items=len(tools))

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        tools_file = ctx.dest / self.name / "tools.json"
        if not tools_file.exists():
            return ModuleResult.skipped("pas de liste uv dans la sauvegarde")
        tools = [UvTool(**t) for t in json.loads(tools_file.read_text(encoding="utf-8"))]
        warnings: list[str] = []
        task = progress.task("uv tool install", total=len(tools))
        for tool in tools:
            res = ctx.runner.run(["uv", "tool", "install", tool.name], check=False)
            if not res.ok:
                warnings.append(f"outil {tool.name} : échec ({res.stderr.strip()[:120]})")
            task.advance()
        task.finish()
        return ModuleResult.ok(
            f"{len(tools) - len(warnings)}/{len(tools)} outil(s) installé(s)",
            items=len(tools),
            warnings=warnings,
        )
```

- [ ] **Step 9: Enregistrer les modules**

`src/osx_backup/modules/__init__.py` :

```python
"""Modules de sauvegarde. Chaque module s'enregistre à l'import (voir base.register)."""

from osx_backup.modules import (  # noqa: F401
    brew,
    claude,
    documents,
    dotfiles,
    editors,
    github,
    mise,
    secrets,
    uv,
)
```

- [ ] **Step 10: Vérifier que les tests passent**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 11: Commit et merge**

```bash
git add src/osx_backup/modules tests/test_brew.py tests/test_mise.py tests/test_uv.py
```
Appel séparé : `git commit -m "feat: modules brew, mise et uv"`. Puis :
```bash
git checkout dev && git merge feature/package-managers --ff-only && git branch -d feature/package-managers
```

---

### Task 11: Module postgres

**Files:**
- Create: `src/osx_backup/modules/postgres.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_postgres.py`

**Interfaces:**
- Consumes: `Context`, `CommandRunner`, `parse_services` (Task 10).
- Produces:
  - `Database(name: str, owner: str)`, `EXCLUDED_DBS = {"template0", "template1", "postgres"}`.
  - `LIST_SQL: str`, `parse_databases(text: str) -> list[Database]`, `list_databases(ctx: Context) -> list[Database] | None` (None si Postgres injoignable).
  - `postgres_formula(dest: Path) -> str` (première formule `postgresql*` de `<dest>/brew/services.json`, sinon `postgresql@18`).
  - `PostgresModule` (`name="postgres"`, `order=100`, attributs de classe `ready_attempts: int = 10`, `ready_delay: float = 1.0`). Fichiers : `<dest>/postgres/globals.sql`, `databases.json`, `<db>.dump`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/postgres
```

- [ ] **Step 2: Écrire `tests/test_postgres.py`**

```python
import json

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.postgres import (
    LIST_SQL,
    Database,
    PostgresModule,
    parse_databases,
    postgres_formula,
)
from osx_backup.runner import RecordingRunner

LIST_OUT = "advmfr_hr|advmfr\npostgres|humperdoo\nseoinsight_data|seoinsight\n"


def test_parse_databases() -> None:
    assert parse_databases(LIST_OUT) == [
        Database("advmfr_hr", "advmfr"),
        Database("seoinsight_data", "seoinsight"),
    ]


def test_postgres_formula(ctx: Context) -> None:
    assert postgres_formula(ctx.dest) == "postgresql@18"
    (ctx.dest / "brew").mkdir()
    (ctx.dest / "brew" / "services.json").write_text(
        json.dumps([{"name": "redis", "status": "none"}, {"name": "postgresql@17", "status": "started"}])
    )
    assert postgres_formula(ctx.dest) == "postgresql@17"


def _runner() -> RecordingRunner:
    r = RecordingRunner()
    r.add(("psql", "-X", "-At", "-d", "postgres", "-c", LIST_SQL), stdout=LIST_OUT)
    r.add(("pg_dumpall", "--globals-only"), stdout="CREATE ROLE advmfr;\n")
    return r


def test_inventory(ctx: Context) -> None:
    ctx.runner = _runner()
    inv = PostgresModule().inventory(ctx)
    assert inv.available and inv.items == 2
    assert inv.details["databases"] == ["advmfr_hr", "seoinsight_data"]


def test_inventory_unreachable(ctx: Context) -> None:
    r = RecordingRunner()
    r.add(("psql",), returncode=2, stderr="connection refused")
    ctx.runner = r
    inv = PostgresModule().inventory(ctx)
    assert not inv.available and "injoignable" in inv.reason


def test_inventory_without_pg_dump(ctx: Context) -> None:
    ctx.runner = RecordingRunner(which_map={})
    assert not PostgresModule().inventory(ctx).available


def test_backup_dumps_globals_and_each_db(ctx: Context) -> None:
    r = _runner()
    ctx.runner = r
    p = NullProgress()
    res = PostgresModule().backup(ctx, p)
    assert res.status == "ok" and res.items == 2
    out = ctx.dest / "postgres"
    assert (out / "globals.sql").read_text() == "CREATE ROLE advmfr;\n"
    assert json.loads((out / "databases.json").read_text()) == [
        {"name": "advmfr_hr", "owner": "advmfr"},
        {"name": "seoinsight_data", "owner": "seoinsight"},
    ]
    assert ["pg_dump", "-Fc", "-f", str(out / "advmfr_hr.dump"), "advmfr_hr"] in r.mutations
    assert p.tasks[0].completed == 2


def _seed_backup(ctx: Context) -> None:
    out = ctx.dest / "postgres"
    out.mkdir()
    (out / "globals.sql").write_text("CREATE ROLE x;\n")
    (out / "databases.json").write_text(
        json.dumps([{"name": "advmfr_hr", "owner": "advmfr"}, {"name": "seo", "owner": "seo"}])
    )
    (out / "advmfr_hr.dump").write_bytes(b"x")
    (out / "seo.dump").write_bytes(b"x")


def test_restore_sequence(ctx: Context) -> None:
    _seed_backup(ctx)
    r = RecordingRunner()
    r.add(("pg_isready",), returncode=0)
    r.add(("psql", "-X", "-At", "-d", "postgres", "-c", "SELECT 1 FROM pg_database WHERE datname='advmfr_hr'"), stdout="1\n")
    r.add(("psql", "-X", "-At", "-d", "postgres", "-c", "SELECT 1 FROM pg_database WHERE datname='seo'"), stdout="")
    ctx.runner = r
    m = PostgresModule()
    res = m.restore(ctx, NullProgress())
    assert res.status == "ok", res.message
    out = ctx.dest / "postgres"
    assert r.mutations == [
        ["psql", "-X", "-d", "postgres", "-v", "ON_ERROR_STOP=0", "-f", str(out / "globals.sql")],
        ["pg_restore", "--no-owner", "--role=advmfr", "-d", "advmfr_hr", str(out / "advmfr_hr.dump")],
        ["createdb", "-O", "seo", "seo"],
        ["pg_restore", "--no-owner", "--role=seo", "-d", "seo", str(out / "seo.dump")],
    ]
    assert not r.has_call("brew", "services", "start")


def test_restore_starts_service_when_down(ctx: Context) -> None:
    _seed_backup(ctx)
    r = RecordingRunner()
    r.add(("pg_isready",), returncode=2)
    ctx.runner = r
    m = PostgresModule()
    m.ready_attempts = 2
    m.ready_delay = 0
    res = m.restore(ctx, NullProgress())
    assert res.status == "failed" and "injoignable" in res.message
    assert ["brew", "services", "start", "postgresql@18"] in r.mutations
    assert not r.has_call("pg_restore")


def test_restore_without_backup_skipped(ctx: Context) -> None:
    assert PostgresModule().restore(ctx, NullProgress()).status == "skipped"
```

- [ ] **Step 3: Vérifier l'échec**

Run: `uv run pytest tests/test_postgres.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/modules/postgres.py`**

```python
"""PostgreSQL Homebrew : dumps par base et globals."""

from __future__ import annotations

import json
import time
from dataclasses import asdict, dataclass
from pathlib import Path

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register
from osx_backup.modules.brew import parse_services

EXCLUDED_DBS = {"template0", "template1", "postgres"}
LIST_SQL = (
    "SELECT datname, pg_get_userbyid(datdba) FROM pg_database "
    "WHERE NOT datistemplate ORDER BY datname"
)
DEFAULT_FORMULA = "postgresql@18"
UNREACHABLE = "PostgreSQL injoignable : brew services start {formula}"


@dataclass(frozen=True)
class Database:
    name: str
    owner: str


def parse_databases(text: str) -> list[Database]:
    dbs: list[Database] = []
    for line in text.splitlines():
        if "|" not in line:
            continue
        name, owner = line.split("|", 1)
        if name.strip() not in EXCLUDED_DBS:
            dbs.append(Database(name.strip(), owner.strip()))
    return dbs


def list_databases(ctx: Context) -> list[Database] | None:
    res = ctx.runner.run(
        ["psql", "-X", "-At", "-d", "postgres", "-c", LIST_SQL], check=False, readonly=True
    )
    return parse_databases(res.stdout) if res.ok else None


def postgres_formula(dest: Path) -> str:
    services_file = dest / "brew" / "services.json"
    if services_file.exists():
        for service in parse_services(services_file.read_text(encoding="utf-8")):
            if service.name.startswith("postgresql"):
                return service.name
    return DEFAULT_FORMULA


@register
class PostgresModule(Module):
    name = "postgres"
    description = "PostgreSQL (Homebrew) : rôles et dump de chaque base"
    order = 100
    ready_attempts: int = 10
    ready_delay: float = 1.0

    def inventory(self, ctx: Context) -> Inventory:
        if ctx.runner.which("pg_dump") is None:
            return Inventory(available=False, reason="pg_dump absent : brew install postgresql@18")
        dbs = list_databases(ctx)
        if dbs is None:
            return Inventory(
                available=False, reason=UNREACHABLE.format(formula=postgres_formula(ctx.dest))
            )
        return Inventory(items=len(dbs), details={"databases": [d.name for d in dbs]})

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        dbs = list_databases(ctx)
        if dbs is None:
            return ModuleResult.failed(UNREACHABLE.format(formula=postgres_formula(ctx.dest)))
        out = ctx.module_dir(self.name)
        globals_res = ctx.runner.run(["pg_dumpall", "--globals-only"], readonly=True)
        ctx.write_text(out / "globals.sql", globals_res.stdout)
        ctx.write_json(out / "databases.json", [asdict(d) for d in dbs])
        task = progress.task("pg_dump", total=len(dbs))
        for db in dbs:
            ctx.runner.run(["pg_dump", "-Fc", "-f", str(out / f"{db.name}.dump"), db.name])
            task.advance()
        task.finish()
        return ModuleResult.ok(f"{len(dbs)} base(s) exportée(s)", items=len(dbs))

    def _is_ready(self, ctx: Context) -> bool:
        return ctx.runner.run(["pg_isready"], check=False, readonly=True).ok

    def _ensure_running(self, ctx: Context) -> bool:
        if self._is_ready(ctx):
            return True
        formula = postgres_formula(ctx.dest)
        ctx.runner.run(["brew", "services", "start", formula], check=False)
        for _ in range(self.ready_attempts):
            if self.ready_delay:
                time.sleep(self.ready_delay)
            if self._is_ready(ctx):
                return True
        return False

    def _exists(self, ctx: Context, name: str) -> bool:
        sql = f"SELECT 1 FROM pg_database WHERE datname='{name}'"
        res = ctx.runner.run(
            ["psql", "-X", "-At", "-d", "postgres", "-c", sql], check=False, readonly=True
        )
        return res.stdout.strip() == "1"

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        pgdir = ctx.dest / self.name
        dbs_file = pgdir / "databases.json"
        if not dbs_file.exists():
            return ModuleResult.skipped("pas de dumps Postgres dans la sauvegarde")
        if not self._ensure_running(ctx):
            return ModuleResult.failed(UNREACHABLE.format(formula=postgres_formula(ctx.dest)))
        dbs = [Database(**d) for d in json.loads(dbs_file.read_text(encoding="utf-8"))]
        warnings: list[str] = []
        globals_sql = pgdir / "globals.sql"
        if globals_sql.exists():
            res = ctx.runner.run(
                ["psql", "-X", "-d", "postgres", "-v", "ON_ERROR_STOP=0", "-f", str(globals_sql)],
                check=False,
            )
            if not res.ok:
                warnings.append("globals.sql : erreurs (rôles déjà existants ?)")
        task = progress.task("pg_restore", total=len(dbs))
        for db in dbs:
            dump = pgdir / f"{db.name}.dump"
            if not dump.exists():
                warnings.append(f"{db.name} : dump absent")
                task.advance()
                continue
            if not self._exists(ctx, db.name):
                ctx.runner.run(["createdb", "-O", db.owner, db.name])
            res = ctx.runner.run(
                ["pg_restore", "--no-owner", f"--role={db.owner}", "-d", db.name, str(dump)],
                check=False,
            )
            if not res.ok:
                warnings.append(f"{db.name} : pg_restore a signalé des erreurs")
            task.advance()
        task.finish()
        return ModuleResult.ok(
            f"{len(dbs)} base(s) restaurée(s)", items=len(dbs), warnings=warnings
        )
```

- [ ] **Step 5: Enregistrer le module** : ajouter `postgres` à la liste d'imports de `src/osx_backup/modules/__init__.py` (ordre alphabétique).

- [ ] **Step 6: Vérifier que les tests passent**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 7: Commit et merge**

```bash
git add src/osx_backup/modules tests/test_postgres.py
```
Appel séparé : `git commit -m "feat: module postgres (dumps et restauration)"`. Puis :
```bash
git checkout dev && git merge feature/postgres --ff-only && git branch -d feature/postgres
```

---

### Task 12: Module docker

**Files:**
- Create: `src/osx_backup/modules/docker.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_docker.py`

**Interfaces:**
- Consumes: `Context` (`docker_no_stop`, `docker_include_anonymous`, `old_home`), `CommandRunner`, `CommandError`.
- Produces:
  - `is_anonymous(name: str) -> bool`, `parse_json_lines(text: str) -> list[dict]`, `parse_labels(text: str) -> dict[str, str]`.
  - `rewrite_home(path: str, old_home: Path | None, new_home: Path) -> str`.
  - `compose_hints(inventory: dict, old_home: Path | None, new_home: Path) -> list[str]` : une ligne `cd <working_dir> && docker compose up -d   # <project>` par projet compose, dédupliquée.
  - `DockerModule` (`name="docker"`, `order=110`). Fichiers : `<dest>/docker/inventory.json` (`{"home", "containers": [{"name","image","state","labels"}], "images": [...], "volumes": [...]}`), `<dest>/docker/volumes/<vol>.tar.gz`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/docker
```

- [ ] **Step 2: Écrire `tests/test_docker.py`**

```python
import json
from pathlib import Path

from osx_backup.context import Context
from osx_backup.modules.base import NullProgress
from osx_backup.modules.docker import (
    DockerModule,
    compose_hints,
    is_anonymous,
    parse_labels,
    rewrite_home,
)
from osx_backup.runner import RecordingRunner

ANON = "3ac321aa9d5ba236d2298d75c388995551da05dfb50bc4057fa8124a2ab3a92d"
PS = "\n".join(
    [
        json.dumps({"Names": "plane-app-api-1", "Image": "plane:1", "State": "running",
                    "Labels": "com.docker.compose.project=plane-app,com.docker.compose.project.working_dir=/Users/old/Github/plane"}),
        json.dumps({"Names": "plane-app-db-1", "Image": "postgres:15", "State": "running",
                    "Labels": "com.docker.compose.project=plane-app,com.docker.compose.project.working_dir=/Users/old/Github/plane"}),
        json.dumps({"Names": "adhoc", "Image": "alpine", "State": "exited", "Labels": ""}),
    ]
)
VOLUMES = "\n".join([json.dumps({"Name": "plane_pgdata"}), json.dumps({"Name": ANON})])
IMAGES = json.dumps({"Repository": "postgres", "Tag": "15"})


def _runner(docker_ok: bool = True) -> RecordingRunner:
    r = RecordingRunner()
    r.add(("docker", "info"), returncode=0 if docker_ok else 1)
    r.add(("docker", "ps", "-a", "--format"), stdout=PS)
    r.add(("docker", "volume", "ls"), stdout=VOLUMES)
    r.add(("docker", "images"), stdout=IMAGES)
    r.add(("docker", "ps", "-a", "--filter", "volume=plane_pgdata"), stdout="plane-app-db-1\trunning\nadhoc\texited\n")
    return r


def test_helpers() -> None:
    assert is_anonymous(ANON) and not is_anonymous("plane_pgdata")
    assert parse_labels("a=1,b=x=y") == {"a": "1", "b": "x=y"}
    assert parse_labels("") == {}
    assert rewrite_home("/Users/old/Github/p", Path("/Users/old"), Path("/Users/new")) == "/Users/new/Github/p"
    assert rewrite_home("/opt/x", Path("/Users/old"), Path("/Users/new")) == "/opt/x"


def test_compose_hints_dedup_and_rewrite() -> None:
    inv = {
        "containers": [
            {"name": "a", "labels": {"com.docker.compose.project": "p", "com.docker.compose.project.working_dir": "/Users/old/Github/p"}},
            {"name": "b", "labels": {"com.docker.compose.project": "p", "com.docker.compose.project.working_dir": "/Users/old/Github/p"}},
            {"name": "c", "labels": {}},
        ]
    }
    assert compose_hints(inv, Path("/Users/old"), Path("/Users/new")) == [
        "cd /Users/new/Github/p && docker compose up -d   # p"
    ]


def test_inventory(ctx: Context) -> None:
    ctx.runner = _runner()
    inv = DockerModule().inventory(ctx)
    assert inv.available and inv.items == 1
    assert inv.details["containers"] == 3
    assert inv.warnings == ["1 volume(s) anonyme(s) ignoré(s) (--include-anonymous pour les inclure)"]


def test_inventory_unavailable(ctx: Context) -> None:
    ctx.runner = _runner(docker_ok=False)
    inv = DockerModule().inventory(ctx)
    assert not inv.available and "colima start" in inv.reason


def test_backup_stops_exports_restarts(ctx: Context) -> None:
    r = _runner()
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok", res.message
    voldir = ctx.dest / "docker" / "volumes"
    assert r.mutations == [
        ["docker", "stop", "plane-app-db-1"],
        ["docker", "run", "--rm", "-v", "plane_pgdata:/from:ro", "-v", f"{voldir}:/to", "alpine",
         "tar", "czf", "/to/plane_pgdata.tar.gz", "-C", "/from", "."],
        ["docker", "start", "plane-app-db-1"],
    ]
    inv = json.loads((ctx.dest / "docker" / "inventory.json").read_text())
    assert inv["home"] == str(ctx.home)
    assert inv["containers"][0]["labels"]["com.docker.compose.project"] == "plane-app"
    assert inv["images"] == ["postgres:15"]
    assert ANON in inv["volumes"]


def test_backup_restarts_even_on_failure(ctx: Context) -> None:
    r = _runner()
    r.add(("docker", "run"), returncode=1, stderr="tar failed")
    ctx.runner = r
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "failed"
    assert ["docker", "start", "plane-app-db-1"] in r.mutations


def test_backup_no_stop_warns(ctx: Context) -> None:
    r = _runner()
    ctx.runner = r
    ctx.docker_no_stop = True
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok"
    assert not r.has_call("docker", "stop")
    assert any("à chaud" in w for w in res.warnings)


def test_backup_include_anonymous(ctx: Context) -> None:
    r = _runner()
    r.add(("docker", "ps", "-a", "--filter", f"volume={ANON}"), stdout="")
    ctx.runner = r
    ctx.docker_include_anonymous = True
    res = DockerModule().backup(ctx, NullProgress())
    assert res.status == "ok" and res.items == 2


def test_restore_creates_volumes_and_prints_hints(ctx: Context) -> None:
    voldir = ctx.dest / "docker" / "volumes"
    voldir.mkdir(parents=True)
    (voldir / "plane_pgdata.tar.gz").write_bytes(b"x")
    (ctx.dest / "docker" / "inventory.json").write_text(json.dumps({
        "home": "/Users/old",
        "containers": [{"name": "a", "labels": {"com.docker.compose.project": "plane-app",
                        "com.docker.compose.project.working_dir": "/Users/old/Github/plane"}}],
        "images": [], "volumes": ["plane_pgdata"],
    }))
    r = RecordingRunner()
    r.add(("docker", "info"), returncode=1)
    ctx.runner = r
    res = DockerModule().restore(ctx, NullProgress())
    assert res.status == "failed" and "injoignable" in res.message
    assert ["colima", "start"] in r.mutations

    r2 = RecordingRunner()
    r2.add(("docker", "info"), returncode=0)
    ctx.runner = r2
    res2 = DockerModule().restore(ctx, NullProgress())
    assert res2.status == "ok", res2.message
    assert r2.mutations == [
        ["docker", "volume", "create", "plane_pgdata"],
        ["docker", "run", "--rm", "-v", "plane_pgdata:/to", "-v", f"{voldir}:/from:ro", "alpine",
         "sh", "-c", "tar xzf /from/plane_pgdata.tar.gz -C /to"],
    ]
    assert f"cd {ctx.home}/Github/plane && docker compose up -d" in res2.message


def test_restore_without_backup_skipped(ctx: Context) -> None:
    assert DockerModule().restore(ctx, NullProgress()).status == "skipped"
```

- [ ] **Step 3: Vérifier l'échec**

Run: `uv run pytest tests/test_docker.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/modules/docker.py`**

```python
"""Docker (Colima) : inventaire et export des volumes nommés."""

from __future__ import annotations

import json
import re
from pathlib import Path
from typing import Any

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register
from osx_backup.runner import CommandError

ANON_RE = re.compile(r"^[0-9a-f]{64}$")
LABEL_PROJECT = "com.docker.compose.project"
LABEL_WORKDIR = "com.docker.compose.project.working_dir"
UNREACHABLE = "Docker injoignable : colima start"


def is_anonymous(name: str) -> bool:
    return bool(ANON_RE.match(name))


def parse_json_lines(text: str) -> list[dict[str, Any]]:
    out: list[dict[str, Any]] = []
    for line in text.splitlines():
        line = line.strip()
        if line:
            out.append(json.loads(line))
    return out


def parse_labels(text: str) -> dict[str, str]:
    labels: dict[str, str] = {}
    for part in text.split(","):
        if "=" in part:
            key, value = part.split("=", 1)
            labels[key.strip()] = value
    return labels


def rewrite_home(path: str, old_home: Path | None, new_home: Path) -> str:
    if old_home and path.startswith(str(old_home) + "/"):
        return str(new_home) + path[len(str(old_home)) :]
    return path


def compose_hints(inventory: dict[str, Any], old_home: Path | None, new_home: Path) -> list[str]:
    seen: dict[str, str] = {}
    for container in inventory.get("containers", []):
        labels = container.get("labels", {})
        project, workdir = labels.get(LABEL_PROJECT), labels.get(LABEL_WORKDIR)
        if project and workdir and project not in seen:
            seen[project] = rewrite_home(workdir, old_home, new_home)
    return [f"cd {wd} && docker compose up -d   # {project}" for project, wd in seen.items()]


def _docker_ok(ctx: Context) -> bool:
    return ctx.runner.run(["docker", "info"], check=False, readonly=True).ok


def _containers(ctx: Context) -> list[dict[str, Any]]:
    res = ctx.runner.run(["docker", "ps", "-a", "--format", "{{json .}}"], readonly=True)
    return [
        {
            "name": c.get("Names", ""),
            "image": c.get("Image", ""),
            "state": c.get("State", ""),
            "labels": parse_labels(c.get("Labels", "") or ""),
        }
        for c in parse_json_lines(res.stdout)
    ]


def _volumes(ctx: Context) -> list[str]:
    res = ctx.runner.run(["docker", "volume", "ls", "--format", "{{json .}}"], readonly=True)
    return [v["Name"] for v in parse_json_lines(res.stdout)]


def _images(ctx: Context) -> list[str]:
    res = ctx.runner.run(["docker", "images", "--format", "{{json .}}"], readonly=True)
    return [f"{i.get('Repository')}:{i.get('Tag')}" for i in parse_json_lines(res.stdout)]


def _volume_users(ctx: Context, volume: str) -> list[tuple[str, str]]:
    res = ctx.runner.run(
        ["docker", "ps", "-a", "--filter", f"volume={volume}", "--format", "{{.Names}}\t{{.State}}"],
        readonly=True,
    )
    users: list[tuple[str, str]] = []
    for line in res.stdout.splitlines():
        if "\t" in line:
            name, state = line.split("\t", 1)
            users.append((name.strip(), state.strip()))
    return users


@register
class DockerModule(Module):
    name = "docker"
    description = "Docker/Colima : inventaire des containers et export des volumes nommés"
    order = 110

    def _selected_volumes(self, ctx: Context, names: list[str]) -> list[str]:
        return [n for n in names if ctx.docker_include_anonymous or not is_anonymous(n)]

    def inventory(self, ctx: Context) -> Inventory:
        if not _docker_ok(ctx):
            return Inventory(available=False, reason=UNREACHABLE)
        containers = _containers(ctx)
        all_volumes = _volumes(ctx)
        selected = self._selected_volumes(ctx, all_volumes)
        warnings: list[str] = []
        skipped = len(all_volumes) - len(selected)
        if skipped:
            warnings.append(
                f"{skipped} volume(s) anonyme(s) ignoré(s) (--include-anonymous pour les inclure)"
            )
        return Inventory(
            items=len(selected),
            warnings=warnings,
            details={"containers": len(containers), "volumes": selected},
        )

    def _export_volume(self, ctx: Context, volume: str, voldir: Path, warnings: list[str]) -> None:
        users = _volume_users(ctx, volume)
        running = [name for name, state in users if state == "running"]
        stopped: list[str] = []
        try:
            if running and ctx.docker_no_stop:
                warnings.append(f"{volume} : export à chaud ({', '.join(running)} en cours)")
            elif running:
                ctx.runner.run(["docker", "stop", *running])
                stopped = running
            ctx.runner.run(
                [
                    "docker", "run", "--rm",
                    "-v", f"{volume}:/from:ro",
                    "-v", f"{voldir}:/to",
                    "alpine", "tar", "czf", f"/to/{volume}.tar.gz", "-C", "/from", ".",
                ]
            )
        finally:
            if stopped:
                ctx.runner.run(["docker", "start", *stopped], check=False)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        if not _docker_ok(ctx):
            return ModuleResult.failed(UNREACHABLE)
        out = ctx.module_dir(self.name)
        all_volumes = _volumes(ctx)
        inventory = {
            "home": str(ctx.home),
            "containers": _containers(ctx),
            "images": _images(ctx),
            "volumes": all_volumes,
        }
        ctx.write_json(out / "inventory.json", inventory)
        voldir = out / "volumes"
        if not ctx.dry_run:
            voldir.mkdir(exist_ok=True)
        volumes = self._selected_volumes(ctx, all_volumes)
        warnings: list[str] = []
        failures: list[str] = []
        task = progress.task("volumes docker", total=len(volumes))
        for volume in volumes:
            try:
                self._export_volume(ctx, volume, voldir, warnings)
            except CommandError as exc:
                failures.append(f"{volume} : {exc.result.stderr.strip()[:120]}")
            task.advance()
        task.finish()
        if failures:
            return ModuleResult.failed(
                f"{len(failures)} volume(s) en échec : " + "; ".join(failures),
                items=len(volumes) - len(failures),
                warnings=warnings,
            )
        return ModuleResult.ok(
            f"{len(volumes)} volume(s) exporté(s)", items=len(volumes), warnings=warnings
        )

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        docker_dir = ctx.dest / self.name
        inventory_file = docker_dir / "inventory.json"
        if not inventory_file.exists():
            return ModuleResult.skipped("pas d'inventaire Docker dans la sauvegarde")
        if not _docker_ok(ctx):
            ctx.runner.run(["colima", "start"], check=False)
            if not _docker_ok(ctx):
                return ModuleResult.failed(UNREACHABLE)
        voldir = docker_dir / "volumes"
        archives = sorted(voldir.glob("*.tar.gz")) if voldir.is_dir() else []
        warnings: list[str] = []
        task = progress.task("volumes docker", total=len(archives))
        for archive in archives:
            volume = archive.name.removesuffix(".tar.gz")
            try:
                ctx.runner.run(["docker", "volume", "create", volume])
                ctx.runner.run(
                    [
                        "docker", "run", "--rm",
                        "-v", f"{volume}:/to",
                        "-v", f"{voldir}:/from:ro",
                        "alpine", "sh", "-c", f"tar xzf /from/{archive.name} -C /to",
                    ]
                )
            except CommandError as exc:
                warnings.append(f"{volume} : {exc.result.stderr.strip()[:120]}")
            task.advance()
        task.finish()
        inventory = json.loads(inventory_file.read_text(encoding="utf-8"))
        old_home = ctx.old_home or (Path(inventory["home"]) if inventory.get("home") else None)
        hints = compose_hints(inventory, old_home, ctx.home)
        message = f"{len(archives)} volume(s) restauré(s)"
        if hints:
            message += ". Projets compose à relancer manuellement :\n  " + "\n  ".join(hints)
            ctx.console.print("[bold]Projets compose à relancer :[/]")
            for hint in hints:
                ctx.console.print("  " + hint, markup=False, highlight=False)
        return ModuleResult.ok(message, items=len(archives), warnings=warnings)
```

- [ ] **Step 5: Enregistrer le module** : ajouter `docker` à la liste d'imports de `src/osx_backup/modules/__init__.py`.

- [ ] **Step 6: Vérifier que les tests passent**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts. Note : `test_backup_restarts_even_on_failure` prépare `("docker", "run")` en échec après `_runner()` ; comme `RecordingRunner` choisit le préfixe le plus long et, à longueur égale, le dernier ajouté, la réponse d'échec l'emporte.

- [ ] **Step 7: Commit et merge**

```bash
git add src/osx_backup/modules tests/test_docker.py
```
Appel séparé : `git commit -m "feat: module docker (inventaire et volumes)"`. Puis :
```bash
git checkout dev && git merge feature/docker --ff-only && git branch -d feature/docker
```

---

### Task 13: Module apps

**Files:**
- Create: `src/osx_backup/modules/apps.py`
- Modify: `src/osx_backup/modules/__init__.py`
- Test: `tests/test_apps.py`

**Interfaces:**
- Consumes: `Context.applications_dir`, `CommandRunner`.
- Produces:
  - `App(name: str, version: str, source: str)`.
  - `app_version(app: Path) -> str`, `app_source(app: Path) -> str` (`"App Store"`, `"Setapp"`, `"manuel"`).
  - `cask_app_names(caskroom: Path | None) -> set[str]`, `caskroom_path(ctx: Context) -> Path | None`.
  - `list_apps(applications_dir: Path, cask_names: set[str]) -> list[App]`.
  - `render_markdown(apps: list[App]) -> str`.
  - `AppsModule` (`name="apps"`, `order=120`). Fichier : `<dest>/apps/applications.md`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/apps
```

- [ ] **Step 2: Écrire `tests/test_apps.py`**

```python
import plistlib
from pathlib import Path

from osx_backup.context import Context
from osx_backup.modules.apps import (
    App,
    AppsModule,
    app_source,
    app_version,
    cask_app_names,
    list_apps,
    render_markdown,
)
from osx_backup.modules.base import NullProgress
from osx_backup.runner import RecordingRunner


def _app(root: Path, name: str, version: str = "1.0", mas: bool = False) -> Path:
    app = root / f"{name}.app"
    (app / "Contents").mkdir(parents=True)
    with (app / "Contents" / "Info.plist").open("wb") as fh:
        plistlib.dump({"CFBundleShortVersionString": version}, fh)
    if mas:
        (app / "Contents" / "_MASReceipt").mkdir()
    return app


def test_version_and_source(tmp_path: Path) -> None:
    a = _app(tmp_path, "Foo", "2.3", mas=True)
    assert app_version(a) == "2.3" and app_source(a) == "App Store"
    (tmp_path / "Bar.app").mkdir()
    assert app_version(tmp_path / "Bar.app") == "" and app_source(tmp_path / "Bar.app") == "manuel"
    setapp = _app(tmp_path / "Setapp", "Baz")
    assert app_source(setapp) == "Setapp"


def test_list_apps_skips_casks(tmp_path: Path) -> None:
    apps_dir = tmp_path / "Applications"
    _app(apps_dir, "Ghostty")
    _app(apps_dir, "Obsidian")
    _app(apps_dir / "Setapp", "CleanShot")
    caskroom = tmp_path / "Caskroom"
    _app(caskroom / "ghostty" / "1.0", "Ghostty")
    names = cask_app_names(caskroom)
    assert names == {"Ghostty.app"}
    apps = list_apps(apps_dir, names)
    assert [a.name for a in apps] == ["Obsidian", "CleanShot"]
    assert apps[1].source == "Setapp"


def test_render_markdown() -> None:
    md = render_markdown([App("Obsidian", "1.5", "manuel")])
    assert "| Obsidian | 1.5 | manuel |" in md


def test_module_backup_and_restore(ctx: Context, tmp_path: Path) -> None:
    apps_dir = tmp_path / "Applications"
    _app(apps_dir, "Obsidian")
    ctx.applications_dir = apps_dir
    r = RecordingRunner()
    r.add(("brew", "--caskroom"), stdout=str(tmp_path / "Caskroom") + "\n")
    ctx.runner = r
    m = AppsModule()
    assert m.inventory(ctx).items == 1
    assert m.backup(ctx, NullProgress()).status == "ok"
    md = ctx.dest / "apps" / "applications.md"
    assert "Obsidian" in md.read_text()
    res = m.restore(ctx, NullProgress())
    assert res.status == "ok"
    assert "Obsidian" in ctx.console.export_text()


def test_module_unavailable(ctx: Context, tmp_path: Path) -> None:
    ctx.applications_dir = tmp_path / "nope"
    assert not AppsModule().inventory(ctx).available
```

- [ ] **Step 3: Vérifier l'échec**

Run: `uv run pytest tests/test_apps.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/modules/apps.py`**

```python
"""Applications de /Applications non gérées par Homebrew : liste à réinstaller à la main."""

from __future__ import annotations

import plistlib
from dataclasses import dataclass
from pathlib import Path

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, register


@dataclass(frozen=True)
class App:
    name: str
    version: str
    source: str


def app_version(app: Path) -> str:
    plist = app / "Contents" / "Info.plist"
    try:
        with plist.open("rb") as fh:
            data = plistlib.load(fh)
        return str(data.get("CFBundleShortVersionString") or data.get("CFBundleVersion") or "")
    except (OSError, plistlib.InvalidFileException, ValueError):
        return ""


def app_source(app: Path) -> str:
    if (app / "Contents" / "_MASReceipt").exists():
        return "App Store"
    if "Setapp" in app.parts:
        return "Setapp"
    return "manuel"


def cask_app_names(caskroom: Path | None) -> set[str]:
    if caskroom is None or not caskroom.is_dir():
        return set()
    return {p.name for p in caskroom.glob("*/*/*.app")} | {p.name for p in caskroom.glob("*/*/*/*.app")}


def caskroom_path(ctx: Context) -> Path | None:
    if ctx.runner.which("brew") is None:
        return None
    res = ctx.runner.run(["brew", "--caskroom"], check=False, readonly=True)
    return Path(res.stdout.strip()) if res.ok and res.stdout.strip() else None


def list_apps(applications_dir: Path, cask_names: set[str]) -> list[App]:
    candidates = sorted(applications_dir.glob("*.app")) + sorted(
        (applications_dir / "Setapp").glob("*.app")
    )
    return [
        App(p.name.removesuffix(".app"), app_version(p), app_source(p))
        for p in candidates
        if p.name not in cask_names
    ]


def render_markdown(apps: list[App]) -> str:
    lines = [
        "# Applications à réinstaller manuellement",
        "",
        "Apps présentes dans /Applications mais absentes des casks Homebrew.",
        "",
        "| Application | Version | Source |",
        "|---|---|---|",
    ]
    lines += [f"| {a.name} | {a.version} | {a.source} |" for a in apps]
    return "\n".join(lines) + "\n"


@register
class AppsModule(Module):
    name = "apps"
    description = "Applications hors Homebrew (App Store, Setapp, dmg) : liste à réinstaller"
    order = 120

    def _apps(self, ctx: Context) -> list[App]:
        return list_apps(ctx.applications_dir, cask_app_names(caskroom_path(ctx)))

    def inventory(self, ctx: Context) -> Inventory:
        if not ctx.applications_dir.is_dir():
            return Inventory(available=False, reason=f"{ctx.applications_dir} absent")
        return Inventory(items=len(self._apps(ctx)))

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        apps = self._apps(ctx)
        task = progress.task("apps", total=1)
        ctx.write_text(ctx.module_dir(self.name) / "applications.md", render_markdown(apps))
        task.advance()
        task.finish()
        return ModuleResult.ok(f"{len(apps)} app(s) listée(s)", items=len(apps))

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        md = ctx.dest / self.name / "applications.md"
        if not md.exists():
            return ModuleResult.skipped("pas de liste d'applications dans la sauvegarde")
        text = md.read_text(encoding="utf-8")
        ctx.console.print(text, markup=False, highlight=False)
        count = sum(1 for line in text.splitlines() if line.startswith("| ") and "---" not in line) - 1
        return ModuleResult.ok(
            f"liste affichée : {max(count, 0)} app(s) à réinstaller à la main ({md})",
            items=max(count, 0),
        )
```

- [ ] **Step 5: Enregistrer le module** : ajouter `apps` à la liste d'imports de `src/osx_backup/modules/__init__.py`. La liste finale est : `apps, brew, claude, docker, documents, dotfiles, editors, github, mise, postgres, secrets, uv`.

- [ ] **Step 6: Vérifier que les tests passent**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 7: Commit et merge**

```bash
git add src/osx_backup/modules tests/test_apps.py
```
Appel séparé : `git commit -m "feat: module apps (inventaire hors Homebrew)"`. Puis :
```bash
git checkout dev && git merge feature/apps --ff-only && git branch -d feature/apps
```

---
### Task 14: Interface (checklist, tables, progression)

**Files:**
- Create: `src/osx_backup/ui.py`
- Test: `tests/test_ui.py`

**Interfaces:**
- Consumes: `Module`, `Inventory`, `ModuleResult`, `ProgressReporter`, `TaskHandle`, `format_size`.
- Produces:
  - `checklist(modules: Sequence[Module], inventories: dict[str, Inventory], preselected: set[str] | None = None, ask: Callable[[list[questionary.Choice]], list[str] | None] | None = None) -> list[str]` : noms sélectionnés dans l'ordre des modules ; lève `KeyboardInterrupt` si `ask` renvoie `None` (Ctrl-C dans questionary).
  - `confirm(message: str, ask: Callable[[str], bool | None] | None = None) -> bool`.
  - `inventory_table(modules, inventories) -> Table`, `plan_table(action: str, names: Sequence[str], inventories, dest: Path) -> Table`, `summary_table(results: dict[str, ModuleResult]) -> Table`.
  - `RichProgress(console: Console)` (context manager, `ProgressReporter`) et `RichTask(TaskHandle)`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/ui
```

- [ ] **Step 2: Écrire `tests/test_ui.py`**

```python
from pathlib import Path

import pytest
from rich.console import Console

from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter
from osx_backup.ui import (
    RichProgress,
    checklist,
    confirm,
    inventory_table,
    plan_table,
    summary_table,
)


class Fake(Module):
    name = "fake"
    description = "module factice"
    order = 1

    def inventory(self, ctx: Context) -> Inventory:
        return Inventory()

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.ok()

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.ok()


class Other(Fake):
    name = "other"
    description = "autre"
    order = 2


class Broken(Fake):
    name = "broken"
    description = "cassé"
    order = 3


MODULES = [Fake(), Other(), Broken()]
INVENTORIES = {
    "fake": Inventory(items=3, size_bytes=2048),
    "other": Inventory(items=1, size_bytes=10, warnings=["un avertissement"]),
    "broken": Inventory(available=False, reason="binaire absent"),
}


def test_checklist_returns_in_module_order_and_disables_unavailable() -> None:
    captured = {}

    def ask(choices):
        captured["choices"] = choices
        return ["other", "fake"]

    assert checklist(MODULES, INVENTORIES, None, ask) == ["fake", "other"]
    by_value = {c.value: c for c in captured["choices"]}
    assert by_value["broken"].disabled == "binaire absent"
    assert by_value["fake"].checked and by_value["other"].checked
    assert "2.0 KB" in by_value["fake"].title


def test_checklist_preselection() -> None:
    captured = {}

    def ask(choices):
        captured["choices"] = choices
        return []

    checklist(MODULES, INVENTORIES, {"other"}, ask)
    by_value = {c.value: c for c in captured["choices"]}
    assert not by_value["fake"].checked and by_value["other"].checked


def test_checklist_cancel_raises() -> None:
    with pytest.raises(KeyboardInterrupt):
        checklist(MODULES, INVENTORIES, None, lambda _c: None)


def test_confirm_injectable() -> None:
    assert confirm("ok ?", lambda _m: True) is True
    assert confirm("ok ?", lambda _m: None) is False


def _render(renderable) -> str:
    console = Console(record=True, width=160)
    console.print(renderable)
    return console.export_text()


def test_inventory_table() -> None:
    text = _render(inventory_table(MODULES, INVENTORIES))
    assert "fake" in text and "2.0 KB" in text
    assert "binaire absent" in text and "un avertissement" in text


def test_plan_table() -> None:
    text = _render(plan_table("backup", ["fake"], INVENTORIES, Path("/Volumes/X")))
    assert "/Volumes/X/fake" in text and "sauvegarde" in text
    text2 = _render(plan_table("restore", ["fake"], INVENTORIES, Path("/Volumes/X")))
    assert "restauration" in text2


def test_summary_table() -> None:
    results = {
        "fake": ModuleResult.ok("bien", duration_s=1.5, size_bytes=100),
        "other": ModuleResult.failed("boom", warnings=["w1", "w2"]),
        "broken": ModuleResult.skipped("rien"),
    }
    text = _render(summary_table(results))
    assert "ok" in text and "failed" in text and "skipped" in text
    assert "boom" in text and "2" in text


def test_rich_progress_tasks() -> None:
    console = Console(record=True, width=80)
    with RichProgress(console) as progress:
        task = progress.task("copie", total=4)
        task.advance()
        task.advance(2)
        task.set_completed(4)
        task.finish()
        spinner = progress.task("brew bundle", total=None)
        spinner.set_total(1)
        spinner.finish()
    assert progress.progress.tasks[0].completed == 4
    assert progress.progress.tasks[1].finished
```

- [ ] **Step 3: Vérifier l'échec**

Run: `uv run pytest tests/test_ui.py -q`
Expected: erreur d'import.

- [ ] **Step 4: Écrire `src/osx_backup/ui.py`**

```python
"""Checklist questionary, tables rich, barres de progression."""

from __future__ import annotations

from collections.abc import Callable, Sequence
from pathlib import Path

import questionary
from rich.console import Console
from rich.progress import (
    BarColumn,
    Progress,
    SpinnerColumn,
    TaskProgressColumn,
    TextColumn,
    TimeElapsedColumn,
)
from rich.table import Table

from osx_backup.fsutil import format_size
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, TaskHandle

AskChoices = Callable[[list[questionary.Choice]], list[str] | None]
AskConfirm = Callable[[str], bool | None]

STATUS_STYLE = {"ok": "green", "failed": "red", "skipped": "yellow"}
ACTION_LABEL = {"backup": "sauvegarde", "restore": "restauration"}


def _label(module: Module, inv: Inventory) -> str:
    return (
        f"{module.name:<10} {inv.items} item(s), {format_size(inv.size_bytes)}"
        f"  ({module.description})"
    )


def _default_ask(choices: list[questionary.Choice]) -> list[str] | None:
    return questionary.checkbox(
        "Modules à traiter (espace : cocher, entrée : valider)", choices=choices
    ).ask()


def checklist(
    modules: Sequence[Module],
    inventories: dict[str, Inventory],
    preselected: set[str] | None = None,
    ask: AskChoices | None = None,
) -> list[str]:
    choices: list[questionary.Choice] = []
    for module in modules:
        inv = inventories[module.name]
        if inv.available:
            checked = preselected is None or module.name in preselected
            choices.append(questionary.Choice(_label(module, inv), value=module.name, checked=checked))
        else:
            choices.append(
                questionary.Choice(
                    f"{module.name:<10} indisponible : {inv.reason}",
                    value=module.name,
                    disabled=inv.reason,
                )
            )
    selected = (ask or _default_ask)(choices)
    if selected is None:
        raise KeyboardInterrupt
    chosen = set(selected)
    return [m.name for m in modules if m.name in chosen]


def confirm(message: str, ask: AskConfirm | None = None) -> bool:
    asker = ask or (lambda m: questionary.confirm(m, default=True).ask())
    return bool(asker(message))


def inventory_table(modules: Sequence[Module], inventories: dict[str, Inventory]) -> Table:
    table = Table(title="Inventaire", show_lines=False)
    table.add_column("Module", style="bold")
    table.add_column("Dispo")
    table.add_column("Items", justify="right")
    table.add_column("Taille", justify="right")
    table.add_column("Détail")
    for module in modules:
        inv = inventories[module.name]
        detail = module.description if inv.available else f"[red]{inv.reason}[/]"
        if inv.warnings:
            detail += "\n" + "\n".join(f"[yellow]⚠ {w}[/]" for w in inv.warnings)
        table.add_row(
            module.name,
            "[green]oui[/]" if inv.available else "[red]non[/]",
            str(inv.items) if inv.available else "-",
            format_size(inv.size_bytes) if inv.available else "-",
            detail,
        )
    return table


def plan_table(
    action: str, names: Sequence[str], inventories: dict[str, Inventory], dest: Path
) -> Table:
    table = Table(title=f"Plan de {ACTION_LABEL.get(action, action)}")
    table.add_column("Module", style="bold")
    table.add_column("Action")
    table.add_column("Source")
    table.add_column("Destination")
    table.add_column("Taille", justify="right")
    for name in names:
        inv = inventories[name]
        module_path = str(dest / name)
        src, dst = ("cette machine", module_path) if action == "backup" else (module_path, "$HOME")
        table.add_row(name, ACTION_LABEL.get(action, action), src, dst, format_size(inv.size_bytes))
    return table


def summary_table(results: dict[str, ModuleResult]) -> Table:
    table = Table(title="Résumé")
    table.add_column("Module", style="bold")
    table.add_column("Statut")
    table.add_column("Durée", justify="right")
    table.add_column("Taille", justify="right")
    table.add_column("Avert.", justify="right")
    table.add_column("Message")
    for name, result in results.items():
        style = STATUS_STYLE.get(result.status, "white")
        message = result.message
        if result.warnings:
            message += "\n" + "\n".join(f"[yellow]⚠ {w}[/]" for w in result.warnings)
        table.add_row(
            name,
            f"[{style}]{result.status}[/]",
            f"{result.duration_s:.1f}s",
            format_size(result.size_bytes),
            str(len(result.warnings)),
            message,
        )
    return table


class RichTask(TaskHandle):
    def __init__(self, progress: Progress, task_id: int) -> None:
        self._progress = progress
        self._id = task_id

    def advance(self, n: float = 1) -> None:
        self._progress.advance(self._id, n)

    def set_total(self, total: float) -> None:
        self._progress.update(self._id, total=total)

    def set_completed(self, completed: float) -> None:
        self._progress.update(self._id, completed=completed)

    def finish(self) -> None:
        task = self._progress.tasks[self._id]
        total = task.total if task.total is not None else max(task.completed, 1)
        self._progress.update(self._id, total=total, completed=total)


class RichProgress(ProgressReporter):
    def __init__(self, console: Console) -> None:
        self.progress = Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            BarColumn(),
            TaskProgressColumn(),
            TimeElapsedColumn(),
            console=console,
        )

    def __enter__(self) -> RichProgress:
        self.progress.start()
        return self

    def __exit__(self, *exc: object) -> None:
        self.progress.stop()

    def task(self, description: str, total: float | None = None) -> TaskHandle:
        task_id = self.progress.add_task(description, total=total)
        return RichTask(self.progress, task_id)
```

- [ ] **Step 5: Vérifier que les tests passent**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 6: Commit et merge**

```bash
git add src/osx_backup/ui.py tests/test_ui.py
```
Appel séparé : `git commit -m "feat: interface checklist, tables et progression rich"`. Puis :
```bash
git checkout dev && git merge feature/ui --ff-only && git branch -d feature/ui
```

---

### Task 15: Config utilisateur et CLI

**Files:**
- Create: `src/osx_backup/config.py`
- Create: `src/osx_backup/cli.py`
- Modify: `src/osx_backup/manifest.py` (paramètre `latest` de `write_manifest`)
- Test: `tests/test_config.py`, `tests/test_cli.py`

**Interfaces:**
- Consumes: tout ce qui précède : `get_modules`, `get_module`, `checklist`, `confirm`, tables, `RichProgress`, `build_manifest`, `write_manifest`, `read_manifest`, `RealRunner`, `DryRunRunner`, `Context`, `format_size`.
- Produces (config) : `UserConfig(dest: str | None = None, last_selection: list[str] = [])`, `config_path(home: Path) -> Path`, `load_user_config(home: Path) -> UserConfig`, `save_user_config(home: Path, cfg: UserConfig) -> Path`.
- Modifie (manifest) : `write_manifest(dest: Path, manifest: Manifest, *, latest: bool = True) -> Path` ; avec `latest=False` seul `manifests/<stamp>-<action>.json` est écrit (utilisé par restore).
- Produces (cli) : `app: typer.Typer` ; fonctions monkeypatchables `make_runner(log) -> CommandRunner`, `list_modules() -> list[Module]` ; helpers `resolve_dest`, `validate_dest`, `check_disk_space`, `select_modules`, `run_modules`, `copy_tool`, `render_readme`, `restore_inventories`.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/cli
```

- [ ] **Step 2: Écrire `tests/test_config.py`**

```python
from pathlib import Path

from osx_backup.config import UserConfig, config_path, load_user_config, save_user_config


def test_roundtrip(fake_home: Path) -> None:
    assert load_user_config(fake_home) == UserConfig()
    cfg = UserConfig(dest="/Volumes/Backup", last_selection=["brew", "github"])
    path = save_user_config(fake_home, cfg)
    assert path == config_path(fake_home) == fake_home / ".config" / "osx-backup" / "config.toml"
    assert load_user_config(fake_home) == cfg


def test_corrupt_file_gives_default(fake_home: Path) -> None:
    p = config_path(fake_home)
    p.parent.mkdir(parents=True)
    p.write_text("not = [toml")
    assert load_user_config(fake_home) == UserConfig()
```

- [ ] **Step 3: Écrire `tests/test_cli.py`**

```python
import json
from pathlib import Path

import pytest
from typer.testing import CliRunner

from osx_backup import cli
from osx_backup.context import Context
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter
from osx_backup.modules.dotfiles import DotfilesModule
from osx_backup.runner import RecordingRunner

runner = CliRunner()


class Failing(Module):
    name = "failing"
    description = "échoue toujours"
    order = 999

    def inventory(self, ctx: Context) -> Inventory:
        return Inventory(items=1)

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.failed("boom")

    def restore(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        return ModuleResult.failed("boom")


class Crashing(Failing):
    name = "crashing"

    def backup(self, ctx: Context, progress: ProgressReporter) -> ModuleResult:
        raise RuntimeError("kaboom")


@pytest.fixture
def env(fake_home: Path, dest: Path, map_path: Path, monkeypatch: pytest.MonkeyPatch):
    monkeypatch.setenv("OSX_BACKUP_MAP", str(map_path))
    recording = RecordingRunner()
    monkeypatch.setattr(cli, "make_runner", lambda _log: recording)
    monkeypatch.setattr(cli, "list_modules", lambda: [DotfilesModule()])
    (fake_home / ".zshrc").write_text("zsh")
    return fake_home, dest, recording


def test_map_prints_inventory(env) -> None:
    result = runner.invoke(cli.app, ["map"])
    assert result.exit_code == 0, result.output
    assert "dotfiles" in result.output


def test_backup_all_yes(env) -> None:
    home, dest, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code == 0, result.output
    assert (dest / "dotfiles" / ".zshrc").read_text() == "zsh"
    manifest = json.loads((dest / "manifest.json").read_text())
    assert manifest["modules"]["dotfiles"]["status"] == "ok"
    assert manifest["home"] == str(home)
    assert (dest / "README.md").exists()
    assert (dest / "osx-backup" / "pyproject.toml").exists()
    assert not (dest / "osx-backup" / ".venv").exists()
    assert list((dest / "logs").glob("*.log"))
    cfg = (home / ".config" / "osx-backup" / "config.toml").read_text()
    assert str(dest) in cfg and "dotfiles" in cfg


def test_backup_remembers_dest(env) -> None:
    home, dest, _ = env
    assert runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"]).exit_code == 0
    result = runner.invoke(cli.app, ["backup", "--all", "--yes"])
    assert result.exit_code == 0, result.output


def test_backup_dry_run_writes_nothing(env) -> None:
    _, dest, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--dry-run"])
    assert result.exit_code == 0, result.output
    assert not (dest / "manifest.json").exists()
    assert not (dest / "dotfiles").exists()


def test_backup_failure_exit_code(env, monkeypatch: pytest.MonkeyPatch) -> None:
    _, dest, _ = env
    monkeypatch.setattr(cli, "list_modules", lambda: [DotfilesModule(), Failing(), Crashing()])
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"])
    assert result.exit_code == 1
    manifest = json.loads((dest / "manifest.json").read_text())
    assert manifest["modules"]["failing"]["status"] == "failed"
    assert "kaboom" in manifest["modules"]["crashing"]["message"]
    assert manifest["modules"]["dotfiles"]["status"] == "ok"


def test_backup_modules_option_validates(env) -> None:
    _, dest, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(dest), "--modules", "nope", "--yes"])
    assert result.exit_code != 0 and "nope" in result.output


def test_backup_rejects_home_as_dest(env) -> None:
    home, _, _ = env
    result = runner.invoke(cli.app, ["backup", "--dest", str(home), "--all", "--yes"])
    assert result.exit_code != 0 and "HOME" in result.output


def test_backup_requires_dest_when_unknown(env) -> None:
    result = runner.invoke(cli.app, ["backup", "--all", "--yes"])
    assert result.exit_code != 0 and "--dest" in result.output


def test_restore_roundtrip(env, tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None:
    home, dest, _ = env
    assert runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"]).exit_code == 0
    home2 = tmp_path / "home2"
    home2.mkdir()
    (home2 / ".zshrc").write_text("old")
    monkeypatch.setenv("HOME", str(home2))
    result = runner.invoke(cli.app, ["restore", "--from", str(dest), "--all", "--yes"])
    assert result.exit_code == 0, result.output
    assert (home2 / ".zshrc").read_text() == "zsh"
    aside = list((home2 / ".osx-backup-pre-restore").glob("*/.zshrc"))
    assert len(aside) == 1 and aside[0].read_text() == "old"
    assert list((dest / "manifests").glob("*-restore.json"))
    assert json.loads((dest / "manifest.json").read_text())["action"] == "backup"


def test_restore_requires_manifest(env, tmp_path: Path) -> None:
    empty = tmp_path / "empty"
    empty.mkdir()
    result = runner.invoke(cli.app, ["restore", "--from", str(empty), "--all", "--yes"])
    assert result.exit_code != 0 and "manifest" in result.output


def test_restore_inventories_from_manifest(env) -> None:
    home, dest, recording = env
    assert runner.invoke(cli.app, ["backup", "--dest", str(dest), "--all", "--yes"]).exit_code == 0
    from osx_backup.manifest import read_manifest

    manifest = read_manifest(dest)
    assert manifest is not None
    inventories = cli.restore_inventories(manifest, [DotfilesModule(), Failing()])
    assert inventories["dotfiles"].available and inventories["dotfiles"].items == 1
    assert not inventories["failing"].available


def test_check_disk_space(dest: Path) -> None:
    import typer

    cli.check_disk_space(dest, 1, force=False)
    with pytest.raises(typer.BadParameter):
        cli.check_disk_space(dest, 10**18, force=False)
    cli.check_disk_space(dest, 10**18, force=True)
```

- [ ] **Step 4: Vérifier l'échec**

Run: `uv run pytest tests/test_config.py tests/test_cli.py -q`
Expected: erreurs d'import.

- [ ] **Step 5: Écrire `src/osx_backup/config.py`**

```python
"""Config utilisateur : destination mémorisée et dernière sélection."""

from __future__ import annotations

import json
import tomllib
from dataclasses import dataclass, field
from pathlib import Path


@dataclass
class UserConfig:
    dest: str | None = None
    last_selection: list[str] = field(default_factory=list)


def config_path(home: Path) -> Path:
    return home / ".config" / "osx-backup" / "config.toml"


def load_user_config(home: Path) -> UserConfig:
    path = config_path(home)
    if not path.exists():
        return UserConfig()
    try:
        data = tomllib.loads(path.read_text(encoding="utf-8"))
    except (tomllib.TOMLDecodeError, OSError):
        return UserConfig()
    dest = data.get("dest")
    selection = data.get("last_selection", [])
    return UserConfig(
        dest=str(dest) if dest else None,
        last_selection=[str(s) for s in selection] if isinstance(selection, list) else [],
    )


def save_user_config(home: Path, cfg: UserConfig) -> Path:
    path = config_path(home)
    path.parent.mkdir(parents=True, exist_ok=True)
    lines = ["# Généré par osx-backup"]
    if cfg.dest:
        lines.append(f"dest = {json.dumps(cfg.dest)}")
    lines.append(f"last_selection = {json.dumps(cfg.last_selection)}")
    path.write_text("\n".join(lines) + "\n", encoding="utf-8")
    return path
```

- [ ] **Step 6: Modifier `write_manifest` dans `src/osx_backup/manifest.py`**

Remplacer la fonction :

```python
def write_manifest(dest: Path, manifest: Manifest, *, latest: bool = True) -> Path:
    text = json.dumps(manifest.to_dict(), indent=2, ensure_ascii=False)
    history = dest / "manifests"
    history.mkdir(parents=True, exist_ok=True)
    stamp = manifest.created_at.replace(":", "-")
    history_path = history / f"{stamp}-{manifest.action}.json"
    history_path.write_text(text, encoding="utf-8")
    if not latest:
        return history_path
    path = dest / "manifest.json"
    path.write_text(text, encoding="utf-8")
    return path
```

Le test `tests/test_manifest.py::test_build_write_read` reste valide (un fichier dans `manifests/`, `manifest.json` écrit).

- [ ] **Step 7: Écrire `src/osx_backup/cli.py`**

```python
"""Commandes map / backup / restore."""

from __future__ import annotations

import os
import shutil
import socket
import sys
import tempfile
import time
import traceback
from collections.abc import Callable, Sequence
from datetime import datetime
from pathlib import Path

import questionary
import typer
from rich.console import Console

import osx_backup.modules  # noqa: F401 - remplit le registre
from osx_backup import __version__
from osx_backup.config import UserConfig, load_user_config, save_user_config
from osx_backup.context import Context
from osx_backup.fsutil import format_size
from osx_backup.manifest import Manifest, build_manifest, read_manifest, write_manifest
from osx_backup.modules.base import Inventory, Module, ModuleResult, ProgressReporter, get_modules
from osx_backup.runner import CommandRunner, DryRunRunner, RealRunner
from osx_backup.ui import (
    RichProgress,
    checklist,
    confirm,
    inventory_table,
    plan_table,
    summary_table,
)

app = typer.Typer(
    help="Sauvegarde et réinstallation d'un Mac de développeur.",
    no_args_is_help=True,
    add_completion=False,
)
console = Console()

TOOL_IGNORES = (".venv", "__pycache__", ".pytest_cache", ".ruff_cache", ".DS_Store")


def make_runner(log: Callable[[str], None]) -> CommandRunner:
    return RealRunner(log)


def list_modules() -> list[Module]:
    return get_modules()


def build_context(
    dest: Path,
    action: str,
    *,
    dry_run: bool = False,
    yes: bool = False,
    verbose: bool = False,
    force: bool = False,
    no_stop: bool = False,
    include_anonymous: bool = False,
    with_log: bool = True,
) -> Context:
    ctx = Context(
        home=Path.home(),
        dest=dest,
        runner=RealRunner(),
        console=console,
        dry_run=dry_run,
        yes=yes,
        verbose=verbose or dry_run,
        force=force,
        docker_no_stop=no_stop,
        docker_include_anonymous=include_anonymous,
    )
    if with_log and not dry_run:
        ctx.log_path = dest / "logs" / f"{ctx.timestamp}-{action}.log"
    real = make_runner(ctx.log)
    ctx.runner = DryRunRunner(real, ctx.log) if dry_run else real
    return ctx


def resolve_dest(option: Path | None, home: Path, flag: str) -> Path:
    if option is not None:
        return option.expanduser().resolve()
    cfg = load_user_config(home)
    if cfg.dest:
        return Path(cfg.dest)
    if not sys.stdin.isatty():
        raise typer.BadParameter(f"aucune destination connue : passer {flag}")
    answer = questionary.path("Dossier de sauvegarde :").ask()
    if not answer:
        raise typer.Abort()
    return Path(answer).expanduser().resolve()


def validate_dest(dest: Path, home: Path) -> None:
    if not dest.is_dir():
        raise typer.BadParameter(f"{dest} n'existe pas ou n'est pas un dossier")
    if dest == home or home.is_relative_to(dest):
        raise typer.BadParameter("la destination ne peut pas être $HOME ni un de ses parents")
    if not os.access(dest, os.W_OK):
        raise typer.BadParameter(f"{dest} n'est pas inscriptible")


def check_disk_space(dest: Path, needed: int, force: bool) -> None:
    free = shutil.disk_usage(dest).free
    if needed > free and not force:
        raise typer.BadParameter(
            f"espace insuffisant : {format_size(needed)} nécessaires, "
            f"{format_size(free)} libres (--force pour ignorer)"
        )


def inventory_all(ctx: Context, modules: Sequence[Module]) -> dict[str, Inventory]:
    inventories: dict[str, Inventory] = {}
    with console.status("Inventaire en cours…"):
        for module in modules:
            try:
                inventories[module.name] = module.inventory(ctx)
            except Exception as exc:  # noqa: BLE001
                ctx.log(traceback.format_exc())
                inventories[module.name] = Inventory(available=False, reason=f"erreur : {exc}")
    return inventories


def restore_inventories(manifest: Manifest, modules: Sequence[Module]) -> dict[str, Inventory]:
    inventories: dict[str, Inventory] = {}
    for module in modules:
        entry = manifest.modules.get(module.name)
        if entry and entry.get("status") == "ok":
            inventories[module.name] = Inventory(
                items=int(entry.get("items", 0)), size_bytes=int(entry.get("size_bytes", 0))
            )
        else:
            inventories[module.name] = Inventory(available=False, reason="absent de la sauvegarde")
    return inventories


def select_modules(
    ctx: Context,
    modules: Sequence[Module],
    inventories: dict[str, Inventory],
    modules_opt: str | None,
    all_flag: bool,
) -> list[str]:
    available = [m.name for m in modules if inventories[m.name].available]
    if modules_opt:
        wanted = [n.strip() for n in modules_opt.split(",") if n.strip()]
        known = {m.name for m in modules}
        for name in wanted:
            if name not in known:
                raise typer.BadParameter(f"module inconnu : {name}")
            if name not in available:
                raise typer.BadParameter(f"module {name} indisponible : {inventories[name].reason}")
        return [m.name for m in modules if m.name in wanted]
    if all_flag or ctx.yes:
        return available
    last = set(load_user_config(ctx.home).last_selection) & set(available)
    return checklist(modules, inventories, last or None)


def run_modules(
    ctx: Context, action: str, names: Sequence[str], progress: ProgressReporter
) -> dict[str, ModuleResult]:
    by_name = {m.name: m for m in list_modules()}
    results: dict[str, ModuleResult] = {}
    overall = progress.task(f"{action} ({len(names)} modules)", total=len(names))
    for name in names:
        module = by_name[name]
        start = time.monotonic()
        try:
            result = module.backup(ctx, progress) if action == "backup" else module.restore(ctx, progress)
        except KeyboardInterrupt:
            results[name] = ModuleResult.failed(
                "interrompu (Ctrl-C)", duration_s=round(time.monotonic() - start, 2)
            )
            ctx.log(f"{name}: interrompu")
            break
        except Exception as exc:  # noqa: BLE001
            ctx.log(traceback.format_exc())
            result = ModuleResult.failed(f"{type(exc).__name__}: {exc}")
        result.duration_s = round(time.monotonic() - start, 2)
        results[name] = result
        ctx.log(f"{name}: {result.status} - {result.message}")
        overall.advance()
    overall.finish()
    return results


def copy_tool(ctx: Context) -> None:
    repo = Path(__file__).resolve().parents[2]
    target = ctx.dest / "osx-backup"
    if ctx.dry_run:
        ctx.log(f"[dry-run] copie de l'outil {repo} -> {target}")
        return
    shutil.copytree(
        repo,
        target,
        ignore=shutil.ignore_patterns(*TOOL_IGNORES),
        dirs_exist_ok=True,
        symlinks=True,
    )


def render_readme(dest: Path, results: dict[str, ModuleResult]) -> str:
    lines = [
        f"# Sauvegarde osx-backup — {socket.gethostname()}",
        "",
        f"Créée le {datetime.now():%Y-%m-%d %H:%M} avec osx-backup {__version__}.",
        "",
        "## Restaurer sur un Mac vierge",
        "",
        "1. Outils de ligne de commande Xcode : `xcode-select --install`",
        '2. Homebrew : `/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"`',
        "3. `brew install uv rsync`",
        f'4. `cd "{dest / "osx-backup"}" && uv run osx-backup restore --from "{dest}"`',
        "",
        "L'existant sur la machine est déplacé dans `~/.osx-backup-pre-restore/<horodatage>/` avant chaque écrasement.",
        "L'archive `secrets.tar.age` demande la passphrase choisie à la sauvegarde.",
        "",
        "## Contenu",
        "",
        "| Module | Statut | Items | Taille |",
        "|---|---|---|---|",
    ]
    lines += [
        f"| {name} | {r.status} | {r.items} | {format_size(r.size_bytes)} |"
        for name, r in results.items()
    ]
    return "\n".join(lines) + "\n"


def _finish(ctx: Context, results: dict[str, ModuleResult]) -> None:
    console.print(summary_table(results))
    if ctx.log_path:
        console.print(f"Log : {ctx.log_path}")
    if any(r.status == "failed" for r in results.values()):
        raise typer.Exit(1)


@app.command("map")
def map_cmd(
    dest: Path | None = typer.Option(None, "--dest", help="Destination (pour estimer rsync)."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """Affiche la carte de ce qui serait sauvegardé, sans rien écrire."""
    home = Path.home()
    cfg = load_user_config(home)
    target = dest or (Path(cfg.dest) if cfg.dest else Path(tempfile.gettempdir()) / "osx-backup-map")
    ctx = build_context(target, "map", verbose=verbose, with_log=False)
    modules = list_modules()
    inventories = inventory_all(ctx, modules)
    console.print(inventory_table(modules, inventories))
    total = sum(i.size_bytes for i in inventories.values() if i.available)
    console.print(f"Taille estimée totale : {format_size(total)}")


@app.command()
def backup(
    dest: Path | None = typer.Option(None, "--dest", help="Dossier de destination."),
    modules: str | None = typer.Option(None, "--modules", help="Liste séparée par des virgules."),
    all_modules: bool = typer.Option(False, "--all", help="Tous les modules disponibles."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Sans questions."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Afficher le plan sans écrire."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
    force: bool = typer.Option(False, "--force", help="Ignorer le contrôle d'espace disque."),
    no_stop: bool = typer.Option(False, "--no-stop", help="Docker : ne pas stopper les containers."),
    include_anonymous: bool = typer.Option(False, "--include-anonymous", help="Docker : volumes anonymes aussi."),
) -> None:
    """Sauvegarde la machine vers un dossier (disque externe)."""
    home = Path.home()
    target = resolve_dest(dest, home, "--dest")
    validate_dest(target, home)
    ctx = build_context(
        target,
        "backup",
        dry_run=dry_run,
        yes=yes,
        verbose=verbose,
        force=force,
        no_stop=no_stop,
        include_anonymous=include_anonymous,
    )
    mods = list_modules()
    inventories = inventory_all(ctx, mods)
    console.print(inventory_table(mods, inventories))
    names = select_modules(ctx, mods, inventories, modules, all_modules)
    if not names:
        console.print("Aucun module sélectionné.")
        raise typer.Exit(0)
    check_disk_space(target, sum(inventories[n].size_bytes for n in names), force)
    console.print(plan_table("backup", names, inventories, target))
    if dry_run:
        console.print("[bold]Mode dry-run : les actions sont listées, rien n'est écrit.[/]")
    elif not yes and not confirm("Lancer la sauvegarde ?"):
        raise typer.Exit(1)
    with RichProgress(console) as progress:
        results = run_modules(ctx, "backup", names, progress)
    if not dry_run:
        copy_tool(ctx)
        ctx.write_text(target / "README.md", render_readme(target, results))
        write_manifest(target, build_manifest(ctx, "backup", results))
        save_user_config(home, UserConfig(dest=str(target), last_selection=list(names)))
    _finish(ctx, results)


@app.command()
def restore(
    source: Path | None = typer.Option(None, "--from", help="Dossier de sauvegarde."),
    modules: str | None = typer.Option(None, "--modules", help="Liste séparée par des virgules."),
    all_modules: bool = typer.Option(False, "--all", help="Tous les modules présents."),
    yes: bool = typer.Option(False, "--yes", "-y", help="Sans questions."),
    dry_run: bool = typer.Option(False, "--dry-run", help="Afficher le plan sans écrire."),
    verbose: bool = typer.Option(False, "--verbose", "-v"),
) -> None:
    """Restaure une sauvegarde sur cette machine."""
    home = Path.home()
    source_dir = resolve_dest(source, home, "--from")
    if not source_dir.is_dir():
        raise typer.BadParameter(f"{source_dir} n'existe pas")
    manifest = read_manifest(source_dir)
    if manifest is None:
        raise typer.BadParameter(f"pas de manifest.json dans {source_dir} : ce n'est pas une sauvegarde osx-backup")
    ctx = build_context(source_dir, "restore", dry_run=dry_run, yes=yes, verbose=verbose)
    ctx.old_home = Path(manifest.home) if manifest.home else None
    mods = list_modules()
    inventories = restore_inventories(manifest, mods)
    console.print(f"Sauvegarde de [bold]{manifest.hostname}[/] du {manifest.created_at} (home : {manifest.home})")
    console.print(inventory_table(mods, inventories))
    names = select_modules(ctx, mods, inventories, modules, all_modules)
    if not names:
        console.print("Aucun module sélectionné.")
        raise typer.Exit(0)
    console.print(plan_table("restore", names, inventories, source_dir))
    console.print(f"L'existant sera déplacé dans {ctx.pre_restore_dir}")
    if dry_run:
        console.print("[bold]Mode dry-run : les actions sont listées, rien n'est écrit.[/]")
    elif not yes and not confirm("Restaurer sur cette machine ?"):
        raise typer.Exit(1)
    with RichProgress(console) as progress:
        results = run_modules(ctx, "restore", names, progress)
    if not dry_run:
        write_manifest(source_dir, build_manifest(ctx, "restore", results), latest=False)
        save_user_config(home, UserConfig(dest=str(source_dir), last_selection=list(names)))
    _finish(ctx, results)


if __name__ == "__main__":
    app()
```

- [ ] **Step 8: Vérifier que les tests passent**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts. Vérifier aussi que `uv run osx-backup --help` affiche les trois commandes.

- [ ] **Step 9: Commit et merge**

```bash
git add src/osx_backup/config.py src/osx_backup/cli.py src/osx_backup/manifest.py tests/test_config.py tests/test_cli.py
```
Appel séparé : `git commit -m "feat: CLI map/backup/restore avec config utilisateur"`. Puis :
```bash
git checkout dev && git merge feature/cli --ff-only && git branch -d feature/cli
```

---

### Task 16: Smoke test sur la machine réelle et README du repo

**Files:**
- Create: `README.md`
- Modify: tout fichier nécessitant une correction révélée par le smoke test (chaque correction avec son test de non-régression).

**Interfaces:**
- Consumes: la CLI complète.
- Produces: un `README.md` de repo ; un rapport de smoke test dans le message final.

- [ ] **Step 1: Créer la branche**

```bash
git checkout dev && git checkout -b feature/smoke
```

- [ ] **Step 2: Lancer l'inventaire réel**

Run: `uv run osx-backup map`
Expected: table avec les 12 modules. Attendus sur cette machine : brew, mise, uv, dotfiles, secrets, claude, editors, postgres, docker, apps disponibles ; github et documents **indisponibles** avec `rsync GNU absent : brew install rsync` tant que `brew install rsync` n'a pas été fait. Si un module plante (exception), le corriger et ajouter un test reproduisant le cas.

- [ ] **Step 3: Installer rsync GNU et relancer**

Run: `brew install rsync && uv run osx-backup map`
Expected: github et documents disponibles avec une taille estimée (github doit être très en dessous de 148 Go : les node_modules sont exclus).

- [ ] **Step 4: Dry-run complet**

Run:
```bash
mkdir -p /private/tmp/claude-501/-Users-humperdoo-Github-osx-backup/a469b6c6-66fa-444f-9ff6-6ae9a1461094/scratchpad/dest
OSX_BACKUP_PASSPHRASE=test uv run osx-backup backup --dry-run --all --dest /private/tmp/claude-501/-Users-humperdoo-Github-osx-backup/a469b6c6-66fa-444f-9ff6-6ae9a1461094/scratchpad/dest
```
Expected: plan affiché, lignes `[dry-run] $ …` pour chaque commande mutante (brew bundle dump, pg_dump par base, docker stop/run/start par volume, rsync), aucun fichier créé dans la destination hormis rien (vérifier avec `ls -la`), code de sortie 0. Noter tout module `failed` et corriger.

- [ ] **Step 5: Backup réel partiel, sans risque**

Run:
```bash
OSX_BACKUP_PASSPHRASE=test uv run osx-backup backup --yes --dest <scratchpad>/dest --modules brew,mise,uv,dotfiles,claude,editors,apps,secrets
```
Expected: code 0, `manifest.json`, `README.md`, `osx-backup/`, `secrets.tar.age`, `brew/Brewfile` contenant `brew "rsync"`. Vérifier `age -d` n'est pas nécessaire : `uv run python -c "import pyrage;print(len(pyrage.passphrase.decrypt(open('<scratchpad>/dest/secrets.tar.age','rb').read(),'test')))"` renvoie une taille > 0. Ne pas lancer postgres, docker, github, documents en réel ici : c'est à l'utilisateur de le faire vers son vrai disque.

- [ ] **Step 6: Restore dry-run**

Run: `uv run osx-backup restore --dry-run --all --from <scratchpad>/dest`
Expected: table basée sur le manifeste, plan, lignes `[dry-run] place …`, code 0, aucun changement dans `$HOME` (vérifier `ls ~/.osx-backup-pre-restore` n'existe pas ou n'a pas de nouveau dossier).

- [ ] **Step 7: Écrire `README.md`**

```markdown
# osx-backup

Sauvegarde et réinstallation interactive d'un Mac de développeur : Homebrew, mise, uv, dotfiles, Claude Code, éditeurs, `~/Github` (sans dépendances), `~/Documents`, PostgreSQL, volumes Docker/Colima, secrets chiffrés (age).

## Prérequis

- macOS, Homebrew, `uv`, `rsync` GNU (`brew install rsync`).

## Usage

```bash
uv run osx-backup map                       # carte de ce qui serait sauvegardé
uv run osx-backup backup --dest /Volumes/X  # checklist, plan, confirmation, progression
uv run osx-backup restore --from /Volumes/X # sur un Mac vierge, après brew + uv + rsync
```

Flags communs : `--modules a,b,c`, `--all`, `--yes`, `--dry-run`, `--verbose`. Backup : `--force` (espace disque), `--no-stop` et `--include-anonymous` (Docker). Passphrase des secrets : demandée, ou `OSX_BACKUP_PASSPHRASE`.

La destination est mémorisée dans `~/.config/osx-backup/config.toml`. À la restauration, tout fichier existant est déplacé dans `~/.osx-backup-pre-restore/<horodatage>/` avant d'être remplacé.

## Adapter la liste des dotfiles

Éditer `map.toml` : une entrée `[[dotfiles]]` par chemin relatif à `~`, `secret = true` pour l'archive chiffrée, `exclude = [...]` pour des globs supplémentaires.

## Développement

```bash
uv sync
uv run pytest
uv run ruff format src tests && uv run ruff check src tests
```

Design : `docs/superpowers/specs/2026-09-03-osx-backup-design.md`. Plan : `docs/superpowers/plans/2026-09-03-osx-backup.md`.
```

- [ ] **Step 8: Tests et lint**

Run: `uv run ruff format src tests && uv run pytest -q && uv run ruff check src tests`
Expected: verts.

- [ ] **Step 9: Commit et merge**

```bash
git add README.md src tests
```
Appel séparé : `git commit -m "docs: README et corrections du smoke test"`. Puis :
```bash
git checkout dev && git merge feature/smoke --ff-only && git branch -d feature/smoke
```

---

## Self-review (fait à la rédaction)

- **Couverture de la spec** : §2 stack (T1), §3 CLI et config mémorisée (T15), §4 modèle de module (T4), §5 les douze modules (T6 dotfiles, T7 secrets, T8 claude/editors, T9 github/documents, T10 brew/mise/uv, T11 postgres, T12 docker, T13 apps), §6 disposition du disque, manifeste, README généré, copie de l'outil (T5, T15), §7 checklist/plan/progression/résumé/log (T14, T15), §8 erreurs (prérequis à l'inventaire, espace disque, refus de `$HOME`, Ctrl-C, pre-restore, mauvaise passphrase : T3, T7, T12, T15), §9 tests (chaque tâche), §10 conventions git (chaque tâche).
- **Écarts assumés par rapport à la spec** : (1) le rsync système macOS étant openrsync, github/documents exigent le rsync Homebrew et le Brewfile force `brew "rsync"` ; (2) les modules dotfiles/claude/editors écrivent un `_entries.json` dans leur dossier pour que la restauration ne dépende pas d'une map qui aurait changé ; (3) le fichier d'extensions VS Code est `editors/vscode-extensions.txt` ; (4) le manifeste de restauration ne remplace jamais `manifest.json`, il va seulement dans `manifests/<ts>-restore.json` ; (5) `--verbose` est activé automatiquement en `--dry-run` pour montrer les commandes planifiées.
- **Cohérence des types** : `CommandRunner.run(..., readonly=)`, `Context.place(src, dst, excludes=())`, `ModuleResult.ok/failed/skipped`, `ProgressReporter.task(description, total=None)`, `PathItem(rel, excludes)`, `backup_paths(ctx, module_name, items, progress)`, `restore_paths(ctx, module_name, progress)`, `find_gnu_rsync(runner)`, `build_command(rsync, src, dst, excludes, *, delete, dry_run, stats)`, `write_manifest(dest, manifest, *, latest=True)` sont utilisés avec ces signatures dans toutes les tâches.
