diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a724713..42a166c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,26 +1,28 @@ name: CI on: - pull_request: - branches: [ main ] push: branches: [ main ] + pull_request: + branches: [ main ] jobs: test: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} strategy: matrix: - python-version: ["3.10", "3.11", "3.12"] + os: [ubuntu-latest, macos-latest, windows-latest] + python-version: ["3.11"] steps: - name: Checkout code uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v6 with: - version: "latest" + enable-cache: true + cache-dependency-glob: "uv.lock" - name: Set up Python ${{ matrix.python-version }} uses: actions/setup-python@v5 @@ -28,16 +30,16 @@ jobs: python-version: ${{ matrix.python-version }} - name: Install dependencies - run: uv sync --all-extras --dev + run: uv sync --dev - name: Run tests run: uv run pytest -v --tb=short - name: Run type checking - run: uv run mypy src/ + run: uv run mypy src - name: Run linting - run: uv run ruff check + run: uv run ruff check . - name: Check formatting run: uv run ruff format --check @@ -51,12 +53,15 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies run: uv sync --dev @@ -84,12 +89,15 @@ jobs: uses: actions/checkout@v4 - name: Install uv - uses: astral-sh/setup-uv@v3 + uses: astral-sh/setup-uv@v6 + with: + enable-cache: true + cache-dependency-glob: "uv.lock" - name: Set up Python uses: actions/setup-python@v5 with: - python-version: "3.10" + python-version: "3.11" - name: Install dependencies run: uv sync --dev diff --git a/main.py b/main.py deleted file mode 100644 index 60e3ab2..0000000 --- a/main.py +++ /dev/null @@ -1,6 +0,0 @@ -def main(): - print("Hello from diffchunk!") - - -if __name__ == "__main__": - main() diff --git a/pyproject.toml b/pyproject.toml index 047d3a7..cecffd8 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "diffchunk" -version = "0.1.5" +version = "0.1.6" description = "MCP server for navigating large diff files with intelligent chunking" readme = "README.md" requires-python = ">=3.10" diff --git a/src/tools.py b/src/tools.py index cd0182d..19729ec 100644 --- a/src/tools.py +++ b/src/tools.py @@ -64,12 +64,14 @@ def _load_diff_internal( resolved_file_path = os.path.realpath(os.path.expanduser(absolute_file_path)) # Validate file exists and is readable + if os.path.exists(resolved_file_path) and not os.path.isfile( + resolved_file_path + ): + raise ValueError(f"Path is not a file: {resolved_file_path}") + if not os.path.exists(resolved_file_path): raise ValueError(f"Diff file not found: {absolute_file_path}") - if not os.path.isfile(resolved_file_path): - raise ValueError(f"Path is not a file: {resolved_file_path}") - if not os.access(resolved_file_path, os.R_OK): raise ValueError(f"Cannot read file: {resolved_file_path}") diff --git a/tests/test_cli_integration.py b/tests/test_cli_integration.py index 774cf88..9c934bb 100644 --- a/tests/test_cli_integration.py +++ b/tests/test_cli_integration.py @@ -1,5 +1,6 @@ """CLI integration tests for main.py entry point.""" +import platform import subprocess import sys import time @@ -86,6 +87,10 @@ def test_cli_server_startup_basic(self): process.kill() process.wait() + @pytest.mark.skipif( + platform.system() == "Windows", + reason="Signal handling unreliable on Windows CI", + ) def test_cli_keyboard_interrupt_handling(self): """Test CLI handles KeyboardInterrupt gracefully.""" process = subprocess.Popen( diff --git a/tests/test_filesystem_edge_cases.py b/tests/test_filesystem_edge_cases.py index 09522ba..fdc6bda 100644 --- a/tests/test_filesystem_edge_cases.py +++ b/tests/test_filesystem_edge_cases.py @@ -38,7 +38,7 @@ def test_load_diff_nonexistent_file(self, tools): def test_load_diff_directory_instead_of_file(self, tools): """Test loading directory instead of file.""" with pytest.raises(ValueError, match="not a file"): - tools.load_diff("/tmp") + tools.load_diff(tempfile.gettempdir()) def test_load_diff_empty_file_path(self, tools): """Test loading with empty file path.""" diff --git a/tests/test_handle_call_tool.py b/tests/test_handle_call_tool.py new file mode 100644 index 0000000..b11a86a --- /dev/null +++ b/tests/test_handle_call_tool.py @@ -0,0 +1,90 @@ +"""Test handle_call_tool for code coverage.""" + +import json +from pathlib import Path + +import pytest +from mcp.types import CallToolRequest + +from src.server import DiffChunkServer + + +class TestHandleCallTool: + """Test handle_call_tool for coverage.""" + + @pytest.fixture + def server(self): + return DiffChunkServer() + + @pytest.fixture + def react_diff_file(self): + diff_file = Path(__file__).parent / "test_data" / "react_18.0_to_18.3.diff" + if not diff_file.exists(): + pytest.skip("React test diff not found") + return str(diff_file) + + @pytest.mark.asyncio + async def test_handle_call_tool_coverage(self, server, react_diff_file): + """Test all paths in handle_call_tool for coverage.""" + handler = server.app.request_handlers[CallToolRequest] + + # Test load_diff + request = CallToolRequest( + method="tools/call", + params={ + "name": "load_diff", + "arguments": {"absolute_file_path": react_diff_file}, + }, + ) + result = await handler(request) + data = json.loads(result.root.content[0].text) + assert data["chunks"] > 0 + + # Test list_chunks + request = CallToolRequest( + method="tools/call", + params={ + "name": "list_chunks", + "arguments": {"absolute_file_path": react_diff_file}, + }, + ) + result = await handler(request) + chunks = json.loads(result.root.content[0].text) + assert len(chunks) > 0 + + # Test get_chunk + request = CallToolRequest( + method="tools/call", + params={ + "name": "get_chunk", + "arguments": {"absolute_file_path": react_diff_file, "chunk_number": 1}, + }, + ) + result = await handler(request) + assert "=== Chunk 1 of" in result.root.content[0].text + + # Test find_chunks_for_files + request = CallToolRequest( + method="tools/call", + params={ + "name": "find_chunks_for_files", + "arguments": {"absolute_file_path": react_diff_file, "pattern": "*"}, + }, + ) + result = await handler(request) + chunk_nums = json.loads(result.root.content[0].text) + assert isinstance(chunk_nums, list) + + # Test unknown tool + request = CallToolRequest( + method="tools/call", params={"name": "unknown_tool", "arguments": {}} + ) + result = await handler(request) + assert "Unknown tool: unknown_tool" in result.root.content[0].text + + # Test None arguments (validation error from MCP layer) + request = CallToolRequest( + method="tools/call", params={"name": "load_diff", "arguments": None} + ) + result = await handler(request) + assert "Input validation error" in result.root.content[0].text diff --git a/tests/test_integration.py b/tests/test_integration.py index 4dfd2f1..5a1e856 100644 --- a/tests/test_integration.py +++ b/tests/test_integration.py @@ -1,5 +1,7 @@ """Integration tests using real diff files from test_data.""" +import tempfile + import pytest from pathlib import Path @@ -200,7 +202,7 @@ def test_invalid_file_errors(self, tools): # Directory instead of file with pytest.raises(ValueError, match="not a file"): - tools.load_diff("/tmp") + tools.load_diff(tempfile.gettempdir()) def test_chunk_size_consistency(self, tools, test_data_dir): """Test that chunk sizes are respected reasonably.""" diff --git a/uv.lock b/uv.lock index f136d39..afef845 100644 --- a/uv.lock +++ b/uv.lock @@ -136,7 +136,7 @@ toml = [ [[package]] name = "diffchunk" -version = "0.1.5" +version = "0.1.6" source = { editable = "." } dependencies = [ { name = "click" },