"""Tests for the top-level ``muse`` CLI entry point (muse/cli/app.py). Coverage -------- Unit - _MuseArgumentParser.error() emits 'Did you mean?' for close-match typos. - _MuseArgumentParser.error() does NOT suggest when no close match exists. - main() --version / -V prints version string and exits 0. - main() --help exits 0 and contains key sections. - main() with no arguments exits 0 and prints help. - main() with unknown command exits 2. - main() unknown command with close match includes suggestion. - main() -C with a valid existing directory succeeds. - main() -C with a nonexistent directory exits 1 and prints to stderr. - main() -C with ANSI escape sequences in path is sanitized (no raw ESC in output). - main() namespace command without subcommand exits 2. - main() dispatches to func on valid subcommand. Integration - --version output matches the installed __version__ string. - -C changes the working directory before dispatch. End-to-end (via CliRunner) - muse --version round-trip through CliRunner. - muse status dispatches successfully in a real repo. """ from __future__ import annotations import json import os import pathlib import sys from unittest.mock import patch import pytest from muse.core.paths import head_path, muse_dir, repo_json_path from tests.cli_test_helper import CliRunner, InvokeResult runner = CliRunner() cli = None # CliRunner ignores this argument # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _invoke(*args: str) -> InvokeResult: return runner.invoke(cli, list(args)) # --------------------------------------------------------------------------- # Unit — _MuseArgumentParser # --------------------------------------------------------------------------- class TestMuseArgumentParser: def test_close_match_suggestion(self) -> None: """`muse comit` should suggest 'commit'.""" result = _invoke("comit") # argparse exits 2 for invalid choice assert result.exit_code == 2 assert "Did you mean" in result.output or "Did you mean" in (result.stderr or "") def test_no_suggestion_for_gibberish(self) -> None: """`muse zzzzz` has no close match — no 'Did you mean' line.""" result = _invoke("zzzzz") assert result.exit_code == 2 assert "Did you mean" not in result.output assert "Did you mean" not in (result.stderr or "") def test_multiple_close_matches(self) -> None: """`muse brnach` is close to 'branch'; suggestion appears.""" result = _invoke("brnach") assert result.exit_code == 2 combined = result.output + (result.stderr or "") assert "Did you mean" in combined # --------------------------------------------------------------------------- # Unit — --version / -V # --------------------------------------------------------------------------- class TestVersion: def test_long_flag(self) -> None: result = _invoke("--version") assert result.exit_code == 0 assert "muse" in result.output def test_short_flag(self) -> None: result = _invoke("-V") assert result.exit_code == 0 assert "muse" in result.output def test_version_matches_package(self) -> None: from muse._version import __version__ result = _invoke("--version") assert __version__ in result.output # --------------------------------------------------------------------------- # Unit — --help / no args # --------------------------------------------------------------------------- class TestHelp: def test_help_flag_exits_zero(self) -> None: result = _invoke("--help") assert result.exit_code == 0 def test_help_mentions_version(self) -> None: result = _invoke("--help") assert "--version" in result.output or "-V" in result.output def test_no_args_exits_zero(self) -> None: result = _invoke() assert result.exit_code == 0 def test_no_args_prints_usage(self) -> None: result = _invoke() assert "muse" in result.output.lower() # --------------------------------------------------------------------------- # Unit — unknown command # --------------------------------------------------------------------------- class TestUnknownCommand: def test_unknown_exits_two(self) -> None: result = _invoke("not-a-real-command-xyz") assert result.exit_code == 2 def test_unknown_error_message(self) -> None: result = _invoke("not-a-real-command-xyz") combined = result.output + (result.stderr or "") assert "invalid choice" in combined.lower() or "error" in combined.lower() # --------------------------------------------------------------------------- # Unit — -C flag # --------------------------------------------------------------------------- class TestChangeDirFlag: def test_valid_dir_succeeds(self, tmp_path: pathlib.Path, monkeypatch: pytest.MonkeyPatch) -> None: """-C changes cwd and runs the command.""" # Create a minimal muse repo so `muse status` works muse_dir(tmp_path).mkdir() (repo_json_path(tmp_path)).write_text('{"id":"r","name":"r"}') (head_path(tmp_path)).write_text("ref: refs/heads/main\n") result = _invoke("-C", str(tmp_path), "status") # We don't care about the exact output — just that -C didn't error. # Exit code may be non-zero if no commits, but it must not be 1 (chdir error). assert result.exit_code != 1 def test_nonexistent_dir_exits_one(self, tmp_path: pathlib.Path) -> None: """-C prints error to stderr and exits 1.""" missing = str(tmp_path / "does_not_exist") result = _invoke("-C", missing) assert result.exit_code == 1 combined = result.output + (result.stderr or "") assert "cannot change to directory" in combined def test_ansi_in_path_is_sanitized(self, tmp_path: pathlib.Path) -> None: """-C with ANSI escape in path must not emit raw ESC bytes in error output.""" ansi_path = str(tmp_path / "\x1b[31mmalicious\x1b[0m") result = _invoke("-C", ansi_path) assert result.exit_code == 1 # Raw ESC character must not appear in the error output combined = result.output + (result.stderr or "") assert "\x1b" not in combined # --------------------------------------------------------------------------- # Unit — namespace commands without subcommand # --------------------------------------------------------------------------- class TestNamespaceWithoutSubcommand: def test_code_alone_exits_nonzero(self) -> None: result = _invoke("code") assert result.exit_code != 0 def test_midi_alone_exits_nonzero(self) -> None: result = _invoke("midi") assert result.exit_code != 0 def test_coord_alone_exits_nonzero(self) -> None: result = _invoke("coord") assert result.exit_code != 0 # --------------------------------------------------------------------------- # Integration — dispatch reaches a real command # --------------------------------------------------------------------------- class TestDispatch: def test_version_integration(self) -> None: """End-to-end: CliRunner → main() → _version import → print → exit 0.""" from muse._version import __version__ result = _invoke("--version") assert result.exit_code == 0 assert __version__ in result.output def test_help_integration(self) -> None: """End-to-end: CliRunner → main() → parser.print_help() → exit 0.""" result = _invoke("--help") assert result.exit_code == 0 # Help must mention at least one known top-level command assert "commit" in result.output or "status" in result.output or "branch" in result.output def test_chdir_integration(self, tmp_path: pathlib.Path) -> None: """-C DIR changes cwd; a command that reads cwd sees the new path.""" # `muse init` reads/writes to cwd. We just verify that -C with a valid # dir does not produce the "cannot change to directory" error (exit 1). # Full cwd-mutation verification is handled by test_valid_dir_succeeds. result = _invoke("-C", str(tmp_path), "--version") # --version is processed BEFORE -C, so cwd is unchanged — but no error assert result.exit_code == 0 # Verify -C is actually processed by running a command that errors # meaningfully only when cwd is wrong: pass an existing dir, expect no # exit-code 1 (which is the -C chdir-failure sentinel). result2 = _invoke("-C", str(tmp_path), "status") assert result2.exit_code != 1 # ────────────────────────────────────────────────────────────────────────────── # CliRunner ergonomics # ────────────────────────────────────────────────────────────────────────────── class TestCliRunnerStdoutStderr: """result.output is stdout-only; result.stderr is stderr-only.""" def test_stdout_only_in_output(self, tmp_path: pathlib.Path) -> None: """result.output must not contain stderr text.""" runner.invoke(cli, ["init"], cwd=tmp_path) result = runner.invoke(cli, ["branch", "-d", "nonexistent"], cwd=tmp_path) assert result.exit_code != 0 assert "nonexistent" not in result.output # error went to stderr assert "nonexistent" in result.stderr def test_stderr_only_in_stderr(self, tmp_path: pathlib.Path) -> None: """result.stderr must not bleed into result.output.""" runner.invoke(cli, ["init"], cwd=tmp_path) result = runner.invoke(cli, ["branch", "-d", "nonexistent"], cwd=tmp_path) assert result.exit_code != 0 assert result.output == "" def test_json_stdout_unpolluted_by_stderr(self, tmp_path: pathlib.Path) -> None: """JSON output on stdout is parseable without stripping stderr noise.""" runner.invoke(cli, ["init"], cwd=tmp_path) (tmp_path / "a.py").write_text("x = 1\n") runner.invoke(cli, ["commit", "-m", "init"], cwd=tmp_path) result = runner.invoke(cli, ["branch", "--json"], cwd=tmp_path) assert result.exit_code == 0 data = json.loads(result.output) # must not raise assert isinstance(data, list) class TestCliRunnerCwd: """invoke(cwd=) changes directory for the duration of the call.""" def test_cwd_initialises_repo_in_target(self, tmp_path: pathlib.Path) -> None: """init with cwd= creates .muse in tmp_path, not CWD.""" result = runner.invoke(cli, ["init"], cwd=tmp_path) assert result.exit_code == 0 assert muse_dir(tmp_path).exists() def test_cwd_restores_after_invoke(self, tmp_path: pathlib.Path) -> None: """CWD is restored to its original value after invoke returns.""" import os before = os.getcwd() runner.invoke(cli, ["init"], cwd=tmp_path) assert os.getcwd() == before def test_cwd_isolates_concurrent_calls(self, tmp_path: pathlib.Path) -> None: """Two repos can be initialised in separate tmp dirs without interference.""" dir_a = tmp_path / "a" dir_b = tmp_path / "b" dir_a.mkdir() dir_b.mkdir() runner.invoke(cli, ["init"], cwd=dir_a) runner.invoke(cli, ["init"], cwd=dir_b) assert muse_dir(dir_a).exists() assert muse_dir(dir_b).exists()