diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..a821aa2 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,181 @@ +name: Tests + +on: + pull_request: + branches: [ main, master ] + push: + branches: [ main, master ] + workflow_dispatch: + +permissions: + contents: read + pull-requests: write + checks: write + +jobs: + test: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.11", "3.12", "3.13"] + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python-version }} + cache: 'pip' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[dev]" + + - name: Run linting + run: | + ruff check src tests + continue-on-error: true + + - name: Run type checking + run: | + mypy src + continue-on-error: true + + - name: Run tests with coverage + run: | + pytest tests/ \ + --cov=src/devman \ + --cov-report=term \ + --cov-report=html \ + --cov-report=json \ + --cov-report=xml \ + -v + + - name: Generate coverage badge + if: matrix.python-version == '3.13' + run: | + COVERAGE=$(python -c "import json; print(json.load(open('coverage.json'))['totals']['percent_covered_display'])") + echo "COVERAGE=$COVERAGE" >> $GITHUB_ENV + echo "Coverage: $COVERAGE%" + + - name: Upload coverage reports + if: matrix.python-version == '3.13' + uses: actions/upload-artifact@v4 + with: + name: coverage-reports + path: | + htmlcov/ + coverage.json + coverage.xml + .coverage + + - name: Comment PR with test results + if: github.event_name == 'pull_request' && matrix.python-version == '3.13' + uses: actions/github-script@v7 + with: + script: | + const fs = require('fs'); + + // Read coverage data + const coverage = JSON.parse(fs.readFileSync('coverage.json', 'utf8')); + const totalCoverage = coverage.totals.percent_covered_display; + + // Read test output + let testOutput = ''; + try { + testOutput = fs.readFileSync('test-output.txt', 'utf8'); + } catch (e) { + testOutput = 'Test output not available'; + } + + // Build coverage table + let coverageTable = '| File | Coverage |\n|------|----------|\n'; + for (const [file, data] of Object.entries(coverage.files)) { + const fileName = file.replace('src/devman/', ''); + const fileCoverage = data.summary.percent_covered_display; + coverageTable += `| \`${fileName}\` | ${fileCoverage}% |\n`; + } + + // Create comment body + const body = `## ๐Ÿงช Test Results (Python ${{ matrix.python-version }}) + + ### ๐Ÿ“Š Coverage Summary + **Total Coverage: ${totalCoverage}%** + +
+ Coverage by File + + ${coverageTable} + +
+ + ### ๐Ÿ“ˆ Coverage Details + - **Lines:** ${coverage.totals.covered_lines}/${coverage.totals.num_statements} covered + - **Branches:** ${coverage.totals.covered_branches}/${coverage.totals.num_branches} covered + - **Missing Lines:** ${coverage.totals.missing_lines} + + --- + *Coverage report generated by pytest-cov*`; + + // Post or update comment + const { data: comments } = await github.rest.issues.listComments({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + }); + + const botComment = comments.find(comment => + comment.user.type === 'Bot' && + comment.body.includes('Test Results (Python ${{ matrix.python-version }})') + ); + + if (botComment) { + await github.rest.issues.updateComment({ + owner: context.repo.owner, + repo: context.repo.repo, + comment_id: botComment.id, + body: body + }); + } else { + await github.rest.issues.createComment({ + owner: context.repo.owner, + repo: context.repo.repo, + issue_number: context.issue.number, + body: body + }); + } + + - name: Check coverage threshold + if: matrix.python-version == '3.13' + run: | + COVERAGE=$(python -c "import json; print(float(json.load(open('coverage.json'))['totals']['percent_covered_display']))") + THRESHOLD=70 + echo "Coverage: $COVERAGE%" + echo "Threshold: $THRESHOLD%" + if (( $(echo "$COVERAGE < $THRESHOLD" | bc -l) )); then + echo "โš ๏ธ Warning: Coverage $COVERAGE% is below threshold $THRESHOLD%" + exit 0 # Don't fail the build, just warn + else + echo "โœ… Coverage $COVERAGE% meets threshold $THRESHOLD%" + fi + + nix-build: + runs-on: ubuntu-latest + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Install Nix + uses: cachix/install-nix-action@v25 + with: + nix_path: nixpkgs=channel:nixos-unstable + + - name: Build with Nix + run: nix build .#devman + + - name: Test devman CLI + run: | + ./result/bin/devman --help || echo "Binary not in expected location" diff --git a/flake.nix b/flake.nix index be6dffd..972e913 100644 --- a/flake.nix +++ b/flake.nix @@ -39,7 +39,6 @@ typer rich pathlib-abc - jinja2 pydantic pyyaml tomli-w diff --git a/src/devman/__init__.py b/src/devman/__init__.py index fd83749..a2aeacf 100644 --- a/src/devman/__init__.py +++ b/src/devman/__init__.py @@ -1,5 +1,5 @@ # src/devman/__init__.py """DevEnv project templating system.""" -__version__ = "0.1.0" +__version__ = "0.2.0" diff --git a/src/devman/cli.py b/src/devman/cli.py index 41391ca..bcf29a4 100644 --- a/src/devman/cli.py +++ b/src/devman/cli.py @@ -5,7 +5,7 @@ import typer -from devman.commands import doctor, down, init, index, switch, up +from devman.commands import bootstrap, doctor, down, init, index, switch, up app = typer.Typer( name="devman", @@ -24,6 +24,7 @@ def main(ctx: typer.Context) -> None: app.command(name="up")(up.run) app.command(name="down")(down.run) app.command(name="switch")(switch.run) +app.command(name="bootstrap")(bootstrap.run) app.command(name="doctor")(doctor.run) app.command(name="init")(init.run) diff --git a/src/devman/commands/__init__.py b/src/devman/commands/__init__.py index ffbe3ed..00fdd9d 100644 --- a/src/devman/commands/__init__.py +++ b/src/devman/commands/__init__.py @@ -3,7 +3,7 @@ from .bootstrap import bootstrap from .doctor import doctor from .down import down -from .index import index_list, index_rebuild, index_status +from . import index from .init import init from .switch import switch from .up import up @@ -12,9 +12,7 @@ "bootstrap", "doctor", "down", - "index_list", - "index_rebuild", - "index_status", + "index", "init", "switch", "up", diff --git a/src/devman/commands/doctor.py b/src/devman/commands/doctor.py index 957082a..364df08 100644 --- a/src/devman/commands/doctor.py +++ b/src/devman/commands/doctor.py @@ -2,8 +2,6 @@ from __future__ import annotations -from typing import Dict - from devman.claude_code import ClaudeCodeWorkspace from devman.integrations import NvimIntegration, TmuxIntegration, TmuxpIntegration @@ -14,7 +12,7 @@ CLAUDE_WORKSPACE = ClaudeCodeWorkspace() -def run() -> Dict[str, bool]: +def run() -> dict[str, bool]: """Return availability of external tools.""" return { "tmux": TMUX.is_available(), @@ -24,7 +22,7 @@ def run() -> Dict[str, bool]: } -def render_report(status: Dict[str, bool]) -> list[str]: +def render_report(status: dict[str, bool]) -> list[str]: """Render a human-readable report.""" lines = [] for tool, available in status.items(): @@ -32,6 +30,6 @@ def render_report(status: Dict[str, bool]) -> list[str]: return lines -def doctor() -> Dict[str, bool]: +def doctor() -> dict[str, bool]: """Backward-compatible alias for run.""" return run() diff --git a/src/devman/commands/index.py b/src/devman/commands/index.py index ef4c547..6daa76e 100644 --- a/src/devman/commands/index.py +++ b/src/devman/commands/index.py @@ -3,11 +3,16 @@ from __future__ import annotations +import json from typing import Iterable +import typer + from devman.discovery import IndexManager, resolve_roots from devman.models import WorkspaceEntry, WorkspaceIndex +app = typer.Typer(help="Manage workspace index") + def _manager(manager: IndexManager | None = None) -> IndexManager: return manager or IndexManager() @@ -57,3 +62,49 @@ def find_entry( ) -> WorkspaceEntry | None: """Find a workspace entry matching the provided query.""" return _manager(manager).find_entry(entries, query) + + +@app.command(name="status") +def index_status() -> None: + """Show index cache status.""" + payload = load_index() + if not payload: + typer.echo("Index cache missing.") + raise typer.Exit(code=1) + + # Convert to dict for JSON serialization + index_dict = { + "entries": [ + { + "name": entry.name, + "workspace_root": str(entry.workspace_root), + "devman_dir": str(entry.devman_dir), + "group": entry.group, + "tags": entry.tags, + } + for entry in payload.entries + ] + } + typer.echo(json.dumps(index_dict, indent=2)) + + +@app.command(name="rebuild") +def index_rebuild( + roots: list[str] = typer.Argument(None, help="Roots to index") +) -> None: + """Force rebuild the index.""" + resolved_roots = roots or [] + index = rebuild_index(resolved_roots) + for line in list_entries(index.entries): + typer.echo(line) + + +@app.command(name="list") +def index_list( + roots: list[str] = typer.Argument(None, help="Roots to index") +) -> None: + """List indexed workspaces.""" + resolved_roots = roots or [] + index = refresh_index(resolved_roots) + for line in list_entries(index.entries): + typer.echo(line) diff --git a/src/devman/commands/switch.py b/src/devman/commands/switch.py index 79ae4ea..d9eb2ad 100644 --- a/src/devman/commands/switch.py +++ b/src/devman/commands/switch.py @@ -5,6 +5,8 @@ from typing import Iterable +import typer + from devman.discovery import IndexManager, resolve_roots from devman.models import WorkspaceEntry @@ -18,3 +20,22 @@ def resolve_workspace( index_manager = manager or IndexManager() index = index_manager.refresh(resolve_roots(roots)) return index_manager.find_entry(index.entries, query) + + +def run(query: str, roots: list[str] | None = None) -> WorkspaceEntry: + """Switch to a different workspace by name or query.""" + resolved_roots = roots or [] + workspace = resolve_workspace(query, resolved_roots) + + if not workspace: + raise typer.Exit(f"No workspace found matching '{query}'") + + typer.echo(f"Switching to workspace: {workspace.name}") + typer.echo(f"Location: {workspace.workspace_root}") + + return workspace + + +def switch(query: str, roots: list[str] | None = None) -> WorkspaceEntry: + """Backward-compatible alias for run.""" + return run(query=query, roots=roots) diff --git a/src/devman/commands/up.py b/src/devman/commands/up.py index 7b9babd..4d40c44 100644 --- a/src/devman/commands/up.py +++ b/src/devman/commands/up.py @@ -45,7 +45,7 @@ def resolve_active_workspace( index_manager = manager or IndexManager() index = index_manager.refresh(resolve_roots(roots)) if not index.entries: - raise ValueError("No workspaces found.") + raise typer.Exit("No workspaces found.") if selector: return selector(index.entries) @@ -67,7 +67,7 @@ def _resolve_workspace_config(root: Path | None = None) -> WorkspaceConfig: workspace_root = root or Path.cwd() devman_dir = find_devman_dir(workspace_root) if not devman_dir: - raise ValueError("No workspace found.") + raise typer.Exit("No workspace found.") return load_workspace_config(devman_dir) @@ -106,7 +106,7 @@ def _record_state(config: WorkspaceConfig, session_name: str | None) -> None: def _load_nvim_session(config: WorkspaceConfig) -> None: - if not config.nvim_sessions_dir or not config.nvim_listen: + if not config.nvim_sessions_dir or config.nvim_listen is None: return session_name = config.nvim_default_session or "home.vim" diff --git a/src/devman/integrations/claude.py b/src/devman/integrations/claude.py index 06bebe0..bb1d79f 100644 --- a/src/devman/integrations/claude.py +++ b/src/devman/integrations/claude.py @@ -6,7 +6,7 @@ import shutil from dataclasses import dataclass from pathlib import Path -from typing import Any, Optional +from typing import Any @dataclass(frozen=True) @@ -20,7 +20,7 @@ def is_available(self) -> bool: def setup( self, workspace_root: Path, - interaction_path: Optional[Path], + interaction_path: Path | None, emit_project_config: bool, ) -> Path: """Write Claude Code settings and optional project config assets.""" @@ -36,7 +36,7 @@ def setup( def launch( self, workspace_root: Path, - interaction_path: Optional[Path], + interaction_path: Path | None, emit_project_config: bool, ) -> Path: """Launch Claude Code integration setup.""" @@ -45,7 +45,7 @@ def launch( def ensure_workspace_settings( self, workspace_root: Path, - interaction_path: Optional[Path], + interaction_path: Path | None, emit_project_config: bool, ) -> Path: """Write Claude Code settings and optional CLAUDE.md instructions.""" @@ -80,13 +80,13 @@ def emit_project_config( "workspace": str(workspace_root), "interaction": str(interaction) if interaction else None, } - config_path.write_text(json.dumps(payload, indent=2)) + config_path.write_text(json.dumps(payload, indent=2), encoding="utf-8") return config_path def _build_settings( self, workspace_root: Path, - interaction_path: Optional[Path], + interaction_path: Path | None, emit_project_config: bool, ) -> dict[str, Any]: settings: dict[str, Any] = { @@ -120,7 +120,7 @@ def _relative_path(self, path: Path, base: Path) -> str: def _render_claude_markdown( self, workspace_root: Path, - interaction_path: Optional[Path], + interaction_path: Path | None, ) -> str: lines = [ "# Claude Code Workspace", diff --git a/src/devman/onboarding/wizard.py b/src/devman/onboarding/wizard.py index daa937f..9534e42 100644 --- a/src/devman/onboarding/wizard.py +++ b/src/devman/onboarding/wizard.py @@ -30,10 +30,11 @@ def run(root: str | None = None, force: bool = False) -> Path: (target_devman / "nvim").mkdir(parents=True, exist_ok=True) (target_devman / "devman.toml").write_text( - render_workspace_toml(target_root.name) + render_workspace_toml(target_root.name), + encoding="utf-8" ) - (target_devman / "interaction.md").write_text(DEFAULT_INTERACTION_MD) - (target_devman / "nvim" / "init.lua").write_text(DEFAULT_NVIM_INIT_LUA) + (target_devman / "interaction.md").write_text(DEFAULT_INTERACTION_MD, encoding="utf-8") + (target_devman / "nvim" / "init.lua").write_text(DEFAULT_NVIM_INIT_LUA, encoding="utf-8") return target_devman diff --git a/tests/unit/test_commands.py b/tests/unit/test_commands.py new file mode 100644 index 0000000..2713bb9 --- /dev/null +++ b/tests/unit/test_commands.py @@ -0,0 +1,164 @@ +"""Unit tests for commands module.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +import typer + +from devman.commands import doctor, index, switch +from devman.models import WorkspaceEntry + + +class TestDoctor: + """Tests for doctor command.""" + + def test_run_returns_availability_dict(self) -> None: + result = doctor.run() + + assert isinstance(result, dict) + assert "tmux" in result + assert "tmuxp" in result + assert "nvim" in result + assert "claude" in result + + # All values should be booleans + for value in result.values(): + assert isinstance(value, bool) + + def test_render_report(self) -> None: + status = { + "tmux": True, + "nvim": False, + "claude": True, + } + + report = doctor.render_report(status) + + assert len(report) == 3 + assert "tmux: ok" in report + assert "nvim: missing" in report + assert "claude: ok" in report + + +class TestSwitch: + """Tests for switch command.""" + + def test_resolve_workspace_finds_by_name(self, tmp_path: Path) -> None: + # Create mock index manager + entry = WorkspaceEntry( + name="my-workspace", + workspace_root=tmp_path / "my-workspace", + devman_dir=tmp_path / "my-workspace" / ".devman", + ) + + with patch("devman.commands.switch.IndexManager") as mock_manager_class: + mock_manager = Mock() + mock_index = Mock() + mock_index.entries = [entry] + mock_manager.refresh.return_value = mock_index + mock_manager.find_entry.return_value = entry + mock_manager_class.return_value = mock_manager + + result = switch.resolve_workspace("my-workspace", []) + + assert result == entry + mock_manager.find_entry.assert_called_once() + + def test_run_exits_when_workspace_not_found(self) -> None: + with patch("devman.commands.switch.resolve_workspace") as mock_resolve: + mock_resolve.return_value = None + + with pytest.raises(typer.Exit): + switch.run("nonexistent") + + def test_run_returns_workspace_when_found(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="found-workspace", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + + with patch("devman.commands.switch.resolve_workspace") as mock_resolve: + mock_resolve.return_value = entry + + result = switch.run("found") + + assert result == entry + + +class TestIndexCommands: + """Tests for index commands.""" + + def test_load_index_returns_none_when_missing(self) -> None: + with patch("devman.commands.index.IndexManager") as mock_manager_class: + mock_manager = Mock() + mock_manager.load.return_value = None + mock_manager_class.return_value = mock_manager + + result = index.load_index(mock_manager) + + assert result is None + + def test_refresh_index_uses_resolved_roots(self, tmp_path: Path) -> None: + with patch("devman.commands.index.resolve_roots") as mock_resolve: + with patch("devman.commands.index.IndexManager") as mock_manager_class: + mock_resolve.return_value = [tmp_path] + mock_manager = Mock() + mock_index = Mock() + mock_manager.refresh.return_value = mock_index + mock_manager_class.return_value = mock_manager + + result = index.refresh_index(["/test"]) + + mock_resolve.assert_called_once_with(["/test"]) + mock_manager.refresh.assert_called_once_with([tmp_path]) + assert result == mock_index + + def test_list_entries_formats_basic_entry(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test-ws", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + + lines = index.list_entries([entry]) + + assert len(lines) == 1 + assert "test-ws" in lines[0] + assert str(tmp_path) in lines[0] + + def test_list_entries_includes_group_and_tags(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test-ws", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + group="mygroup", + tags=["tag1", "tag2"], + ) + + lines = index.list_entries([entry]) + + assert len(lines) == 1 + assert "mygroup" in lines[0] + assert "tag1" in lines[0] + assert "tag2" in lines[0] + + def test_find_entry_returns_match(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="target", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + + with patch("devman.commands.index.IndexManager") as mock_manager_class: + mock_manager = Mock() + mock_manager.find_entry.return_value = entry + mock_manager_class.return_value = mock_manager + + result = index.find_entry([entry], "target", mock_manager) + + assert result == entry + mock_manager.find_entry.assert_called_once_with([entry], "target") diff --git a/tests/unit/test_discovery.py b/tests/unit/test_discovery.py new file mode 100644 index 0000000..3fd488d --- /dev/null +++ b/tests/unit/test_discovery.py @@ -0,0 +1,230 @@ +"""Unit tests for discovery module.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from devman.discovery import ( + IndexManager, + build_entry, + find_devman_dir, + resolve_roots, +) +from devman.models import WorkspaceEntry, WorkspaceIndex + + +class TestFindDevmanDir: + """Tests for find_devman_dir function.""" + + def test_finds_devman_in_current_dir(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + assert find_devman_dir(tmp_path) == devman_dir + + def test_finds_devman_in_parent_dir(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + child_dir = tmp_path / "child" + child_dir.mkdir() + assert find_devman_dir(child_dir) == devman_dir + + def test_returns_none_when_not_found(self, tmp_path: Path) -> None: + assert find_devman_dir(tmp_path) is None + + def test_stops_at_home_directory(self, tmp_path: Path) -> None: + # Should not traverse above home directory + deep_path = tmp_path / "a" / "b" / "c" / "d" + deep_path.mkdir(parents=True) + assert find_devman_dir(deep_path) is None + + +class TestBuildEntry: + """Tests for build_entry function.""" + + def test_builds_basic_entry(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + entry = build_entry(tmp_path, devman_dir) + + assert entry.name == tmp_path.name + assert entry.workspace_root == tmp_path + assert entry.devman_dir == devman_dir + assert entry.group is None + assert entry.tags == [] + + def test_reads_group_from_file(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + (devman_dir / "group.txt").write_text("mygroup", encoding="utf-8") + + entry = build_entry(tmp_path, devman_dir) + + assert entry.group == "mygroup" + + def test_reads_tags_from_file(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + (devman_dir / "tags.txt").write_text("tag1\ntag2\ntag3", encoding="utf-8") + + entry = build_entry(tmp_path, devman_dir) + + assert entry.tags == ["tag1", "tag2", "tag3"] + + +class TestResolveRoots: + """Tests for resolve_roots function.""" + + def test_returns_empty_list_for_empty_input(self) -> None: + assert resolve_roots([]) == [] + + def test_expands_tilde_in_paths(self) -> None: + with patch.object(Path, "expanduser") as mock_expand: + mock_expand.return_value = Path("/home/user/test") + result = resolve_roots(["~/test"]) + assert len(result) == 1 + mock_expand.assert_called() + + def test_converts_strings_to_paths(self) -> None: + result = resolve_roots(["/tmp/test"]) + assert len(result) == 1 + assert isinstance(result[0], Path) + + +class TestIndexManager: + """Tests for IndexManager class.""" + + def test_init_creates_default_cache_path(self) -> None: + manager = IndexManager() + assert manager.cache_path is not None + + def test_load_returns_none_when_cache_missing(self, tmp_path: Path) -> None: + cache_path = tmp_path / "nonexistent_cache.json" + manager = IndexManager(cache_path) + assert manager.load() is None + + def test_save_and_load_roundtrip(self, tmp_path: Path) -> None: + cache_path = tmp_path / "index_cache.json" + manager = IndexManager(cache_path) + + # Create test entry + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path / "workspace", + devman_dir=tmp_path / "workspace" / ".devman", + group="testgroup", + tags=["tag1", "tag2"], + ) + index = WorkspaceIndex( + entries=[entry], + roots=[tmp_path], + ) + + # Save and load + manager.save(index) + loaded = manager.load() + + assert loaded is not None + assert len(loaded.entries) == 1 + assert loaded.entries[0].name == "test" + assert loaded.entries[0].group == "testgroup" + assert loaded.entries[0].tags == ["tag1", "tag2"] + + def test_is_valid_checks_roots(self, tmp_path: Path) -> None: + manager = IndexManager() + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + index = WorkspaceIndex(entries=[entry], roots=[tmp_path]) + + # Same roots should be valid + assert manager.is_valid(index, [tmp_path]) + + # Different roots should be invalid + assert not manager.is_valid(index, [tmp_path / "other"]) + + def test_is_valid_checks_directory_existence(self, tmp_path: Path) -> None: + manager = IndexManager() + + workspace_root = tmp_path / "workspace" + workspace_root.mkdir() + devman_dir = workspace_root / ".devman" + devman_dir.mkdir() + + entry = WorkspaceEntry( + name="test", + workspace_root=workspace_root, + devman_dir=devman_dir, + ) + index = WorkspaceIndex(entries=[entry], roots=[tmp_path]) + + # Should be valid when directories exist + assert manager.is_valid(index, [tmp_path]) + + # Should be invalid when devman_dir doesn't exist + devman_dir.rmdir() + assert not manager.is_valid(index, [tmp_path]) + + def test_scan_finds_devman_directories(self, tmp_path: Path) -> None: + # Create test workspaces + ws1 = tmp_path / "workspace1" + ws1.mkdir() + (ws1 / ".devman").mkdir() + + ws2 = tmp_path / "workspace2" + ws2.mkdir() + (ws2 / ".devman").mkdir() + + manager = IndexManager() + entries = manager.scan([tmp_path]) + + assert len(entries) == 2 + names = {e.name for e in entries} + assert "workspace1" in names + assert "workspace2" in names + + def test_find_entry_by_exact_name(self, tmp_path: Path) -> None: + entry1 = WorkspaceEntry( + name="workspace1", + workspace_root=tmp_path / "workspace1", + devman_dir=tmp_path / "workspace1" / ".devman", + ) + entry2 = WorkspaceEntry( + name="workspace2", + workspace_root=tmp_path / "workspace2", + devman_dir=tmp_path / "workspace2" / ".devman", + ) + + manager = IndexManager() + found = manager.find_entry([entry1, entry2], "workspace1") + + assert found == entry1 + + def test_find_entry_by_partial_name(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="my-workspace", + workspace_root=tmp_path / "my-workspace", + devman_dir=tmp_path / "my-workspace" / ".devman", + ) + + manager = IndexManager() + found = manager.find_entry([entry], "workspace") + + assert found == entry + + def test_find_entry_returns_none_when_not_found(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="workspace1", + workspace_root=tmp_path / "workspace1", + devman_dir=tmp_path / "workspace1" / ".devman", + ) + + manager = IndexManager() + found = manager.find_entry([entry], "nonexistent") + + assert found is None diff --git a/tests/unit/test_integrations.py b/tests/unit/test_integrations.py new file mode 100644 index 0000000..9c6624f --- /dev/null +++ b/tests/unit/test_integrations.py @@ -0,0 +1,224 @@ +"""Unit tests for integrations module.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest + +from devman.integrations import ( + ClaudeIntegration, + NvimIntegration, + TmuxIntegration, + TmuxpIntegration, +) + + +class TestTmuxIntegration: + """Tests for TmuxIntegration.""" + + @pytest.fixture + def tmux(self) -> TmuxIntegration: + return TmuxIntegration() + + def test_is_available_when_command_exists(self, tmux: TmuxIntegration) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = "/usr/bin/tmux" + assert tmux.is_available() is True + + def test_is_available_when_command_missing(self, tmux: TmuxIntegration) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = None + assert tmux.is_available() is False + + def test_session_exists_calls_correct_command(self, tmux: TmuxIntegration) -> None: + with patch("subprocess.run") as mock_run: + mock_run.return_value = Mock(returncode=0) + + result = tmux.session_exists("my-session") + + assert result is True + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "tmux" in args + assert "has-session" in args + assert "my-session" in args + + def test_kill_session_calls_correct_command(self, tmux: TmuxIntegration) -> None: + with patch("subprocess.run") as mock_run: + tmux.kill_session("my-session") + + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "tmux" in args + assert "kill-session" in args + assert "my-session" in args + + +class TestTmuxpIntegration: + """Tests for TmuxpIntegration.""" + + @pytest.fixture + def tmuxp(self) -> TmuxpIntegration: + return TmuxpIntegration() + + def test_is_available_when_command_exists(self, tmuxp: TmuxpIntegration) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = "/usr/bin/tmuxp" + assert tmuxp.is_available() is True + + def test_is_available_when_command_missing(self, tmuxp: TmuxpIntegration) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = None + assert tmuxp.is_available() is False + + +class TestNvimIntegration: + """Tests for NvimIntegration.""" + + @pytest.fixture + def nvim(self) -> NvimIntegration: + return NvimIntegration() + + def test_is_available_when_command_exists(self, nvim: NvimIntegration) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = "/usr/bin/nvim" + assert nvim.is_available() is True + + def test_is_available_when_command_missing(self, nvim: NvimIntegration) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = None + assert nvim.is_available() is False + + def test_build_session_commands(self, nvim: NvimIntegration, tmp_path: Path) -> None: + session_path = tmp_path / "session.vim" + commands = nvim.build_session_commands(tmp_path, session_path) + + assert isinstance(commands, list) + assert len(commands) > 0 + # Should include cd command and source command + assert any("cd" in cmd for cmd in commands) + assert any("source" in cmd for cmd in commands) + + def test_remote_send_uses_correct_socket( + self, + nvim: NvimIntegration, + tmp_path: Path, + ) -> None: + socket = tmp_path / "nvim.sock" + + with patch("subprocess.run") as mock_run: + nvim.remote_send(socket, "echo 'test'") + + mock_run.assert_called_once() + args = mock_run.call_args[0][0] + assert "nvim" in args + assert "--server" in args + assert str(socket) in args + + +class TestClaudeIntegration: + """Tests for ClaudeIntegration.""" + + @pytest.fixture + def claude(self) -> ClaudeIntegration: + return ClaudeIntegration() + + def test_is_available_when_command_exists( + self, + claude: ClaudeIntegration, + ) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = "/usr/bin/claude" + assert claude.is_available() is True + + def test_is_available_when_command_missing( + self, + claude: ClaudeIntegration, + ) -> None: + with patch("shutil.which") as mock_which: + mock_which.return_value = None + assert claude.is_available() is False + + def test_ensure_workspace_settings_creates_directory( + self, + claude: ClaudeIntegration, + tmp_path: Path, + ) -> None: + settings_path = claude.ensure_workspace_settings( + tmp_path, + None, + False, + ) + + assert settings_path.exists() + assert settings_path.parent == tmp_path / ".claude" + assert (tmp_path / ".claude").exists() + + def test_ensure_workspace_settings_writes_json( + self, + claude: ClaudeIntegration, + tmp_path: Path, + ) -> None: + settings_path = claude.ensure_workspace_settings( + tmp_path, + None, + False, + ) + + # Should be valid JSON + import json + settings = json.loads(settings_path.read_text()) + assert "project" in settings + assert "root" in settings["project"] + + def test_ensure_workspace_settings_with_interaction( + self, + claude: ClaudeIntegration, + tmp_path: Path, + ) -> None: + interaction = tmp_path / "interaction.md" + interaction.write_text("# Test", encoding="utf-8") + + settings_path = claude.ensure_workspace_settings( + tmp_path, + interaction, + False, + ) + + import json + settings = json.loads(settings_path.read_text()) + assert "interaction" in settings["project"] + + def test_emit_project_config_creates_file( + self, + claude: ClaudeIntegration, + tmp_path: Path, + ) -> None: + config_path = claude.emit_project_config(tmp_path, None) + + assert config_path.exists() + assert config_path.name == "project.json" + + import json + config = json.loads(config_path.read_text()) + assert "workspace" in config + + def test_emit_project_config_when_emit_flag_true( + self, + claude: ClaudeIntegration, + tmp_path: Path, + ) -> None: + claude.ensure_workspace_settings( + tmp_path, + None, + emit_project_config=True, + ) + + # Should create CLAUDE.md + claude_md = tmp_path / "CLAUDE.md" + assert claude_md.exists() + + content = claude_md.read_text() + assert "Claude Code Workspace" in content diff --git a/tests/unit/test_loaders.py b/tests/unit/test_loaders.py new file mode 100644 index 0000000..6d5067f --- /dev/null +++ b/tests/unit/test_loaders.py @@ -0,0 +1,150 @@ +"""Unit tests for loaders module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from devman.loaders import load_workspace_config + + +class TestLoadWorkspaceConfig: + """Tests for load_workspace_config function.""" + + def test_loads_minimal_config(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + # Create minimal devman.toml + config_file = devman_dir / "devman.toml" + config_file.write_text( + '[workspace]\nname = "test-workspace"\n', + encoding="utf-8" + ) + + config = load_workspace_config(devman_dir) + + assert config.name == "test-workspace" + assert config.root == tmp_path + assert config.devman_dir == devman_dir + + def test_loads_full_config(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + # Create interaction.md + interaction_file = devman_dir / "interaction.md" + interaction_file.write_text("# Test Interaction", encoding="utf-8") + + # Create nvim init + nvim_dir = devman_dir / "nvim" + nvim_dir.mkdir() + init_file = nvim_dir / "init.lua" + init_file.write_text("-- Neovim init", encoding="utf-8") + + # Create full devman.toml + config_file = devman_dir / "devman.toml" + config_file.write_text( + """ +[workspace] +name = "full-workspace" + +[claude] +interaction = "interaction.md" +emit_project_config = true + +[nvim] +listen = "/tmp/nvim.sock" +init = "nvim/init.lua" +default_session = "session.vim" +sessions_dir = "nvim/sessions" + +[tmuxp] +workspace = "tmuxp.yaml" +session_name = "my-session" +""", + encoding="utf-8" + ) + + config = load_workspace_config(devman_dir) + + assert config.name == "full-workspace" + assert config.claude_interaction == interaction_file + assert config.claude_emit_project_config is True + assert config.nvim_listen == Path("/tmp/nvim.sock") + assert config.nvim_init == init_file + assert config.nvim_default_session == "session.vim" + assert config.tmuxp_session_name == "my-session" + + def test_raises_when_config_missing(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + with pytest.raises(FileNotFoundError): + load_workspace_config(devman_dir) + + def test_handles_relative_paths(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + # Create config with relative paths + config_file = devman_dir / "devman.toml" + config_file.write_text( + """ +[workspace] +name = "test" + +[claude] +interaction = "interaction.md" + +[nvim] +init = "nvim/init.lua" +""", + encoding="utf-8" + ) + + config = load_workspace_config(devman_dir) + + # Paths should be resolved relative to devman_dir + assert config.claude_interaction == devman_dir / "interaction.md" + assert config.nvim_init == devman_dir / "nvim" / "init.lua" + + def test_handles_absolute_paths(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + # Create config with absolute path + config_file = devman_dir / "devman.toml" + config_file.write_text( + f""" +[workspace] +name = "test" + +[nvim] +listen = "/tmp/nvim.sock" +""", + encoding="utf-8" + ) + + config = load_workspace_config(devman_dir) + + # Absolute path should remain absolute + assert config.nvim_listen == Path("/tmp/nvim.sock") + + def test_optional_fields_are_none_when_missing(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + + config_file = devman_dir / "devman.toml" + config_file.write_text( + '[workspace]\nname = "minimal"\n', + encoding="utf-8" + ) + + config = load_workspace_config(devman_dir) + + assert config.claude_interaction is None + assert config.nvim_listen is None + assert config.nvim_init is None + assert config.tmuxp_workspace is None diff --git a/tests/unit/test_models.py b/tests/unit/test_models.py new file mode 100644 index 0000000..73bd345 --- /dev/null +++ b/tests/unit/test_models.py @@ -0,0 +1,162 @@ +"""Unit tests for models module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from devman.models import WorkspaceEntry, WorkspaceIndex + + +class TestWorkspaceEntry: + """Tests for WorkspaceEntry model.""" + + def test_creates_basic_entry(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + + assert entry.name == "test" + assert entry.workspace_root == tmp_path + assert entry.devman_dir == tmp_path / ".devman" + assert entry.group is None + assert entry.tags == [] + + def test_creates_entry_with_group_and_tags(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + group="mygroup", + tags=["tag1", "tag2"], + ) + + assert entry.group == "mygroup" + assert entry.tags == ["tag1", "tag2"] + + def test_to_dict_conversion(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + group="mygroup", + tags=["tag1"], + ) + + data = entry.to_dict() + + assert data["name"] == "test" + assert data["workspace_root"] == str(tmp_path) + assert data["devman_dir"] == str(tmp_path / ".devman") + assert data["group"] == "mygroup" + assert data["tags"] == ["tag1"] + + def test_from_dict_conversion(self, tmp_path: Path) -> None: + data = { + "name": "test", + "workspace_root": str(tmp_path), + "devman_dir": str(tmp_path / ".devman"), + "group": "mygroup", + "tags": ["tag1", "tag2"], + } + + entry = WorkspaceEntry.from_dict(data) + + assert entry.name == "test" + assert entry.workspace_root == tmp_path + assert entry.devman_dir == tmp_path / ".devman" + assert entry.group == "mygroup" + assert entry.tags == ["tag1", "tag2"] + + def test_roundtrip_to_dict_from_dict(self, tmp_path: Path) -> None: + original = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + group="group", + tags=["a", "b"], + ) + + data = original.to_dict() + restored = WorkspaceEntry.from_dict(data) + + assert restored == original + + +class TestWorkspaceIndex: + """Tests for WorkspaceIndex model.""" + + def test_creates_empty_index(self) -> None: + index = WorkspaceIndex(entries=[], roots=[]) + + assert index.entries == [] + assert index.roots == [] + + def test_creates_index_with_entries(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + + index = WorkspaceIndex(entries=[entry], roots=[tmp_path]) + + assert len(index.entries) == 1 + assert index.entries[0] == entry + assert index.roots == [tmp_path] + + def test_to_dict_conversion(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + ) + index = WorkspaceIndex(entries=[entry], roots=[tmp_path]) + + data = index.to_dict() + + assert "entries" in data + assert "roots" in data + assert len(data["entries"]) == 1 + assert data["roots"] == [str(tmp_path)] + + def test_from_dict_conversion(self, tmp_path: Path) -> None: + data = { + "entries": [ + { + "name": "test", + "workspace_root": str(tmp_path), + "devman_dir": str(tmp_path / ".devman"), + "group": None, + "tags": [], + } + ], + "roots": [str(tmp_path)], + } + + index = WorkspaceIndex.from_dict(data) + + assert len(index.entries) == 1 + assert index.entries[0].name == "test" + assert len(index.roots) == 1 + assert index.roots[0] == tmp_path + + def test_roundtrip_to_dict_from_dict(self, tmp_path: Path) -> None: + entry = WorkspaceEntry( + name="test", + workspace_root=tmp_path, + devman_dir=tmp_path / ".devman", + group="group", + tags=["tag"], + ) + original = WorkspaceIndex(entries=[entry], roots=[tmp_path]) + + data = original.to_dict() + restored = WorkspaceIndex.from_dict(data) + + assert len(restored.entries) == len(original.entries) + assert restored.entries[0] == original.entries[0] + assert restored.roots == original.roots diff --git a/tests/unit/test_onboarding.py b/tests/unit/test_onboarding.py new file mode 100644 index 0000000..9795a15 --- /dev/null +++ b/tests/unit/test_onboarding.py @@ -0,0 +1,115 @@ +"""Unit tests for onboarding module.""" + +from __future__ import annotations + +from pathlib import Path +from unittest.mock import Mock, patch + +import pytest +import typer + +from devman.onboarding import wizard + + +class TestWizard: + """Tests for onboarding wizard.""" + + def test_run_creates_devman_directory(self, tmp_path: Path) -> None: + with patch("devman.onboarding.wizard._report_dependencies"): + result = wizard.run(root=str(tmp_path)) + + assert result == tmp_path / ".devman" + assert (tmp_path / ".devman").exists() + + def test_run_creates_required_files(self, tmp_path: Path) -> None: + with patch("devman.onboarding.wizard._report_dependencies"): + wizard.run(root=str(tmp_path)) + + devman_dir = tmp_path / ".devman" + assert (devman_dir / "devman.toml").exists() + assert (devman_dir / "interaction.md").exists() + assert (devman_dir / "nvim" / "init.lua").exists() + + def test_run_exits_if_devman_exists_without_force(self, tmp_path: Path) -> None: + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + (devman_dir / "test.txt").write_text("test", encoding="utf-8") + + with pytest.raises(typer.Exit): + wizard.run(root=str(tmp_path), force=False) + + def test_run_overwrites_if_force_flag_set(self, tmp_path: Path) -> None: + # Create existing .devman with file + devman_dir = tmp_path / ".devman" + devman_dir.mkdir() + test_file = devman_dir / "test.txt" + test_file.write_text("old content", encoding="utf-8") + + with patch("devman.onboarding.wizard._report_dependencies"): + wizard.run(root=str(tmp_path), force=True) + + # Old file should be gone + assert not test_file.exists() + + # New files should exist + assert (devman_dir / "devman.toml").exists() + + def test_run_uses_current_dir_when_root_not_specified(self) -> None: + with patch("devman.onboarding.wizard._report_dependencies"): + with patch("pathlib.Path.cwd") as mock_cwd: + with patch("pathlib.Path.mkdir"): + with patch("pathlib.Path.write_text"): + mock_cwd.return_value = Path("/fake/path") + + result = wizard.run() + + expected = Path("/fake/path") / ".devman" + assert result == expected + + def test_run_expands_tilde_in_root(self, tmp_path: Path) -> None: + with patch("devman.onboarding.wizard._report_dependencies"): + with patch("pathlib.Path.expanduser") as mock_expand: + mock_expand.return_value = tmp_path + with patch("pathlib.Path.mkdir"): + with patch("pathlib.Path.write_text"): + wizard.run(root="~/test") + + mock_expand.assert_called() + + def test_rendered_toml_contains_workspace_name(self, tmp_path: Path) -> None: + with patch("devman.onboarding.wizard._report_dependencies"): + wizard.run(root=str(tmp_path)) + + toml_file = tmp_path / ".devman" / "devman.toml" + content = toml_file.read_text() + + assert tmp_path.name in content + + def test_report_dependencies_warns_on_missing_tools(self) -> None: + with patch("devman.onboarding.wizard.doctor_run") as mock_doctor: + with patch("typer.echo") as mock_echo: + mock_doctor.return_value = { + "tmux": True, + "claude": False, + } + + wizard._report_dependencies() + + # Should report missing tools + mock_echo.assert_called() + calls = [str(call) for call in mock_echo.call_args_list] + assert any("Missing" in str(call) for call in calls) + + def test_report_dependencies_silent_when_all_available(self) -> None: + with patch("devman.onboarding.wizard.doctor_run") as mock_doctor: + with patch("typer.echo") as mock_echo: + mock_doctor.return_value = { + "tmux": True, + "claude": True, + "nvim": True, + } + + wizard._report_dependencies() + + # Should not echo anything + mock_echo.assert_not_called() diff --git a/tests/unit/test_state.py b/tests/unit/test_state.py new file mode 100644 index 0000000..229f7bf --- /dev/null +++ b/tests/unit/test_state.py @@ -0,0 +1,113 @@ +"""Unit tests for state module.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from devman.models import SessionState, WorkspaceConfig +from devman.state import StateManager + + +class TestStateManager: + """Tests for StateManager class.""" + + @pytest.fixture + def workspace_config(self, tmp_path: Path) -> WorkspaceConfig: + """Create a test workspace config.""" + devman_dir = tmp_path / ".devman" + devman_dir.mkdir(parents=True) + return WorkspaceConfig( + name="test-workspace", + root=tmp_path, + devman_dir=devman_dir, + claude_interaction=None, + claude_emit_project_config=False, + ) + + @pytest.fixture + def state_manager(self) -> StateManager: + """Create a StateManager instance.""" + return StateManager() + + def test_read_returns_empty_state_when_file_missing( + self, + state_manager: StateManager, + workspace_config: WorkspaceConfig, + ) -> None: + state = state_manager.read(workspace_config) + assert state.tmux_session is None + assert state.nvim_listen is None + + def test_write_and_read_roundtrip( + self, + state_manager: StateManager, + workspace_config: WorkspaceConfig, + tmp_path: Path, + ) -> None: + # Create state + nvim_socket = tmp_path / "nvim.sock" + test_state = SessionState( + tmux_session="my-session", + nvim_listen=nvim_socket, + ) + + # Write state + state_manager.write(workspace_config, test_state) + + # Read state back + loaded_state = state_manager.read(workspace_config) + + assert loaded_state.tmux_session == "my-session" + assert loaded_state.nvim_listen == nvim_socket + + def test_write_creates_directory_if_missing( + self, + state_manager: StateManager, + tmp_path: Path, + ) -> None: + # Create config with non-existent devman dir + devman_dir = tmp_path / ".devman" + config = WorkspaceConfig( + name="test", + root=tmp_path, + devman_dir=devman_dir, + claude_interaction=None, + claude_emit_project_config=False, + ) + + test_state = SessionState( + tmux_session="test-session", + nvim_listen=None, + ) + + # Should create directory and write state + state_manager.write(config, test_state) + + assert devman_dir.exists() + state_file = devman_dir / ".state.json" + assert state_file.exists() + + def test_read_handles_corrupted_json( + self, + state_manager: StateManager, + workspace_config: WorkspaceConfig, + ) -> None: + # Write corrupted JSON + state_file = workspace_config.devman_dir / ".state.json" + state_file.write_text("invalid json {{{", encoding="utf-8") + + # Should return empty state instead of crashing + state = state_manager.read(workspace_config) + assert state.tmux_session is None + assert state.nvim_listen is None + + def test_state_file_path( + self, + state_manager: StateManager, + workspace_config: WorkspaceConfig, + ) -> None: + expected_path = workspace_config.devman_dir / ".state.json" + actual_path = state_manager.state_file_path(workspace_config) + assert actual_path == expected_path