diff options
Diffstat (limited to 'spec/dispatch/tool/files')
| -rw-r--r-- | spec/dispatch/tool/files/create_file_spec.rb | 70 | ||||
| -rw-r--r-- | spec/dispatch/tool/files/edit_file_spec.rb | 142 | ||||
| -rw-r--r-- | spec/dispatch/tool/files/list_files_spec.rb | 125 | ||||
| -rw-r--r-- | spec/dispatch/tool/files/read_file_spec.rb | 108 | ||||
| -rw-r--r-- | spec/dispatch/tool/files/sandbox_spec.rb | 163 | ||||
| -rw-r--r-- | spec/dispatch/tool/files/search_files_spec.rb | 132 | ||||
| -rw-r--r-- | spec/dispatch/tool/files/write_file_spec.rb | 74 |
7 files changed, 814 insertions, 0 deletions
diff --git a/spec/dispatch/tool/files/create_file_spec.rb b/spec/dispatch/tool/files/create_file_spec.rb new file mode 100644 index 0000000..91c1469 --- /dev/null +++ b/spec/dispatch/tool/files/create_file_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +RSpec.describe "create_file tool" do + let(:worktree_path) { Dir.mktmpdir("create-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("create_file") } + + describe "creating a new file" do + it "creates a file with the given content" do + result = tool.call({ "path" => "new_file.rb", "content" => "puts 'hello'" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "new_file.rb"))).to eq("puts 'hello'") + end + + it "returns a confirmation message" do + result = tool.call({ "path" => "created.txt", "content" => "content" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("created.txt") + end + + it "creates parent directories as needed" do + result = tool.call({ "path" => "deep/nested/dir/file.txt", "content" => "deep content" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "deep/nested/dir/file.txt"))).to eq("deep content") + end + end + + describe "error cases" do + it "returns failure when the file already exists" do + file_path = File.join(worktree_path, "existing.txt") + File.write(file_path, "original") + + result = tool.call({ "path" => "existing.txt", "content" => "overwrite attempt" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/already exists/i) + expect(File.read(file_path)).to eq("original") + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../tmp/evil.txt", "content" => "bad" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({ "content" => "data" }, context:) + + expect(result.failure?).to be true + end + + it "requires the content parameter" do + result = tool.call({ "path" => "file.txt" }, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/edit_file_spec.rb b/spec/dispatch/tool/files/edit_file_spec.rb new file mode 100644 index 0000000..b0366dd --- /dev/null +++ b/spec/dispatch/tool/files/edit_file_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +RSpec.describe "edit_file tool" do + let(:worktree_path) { Dir.mktmpdir("edit-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("edit_file") } + + describe "single edit" do + it "replaces old_text with new_text in the file" do + file_path = File.join(worktree_path, "code.rb") + File.write(file_path, "def hello\n puts 'hello'\nend\n") + + result = tool.call({ + "path" => "code.rb", + "edits" => [{ "old_text" => "puts 'hello'", "new_text" => "puts 'goodbye'" }] + }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to include("puts 'goodbye'") + expect(File.read(file_path)).not_to include("puts 'hello'") + end + + it "returns a confirmation with the number of edits applied" do + file_path = File.join(worktree_path, "file.txt") + File.write(file_path, "foo bar baz") + + result = tool.call({ + "path" => "file.txt", + "edits" => [{ "old_text" => "bar", "new_text" => "qux" }] + }, context:) + + expect(result.success?).to be true + expect(result.output).to match(/1/i) + end + end + + describe "multiple edits" do + it "applies multiple edits sequentially" do + file_path = File.join(worktree_path, "multi.txt") + File.write(file_path, "aaa bbb ccc") + + result = tool.call({ + "path" => "multi.txt", + "edits" => [ + { "old_text" => "aaa", "new_text" => "xxx" }, + { "old_text" => "ccc", "new_text" => "zzz" } + ] + }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to eq("xxx bbb zzz") + end + + it "applies edits sequentially so later edits see results of earlier ones" do + file_path = File.join(worktree_path, "sequential.txt") + File.write(file_path, "hello world") + + result = tool.call({ + "path" => "sequential.txt", + "edits" => [ + { "old_text" => "hello", "new_text" => "hi" }, + { "old_text" => "hi world", "new_text" => "hi there" } + ] + }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to eq("hi there") + end + end + + describe "error cases" do + it "returns failure when old_text is not found in the file" do + file_path = File.join(worktree_path, "missing.txt") + File.write(file_path, "actual content") + + result = tool.call({ + "path" => "missing.txt", + "edits" => [{ "old_text" => "nonexistent text", "new_text" => "replacement" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found/i) + end + + it "returns failure when the file does not exist" do + result = tool.call({ + "path" => "ghost.txt", + "edits" => [{ "old_text" => "a", "new_text" => "b" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found|does not exist/i) + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ + "path" => "../../../etc/passwd", + "edits" => [{ "old_text" => "root", "new_text" => "hacked" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + + it "returns failure when old_text matches ambiguously (multiple occurrences)" do + file_path = File.join(worktree_path, "ambiguous.txt") + File.write(file_path, "foo bar foo baz foo") + + result = tool.call({ + "path" => "ambiguous.txt", + "edits" => [{ "old_text" => "foo", "new_text" => "qux" }] + }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/ambiguous|multiple/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({ + "edits" => [{ "old_text" => "a", "new_text" => "b" }] + }, context:) + + expect(result.failure?).to be true + end + + it "requires the edits parameter" do + File.write(File.join(worktree_path, "file.txt"), "content") + + result = tool.call({ "path" => "file.txt" }, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/list_files_spec.rb b/spec/dispatch/tool/files/list_files_spec.rb new file mode 100644 index 0000000..05c4088 --- /dev/null +++ b/spec/dispatch/tool/files/list_files_spec.rb @@ -0,0 +1,125 @@ +# frozen_string_literal: true + +RSpec.describe "list_files tool" do + let(:worktree_path) { Dir.mktmpdir("list-files-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("list_files") } + + describe "listing all files" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src", "lib")) + File.write(File.join(worktree_path, "README.md"), "readme") + File.write(File.join(worktree_path, "src", "main.rb"), "main") + File.write(File.join(worktree_path, "src", "lib", "helper.rb"), "helper") + end + + it "lists all files recursively by default" do + result = tool.call({}, context:) + + expect(result.success?).to be true + expect(result.output).to include("README.md") + expect(result.output).to include("src/main.rb") + expect(result.output).to include("src/lib/helper.rb") + end + + it "returns paths relative to the worktree root" do + result = tool.call({}, context:) + + expect(result.success?).to be true + expect(result.output).not_to include(worktree_path) + end + end + + describe "listing with a path" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + FileUtils.mkdir_p(File.join(worktree_path, "test")) + File.write(File.join(worktree_path, "src", "app.rb"), "app") + File.write(File.join(worktree_path, "test", "app_test.rb"), "test") + end + + it "lists files only in the specified subdirectory" do + result = tool.call({ "path" => "src" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("app.rb") + expect(result.output).not_to include("app_test.rb") + end + end + + describe "glob pattern filtering" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + File.write(File.join(worktree_path, "src", "main.rb"), "main") + File.write(File.join(worktree_path, "src", "style.css"), "css") + File.write(File.join(worktree_path, "README.md"), "readme") + end + + it "filters files using a glob pattern" do + result = tool.call({ "pattern" => "**/*.rb" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("main.rb") + expect(result.output).not_to include("style.css") + expect(result.output).not_to include("README.md") + end + + it "supports multiple extension glob patterns" do + result = tool.call({ "pattern" => "**/*.{rb,md}" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("main.rb") + expect(result.output).to include("README.md") + expect(result.output).not_to include("style.css") + end + end + + describe "recursive vs non-recursive" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "nested", "deep")) + File.write(File.join(worktree_path, "top.txt"), "top") + File.write(File.join(worktree_path, "nested", "mid.txt"), "mid") + File.write(File.join(worktree_path, "nested", "deep", "bottom.txt"), "bottom") + end + + it "includes nested files when recursive is true" do + result = tool.call({ "recursive" => true }, context:) + + expect(result.success?).to be true + expect(result.output).to include("top.txt") + expect(result.output).to include("nested/mid.txt") + expect(result.output).to include("nested/deep/bottom.txt") + end + + it "lists only top-level files when recursive is false" do + result = tool.call({ "recursive" => false }, context:) + + expect(result.success?).to be true + expect(result.output).to include("top.txt") + expect(result.output).not_to include("mid.txt") + expect(result.output).not_to include("bottom.txt") + end + end + + describe "error cases" do + it "returns failure when the directory does not exist" do + result = tool.call({ "path" => "nonexistent_dir" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found|does not exist/i) + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../etc" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end +end diff --git a/spec/dispatch/tool/files/read_file_spec.rb b/spec/dispatch/tool/files/read_file_spec.rb new file mode 100644 index 0000000..01b7828 --- /dev/null +++ b/spec/dispatch/tool/files/read_file_spec.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +RSpec.describe "read_file tool" do + let(:worktree_path) { Dir.mktmpdir("read-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("read_file") } + + describe "reading a full file" do + it "returns file contents with line numbers" do + File.write(File.join(worktree_path, "hello.txt"), "line one\nline two\nline three\n") + + result = tool.call({ "path" => "hello.txt" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line one") + expect(result.output).to include("line two") + expect(result.output).to include("line three") + end + + it "prefixes each line with its line number" do + File.write(File.join(worktree_path, "numbered.txt"), "alpha\nbeta\ngamma\n") + + result = tool.call({ "path" => "numbered.txt" }, context:) + + expect(result.success?).to be true + lines = result.output.split("\n") + expect(lines[0]).to match(/\A\s*0.*alpha/) + expect(lines[1]).to match(/\A\s*1.*beta/) + expect(lines[2]).to match(/\A\s*2.*gamma/) + end + end + + describe "reading a line range" do + before do + content = (0..9).map { |i| "line #{i}" }.join("\n") + "\n" + File.write(File.join(worktree_path, "lines.txt"), content) + end + + it "returns only the specified line range (0-based)" do + result = tool.call({ "path" => "lines.txt", "start_line" => 2, "end_line" => 4 }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line 2") + expect(result.output).to include("line 3") + expect(result.output).to include("line 4") + expect(result.output).not_to include("line 1") + expect(result.output).not_to include("line 5") + end + + it "reads from start_line to end of file when end_line is -1" do + result = tool.call({ "path" => "lines.txt", "start_line" => 8, "end_line" => -1 }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line 8") + expect(result.output).to include("line 9") + expect(result.output).not_to include("line 7") + end + + it "reads from the beginning when only end_line is specified" do + result = tool.call({ "path" => "lines.txt", "end_line" => 1 }, context:) + + expect(result.success?).to be true + expect(result.output).to include("line 0") + expect(result.output).to include("line 1") + expect(result.output).not_to include("line 2") + end + end + + describe "error cases" do + it "returns failure when the file does not exist" do + result = tool.call({ "path" => "nonexistent.txt" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/not found|does not exist/i) + end + + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../etc/passwd" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + + it "returns failure for a binary file" do + binary_path = File.join(worktree_path, "binary.bin") + File.write(binary_path, "Hello\x00World\x00Binary\x00Content") + + result = tool.call({ "path" => "binary.bin" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/binary/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({}, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/sandbox_spec.rb b/spec/dispatch/tool/files/sandbox_spec.rb new file mode 100644 index 0000000..f86cb5a --- /dev/null +++ b/spec/dispatch/tool/files/sandbox_spec.rb @@ -0,0 +1,163 @@ +# frozen_string_literal: true + +RSpec.describe Dispatch::Tool::Files::Sandbox do + let(:worktree_path) { Dir.mktmpdir("sandbox-test") } + + after { FileUtils.remove_entry(worktree_path) } + + describe ".resolve_path" do + it "resolves a simple relative path within the worktree" do + file_path = File.join(worktree_path, "hello.txt") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("hello.txt", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "resolves a nested relative path within the worktree" do + nested_dir = File.join(worktree_path, "src", "lib") + FileUtils.mkdir_p(nested_dir) + file_path = File.join(nested_dir, "main.rb") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("src/lib/main.rb", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "resolves paths with . components" do + file_path = File.join(worktree_path, "hello.txt") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("./hello.txt", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "raises SandboxError for .. traversal escaping the worktree" do + expect do + described_class.resolve_path("../../../etc/passwd", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for deeply nested .. traversal that escapes" do + FileUtils.mkdir_p(File.join(worktree_path, "a", "b")) + + expect do + described_class.resolve_path("a/b/../../../../etc/passwd", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "allows .. traversal that stays within the worktree" do + FileUtils.mkdir_p(File.join(worktree_path, "a", "b")) + file_path = File.join(worktree_path, "a", "file.txt") + FileUtils.touch(file_path) + + resolved = described_class.resolve_path("a/b/../file.txt", worktree_path:) + + expect(resolved).to eq(file_path) + end + + it "raises SandboxError for absolute paths outside the worktree" do + expect do + described_class.resolve_path("/etc/passwd", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for absolute paths that happen to share a prefix" do + expect do + described_class.resolve_path("#{worktree_path}-evil/secret.txt", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for symlinks pointing outside the worktree" do + link_path = File.join(worktree_path, "evil_link") + File.symlink("/etc/passwd", link_path) + + expect do + described_class.resolve_path("evil_link", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "raises SandboxError for symlinked directories pointing outside the worktree" do + link_path = File.join(worktree_path, "evil_dir") + File.symlink("/tmp", link_path) + + expect do + described_class.resolve_path("evil_dir/some_file.txt", worktree_path:) + end.to raise_error(Dispatch::Tool::Files::SandboxError) + end + + it "allows symlinks that resolve within the worktree" do + target_path = File.join(worktree_path, "real.txt") + FileUtils.touch(target_path) + + link_path = File.join(worktree_path, "link.txt") + File.symlink(target_path, link_path) + + resolved = described_class.resolve_path("link.txt", worktree_path:) + + expect(resolved).to eq(target_path) + end + + it "resolves the worktree path itself for an empty string" do + resolved = described_class.resolve_path("", worktree_path:) + + expect(resolved).to eq(worktree_path) + end + + it "resolves the worktree path for ." do + resolved = described_class.resolve_path(".", worktree_path:) + + expect(resolved).to eq(worktree_path) + end + + it "handles paths to non-existent files within the worktree" do + resolved = described_class.resolve_path("nonexistent.txt", worktree_path:) + + expect(resolved).to eq(File.join(worktree_path, "nonexistent.txt")) + end + + it "handles paths to non-existent nested directories within the worktree" do + resolved = described_class.resolve_path("a/b/c/file.txt", worktree_path:) + + expect(resolved).to eq(File.join(worktree_path, "a/b/c/file.txt")) + end + end + + describe ".within_worktree?" do + it "returns true for a path inside the worktree" do + FileUtils.touch(File.join(worktree_path, "file.txt")) + + expect(described_class.within_worktree?("file.txt", worktree_path:)).to be true + end + + it "returns false for a path escaping the worktree" do + expect(described_class.within_worktree?("../../etc/passwd", worktree_path:)).to be false + end + + it "returns false for an absolute path outside the worktree" do + expect(described_class.within_worktree?("/etc/passwd", worktree_path:)).to be false + end + + it "returns false for a symlink pointing outside the worktree" do + link_path = File.join(worktree_path, "evil_link") + File.symlink("/etc/passwd", link_path) + + expect(described_class.within_worktree?("evil_link", worktree_path:)).to be false + end + + it "returns true for a symlink that stays inside the worktree" do + target = File.join(worktree_path, "real.txt") + FileUtils.touch(target) + File.symlink(target, File.join(worktree_path, "link.txt")) + + expect(described_class.within_worktree?("link.txt", worktree_path:)).to be true + end + + it "returns true for non-existent paths within the worktree" do + expect(described_class.within_worktree?("does/not/exist.txt", worktree_path:)).to be true + end + end +end diff --git a/spec/dispatch/tool/files/search_files_spec.rb b/spec/dispatch/tool/files/search_files_spec.rb new file mode 100644 index 0000000..139a5e9 --- /dev/null +++ b/spec/dispatch/tool/files/search_files_spec.rb @@ -0,0 +1,132 @@ +# frozen_string_literal: true + +RSpec.describe "search_files tool" do + let(:worktree_path) { Dir.mktmpdir("search-files-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("search_files") } + + describe "plain text search" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + File.write(File.join(worktree_path, "src", "app.rb"), "def hello\n puts 'hello world'\nend\n") + File.write(File.join(worktree_path, "src", "utils.rb"), "def goodbye\n puts 'goodbye world'\nend\n") + File.write(File.join(worktree_path, "README.md"), "# Hello World\n\nA sample project.\n") + end + + it "finds matches across multiple files" do + result = tool.call({ "query" => "world" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("hello world") + expect(result.output).to include("goodbye world") + end + + it "includes file paths in the results" do + result = tool.call({ "query" => "hello" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("src/app.rb") + end + + it "includes line numbers in the results" do + result = tool.call({ "query" => "puts" }, context:) + + expect(result.success?).to be true + # line numbers should appear in the output + expect(result.output).to match(/\d+/) + end + end + + describe "regex search" do + before do + File.write(File.join(worktree_path, "data.txt"), "foo123bar\nbaz456qux\nhello789\n") + end + + it "finds matches using a regular expression" do + result = tool.call({ "query" => "\\d{3}", "is_regex" => true }, context:) + + expect(result.success?).to be true + expect(result.output).to include("foo123bar") + expect(result.output).to include("baz456qux") + expect(result.output).to include("hello789") + end + + it "returns failure for an invalid regex" do + result = tool.call({ "query" => "[unclosed", "is_regex" => true }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/invalid|regex|regexp/i) + end + end + + describe "scoped search with path" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + FileUtils.mkdir_p(File.join(worktree_path, "test")) + File.write(File.join(worktree_path, "src", "main.rb"), "target line\n") + File.write(File.join(worktree_path, "test", "main_test.rb"), "target line\n") + end + + it "searches only within the specified path" do + result = tool.call({ "query" => "target", "path" => "src" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("src/main.rb") + expect(result.output).not_to include("test/main_test.rb") + end + end + + describe "file pattern filtering" do + before do + FileUtils.mkdir_p(File.join(worktree_path, "src")) + File.write(File.join(worktree_path, "src", "app.rb"), "target\n") + File.write(File.join(worktree_path, "src", "style.css"), "target\n") + end + + it "filters search results by file glob pattern" do + result = tool.call({ "query" => "target", "pattern" => "**/*.rb" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("app.rb") + expect(result.output).not_to include("style.css") + end + end + + describe "result limiting" do + before do + content = (1..200).map { |i| "match_target line #{i}" }.join("\n") + "\n" + File.write(File.join(worktree_path, "big_file.txt"), content) + end + + it "limits results to a reasonable maximum" do + result = tool.call({ "query" => "match_target" }, context:) + + expect(result.success?).to be true + match_count = result.output.scan(/match_target/).size + expect(match_count).to be <= 100 + end + end + + describe "error cases" do + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "query" => "root", "path" => "../../../etc" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end + + describe "parameter validation" do + it "requires the query parameter" do + result = tool.call({}, context:) + + expect(result.failure?).to be true + end + end +end diff --git a/spec/dispatch/tool/files/write_file_spec.rb b/spec/dispatch/tool/files/write_file_spec.rb new file mode 100644 index 0000000..8b7bacb --- /dev/null +++ b/spec/dispatch/tool/files/write_file_spec.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +RSpec.describe "write_file tool" do + let(:worktree_path) { Dir.mktmpdir("write-file-test") } + let(:context) { { worktree_path: } } + let(:registry) { Dispatch::Tools::Registry.new } + + before { Dispatch::Tool::Files.register(registry) } + + after { FileUtils.remove_entry(worktree_path) } + + subject(:tool) { registry.get("write_file") } + + describe "writing a new file" do + it "creates a new file with the given content" do + result = tool.call({ "path" => "new_file.txt", "content" => "Hello, World!" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "new_file.txt"))).to eq("Hello, World!") + end + + it "returns a confirmation message with path and byte count" do + result = tool.call({ "path" => "output.txt", "content" => "12345" }, context:) + + expect(result.success?).to be true + expect(result.output).to include("output.txt") + expect(result.output).to match(/5\b.*bytes?/i) + end + end + + describe "overwriting an existing file" do + it "replaces the entire content of an existing file" do + file_path = File.join(worktree_path, "existing.txt") + File.write(file_path, "old content") + + result = tool.call({ "path" => "existing.txt", "content" => "new content" }, context:) + + expect(result.success?).to be true + expect(File.read(file_path)).to eq("new content") + end + end + + describe "creating parent directories" do + it "creates intermediate directories as needed" do + result = tool.call({ "path" => "a/b/c/deep.txt", "content" => "deep" }, context:) + + expect(result.success?).to be true + expect(File.read(File.join(worktree_path, "a/b/c/deep.txt"))).to eq("deep") + end + end + + describe "error cases" do + it "returns failure when the path escapes the sandbox" do + result = tool.call({ "path" => "../../../tmp/evil.txt", "content" => "bad" }, context:) + + expect(result.failure?).to be true + expect(result.error).to match(/sandbox|outside/i) + end + end + + describe "parameter validation" do + it "requires the path parameter" do + result = tool.call({ "content" => "data" }, context:) + + expect(result.failure?).to be true + end + + it "requires the content parameter" do + result = tool.call({ "path" => "file.txt" }, context:) + + expect(result.failure?).to be true + end + end +end |
