Coverage for smith / infrastructure / filesystem.py: 100%
0 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-01 18:48 +0000
« prev ^ index » next coverage.py v7.13.5, created at 2026-05-01 18:48 +0000
1"""Atomic file-system adapter — write files transactionally or remove them."""
3import shutil
4import tempfile
5from pathlib import Path
7from smith.domain.value_objects import FileSpec
10class FileSystemError(Exception):
11 """Raised when an atomic file operation fails."""
14class AtomicFileSystem:
15 """File-system adapter that writes files atomically via a staging directory."""
17 def __init__(self, project_dir: Path) -> None:
18 """Initialise with the project root directory."""
19 self._project_dir = project_dir
21 def check_conflicts(self, paths: list[Path]) -> list[Path]:
22 """Return the subset of *paths* that already exist on disk."""
23 return [p for p in paths if (self._project_dir / p).exists()]
25 def write_atomic(self, specs: list[FileSpec]) -> None:
26 """Write all specs atomically; roll back on failure."""
27 if not specs:
28 return
29 staging = Path(tempfile.mkdtemp(dir=self._project_dir))
30 try:
31 for spec in specs:
32 dest = staging / spec.relative_path
33 dest.parent.mkdir(parents=True, exist_ok=True)
34 dest.write_bytes(spec.content)
35 for spec in specs:
36 final = self._project_dir / spec.relative_path
37 final.parent.mkdir(parents=True, exist_ok=True)
38 Path(staging / spec.relative_path).replace(final)
39 except Exception as err:
40 shutil.rmtree(staging, ignore_errors=True)
41 raise FileSystemError("Atomic write failed") from err
42 else:
43 shutil.rmtree(staging, ignore_errors=True)
45 def remove(self, paths: list[Path]) -> None:
46 """Remove the given paths from the project directory."""
47 for p in paths:
48 full = self._project_dir / p
49 if full.exists():
50 full.unlink()
52 def exists(self, paths: list[Path]) -> dict[Path, bool]:
53 """Return a mapping of each path to whether it exists on disk."""
54 return {p: (self._project_dir / p).exists() for p in paths}