Coverage for src/lilbee/core/project_state.py: 100%
45 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-11-06 08:17 +0000
1"""The project's analyze state in state.toml: when it was analyzed and whether the tip is hidden."""
3from __future__ import annotations
5import logging
6import tomllib
7from dataclasses import dataclass
8from datetime import UTC, datetime
9from pathlib import Path
10from typing import Any
12import tomli_w
14from lilbee.core.security import file_lock_or_warn, write_private_text
16log = logging.getLogger(__name__)
18STATE_FILE_NAME = "state.toml"
19_ANALYZED_AT_KEY = "analyzed_at"
20_TIP_DISMISSED_KEY = "tip_dismissed"
21_STATE_LOCK_TIMEOUT_S = 10.0
24@dataclass(frozen=True)
25class ProjectState:
26 """When analyze last completed (ISO 8601 UTC) and whether the user hid the tip."""
28 analyzed_at: str | None = None
29 tip_dismissed: bool = False
32def _state_path(root: Path) -> Path:
33 return root / STATE_FILE_NAME
36def _read_table(path: Path) -> dict[str, Any]:
37 """The TOML table at *path*; a missing, unreadable or broken file reads as empty."""
38 try:
39 with path.open("rb") as f:
40 return tomllib.load(f)
41 except FileNotFoundError:
42 return {}
43 except (OSError, tomllib.TOMLDecodeError) as exc:
44 log.warning("Ignoring unreadable %s: %s", path, exc)
45 return {}
48def read_state(root: Path) -> ProjectState:
49 """The analyze state of the project at *root*; values of the wrong type read as unset."""
50 table = _read_table(_state_path(root))
51 analyzed_at = table.get(_ANALYZED_AT_KEY)
52 dismissed = table.get(_TIP_DISMISSED_KEY)
53 # untyped TOML: a hand-edited file can hold any type under either key
54 return ProjectState(
55 analyzed_at=analyzed_at if isinstance(analyzed_at, str) else None,
56 tip_dismissed=dismissed is True,
57 )
60def _update(root: Path, key: str, value: Any) -> None:
61 """Set *key* in state.toml under its lock, keeping the other keys."""
62 path = _state_path(root)
63 path.parent.mkdir(parents=True, exist_ok=True)
64 with file_lock_or_warn(path, _STATE_LOCK_TIMEOUT_S):
65 table = _read_table(path)
66 table[key] = value
67 write_private_text(path, tomli_w.dumps(table))
70def mark_analyzed(root: Path) -> None:
71 """Record that analyze completed now."""
72 _update(root, _ANALYZED_AT_KEY, datetime.now(UTC).isoformat(timespec="seconds"))
75def dismiss_tip(root: Path) -> None:
76 """Hide the analyze tip for the project at *root*."""
77 _update(root, _TIP_DISMISSED_KEY, True)