"""Tests for write/mkdir/delete filesystem tools (Phase 6).""" from pathlib import Path import pytest from app.models.config import AppConfig, load_config from app.models.tool_call import ToolResultStatus from app.tools.filesystem import DeleteFileTool, MakeDirTool, WriteFileTool @pytest.fixture def config() -> AppConfig: return load_config() @pytest.fixture def workspace(config: AppConfig) -> Path: return config.agent.workspace_root @pytest.fixture def tmp_workspace(tmp_path: Path, config: AppConfig) -> tuple[Path, AppConfig]: """Create a temporary workspace for write tests.""" # Override workspace_root to tmp_path for isolation config.agent.workspace_root = tmp_path return tmp_path, config # --- WriteFileTool --- class TestWriteFileTool: def test_write_new_file(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = WriteFileTool(ws, cfg) result = tool.run("tc-1", {"file_path": "hello.txt", "content": "Hello, world!"}) assert result.status == ToolResultStatus.SUCCESS assert "13 characters" in result.output assert (ws / "hello.txt").read_text() == "Hello, world!" def test_write_creates_parent_dirs(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = WriteFileTool(ws, cfg) result = tool.run("tc-2", {"file_path": "a/b/c/deep.txt", "content": "deep"}) assert result.status == ToolResultStatus.SUCCESS assert (ws / "a" / "b" / "c" / "deep.txt").read_text() == "deep" def test_write_overwrites_existing(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace (ws / "existing.txt").write_text("old content") tool = WriteFileTool(ws, cfg) result = tool.run("tc-3", {"file_path": "existing.txt", "content": "new content"}) assert result.status == ToolResultStatus.SUCCESS assert (ws / "existing.txt").read_text() == "new content" def test_write_path_traversal_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = WriteFileTool(ws, cfg) result = tool.run("tc-4", {"file_path": "../../etc/evil.txt", "content": "bad"}) assert result.status == ToolResultStatus.ERROR assert "outside" in (result.error or "").lower() # --- MakeDirTool --- class TestMakeDirTool: def test_make_new_dir(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = MakeDirTool(ws, cfg) result = tool.run("tc-1", {"directory_path": "newdir"}) assert result.status == ToolResultStatus.SUCCESS assert (ws / "newdir").is_dir() def test_make_nested_dirs(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = MakeDirTool(ws, cfg) result = tool.run("tc-2", {"directory_path": "a/b/c"}) assert result.status == ToolResultStatus.SUCCESS assert (ws / "a" / "b" / "c").is_dir() def test_make_existing_dir_ok(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace (ws / "existing_dir").mkdir() tool = MakeDirTool(ws, cfg) result = tool.run("tc-3", {"directory_path": "existing_dir"}) assert result.status == ToolResultStatus.SUCCESS def test_make_dir_over_file_fails(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace (ws / "afile").write_text("content") tool = MakeDirTool(ws, cfg) result = tool.run("tc-4", {"directory_path": "afile"}) assert result.status == ToolResultStatus.ERROR assert "not a directory" in (result.error or "").lower() def test_make_dir_path_traversal_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = MakeDirTool(ws, cfg) result = tool.run("tc-5", {"directory_path": "../../outside"}) assert result.status == ToolResultStatus.ERROR assert "outside" in (result.error or "").lower() # --- DeleteFileTool --- class TestDeleteFileTool: def test_delete_existing_file(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace (ws / "doomed.txt").write_text("bye") tool = DeleteFileTool(ws, cfg) result = tool.run("tc-1", {"file_path": "doomed.txt"}) assert result.status == ToolResultStatus.SUCCESS assert not (ws / "doomed.txt").exists() def test_delete_nonexistent_file(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = DeleteFileTool(ws, cfg) result = tool.run("tc-2", {"file_path": "ghost.txt"}) assert result.status == ToolResultStatus.ERROR assert "not found" in (result.error or "").lower() def test_delete_directory_refused(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace (ws / "adir").mkdir() tool = DeleteFileTool(ws, cfg) result = tool.run("tc-3", {"file_path": "adir"}) assert result.status == ToolResultStatus.ERROR assert "directory" in (result.error or "").lower() def test_delete_path_traversal_blocked(self, tmp_workspace: tuple[Path, AppConfig]) -> None: ws, cfg = tmp_workspace tool = DeleteFileTool(ws, cfg) result = tool.run("tc-4", {"file_path": "../../etc/passwd"}) assert result.status == ToolResultStatus.ERROR assert "outside" in (result.error or "").lower()