"""state.json read/write + divergence helpers.""" from __future__ import annotations import json from datetime import datetime, timedelta, timezone from pathlib import Path import pytest from cloud_sync import scope as scopemod from cloud_sync import state as statemod from cloud_sync.sync import ( _find_modified_in_scope, _format_dt, _matches_any, _parse_backup_summary, _parse_restic_time, _parse_snapshots, ) # ---- state.read/write ---- def test_read_missing_returns_none(tmp_path: Path): assert statemod.read(tmp_path) is None def test_write_then_read_roundtrip(tmp_path: Path): dt = datetime(2026, 6, 5, 12, 34, 56, tzinfo=timezone.utc) statemod.write( tmp_path, statemod.State(last_pulled_snapshot_id="abc123", last_pulled_at=dt), ) got = statemod.read(tmp_path) assert got is not None assert got.last_pulled_snapshot_id == "abc123" assert got.last_pulled_at == dt def test_write_sets_mode_600(tmp_path: Path): statemod.write( tmp_path, statemod.State( last_pulled_snapshot_id="x", last_pulled_at=datetime.now(timezone.utc), ), ) mode = statemod.state_path(tmp_path).stat().st_mode & 0o777 assert mode == 0o600 def test_clear_idempotent(tmp_path: Path): statemod.clear(tmp_path) # no-op when missing statemod.write( tmp_path, statemod.State( last_pulled_snapshot_id="x", last_pulled_at=datetime.now(timezone.utc), ), ) assert statemod.state_path(tmp_path).exists() statemod.clear(tmp_path) assert not statemod.state_path(tmp_path).exists() def test_wrong_schema_returns_none(tmp_path: Path): p = tmp_path / ".cloud-sync" / "state.json" p.parent.mkdir(parents=True) p.write_text( json.dumps({"schema": 999, "last_pulled_snapshot_id": "x", "last_pulled_at": "2026-01-01T00:00:00Z"}) ) assert statemod.read(tmp_path) is None def test_garbage_json_returns_none(tmp_path: Path): p = tmp_path / ".cloud-sync" / "state.json" p.parent.mkdir(parents=True) p.write_text("{not json") assert statemod.read(tmp_path) is None # ---- _find_modified_in_scope ---- def _touch(p: Path, content: str = "x") -> None: p.parent.mkdir(parents=True, exist_ok=True) p.write_text(content) def test_no_in_scope_changes_returns_empty(tmp_path: Path): _touch(tmp_path / "options.txt") # mtime is "now", `since` is the future future = datetime.now(timezone.utc) + timedelta(hours=1) hits = _find_modified_in_scope(tmp_path, scopemod.Scope(), future) assert hits == [] def test_in_scope_file_modified_after_since_detected(tmp_path: Path): _touch(tmp_path / "options.txt") past = datetime.now(timezone.utc) - timedelta(hours=1) hits = _find_modified_in_scope(tmp_path, scopemod.Scope(), past) assert any(rel.name == "options.txt" for rel, _ in hits) def test_excluded_path_skipped(tmp_path: Path): # config/packwiz-installer.log matches exclude "**/*.log" AND "config/packwiz*" _touch(tmp_path / "config" / "packwiz-installer.log") past = datetime.now(timezone.utc) - timedelta(hours=1) hits = _find_modified_in_scope(tmp_path, scopemod.Scope(), past) assert hits == [] def test_only_excluded_changes_not_a_conflict(tmp_path: Path): _touch(tmp_path / ".cloud-sync" / "files-from.txt") # excluded _touch(tmp_path / "config" / "packwiz-installer.log") # excluded past = datetime.now(timezone.utc) - timedelta(hours=1) hits = _find_modified_in_scope(tmp_path, scopemod.Scope(), past) assert hits == [] def test_walks_subdirectories(tmp_path: Path): _touch(tmp_path / "config" / "fabric" / "custom.json") past = datetime.now(timezone.utc) - timedelta(hours=1) hits = _find_modified_in_scope(tmp_path, scopemod.Scope(), past) assert any("custom.json" in str(rel) for rel, _ in hits) # ---- _matches_any ---- def test_dir_pattern_matches_anything_under(tmp_path: Path): assert _matches_any(Path(".cloud-sync/token"), [".cloud-sync/"]) assert _matches_any(Path("a/b/cache/x"), ["**/cache/"]) assert not _matches_any(Path("config/options.txt"), [".cloud-sync/"]) def test_glob_pattern(tmp_path: Path): assert _matches_any(Path("config/packwiz-installer.log"), ["config/packwiz*"]) assert _matches_any(Path("foo/bar.log"), ["**/*.log"]) # ---- restic output parsing ---- def test_parse_snapshots_empty(): assert _parse_snapshots("") == [] assert _parse_snapshots("null") == [] assert _parse_snapshots("[]") == [] def test_parse_snapshots_one(): out = json.dumps([{"id": "abc", "time": "2026-06-05T12:00:00Z"}]) parsed = _parse_snapshots(out) assert len(parsed) == 1 assert parsed[0]["id"] == "abc" def test_parse_restic_time_with_nanos(): dt = _parse_restic_time("2026-06-04T18:33:21.123456789Z") assert dt.tzinfo is timezone.utc assert dt.year == 2026 and dt.day == 4 and dt.hour == 18 def test_parse_backup_summary_finds_snapshot_id(): out = ( '{"message_type":"status","percent_done":0.5}\n' '{"message_type":"status","percent_done":1.0}\n' '{"message_type":"summary","snapshot_id":"deadbeef","files_new":3}\n' ) assert _parse_backup_summary(out) == "deadbeef" def test_parse_backup_summary_missing_returns_none(): assert _parse_backup_summary("") is None assert _parse_backup_summary("not json\nstill not json") is None # ---- _format_dt ---- def test_format_dt_strips_leading_zero_on_hour(): # 7:12 PM, not 07:12 PM. Day of month also no leading zero. dt = datetime(2021, 10, 21, 19, 12, tzinfo=timezone.utc) out = _format_dt(dt) # Output is local-time-converted, so don't pin the weekday/AM-PM exactly # in case CI runs UTC vs PT; just check the formatting shape. assert "October" in out or "Oct" not in out # full month name assert ", 2021 at" in out assert " AM" in out or " PM" in out assert " 0:" not in out # no leading-zero hour