"""Manifest dataclass for vault metadata.""" import json from dataclasses import dataclass, field from datetime import datetime from pathlib import Path from typing import Literal from uuid import uuid4 from src.core.file_entry import FileEntry LocationStatus = Literal["active", "unreachable"] @dataclass class Location: """Represents a vault replica location. Attributes: path: Absolute path to the .vault file last_seen: Last time this location was accessible status: Current status (active/unreachable) """ path: str last_seen: datetime status: LocationStatus = "active" def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { "path": self.path, "last_seen": self.last_seen.isoformat(), "status": self.status, } @classmethod def from_dict(cls, data: dict) -> "Location": """Create Location from dictionary.""" return cls( path=data["path"], last_seen=datetime.fromisoformat(data["last_seen"]), status=data["status"], ) @dataclass class Manifest: """Vault manifest containing all metadata. This is stored as .vault/manifest.json inside each vault image. Attributes: vault_id: Unique identifier for the vault (UUID) vault_name: Human-readable name version: Manifest format version created: Creation timestamp last_modified: Last modification timestamp image_size_mb: Size of the vault image in MB locations: List of all replica locations files: List of all files in the vault """ vault_id: str vault_name: str image_size_mb: int created: datetime last_modified: datetime version: int = 1 locations: list[Location] = field(default_factory=list) files: list[FileEntry] = field(default_factory=list) @classmethod def create_new(cls, vault_name: str, image_size_mb: int, location_path: str) -> "Manifest": """Create a new manifest for a fresh vault. Args: vault_name: Human-readable name for the vault image_size_mb: Size of the vault image in MB location_path: Path to the first .vault file Returns: New Manifest instance """ now = datetime.now() resolved = str(Path(location_path).resolve()) return cls( vault_id=str(uuid4()), vault_name=vault_name, image_size_mb=image_size_mb, created=now, last_modified=now, locations=[Location(path=resolved, last_seen=now, status="active")], files=[], ) def to_dict(self) -> dict: """Convert to dictionary for JSON serialization.""" return { "vault_id": self.vault_id, "vault_name": self.vault_name, "version": self.version, "created": self.created.isoformat(), "last_modified": self.last_modified.isoformat(), "image_size_mb": self.image_size_mb, "locations": [loc.to_dict() for loc in self.locations], "files": [f.to_dict() for f in self.files], } @classmethod def from_dict(cls, data: dict) -> "Manifest": """Create Manifest from dictionary.""" return cls( vault_id=data["vault_id"], vault_name=data["vault_name"], version=data["version"], created=datetime.fromisoformat(data["created"]), last_modified=datetime.fromisoformat(data["last_modified"]), image_size_mb=data["image_size_mb"], locations=[Location.from_dict(loc) for loc in data["locations"]], files=[FileEntry.from_dict(f) for f in data["files"]], ) def save(self, mount_point: Path) -> None: """Save manifest to .vault/manifest.json. Args: mount_point: Path where the vault is mounted """ vault_dir = mount_point / ".vault" vault_dir.mkdir(exist_ok=True) manifest_path = vault_dir / "manifest.json" with open(manifest_path, "w", encoding="utf-8") as f: json.dump(self.to_dict(), f, indent=2) @classmethod def load(cls, mount_point: Path) -> "Manifest": """Load manifest from .vault/manifest.json. Args: mount_point: Path where the vault is mounted Returns: Loaded Manifest instance Raises: FileNotFoundError: If manifest doesn't exist """ manifest_path = mount_point / ".vault" / "manifest.json" with open(manifest_path, "r", encoding="utf-8") as f: data = json.load(f) return cls.from_dict(data) def add_location(self, path: str) -> None: """Add a new replica location. Args: path: Absolute path to the .vault file """ resolved = str(Path(path).resolve()) # Don't add duplicate locations for loc in self.locations: if str(Path(loc.path).resolve()) == resolved: loc.status = "active" loc.last_seen = datetime.now() self.last_modified = datetime.now() return self.locations.append( Location(path=resolved, last_seen=datetime.now(), status="active") ) self.last_modified = datetime.now() def update_location_status(self, path: str, status: LocationStatus) -> None: """Update status of a location. Args: path: Path to the location status: New status """ resolved = str(Path(path).resolve()) for loc in self.locations: if str(Path(loc.path).resolve()) == resolved: loc.status = status if status == "active": loc.last_seen = datetime.now() break self.last_modified = datetime.now() def add_file(self, file_entry: FileEntry) -> None: """Add or update a file entry. Args: file_entry: File entry to add/update """ # Remove existing entry with same path self.files = [f for f in self.files if f.path != file_entry.path] self.files.append(file_entry) self.last_modified = datetime.now() def add_file_from_path(self, base_path: Path, file_path: Path) -> FileEntry: """Add a file entry from a file path. Args: base_path: Base path (mount point) for relative path calculation file_path: Absolute path to the file Returns: The created FileEntry """ entry = FileEntry.from_path(base_path, file_path) self.add_file(entry) return entry def remove_file(self, path: str) -> None: """Remove a file entry by path. Args: path: Relative path of the file to remove """ self.files = [f for f in self.files if f.path != path] self.last_modified = datetime.now() def get_file(self, path: str) -> FileEntry | None: """Get file entry by path. Args: path: Relative path of the file Returns: FileEntry if found, None otherwise """ for f in self.files: if f.path == path: return f return None