2026-07-14 18:20:32 +08:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
2026-08-11 15:02:37 +08:00
|
|
|
import importlib
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
|
2026-07-14 18:20:32 +08:00
|
|
|
from plyngent.tools.file import edit_replace, listdir, read_file, write_file
|
|
|
|
|
from tests.test_tools.helpers import call_sync
|
|
|
|
|
|
2026-08-11 15:02:37 +08:00
|
|
|
get_truncated_module = importlib.import_module("plyngent.tools.file.get_truncated")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _extract_token(out: str) -> str | None:
|
|
|
|
|
for line in out.splitlines():
|
2026-08-11 18:53:06 +08:00
|
|
|
if "truncate_token=" in line:
|
|
|
|
|
return line.split("truncate_token=", 1)[1].rstrip("]")
|
2026-08-11 15:02:37 +08:00
|
|
|
return None
|
|
|
|
|
|
2026-07-14 18:20:32 +08:00
|
|
|
|
|
|
|
|
def test_write_read_listdir_edit(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
assert "wrote" in call_sync(write_file, "notes/a.txt", "hello world")
|
2026-08-11 15:33:12 +08:00
|
|
|
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello world"
|
2026-07-14 18:20:32 +08:00
|
|
|
listing = call_sync(listdir, "notes")
|
|
|
|
|
assert "a.txt" in listing
|
|
|
|
|
assert "file" in listing
|
|
|
|
|
result = call_sync(edit_replace, "notes/a.txt", "world", "there")
|
|
|
|
|
assert "replaced" in result
|
2026-08-11 15:33:12 +08:00
|
|
|
assert call_sync(read_file, "notes/a.txt") == "L1-1\nhello there"
|
2026-07-14 18:20:32 +08:00
|
|
|
|
|
|
|
|
|
2026-10-16 18:44:14 +08:00
|
|
|
def test_write_file_refuses_existing_file_without_overwrite(workspace: Path) -> None:
|
|
|
|
|
assert "wrote" in call_sync(write_file, "keep.txt", "first")
|
|
|
|
|
refused = call_sync(write_file, "keep.txt", "second")
|
|
|
|
|
assert refused == "error: file exists: keep.txt (set overwrite=true to replace it)"
|
|
|
|
|
assert (workspace / "keep.txt").read_text(encoding="utf-8") == "first"
|
|
|
|
|
assert "wrote" in call_sync(write_file, "keep.txt", "second", overwrite=True)
|
|
|
|
|
assert (workspace / "keep.txt").read_text(encoding="utf-8") == "second"
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 15:02:37 +08:00
|
|
|
def test_read_file_max_chars_embeds_token(workspace: Path) -> None:
|
|
|
|
|
(workspace / "big.txt").write_text("a" * 1000, encoding="utf-8")
|
|
|
|
|
out = call_sync(read_file, "big.txt", max_chars=200)
|
2026-08-11 15:33:12 +08:00
|
|
|
header, _, rest = out.partition("\n")
|
|
|
|
|
assert header.startswith("L1-") # 1-based range of the raw slice read
|
2026-08-11 18:53:06 +08:00
|
|
|
content, _, marker = rest.partition("\n[Truncated")
|
2026-08-11 15:02:37 +08:00
|
|
|
assert content == "a" * len(content)
|
|
|
|
|
assert len(content) < 200
|
2026-08-11 18:53:06 +08:00
|
|
|
assert "truncate_token=" in marker
|
2026-08-11 15:02:37 +08:00
|
|
|
token = _extract_token(out)
|
|
|
|
|
assert token is not None
|
|
|
|
|
parsed = get_truncated_module.decode_truncate_token(token)
|
|
|
|
|
assert parsed is not None
|
|
|
|
|
assert parsed.kind == "file"
|
|
|
|
|
assert parsed.offset == len(content) # cursor lands exactly where content stops
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 18:55:14 +08:00
|
|
|
def test_read_file_default_truncates_at_cap(workspace: Path) -> None:
|
|
|
|
|
"""Truncation is the default: a large read embeds a token without max_chars."""
|
|
|
|
|
(workspace / "huge.txt").write_text("\n".join(f"line-{i}" for i in range(10_000)), encoding="utf-8")
|
|
|
|
|
out = call_sync(read_file, "huge.txt")
|
|
|
|
|
header, _, rest = out.partition("\n")
|
|
|
|
|
assert header == "L1-10000" # the requested slice (whole file)
|
|
|
|
|
assert "truncate_token=" in rest
|
|
|
|
|
# The slice is capped near the default tool-result cap.
|
|
|
|
|
assert len(rest) <= 32_000
|
|
|
|
|
# No marker when the file is small (no truncation occurred).
|
|
|
|
|
small = call_sync(read_file, "huge.txt", max_chars=0)
|
|
|
|
|
assert "Truncated" not in small
|
|
|
|
|
assert small.startswith("L1-10000\n")
|
|
|
|
|
|
|
|
|
|
|
2026-08-11 15:02:37 +08:00
|
|
|
def test_read_file_max_chars_small_file_untouched(workspace: Path) -> None:
|
|
|
|
|
(workspace / "small.txt").write_text("tiny", encoding="utf-8")
|
|
|
|
|
out = call_sync(read_file, "small.txt", max_chars=200)
|
2026-08-11 15:33:12 +08:00
|
|
|
assert out == "L1-1\ntiny"
|
2026-08-11 18:53:06 +08:00
|
|
|
assert "truncate_token=" not in out
|
2026-08-11 15:02:37 +08:00
|
|
|
|
|
|
|
|
|
2026-07-14 18:20:32 +08:00
|
|
|
def test_edit_replace_first_only(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "t.txt", "aa aa")
|
2026-07-19 21:55:16 +08:00
|
|
|
result = call_sync(edit_replace, "t.txt", "aa", "bb")
|
2026-08-11 15:33:12 +08:00
|
|
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb aa"
|
2026-07-19 21:55:16 +08:00
|
|
|
assert "1 of 2" in result or "1 of 2 matches" in result
|
|
|
|
|
assert "remain" in result
|
2026-08-15 17:40:26 +08:00
|
|
|
assert "'aa' → 'bb'" in result # old→new excerpt for self-correction
|
2026-07-19 21:55:16 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edit_replace_max_replaces(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "t.txt", "aa aa aa")
|
|
|
|
|
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=2)
|
2026-08-11 15:33:12 +08:00
|
|
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb bb aa"
|
2026-07-19 21:55:16 +08:00
|
|
|
assert "2 of 3" in result
|
|
|
|
|
assert "1 remain" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edit_replace_all_matches(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "t.txt", "aa aa")
|
|
|
|
|
result = call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=10)
|
2026-08-11 15:33:12 +08:00
|
|
|
assert call_sync(read_file, "t.txt") == "L1-1\nbb bb"
|
2026-07-19 21:55:16 +08:00
|
|
|
assert "all 2 matches" in result
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edit_replace_max_replaces_invalid(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "t.txt", "aa")
|
|
|
|
|
assert "max_replaces" in call_sync(edit_replace, "t.txt", "aa", "bb", max_replaces=0)
|
2026-07-14 18:20:32 +08:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_edit_missing_old_string(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "t.txt", "x")
|
|
|
|
|
assert "not found" in call_sync(edit_replace, "t.txt", "missing", "y")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_offset_limit(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "lines.txt", "a\nb\nc\nd\n")
|
2026-08-11 15:33:12 +08:00
|
|
|
# offset is 0-based: offset=1 skips line 1, so the read starts at 1-based line 2.
|
|
|
|
|
assert call_sync(read_file, "lines.txt", offset=1, limit=2) == "L2-3\nb\nc\n"
|
2026-07-14 18:20:32 +08:00
|
|
|
|
|
|
|
|
|
2026-08-11 15:37:56 +08:00
|
|
|
def test_read_file_missing_and_directory(workspace: Path) -> None:
|
|
|
|
|
(workspace / "sub").mkdir()
|
|
|
|
|
assert call_sync(read_file, "nope.txt") == "error: file not found: nope.txt"
|
|
|
|
|
assert call_sync(read_file, "sub") == "error: not a file: sub"
|
|
|
|
|
|
|
|
|
|
|
2026-07-18 17:52:25 +08:00
|
|
|
def test_read_with_lineno(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
_ = call_sync(write_file, "num.txt", "a\nb\nc\n")
|
|
|
|
|
out = call_sync(read_file, "num.txt", with_lineno=True)
|
|
|
|
|
assert " 1|a\n" in out
|
|
|
|
|
assert " 2|b\n" in out
|
|
|
|
|
assert " 3|c\n" in out
|
|
|
|
|
# offset is 0-based; line numbers stay absolute 1-based file lines
|
|
|
|
|
mid = call_sync(read_file, "num.txt", offset=1, limit=1, with_lineno=True)
|
|
|
|
|
assert mid == " 2|b\n"
|
|
|
|
|
|
|
|
|
|
|
2026-07-14 18:20:32 +08:00
|
|
|
def test_listdir_missing(workspace: object) -> None:
|
|
|
|
|
del workspace
|
|
|
|
|
assert "error" in call_sync(listdir, "nope")
|