diff --git a/mempalace/cli.py b/mempalace/cli.py index 88ad0ca..7382cf3 100644 --- a/mempalace/cli.py +++ b/mempalace/cli.py @@ -238,7 +238,7 @@ def _maybe_run_mine_after_init(args, cfg) -> None: # we don't block. User can re-run with --auto-mine to opt in. answer = "n" if answer not in ("", "y", "yes"): - print(f"\n Skipped. Run `mempalace mine {project_dir}` when ready.") + print(f"\n Skipped. Run `mempalace mine {shlex.quote(project_dir)}` when ready.") return palace_path = cfg.palace_path diff --git a/mempalace/miner.py b/mempalace/miner.py index 34f46ae..b593797 100644 --- a/mempalace/miner.py +++ b/mempalace/miner.py @@ -9,6 +9,7 @@ Stores verbatim chunks as drawers. No summaries. Ever. import os import sys +import shlex import hashlib import fnmatch from pathlib import Path @@ -1103,7 +1104,7 @@ def mine( print(f" drawers_filed: {total_drawers}") print(f" last_file: {last_file or ''}") print( - f"\n Re-run `mempalace mine {project_dir}` to resume — " + f"\n Re-run `mempalace mine {shlex.quote(project_dir)}` to resume — " "already-filed drawers are\n upserted idempotently and will not duplicate.\n" ) sys.exit(130) diff --git a/tests/test_cli.py b/tests/test_cli.py index 63b3892..b21ed8c 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -281,6 +281,29 @@ def test_maybe_run_mine_yes_and_auto_mine_fully_noninteractive(tmp_path): mock_mine.assert_called_once() +def test_maybe_run_mine_decline_quotes_path_with_spaces(tmp_path, capsys): + """The resume hint must shell-quote the project dir so paths with + spaces / metacharacters produce a copy-paste-safe command.""" + from mempalace.cli import _maybe_run_mine_after_init + + spaced_dir = tmp_path / "my project dir" + spaced_dir.mkdir() + args = argparse.Namespace(dir=str(spaced_dir), yes=False, auto_mine=False) + cfg = _fake_cfg(tmp_path) + with ( + patch("mempalace.miner.mine"), + patch("mempalace.miner.scan_project", return_value=[]), + patch("builtins.input", return_value="n"), + ): + _maybe_run_mine_after_init(args, cfg) + out = capsys.readouterr().out + # shlex.quote wraps paths with spaces in single quotes. + assert f"mempalace mine '{spaced_dir}'" in out + # And the bare unquoted form is NOT printed (would break paste). + assert f"mempalace mine {spaced_dir} " not in out + assert f"mempalace mine {spaced_dir}`" not in out + + def test_maybe_run_mine_eof_on_stdin_treated_as_decline(tmp_path, capsys): """Piped / non-interactive stdin (EOFError) declines without crashing.""" from mempalace.cli import _maybe_run_mine_after_init diff --git a/tests/test_miner.py b/tests/test_miner.py index bb4f437..2dee259 100644 --- a/tests/test_miner.py +++ b/tests/test_miner.py @@ -653,6 +653,30 @@ def test_mine_keyboard_interrupt_prints_summary_and_exits_130(tmp_path, capsys): assert "upserted idempotently" in out +def test_mine_keyboard_interrupt_quotes_path_with_spaces_in_resume_hint(tmp_path, capsys): + """Resume hint must shell-quote the project dir so a path containing + spaces / metacharacters yields a copy-paste-safe `mempalace mine ...` + command. Otherwise users on a path like "My Project" hit a broken + invocation when they re-run after Ctrl-C.""" + import pytest + from unittest.mock import patch + + project_root = tmp_path / "my project" + project_root.mkdir() + _make_minable_project(project_root, n_files=2) + palace_path = project_root / "palace" + + def fake_process_file(*args, **kwargs): + raise KeyboardInterrupt + + with patch("mempalace.miner.process_file", side_effect=fake_process_file): + with pytest.raises(SystemExit): + mine(str(project_root), str(palace_path)) + + out = capsys.readouterr().out + assert f"mempalace mine '{project_root}'" in out + + def test_mine_cleans_up_pid_file_on_interrupt(tmp_path): """Our own PID entry in mine.pid is removed in the finally clause.""" import pytest