diff --git a/Gradata/src/gradata/cli.py b/Gradata/src/gradata/cli.py index 6fa9dc5b..1ba4c3ce 100644 --- a/Gradata/src/gradata/cli.py +++ b/Gradata/src/gradata/cli.py @@ -156,7 +156,7 @@ def cmd_status(args): import time as _time import urllib.error as _urllib_error import urllib.request as _urllib_request - from datetime import datetime, timezone + from datetime import datetime brain = _get_brain(args) stats = brain.stats() @@ -301,6 +301,116 @@ def cmd_status(args): print(" (events schema not available)") +def cmd_projects(args): + """List every project the SDK knows about from ``~/.gradata/projects.toml``. + + Columns: + * ``name`` — project identifier from the registry + * ``brain_dir`` — path to the brain directory on disk + * ``rules`` — count of RULE_GRADUATED events in ``system.db`` + (0 if the brain is fresh or the db is unreadable) + * ``last_correction`` — MAX(ts) of CORRECTION events, or ``(never)`` + * ``sync_status`` — ``ok`` if brain_dir exists, ``missing`` if not, + ``error`` if the db is present but unreadable + + The registry schema is: + + [[projects]] + name = "my-project" + brain_dir = "/path/to/.gradata/brain" + + Missing registry file → friendly message + exit 0 (NOT a crash). Malformed + TOML → friendly error + exit 1. + """ + import json as _json + import sqlite3 as _sqlite3 + import tomllib as _tomllib + from pathlib import Path as _Path + + registry_path = _Path.home() / ".gradata" / "projects.toml" + as_json = getattr(args, "json", False) + + if not registry_path.is_file(): + if as_json: + print("[]") + else: + print("No projects registered. Run `gradata init ` to add one.") + return + + try: + with registry_path.open("rb") as fh: + data = _tomllib.load(fh) + except _tomllib.TOMLDecodeError as exc: + print(f"Error: malformed projects.toml ({exc})") + raise SystemExit(1) from None + + raw_projects = data.get("projects", []) or [] + rows = [] + for proj in raw_projects: + name = proj.get("name", "(unnamed)") + brain_dir = proj.get("brain_dir", "") + rules_count = 0 + last_correction = None + sync_status = "ok" + + brain_path = _Path(brain_dir) if brain_dir else None + if not brain_path or not brain_path.is_dir(): + sync_status = "missing" + else: + db_path = brain_path / "system.db" + if not db_path.is_file(): + # Brain dir exists but no db yet — treat as fresh, not error. + sync_status = "ok" + else: + try: + con = _sqlite3.connect(str(db_path)) + cur = con.cursor() + rules_count = cur.execute( + "SELECT COUNT(*) FROM events WHERE type='RULE_GRADUATED'" + ).fetchone()[0] + row = cur.execute( + "SELECT MAX(ts) FROM events WHERE type='CORRECTION'" + ).fetchone() + last_correction = row[0] if row else None + con.close() + except (_sqlite3.OperationalError, _sqlite3.DatabaseError, OSError): + sync_status = "error" + + rows.append( + { + "name": name, + "brain_dir": brain_dir, + "rules": rules_count, + "last_correction": last_correction, + "sync_status": sync_status, + } + ) + + if as_json: + print(_json.dumps(rows, indent=2)) + return + + if not rows: + print("No projects registered. Run `gradata init ` to add one.") + return + + headers = ("NAME", "BRAIN_DIR", "RULES", "LAST_CORRECTION", "SYNC_STATUS") + table: list[tuple[str, str, str, str, str]] = [headers] + for r in rows: + table.append( + ( + str(r["name"]), + str(r["brain_dir"]), + str(r["rules"]), + str(r["last_correction"] or "(never)"), + str(r["sync_status"]), + ) + ) + widths = [max(len(row[i]) for row in table) for i in range(len(headers))] + for row in table: + print(" ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))) + + def cmd_audit(args): from gradata._audit import format_audit_text, run_audit @@ -783,20 +893,20 @@ def cmd_prove(args): den = sum((x - mean_x) ** 2 for x in xs) or 1.0 slope = num / den - print(f"Corrections per session:") + print("Corrections per session:") print(f" Sessions: {n}") print(f" Total corrections: {sum(counts)}") print(f" Mean: {mean_y:.1f}/session") if n >= 3: print(f" Trend slope: {slope:+.3f} corrections/session") if slope < -0.05: - print(f" Verdict: CONVERGING (brain is learning — fewer corrections over time)") + print(" Verdict: CONVERGING (brain is learning — fewer corrections over time)") elif slope > 0.05: - print(f" Verdict: DIVERGING (corrections rising — brain may need tuning)") + print(" Verdict: DIVERGING (corrections rising — brain may need tuning)") else: - print(f" Verdict: STABLE (flat trend)") + print(" Verdict: STABLE (flat trend)") else: - print(f" Trend: need >=3 sessions to estimate") + print(" Trend: need >=3 sessions to estimate") # Rule application rate total_apps = sum(rule_apps_by_session.values()) @@ -1867,6 +1977,10 @@ def main(): # status (umbrella health check: stats + daemon + cloud + convergence) sub.add_parser("status", help="Single-page brain/daemon/cloud summary") + # projects (registry listing from ~/.gradata/projects.toml) + p_projects = sub.add_parser("projects", help="List registered projects") + p_projects.add_argument("--json", action="store_true", help="Emit JSON array") + # audit p_audit = sub.add_parser("audit", help="Data flow audit") p_audit.add_argument("--json", action="store_true") @@ -2206,6 +2320,7 @@ def main(): "manifest": cmd_manifest, "stats": cmd_stats, "status": cmd_status, + "projects": cmd_projects, "audit": cmd_audit, "sync": cmd_sync, "recall": cmd_recall, diff --git a/Gradata/tests/test_projects_command.py b/Gradata/tests/test_projects_command.py new file mode 100644 index 00000000..8f81a2f7 --- /dev/null +++ b/Gradata/tests/test_projects_command.py @@ -0,0 +1,179 @@ +"""Tests for the ``gradata projects`` subcommand (GRA-1238). + +Covers: + * missing registry → friendly message, exit 0 + * empty registry → friendly message, exit 0 + * single project (brain_dir missing on disk → sync_status='missing') + * multi-project registry + * malformed TOML → SystemExit(1) with error message + * --json output shape + * missing brain_dir → sync_status='missing' (explicit case) + * brain_dir present + no system.db → sync_status='ok', rules=0 +""" + +from __future__ import annotations + +import json +from pathlib import Path +from unittest.mock import patch + +import pytest + +from gradata.cli import cmd_projects + + +class _Args: + """Minimal stand-in for argparse.Namespace.""" + + def __init__(self, *, json: bool = False) -> None: + self.json = json + + +def _patch_home(tmp_path: Path): + """Return a patcher pinning ``Path.home()`` to tmp_path for cmd_projects.""" + return patch("pathlib.Path.home", return_value=tmp_path) + + +def _write_registry(home: Path, contents: str) -> None: + cfg_dir = home / ".gradata" + cfg_dir.mkdir(parents=True, exist_ok=True) + (cfg_dir / "projects.toml").write_text(contents, encoding="utf-8") + + +def test_missing_registry_prints_hint_and_exits_zero(tmp_path, capsys): + # No projects.toml at all → friendly message, no crash, exit 0 (function returns). + with _patch_home(tmp_path): + cmd_projects(_Args()) + out = capsys.readouterr().out + assert "No projects registered" in out + assert "gradata init" in out + + +def test_missing_registry_json_returns_empty_array(tmp_path, capsys): + with _patch_home(tmp_path): + cmd_projects(_Args(json=True)) + out = capsys.readouterr().out.strip() + assert json.loads(out) == [] + + +def test_empty_registry_prints_hint(tmp_path, capsys): + _write_registry(tmp_path, "") # valid TOML, no [[projects]] + with _patch_home(tmp_path): + cmd_projects(_Args()) + out = capsys.readouterr().out + assert "No projects registered" in out + + +def test_single_project_missing_brain_dir(tmp_path, capsys): + _write_registry( + tmp_path, + '[[projects]]\nname = "alpha"\nbrain_dir = "/nonexistent/path/brain"\n', + ) + with _patch_home(tmp_path): + cmd_projects(_Args()) + out = capsys.readouterr().out + # Header + one row. + assert "NAME" in out + assert "alpha" in out + assert "missing" in out + assert "(never)" in out + + +def test_single_project_brain_dir_exists_no_db(tmp_path, capsys): + brain_dir = tmp_path / "my-brain" + brain_dir.mkdir() + _write_registry( + tmp_path, + f'[[projects]]\nname = "alpha"\nbrain_dir = "{brain_dir.as_posix()}"\n', + ) + with _patch_home(tmp_path): + cmd_projects(_Args(json=True)) + payload = json.loads(capsys.readouterr().out) + assert len(payload) == 1 + row = payload[0] + assert row["name"] == "alpha" + # Compare via Path normalization so Windows '/' vs '\\' doesn't break the test + assert Path(row["brain_dir"]) == Path(brain_dir.as_posix()) + assert row["rules"] == 0 + assert row["last_correction"] is None + assert row["sync_status"] == "ok" + + +def test_multi_project_registry(tmp_path, capsys): + other_dir = tmp_path / "other-brain" + other_dir.mkdir() + _write_registry( + tmp_path, + f""" +[[projects]] +name = "alpha" +brain_dir = "/nope/alpha" + +[[projects]] +name = "beta" +brain_dir = "{other_dir.as_posix()}" +""", + ) + with _patch_home(tmp_path): + cmd_projects(_Args(json=True)) + payload = json.loads(capsys.readouterr().out) + assert len(payload) == 2 + names = {row["name"] for row in payload} + assert names == {"alpha", "beta"} + by_name = {row["name"]: row for row in payload} + assert by_name["alpha"]["sync_status"] == "missing" + assert by_name["beta"]["sync_status"] == "ok" + + +def test_malformed_toml_exits_one(tmp_path, capsys): + _write_registry(tmp_path, "this = is = broken =\n[[[\n") + with _patch_home(tmp_path), pytest.raises(SystemExit) as excinfo: + cmd_projects(_Args()) + assert excinfo.value.code == 1 + out = capsys.readouterr().out + assert "malformed" in out.lower() or "error" in out.lower() + + +def test_json_output_shape(tmp_path, capsys): + _write_registry( + tmp_path, + '[[projects]]\nname = "alpha"\nbrain_dir = "/nope/alpha"\n', + ) + with _patch_home(tmp_path): + cmd_projects(_Args(json=True)) + payload = json.loads(capsys.readouterr().out) + assert isinstance(payload, list) + assert payload[0].keys() == { + "name", + "brain_dir", + "rules", + "last_correction", + "sync_status", + } + + +def test_real_db_with_rule_graduated_event(tmp_path, capsys): + """Real sqlite db with an events table → rules count is read.""" + import sqlite3 + + brain_dir = tmp_path / "brain-x" + brain_dir.mkdir() + db = brain_dir / "system.db" + con = sqlite3.connect(str(db)) + con.execute("CREATE TABLE events (type TEXT, ts TEXT)") + con.execute("INSERT INTO events (type, ts) VALUES ('RULE_GRADUATED', '2025-01-01')") + con.execute("INSERT INTO events (type, ts) VALUES ('RULE_GRADUATED', '2025-01-02')") + con.execute("INSERT INTO events (type, ts) VALUES ('CORRECTION', '2025-05-10T12:00:00Z')") + con.commit() + con.close() + + _write_registry( + tmp_path, + f'[[projects]]\nname = "x"\nbrain_dir = "{brain_dir.as_posix()}"\n', + ) + with _patch_home(tmp_path): + cmd_projects(_Args(json=True)) + payload = json.loads(capsys.readouterr().out) + assert payload[0]["rules"] == 2 + assert payload[0]["last_correction"] == "2025-05-10T12:00:00Z" + assert payload[0]["sync_status"] == "ok"