diff --git a/pythonlings/cli.py b/pythonlings/cli.py index e450224..0fcdb33 100644 --- a/pythonlings/cli.py +++ b/pythonlings/cli.py @@ -69,6 +69,10 @@ def _build_parser() -> argparse.ArgumentParser: ) p_verify.add_argument("topic", nargs="?", help="Verify only this topic.") + sub.add_parser( + "doctor", help="Check the Pythonlings installation and workspace." + ) + return parser @@ -269,6 +273,34 @@ def _cmd_reset(root: Path, name: str, yes: bool) -> int: return 0 +def _cmd_doctor(root: Path, resolution_error: Exception | None = None) -> int: + from pythonlings.core.doctor import CheckStatus, run_diagnostics + + report = run_diagnostics( + root, + package_version=__version__, + resolution_error=resolution_error, + ) + print("Pythonlings doctor") + print(f"Workspace: {report.root}") + print() + for check in report.checks: + print(f"[{check.status.value}] {check.name}: {check.message}") + + warnings = sum( + check.status is CheckStatus.WARNING for check in report.checks + ) + failures = sum( + check.status is CheckStatus.FAILURE for check in report.checks + ) + print() + print( + f"Summary: {len(report.checks)} checks, " + f"{warnings} warning(s), {failures} failure(s)" + ) + return 1 if report.has_failures else 0 + + def main(argv: list[str] | None = None) -> int: parser = _build_parser() args = parser.parse_args(argv if argv is not None else sys.argv[1:]) @@ -288,18 +320,29 @@ def main(argv: list[str] | None = None) -> int: ).root else: launches_tui = args.command in (None, "watch", "start", "topics") - resolved = resolve_workspace_root( - Path.cwd(), args.root, create_if_missing=launches_tui - ) + try: + resolved = resolve_workspace_root( + Path.cwd(), args.root, create_if_missing=launches_tui + ) + except (OSError, RuntimeError) as exc: + if args.command == "doctor": + unresolved = args.root if args.root is not None else Path(".") + return _cmd_doctor(unresolved, resolution_error=exc) + raise root = resolved.root - migrate_legacy_state_dir(root) + if args.command != "doctor": + migrate_legacy_state_dir(root) if resolved.created: print( f"Created your workspace at {_display_path(root)} " "(edit in-app, or open that folder in your editor)" ) - if getattr(args, "debug", False) and root is not None: + if ( + getattr(args, "debug", False) + and root is not None + and args.command != "doctor" + ): try: (root / ".pythonlings_debug.log").write_text( f"argv={argv if argv is not None else sys.argv[1:]!r}\n", @@ -329,6 +372,8 @@ def main(argv: list[str] | None = None) -> int: return _cmd_solution(root, args.name) if args.command == "reset": return _cmd_reset(root, args.name, args.yes) + if args.command == "doctor": + return _cmd_doctor(root) if args.command in (None, "watch", "start", "topics"): start_topic = getattr(args, "topic", None) diff --git a/pythonlings/core/doctor.py b/pythonlings/core/doctor.py new file mode 100644 index 0000000..0f3529c --- /dev/null +++ b/pythonlings/core/doctor.py @@ -0,0 +1,389 @@ +from __future__ import annotations + +import errno +import json +import stat +import sys +from dataclasses import dataclass +from enum import Enum +from pathlib import Path + +from pythonlings.core.docs import load_snippet +from pythonlings.core.manifest import Manifest, ManifestError +from pythonlings.core.manifest import load as load_manifest +from pythonlings.core.state import FORMAT_VERSION + + +class CheckStatus(Enum): + OK = "OK" + WARNING = "WARN" + FAILURE = "FAIL" + + +@dataclass(frozen=True) +class CheckResult: + name: str + status: CheckStatus + message: str + + +@dataclass(frozen=True) +class DoctorReport: + root: Path + checks: tuple[CheckResult, ...] + + @property + def has_failures(self) -> bool: + return any(check.status is CheckStatus.FAILURE for check in self.checks) + + +def run_diagnostics( + root: Path, + *, + package_version: str, + resolution_error: Exception | None = None, +) -> DoctorReport: + """Inspect an installation and workspace without modifying either.""" + display_root = root + checks = [ + _check_runtime(), + _check_package_version(package_version), + ] + if resolution_error is not None: + checks.append( + CheckResult( + "Workspace", + CheckStatus.FAILURE, + f"could not resolve {display_root}: {resolution_error}; " + "check the path and symlinks", + ) + ) + return DoctorReport(root=display_root, checks=tuple(checks)) + try: + display_root = root.expanduser() + root = display_root.resolve() + except (OSError, RuntimeError) as exc: + checks.append( + CheckResult( + "Workspace", + CheckStatus.FAILURE, + f"could not resolve {display_root}: {exc}; check the path and symlinks", + ) + ) + return DoctorReport(root=display_root, checks=tuple(checks)) + + checks.append(_check_workspace(root)) + + manifest_result, manifest = _check_manifest(root) + checks.append(manifest_result) + checks.extend( + [ + _check_solutions(root, manifest), + _check_state(root), + _check_originals(root, manifest), + _check_docs(manifest), + ] + ) + return DoctorReport(root=root, checks=tuple(checks)) + + +def _check_runtime() -> CheckResult: + version = ".".join(str(part) for part in sys.version_info[:3]) + if sys.version_info < (3, 9): + return CheckResult( + "Python runtime", + CheckStatus.FAILURE, + f"Python {version} is unsupported; Python 3.9+ is required", + ) + return CheckResult("Python runtime", CheckStatus.OK, f"Python {version}") + + +def _check_package_version(package_version: str) -> CheckResult: + if package_version == "0.0.0+unknown": + return CheckResult( + "Pythonlings version", + CheckStatus.WARNING, + "package metadata is unavailable (source checkout)", + ) + return CheckResult( + "Pythonlings version", CheckStatus.OK, f"pythonlings {package_version}" + ) + + +def _check_workspace(root: Path) -> CheckResult: + try: + mode = root.stat().st_mode + except OSError as exc: + if exc.errno == errno.ELOOP: + return CheckResult( + "Workspace", + CheckStatus.FAILURE, + f"{root} is a symlink loop; check the path and symlinks", + ) + if exc.errno in (errno.ENOENT, errno.ENOTDIR): + return CheckResult( + "Workspace", + CheckStatus.FAILURE, + f"{root} does not exist; run `pythonlings init --path {root}`", + ) + raise + if not stat.S_ISDIR(mode): + return CheckResult( + "Workspace", CheckStatus.FAILURE, f"{root} is not a directory" + ) + + invalid: list[str] = [] + if not (root / "info.toml").is_file(): + invalid.append("info.toml (file)") + for dirname in ("exercises", "checks"): + if not (root / dirname).is_dir(): + invalid.append(f"{dirname}/ (directory)") + if invalid: + return CheckResult( + "Workspace", + CheckStatus.FAILURE, + f"missing or invalid required paths: {', '.join(invalid)}; " + "run `pythonlings update --path ` or initialize a new workspace", + ) + return CheckResult("Workspace", CheckStatus.OK, str(root)) + + +def _check_manifest(root: Path) -> tuple[CheckResult, Manifest | None]: + try: + manifest = load_manifest(root) + except ( + ManifestError, + OSError, + ValueError, + KeyError, + TypeError, + AttributeError, + ) as exc: + return ( + CheckResult( + "Manifest", + CheckStatus.FAILURE, + f"{exc}; fix info.toml or run " + "`pythonlings update --path `", + ), + None, + ) + + validation_error = _manifest_validation_error(manifest) + if validation_error is not None: + return ( + CheckResult( + "Manifest", + CheckStatus.FAILURE, + f"{validation_error}; fix info.toml or run " + "`pythonlings update --path `", + ), + None, + ) + return ( + CheckResult( + "Manifest", + CheckStatus.OK, + f"{len(manifest.exercises)} exercises across " + f"{len(manifest.topics())} topics", + ), + manifest, + ) + + +def _check_solutions(root: Path, manifest: Manifest | None) -> CheckResult: + solutions = root / "solutions" + if manifest is None: + message = ( + "directory is missing and coverage could not be checked" + if not solutions.is_dir() + else "coverage could not be checked because the manifest is invalid" + ) + return CheckResult("Solutions", CheckStatus.WARNING, message) + + missing = [ + exercise.name + for exercise in manifest.exercises + if not (solutions / f"{exercise.name}.py").is_file() + ] + if missing: + return CheckResult( + "Solutions", + CheckStatus.WARNING, + _missing_message(missing, len(manifest.exercises)) + + "; run `pythonlings update --path `", + ) + return CheckResult( + "Solutions", + CheckStatus.OK, + f"{len(manifest.exercises)}/{len(manifest.exercises)} available", + ) + + +def _check_state(root: Path) -> CheckResult: + path = root / ".pythonlings" / "state.json" + if path.is_symlink(): + try: + path.resolve(strict=True) + except (OSError, RuntimeError) as exc: + return CheckResult( + "Progress state", + CheckStatus.WARNING, + f"state.json is a broken symlink: {exc}; replace or remove the link", + ) + if not path.exists(): + return CheckResult("Progress state", CheckStatus.OK, "no progress file yet") + if not path.is_file(): + return CheckResult( + "Progress state", + CheckStatus.WARNING, + f"{path} is not a regular file; replace it with a valid state.json", + ) + + try: + data = json.loads(path.read_text(encoding="utf-8")) + except (OSError, UnicodeError, json.JSONDecodeError) as exc: + return CheckResult( + "Progress state", + CheckStatus.WARNING, + f"state.json is unreadable: {exc}; back it up before starting Pythonlings", + ) + + if not isinstance(data, dict): + return CheckResult( + "Progress state", + CheckStatus.WARNING, + "state.json must contain an object; back it up before starting Pythonlings", + ) + if data.get("format_version") != FORMAT_VERSION: + return CheckResult( + "Progress state", + CheckStatus.WARNING, + "unsupported format_version " + f"{data.get('format_version')!r}; expected {FORMAT_VERSION}; " + "back it up before starting Pythonlings", + ) + completed = data.get("completed", []) + if not isinstance(completed, list) or not all( + isinstance(name, str) for name in completed + ): + return CheckResult( + "Progress state", + CheckStatus.WARNING, + "completed must be a list of exercise names; " + "back up state.json before starting Pythonlings", + ) + return CheckResult( + "Progress state", CheckStatus.OK, f"readable ({len(completed)} completed)" + ) + + +def _check_originals(root: Path, manifest: Manifest | None) -> CheckResult: + originals = root / ".pythonlings" / "originals" + if manifest is None: + message = ( + "directory is missing and coverage could not be checked" + if not originals.is_dir() + else "coverage could not be checked because the manifest is invalid" + ) + return CheckResult("Reset snapshots", CheckStatus.WARNING, message) + + missing: list[str] = [] + for exercise in manifest.exercises: + if exercise.rel_path is None: + original = originals / f"{exercise.name}.py" + else: + original = originals / exercise.rel_path.relative_to("exercises") + if not original.is_file(): + missing.append(exercise.name) + + if missing: + return CheckResult( + "Reset snapshots", + CheckStatus.WARNING, + _missing_message(missing, len(manifest.exercises)) + + "; run `pythonlings update --path `", + ) + return CheckResult( + "Reset snapshots", + CheckStatus.OK, + f"{len(manifest.exercises)}/{len(manifest.exercises)} available", + ) + + +def _check_docs(manifest: Manifest | None) -> CheckResult: + try: + if manifest is None: + snippet = load_snippet("variables") + if snippet is None: + return CheckResult( + "Bundled docs", + CheckStatus.WARNING, + "documentation is unavailable; reinstall pythonlings", + ) + return CheckResult( + "Bundled docs", + CheckStatus.OK, + "documentation index is readable; topic coverage was not checked", + ) + + missing: list[str] = [] + topics = manifest.topics() + for topic in topics: + exercise = manifest.exercises_in(topic)[0] + if load_snippet(topic, exercise.docs) is None: + missing.append(topic) + except ( + OSError, + UnicodeError, + KeyError, + TypeError, + AttributeError, + ValueError, + ) as exc: + return CheckResult( + "Bundled docs", + CheckStatus.WARNING, + f"documentation is unreadable: {exc}; reinstall pythonlings", + ) + if missing: + return CheckResult( + "Bundled docs", + CheckStatus.WARNING, + _missing_message(missing, len(topics)) + "; reinstall pythonlings", + ) + return CheckResult( + "Bundled docs", + CheckStatus.OK, + f"{len(topics)}/{len(topics)} topics available", + ) + + +def _missing_message(missing: list[str], total: int) -> str: + shown = ", ".join(str(item) for item in missing[:3]) + if len(missing) > 3: + shown += ", ..." + return f"{len(missing)}/{total} missing ({shown})" + + +def _manifest_validation_error(manifest: Manifest) -> str | None: + if not isinstance(manifest.welcome_message, str): + return "welcome_message must be a string" + if not isinstance(manifest.final_message, str): + return "final_message must be a string" + + for exercise in manifest.exercises: + if not isinstance(exercise.name, str) or not exercise.name: + return f"exercise name must be a non-empty string, got {exercise.name!r}" + if not isinstance(exercise.hint, str): + return f"hint for {exercise.name!r} must be a string" + if not isinstance(exercise.docs, str): + return f"docs for {exercise.name!r} must be a string" + if not exercise.path.is_file(): + return f"exercise path is not a file: {exercise.rel_path or exercise.path}" + if not exercise.check_path.is_file(): + return ( + "check path is not a file: " + f"{exercise.check_rel_path or exercise.check_path}" + ) + return None diff --git a/tests/integration/test_cli_doctor.py b/tests/integration/test_cli_doctor.py new file mode 100644 index 0000000..8dcaa5f --- /dev/null +++ b/tests/integration/test_cli_doctor.py @@ -0,0 +1,224 @@ +from __future__ import annotations + +import os +import subprocess +import sys +from pathlib import Path + + +def _make_workspace(root: Path) -> Path: + (root / "exercises" / "variables").mkdir(parents=True) + (root / "checks" / "variables").mkdir(parents=True) + (root / "solutions").mkdir() + (root / ".pythonlings" / "originals" / "variables").mkdir(parents=True) + (root / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "variables1"\n' + 'path = "exercises/variables/variables1.py"\n' + 'hint = "Use an assignment."\n' + 'docs = "https://docs.python.org/3/tutorial/introduction.html"\n', + encoding="utf-8", + ) + exercise = "# I AM NOT DONE\nanswer = 0\n" + (root / "exercises" / "variables" / "variables1.py").write_text( + exercise, encoding="utf-8" + ) + (root / "checks" / "variables" / "variables1.py").write_text( + "assert answer == 42\n", encoding="utf-8" + ) + (root / "solutions" / "variables1.py").write_text( + "answer = 42\n", encoding="utf-8" + ) + ( + root + / ".pythonlings" + / "originals" + / "variables" + / "variables1.py" + ).write_text(exercise, encoding="utf-8") + return root + + +def _run(*args: str, cwd: Path | None = None) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "-m", "pythonlings", *args], + cwd=cwd, + env=os.environ.copy(), + capture_output=True, + text=True, + ) + + +def _tree_snapshot(root: Path) -> list[tuple[str, str, bytes | str]]: + snapshot: list[tuple[str, str, bytes | str]] = [] + for path in sorted(root.rglob("*")): + relative = str(path.relative_to(root)) + if path.is_symlink(): + snapshot.append((relative, "symlink", os.readlink(path))) + elif path.is_file(): + snapshot.append((relative, "file", path.read_bytes())) + elif path.is_dir(): + snapshot.append((relative, "directory", "")) + return snapshot + + +def test_doctor_reports_a_healthy_workspace(tmp_path: Path) -> None: + root = _make_workspace(tmp_path / "healthy") + before = _tree_snapshot(root) + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 0, result.stderr + assert f"Workspace: {root.resolve()}" in result.stdout + assert "[OK] Manifest: 1 exercises across 1 topics" in result.stdout + assert "0 failure(s)" in result.stdout + assert _tree_snapshot(root) == before + + +def test_doctor_warning_does_not_fail_or_modify_state(tmp_path: Path) -> None: + root = _make_workspace(tmp_path / "warning") + state_path = root / ".pythonlings" / "state.json" + state_path.write_text("not json {{", encoding="utf-8") + before = state_path.read_bytes() + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 0, result.stderr + assert "[WARN] Progress state:" in result.stdout + assert state_path.read_bytes() == before + assert not state_path.with_suffix(".json.bak").exists() + + +def test_doctor_required_failure_is_friendly(tmp_path: Path) -> None: + root = tmp_path / "not-a-workspace" + root.mkdir() + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 1 + assert "[FAIL] Workspace:" in result.stdout + assert "[FAIL] Manifest:" in result.stdout + assert "Traceback" not in result.stdout + result.stderr + + +def test_doctor_malformed_manifest_is_friendly(tmp_path: Path) -> None: + root = tmp_path / "malformed-manifest" + (root / "exercises").mkdir(parents=True) + (root / "checks").mkdir() + (root / "info.toml").write_text("[[exercises\n", encoding="utf-8") + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 1 + assert "[OK] Workspace:" in result.stdout + assert "[FAIL] Manifest:" in result.stdout + assert "Traceback" not in result.stdout + result.stderr + + +def test_doctor_rejects_non_string_manifest_fields(tmp_path: Path) -> None: + for field, replacement in ( + ('name = "variables1"', "name = 123"), + ( + 'docs = "https://docs.python.org/3/tutorial/introduction.html"', + "docs = 123", + ), + ): + root = _make_workspace(tmp_path / field.split()[0]) + info = root / "info.toml" + info.write_text( + info.read_text(encoding="utf-8").replace(field, replacement), + encoding="utf-8", + ) + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 1 + assert "[FAIL] Manifest:" in result.stdout + assert "Traceback" not in result.stdout + result.stderr + + +def test_doctor_rejects_exercise_and_check_directories(tmp_path: Path) -> None: + root = _make_workspace(tmp_path / "directory-paths") + exercise = root / "exercises" / "variables" / "variables1.py" + check = root / "checks" / "variables" / "variables1.py" + exercise.unlink() + check.unlink() + exercise.mkdir() + check.mkdir() + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 1 + assert "[FAIL] Manifest:" in result.stdout + assert "is not a file" in result.stdout + assert "Traceback" not in result.stdout + result.stderr + + +def test_doctor_symlink_loop_is_friendly(tmp_path: Path) -> None: + root = tmp_path / "workspace-loop" + root.symlink_to(root) + + result = _run("--root", str(root), "doctor") + + assert result.returncode == 1 + assert "[FAIL] Workspace:" in result.stdout + assert "check the path and symlinks" in result.stdout + assert "Traceback" not in result.stdout + result.stderr + + +def test_doctor_unknown_home_user_is_friendly() -> None: + root = "~pythonlings_no_such_user_93847/work" + + result = _run("--root", root, "doctor") + + assert result.returncode == 1 + assert "[FAIL] Workspace:" in result.stdout + assert root in result.stdout + assert "check the path and symlinks" in result.stdout + assert "Traceback" not in result.stdout + result.stderr + + +def test_doctor_is_listed_in_cli_help() -> None: + result = _run("--help") + + assert result.returncode == 0 + assert "doctor" in result.stdout + + +def test_doctor_honors_explicit_root_over_current_workspace(tmp_path: Path) -> None: + current = _make_workspace(tmp_path / "current") + explicit = _make_workspace(tmp_path / "explicit") + + result = _run("--root", str(explicit), "doctor", cwd=current) + + assert result.returncode == 0, result.stderr + assert f"Workspace: {explicit.resolve()}" in result.stdout + assert f"Workspace: {current.resolve()}" not in result.stdout + + +def test_doctor_does_not_migrate_legacy_state_or_write_debug_log( + tmp_path: Path, +) -> None: + root = _make_workspace(tmp_path / "legacy") + current_state = root / ".pythonlings" + legacy_state = root / ".pylings" + current_state.rename(legacy_state) + before = { + path.relative_to(root): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + + result = _run("--debug", "--root", str(root), "doctor") + + after = { + path.relative_to(root): path.read_bytes() + for path in root.rglob("*") + if path.is_file() + } + assert result.returncode == 0, result.stderr + assert before == after + assert legacy_state.is_dir() + assert not current_state.exists() + assert not (root / ".pythonlings_debug.log").exists() diff --git a/tests/unit/test_doctor.py b/tests/unit/test_doctor.py new file mode 100644 index 0000000..204a49d --- /dev/null +++ b/tests/unit/test_doctor.py @@ -0,0 +1,138 @@ +from __future__ import annotations + +from pathlib import Path + +from pythonlings.core.doctor import CheckStatus, run_diagnostics + + +def _make_workspace(root: Path) -> Path: + (root / "exercises" / "variables").mkdir(parents=True) + (root / "checks" / "variables").mkdir(parents=True) + (root / "solutions").mkdir() + (root / ".pythonlings" / "originals" / "variables").mkdir(parents=True) + (root / "info.toml").write_text( + "format_version = 1\n" + "[[exercises]]\n" + 'name = "variables1"\n' + 'path = "exercises/variables/variables1.py"\n' + 'hint = "Use an assignment."\n' + 'docs = "https://docs.python.org/3/tutorial/introduction.html"\n', + encoding="utf-8", + ) + exercise = "# I AM NOT DONE\nanswer = 0\n" + (root / "exercises" / "variables" / "variables1.py").write_text( + exercise, encoding="utf-8" + ) + (root / "checks" / "variables" / "variables1.py").write_text( + "assert answer == 42\n", encoding="utf-8" + ) + (root / "solutions" / "variables1.py").write_text( + "answer = 42\n", encoding="utf-8" + ) + ( + root + / ".pythonlings" + / "originals" + / "variables" + / "variables1.py" + ).write_text(exercise, encoding="utf-8") + return root + + +def _result(report, name: str): + return next(check for check in report.checks if check.name == name) + + +def test_healthy_workspace_has_no_warnings_or_failures(tmp_path: Path) -> None: + root = _make_workspace(tmp_path) + + report = run_diagnostics(root, package_version="0.4.1") + + assert report.has_failures is False + assert all(check.status is CheckStatus.OK for check in report.checks) + + +def test_corrupt_state_is_a_warning_and_is_not_modified(tmp_path: Path) -> None: + root = _make_workspace(tmp_path) + state_path = root / ".pythonlings" / "state.json" + state_path.write_text("not json {{", encoding="utf-8") + before = state_path.read_bytes() + + report = run_diagnostics(root, package_version="0.4.1") + + assert report.has_failures is False + assert _result(report, "Progress state").status is CheckStatus.WARNING + assert state_path.read_bytes() == before + assert not state_path.with_suffix(".json.bak").exists() + + +def test_missing_workspace_is_a_required_failure(tmp_path: Path) -> None: + root = tmp_path / "missing" + + report = run_diagnostics(root, package_version="0.4.1") + + assert report.has_failures is True + assert _result(report, "Workspace").status is CheckStatus.FAILURE + assert _result(report, "Manifest").status is CheckStatus.FAILURE + assert not root.exists() + + +def test_missing_solution_is_a_warning(tmp_path: Path) -> None: + root = _make_workspace(tmp_path) + (root / "solutions" / "variables1.py").unlink() + + report = run_diagnostics(root, package_version="0.4.1") + + result = _result(report, "Solutions") + assert report.has_failures is False + assert result.status is CheckStatus.WARNING + assert "variables1" in result.message + assert "pythonlings update" in result.message + + +def test_missing_snapshot_is_an_actionable_warning(tmp_path: Path) -> None: + root = _make_workspace(tmp_path) + ( + root + / ".pythonlings" + / "originals" + / "variables" + / "variables1.py" + ).unlink() + + report = run_diagnostics(root, package_version="0.4.1") + + result = _result(report, "Reset snapshots") + assert report.has_failures is False + assert result.status is CheckStatus.WARNING + assert "pythonlings update" in result.message + + +def test_bundled_docs_error_is_an_actionable_warning( + tmp_path: Path, monkeypatch +) -> None: + root = _make_workspace(tmp_path) + + def fail_to_load(*args, **kwargs): + raise PermissionError("permission denied") + + monkeypatch.setattr("pythonlings.core.doctor.load_snippet", fail_to_load) + + report = run_diagnostics(root, package_version="0.4.1") + + result = _result(report, "Bundled docs") + assert report.has_failures is False + assert result.status is CheckStatus.WARNING + assert "reinstall pythonlings" in result.message + + +def test_dangling_state_symlink_is_a_warning(tmp_path: Path) -> None: + root = _make_workspace(tmp_path) + state_path = root / ".pythonlings" / "state.json" + state_path.symlink_to(root / "missing-state.json") + + report = run_diagnostics(root, package_version="0.4.1") + + result = _result(report, "Progress state") + assert result.status is CheckStatus.WARNING + assert "broken symlink" in result.message