diff --git a/src/pkgmgr/cli/commands/tools.py b/src/pkgmgr/cli/commands/tools.py index 9af9eaa..c81620a 100644 --- a/src/pkgmgr/cli/commands/tools.py +++ b/src/pkgmgr/cli/commands/tools.py @@ -1,115 +1,41 @@ -from __future__ import annotations +from __future__ import annotations -import json -import os - -from typing import Any, Dict, List - -from pkgmgr .cli .context import CLIContext -from pkgmgr .core .command .run import run_command -from pkgmgr .core .repository .identifier import get_repo_identifier -from pkgmgr .core .repository .dir import get_repo_dir +from typing import Any, Dict, List +from pkgmgr.cli.context import CLIContext +from pkgmgr.cli.tools import open_vscode_workspace +from pkgmgr.cli.tools.paths import resolve_repository_path +from pkgmgr.core.command.run import run_command Repository = Dict[str, Any] -def _resolve_repository_path(repository: Repository, ctx: CLIContext) -> str: - """ - Resolve the filesystem path for a repository. - - Priority: - 1. Use explicit keys if present (directory / path / workspace / workspace_dir). - 2. Fallback to get_repo_dir(...) using the repositories base directory - from the CLI context. - """ - - # 1) Explicit path-like keys on the repository object - for key in ("directory", "path", "workspace", "workspace_dir"): - value = repository.get(key) - if value: - return value - - # 2) Fallback: compute from base dir + repository metadata - base_dir = ( - getattr(ctx, "repositories_base_dir", None) - or getattr(ctx, "repositories_dir", None) - ) - if not base_dir: - raise RuntimeError( - "Cannot resolve repositories base directory from context; " - "expected ctx.repositories_base_dir or ctx.repositories_dir." - ) - - return get_repo_dir(base_dir, repository) - - def handle_tools_command( args, ctx: CLIContext, selected: List[Repository], ) -> None: - # ------------------------------------------------------------------ # nautilus "explore" command # ------------------------------------------------------------------ if args.command == "explore": for repository in selected: - repo_path = _resolve_repository_path(repository, ctx) - run_command( - f'nautilus "{repo_path}" & disown' - ) - return + repo_path = resolve_repository_path(repository, ctx) + run_command(f'nautilus "{repo_path}" & disown') + return # ------------------------------------------------------------------ # GNOME terminal command # ------------------------------------------------------------------ if args.command == "terminal": for repository in selected: - repo_path = _resolve_repository_path(repository, ctx) - run_command( - f'gnome-terminal --tab --working-directory="{repo_path}"' - ) - return + repo_path = resolve_repository_path(repository, ctx) + run_command(f'gnome-terminal --tab --working-directory="{repo_path}"') + return # ------------------------------------------------------------------ # VS Code workspace command # ------------------------------------------------------------------ if args.command == "code": - if not selected: - print("No repositories selected.") - return - - identifiers = [ - get_repo_identifier(repo, ctx.all_repositories) - for repo in selected - ] - sorted_identifiers = sorted(identifiers) - workspace_name = "_".join(sorted_identifiers) + ".code-workspace" - - directories_cfg = ctx.config_merged.get("directories") or {} - workspaces_dir = os.path.expanduser( - directories_cfg.get("workspaces", "~/Workspaces") - ) - os.makedirs(workspaces_dir, exist_ok=True) - workspace_file = os.path.join(workspaces_dir, workspace_name) - - folders = [ - {"path": _resolve_repository_path(repository, ctx)} - for repository in selected - ] - - workspace_data = { - "folders": folders, - "settings": {}, - } - - if not os.path.exists(workspace_file): - with open(workspace_file, "w", encoding="utf-8") as f: - json.dump(workspace_data, f, indent=4) - print(f"Created workspace file: {workspace_file}") - else: - print(f"Using existing workspace file: {workspace_file}") - - run_command(f'code "{workspace_file}"') + open_vscode_workspace(ctx, selected) return diff --git a/src/pkgmgr/cli/tools/__init__.py b/src/pkgmgr/cli/tools/__init__.py new file mode 100644 index 0000000..62651a4 --- /dev/null +++ b/src/pkgmgr/cli/tools/__init__.py @@ -0,0 +1,5 @@ +from __future__ import annotations + +from .vscode import open_vscode_workspace + +__all__ = ["open_vscode_workspace"] diff --git a/src/pkgmgr/cli/tools/paths.py b/src/pkgmgr/cli/tools/paths.py new file mode 100644 index 0000000..4394cbc --- /dev/null +++ b/src/pkgmgr/cli/tools/paths.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from typing import Any, Dict + +from pkgmgr.cli.context import CLIContext +from pkgmgr.core.repository.dir import get_repo_dir + +Repository = Dict[str, Any] + + +def resolve_repository_path(repository: Repository, ctx: CLIContext) -> str: + """ + Resolve the filesystem path for a repository. + + Priority: + 1. Use explicit keys if present (directory / path / workspace / workspace_dir). + 2. Fallback to get_repo_dir(...) using the repositories base directory + from the CLI context. + """ + for key in ("directory", "path", "workspace", "workspace_dir"): + value = repository.get(key) + if value: + return value + + base_dir = ( + getattr(ctx, "repositories_base_dir", None) + or getattr(ctx, "repositories_dir", None) + ) + if not base_dir: + raise RuntimeError( + "Cannot resolve repositories base directory from context; " + "expected ctx.repositories_base_dir or ctx.repositories_dir." + ) + + return get_repo_dir(base_dir, repository) diff --git a/src/pkgmgr/cli/tools/vscode.py b/src/pkgmgr/cli/tools/vscode.py new file mode 100644 index 0000000..c632b29 --- /dev/null +++ b/src/pkgmgr/cli/tools/vscode.py @@ -0,0 +1,102 @@ +from __future__ import annotations + +import json +import os +import shutil +from typing import Any, Dict, List + +from pkgmgr.cli.context import CLIContext +from pkgmgr.cli.tools.paths import resolve_repository_path +from pkgmgr.core.command.run import run_command +from pkgmgr.core.repository.identifier import get_repo_identifier + +Repository = Dict[str, Any] + + +def _ensure_vscode_cli_available() -> None: + """ + Ensure that the VS Code CLI ('code') is available in PATH. + """ + if shutil.which("code") is None: + raise RuntimeError( + "VS Code CLI ('code') not found in PATH.\n\n" + "Hint:\n" + " Install Visual Studio Code and ensure the 'code' command is available.\n" + " VS Code → Command Palette → 'Shell Command: Install code command in PATH'\n" + ) + + +def _ensure_identifiers_are_filename_safe(identifiers: List[str]) -> None: + """ + Ensure identifiers can be used in a filename. + + If an identifier contains '/', it likely means the repository has not yet + been explicitly identified (no short identifier configured). + """ + invalid = [i for i in identifiers if "/" in i or os.sep in i] + if invalid: + raise RuntimeError( + "Cannot create VS Code workspace.\n\n" + "The following repositories are not yet identified " + "(identifier contains '/'): \n" + + "\n".join(f" - {i}" for i in invalid) + + "\n\n" + "Hint:\n" + " The repository has no short identifier yet.\n" + " Add an explicit identifier in your configuration before using `pkgmgr tools code`.\n" + ) + + +def _resolve_workspaces_dir(ctx: CLIContext) -> str: + directories_cfg = ctx.config_merged.get("directories") or {} + return os.path.expanduser(directories_cfg.get("workspaces", "~/Workspaces")) + + +def _build_workspace_filename(identifiers: List[str]) -> str: + sorted_identifiers = sorted(identifiers) + return "_".join(sorted_identifiers) + ".code-workspace" + + +def _build_workspace_data(selected: List[Repository], ctx: CLIContext) -> Dict[str, Any]: + folders = [{"path": resolve_repository_path(repo, ctx)} for repo in selected] + return { + "folders": folders, + "settings": {}, + } + + +def open_vscode_workspace(ctx: CLIContext, selected: List[Repository]) -> None: + """ + Create (if missing) and open a VS Code workspace for the selected repositories. + + Policy: + - Fail with a clear error if VS Code CLI is missing. + - Fail with a clear error if any repository identifier contains '/', because that + indicates the repo has not been explicitly identified (no short identifier). + - Do NOT auto-sanitize identifiers and do NOT create subfolders under workspaces. + """ + if not selected: + print("No repositories selected.") + return + + _ensure_vscode_cli_available() + + identifiers = [get_repo_identifier(repo, ctx.all_repositories) for repo in selected] + _ensure_identifiers_are_filename_safe(identifiers) + + workspaces_dir = _resolve_workspaces_dir(ctx) + os.makedirs(workspaces_dir, exist_ok=True) + + workspace_name = _build_workspace_filename(identifiers) + workspace_file = os.path.join(workspaces_dir, workspace_name) + + workspace_data = _build_workspace_data(selected, ctx) + + if not os.path.exists(workspace_file): + with open(workspace_file, "w", encoding="utf-8") as f: + json.dump(workspace_data, f, indent=4) + print(f"Created workspace file: {workspace_file}") + else: + print(f"Using existing workspace file: {workspace_file}") + + run_command(f'code "{workspace_file}"') diff --git a/tests/unit/pkgmgr/cli/commands/test_tools.py b/tests/unit/pkgmgr/cli/commands/test_tools.py index e2ab31c..074fc93 100644 --- a/tests/unit/pkgmgr/cli/commands/test_tools.py +++ b/tests/unit/pkgmgr/cli/commands/test_tools.py @@ -1,11 +1,9 @@ from __future__ import annotations -import json -import os -import tempfile import unittest from types import SimpleNamespace from typing import Any, Dict, List +from unittest.mock import call, patch from pkgmgr.cli.commands.tools import handle_tools_command @@ -26,36 +24,23 @@ class TestHandleToolsCommand(unittest.TestCase): Unit tests for pkgmgr.cli.commands.tools.handle_tools_command. We focus on: - - Correct path resolution for repositories that have a 'directory' key. - - Correct shell commands for 'explore' and 'terminal'. - - Proper workspace creation and invocation of 'code' for the 'code' command. + - Correct path resolution and shell commands for 'explore' and 'terminal'. + - For 'code': delegation to pkgmgr.cli.tools.open_vscode_workspace. """ def setUp(self) -> None: - # Two fake repositories with explicit 'directory' entries so that - # _resolve_repository_path() does not need to call get_repo_dir(). self.repos: List[Repository] = [ {"alias": "repo1", "directory": "/tmp/repo1"}, {"alias": "repo2", "directory": "/tmp/repo2"}, ] - # Minimal CLI context; only attributes used in tools.py are provided. self.ctx = SimpleNamespace( config_merged={"directories": {"workspaces": "~/Workspaces"}}, all_repositories=self.repos, repositories_base_dir="/base/dir", ) - # ------------------------------------------------------------------ # - # Helper - # ------------------------------------------------------------------ # - def _patch_run_command(self): - """ - Convenience context manager for patching run_command in tools module. - """ - from unittest.mock import patch - return patch("pkgmgr.cli.commands.tools.run_command") # ------------------------------------------------------------------ # @@ -63,12 +48,6 @@ class TestHandleToolsCommand(unittest.TestCase): # ------------------------------------------------------------------ # def test_explore_uses_directory_paths(self) -> None: - """ - The 'explore' command should call Nautilus with the resolved - repository paths and use '& disown' as in the implementation. - """ - from unittest.mock import call - args = _Args(command="explore") with self._patch_run_command() as mock_run_command: @@ -85,12 +64,6 @@ class TestHandleToolsCommand(unittest.TestCase): # ------------------------------------------------------------------ # def test_terminal_uses_directory_paths(self) -> None: - """ - The 'terminal' command should open a GNOME Terminal tab with the - repository as its working directory. - """ - from unittest.mock import call - args = _Args(command="terminal") with self._patch_run_command() as mock_run_command: @@ -106,63 +79,10 @@ class TestHandleToolsCommand(unittest.TestCase): # Tests for 'code' # ------------------------------------------------------------------ # - def test_code_creates_workspace_and_calls_code(self) -> None: - """ - The 'code' command should: - - - Build a workspace file name from sorted repository identifiers. - - Resolve the repository paths into VS Code 'folders'. - - Create the workspace file if it does not exist. - - Call 'code ""' via run_command. - """ - from unittest.mock import patch - + def test_code_delegates_to_open_vscode_workspace(self) -> None: args = _Args(command="code") - with tempfile.TemporaryDirectory() as tmpdir: - # Patch expanduser so that the configured '~/Workspaces' - # resolves into our temporary directory. - with patch( - "pkgmgr.cli.commands.tools.os.path.expanduser" - ) as mock_expanduser: - mock_expanduser.return_value = tmpdir + with patch("pkgmgr.cli.commands.tools.open_vscode_workspace") as m: + handle_tools_command(args, self.ctx, self.repos) - # Patch get_repo_identifier so the resulting workspace file - # name is deterministic and easy to assert. - with patch( - "pkgmgr.cli.commands.tools.get_repo_identifier" - ) as mock_get_identifier: - mock_get_identifier.side_effect = ["repo-b", "repo-a"] - - with self._patch_run_command() as mock_run_command: - handle_tools_command(args, self.ctx, self.repos) - - # The identifiers are ['repo-b', 'repo-a'], which are - # sorted to ['repo-a', 'repo-b'] and joined with '_'. - expected_workspace_name = "repo-a_repo-b.code-workspace" - expected_workspace_file = os.path.join( - tmpdir, expected_workspace_name - ) - - # Workspace file should have been created. - self.assertTrue( - os.path.exists(expected_workspace_file), - "Workspace file was not created.", - ) - - # The content of the workspace must be valid JSON with - # the expected folder paths. - with open(expected_workspace_file, "r", encoding="utf-8") as f: - data = json.load(f) - - self.assertIn("folders", data) - folder_paths = {f["path"] for f in data["folders"]} - self.assertEqual( - folder_paths, - {"/tmp/repo1", "/tmp/repo2"}, - ) - - # And VS Code must have been invoked with that workspace. - mock_run_command.assert_called_once_with( - f'code "{expected_workspace_file}"' - ) + m.assert_called_once_with(self.ctx, self.repos) diff --git a/tests/unit/pkgmgr/cli/tools/test_paths.py b/tests/unit/pkgmgr/cli/tools/test_paths.py new file mode 100644 index 0000000..110c6b8 --- /dev/null +++ b/tests/unit/pkgmgr/cli/tools/test_paths.py @@ -0,0 +1,38 @@ +from __future__ import annotations + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + + +class TestResolveRepositoryPath(unittest.TestCase): + def test_explicit_directory_key_wins(self) -> None: + from pkgmgr.cli.tools.paths import resolve_repository_path + + ctx = SimpleNamespace(repositories_base_dir="/base", repositories_dir="/base2") + repo = {"directory": "/explicit/repo"} + + self.assertEqual(resolve_repository_path(repo, ctx), "/explicit/repo") + + def test_fallback_uses_get_repo_dir_with_repositories_base_dir(self) -> None: + from pkgmgr.cli.tools.paths import resolve_repository_path + + ctx = SimpleNamespace(repositories_base_dir="/base", repositories_dir="/base2") + repo = {"provider": "github.com", "account": "acme", "repository": "demo"} + + with patch("pkgmgr.cli.tools.paths.get_repo_dir", return_value="/computed/repo") as m: + out = resolve_repository_path(repo, ctx) + + self.assertEqual(out, "/computed/repo") + m.assert_called_once_with("/base", repo) + + def test_raises_if_no_base_dir_in_context(self) -> None: + from pkgmgr.cli.tools.paths import resolve_repository_path + + ctx = SimpleNamespace(repositories_base_dir=None, repositories_dir=None) + repo = {"provider": "github.com", "account": "acme", "repository": "demo"} + + with self.assertRaises(RuntimeError) as cm: + resolve_repository_path(repo, ctx) + + self.assertIn("Cannot resolve repositories base directory", str(cm.exception)) diff --git a/tests/unit/pkgmgr/cli/tools/test_vscode.py b/tests/unit/pkgmgr/cli/tools/test_vscode.py new file mode 100644 index 0000000..b28bc1e --- /dev/null +++ b/tests/unit/pkgmgr/cli/tools/test_vscode.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import json +import os +import tempfile +import unittest +from types import SimpleNamespace +from typing import Any, Dict, List +from unittest.mock import patch + +Repository = Dict[str, Any] + + +class TestOpenVSCodeWorkspace(unittest.TestCase): + def test_no_selected_repos_prints_message_and_returns(self) -> None: + from pkgmgr.cli.tools.vscode import open_vscode_workspace + + ctx = SimpleNamespace(config_merged={}, all_repositories=[]) + + with patch("builtins.print") as p: + open_vscode_workspace(ctx, []) + + p.assert_called_once() + self.assertIn("No repositories selected.", str(p.call_args[0][0])) + + def test_raises_if_code_cli_missing(self) -> None: + from pkgmgr.cli.tools.vscode import open_vscode_workspace + + ctx = SimpleNamespace(config_merged={}, all_repositories=[]) + selected: List[Repository] = [{"provider": "github.com", "account": "x", "repository": "y"}] + + with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value=None): + with self.assertRaises(RuntimeError) as cm: + open_vscode_workspace(ctx, selected) + + self.assertIn("VS Code CLI ('code') not found", str(cm.exception)) + + def test_raises_if_identifier_contains_slash(self) -> None: + from pkgmgr.cli.tools.vscode import open_vscode_workspace + + ctx = SimpleNamespace( + config_merged={"directories": {"workspaces": "~/Workspaces"}}, + all_repositories=[], + ) + selected: List[Repository] = [{"provider": "github.com", "account": "x", "repository": "y"}] + + with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value="/usr/bin/code"), patch( + "pkgmgr.cli.tools.vscode.get_repo_identifier", + return_value="github.com/x/y", + ): + with self.assertRaises(RuntimeError) as cm: + open_vscode_workspace(ctx, selected) + + msg = str(cm.exception) + self.assertIn("not yet identified", msg) + self.assertIn("identifier contains '/'", msg) + + def test_creates_workspace_file_and_calls_code(self) -> None: + from pkgmgr.cli.tools.vscode import open_vscode_workspace + + with tempfile.TemporaryDirectory() as tmp: + workspaces_dir = os.path.join(tmp, "Workspaces") + repo_path = os.path.join(tmp, "Repos", "dotlinker") + + ctx = SimpleNamespace( + config_merged={"directories": {"workspaces": workspaces_dir}}, + all_repositories=[], + repositories_base_dir=os.path.join(tmp, "Repos"), + ) + selected: List[Repository] = [ + {"provider": "github.com", "account": "kevin", "repository": "dotlinker"} + ] + + with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value="/usr/bin/code"), patch( + "pkgmgr.cli.tools.vscode.get_repo_identifier", + return_value="dotlinker", + ), patch( + "pkgmgr.cli.tools.vscode.resolve_repository_path", + return_value=repo_path, + ), patch( + "pkgmgr.cli.tools.vscode.run_command" + ) as run_cmd: + open_vscode_workspace(ctx, selected) + + workspace_file = os.path.join(workspaces_dir, "dotlinker.code-workspace") + self.assertTrue(os.path.exists(workspace_file)) + + with open(workspace_file, "r", encoding="utf-8") as f: + data = json.load(f) + + self.assertEqual(data["folders"], [{"path": repo_path}]) + self.assertEqual(data["settings"], {}) + + run_cmd.assert_called_once_with(f'code "{workspace_file}"') + + def test_uses_existing_workspace_file_without_overwriting(self) -> None: + from pkgmgr.cli.tools.vscode import open_vscode_workspace + + with tempfile.TemporaryDirectory() as tmp: + workspaces_dir = os.path.join(tmp, "Workspaces") + os.makedirs(workspaces_dir, exist_ok=True) + + workspace_file = os.path.join(workspaces_dir, "dotlinker.code-workspace") + original = {"folders": [{"path": "/original"}], "settings": {"x": 1}} + with open(workspace_file, "w", encoding="utf-8") as f: + json.dump(original, f) + + ctx = SimpleNamespace( + config_merged={"directories": {"workspaces": workspaces_dir}}, + all_repositories=[], + ) + selected: List[Repository] = [ + {"provider": "github.com", "account": "kevin", "repository": "dotlinker"} + ] + + with patch("pkgmgr.cli.tools.vscode.shutil.which", return_value="/usr/bin/code"), patch( + "pkgmgr.cli.tools.vscode.get_repo_identifier", + return_value="dotlinker", + ), patch( + "pkgmgr.cli.tools.vscode.resolve_repository_path", + return_value="/new/path", + ), patch( + "pkgmgr.cli.tools.vscode.run_command" + ) as run_cmd: + open_vscode_workspace(ctx, selected) + + with open(workspace_file, "r", encoding="utf-8") as f: + data = json.load(f) + + self.assertEqual(data, original) + run_cmd.assert_called_once_with(f'code "{workspace_file}"')