# pylint: disable=too-many-lines """Tests for crud steps — the opaque-byte vault_dir surface plus the text-content ops (``read`` / ``write`` / ``edit``). Every test drives a step directly against a freshly built ``LocalFileStore`` (embedding disabled, BM25 kept) with files seeded on disk (and, where relevant, registered in the graph so retarget's reverse-index lookup finds inbound edges). No app config, no HTTP server — the step's ``vault_path`` defaults to ``cwd()`` and tests chdir into a tmpdir to scope the vault. Covers ``stat`` / ``list`` / ``download`` / ``move`` / ``delete`` plus the text ops ``read`` / ``write`` / ``edit`` (including non-md degraded mode + encoding edge cases). Frontmatter-only ops live in ``test_frontmatter_steps.py``. The ``upload`` step is a passive resource-ingest entry point with its own bucket semantics — tests for it live in ``test_resource_steps.py``. Path-shape contract (enforced by ``read`` / ``write`` / ``edit``): ``path=`` is vault-relative by default. A bare path with no suffix auto-appends ``.md``; non-``.md`` suffix is accepted in degraded mode. Absolute paths are accepted with a warning. """ # pylint: disable=protected-access,redefined-builtin import asyncio import os import tempfile import warnings from pathlib import Path from reme4.components.file_store import LocalFileStore from reme4.schema import FileNode from reme4.steps.file_io import ( delete as crud_delete, edit as crud_edit, list as crud_list, move as crud_move, read as crud_read, stat as crud_stat, write as crud_write, ) from reme4.steps.transfer import download as crud_download from reme4.utils.wikilink_handler import WikilinkHandler warnings.filterwarnings("ignore", category=DeprecationWarning, module="jieba") warnings.filterwarnings("ignore", category=DeprecationWarning, module="pkg_resources") class temp_chdir: """Context manager to temporarily chdir into a path and restore on exit.""" def __init__(self, path): self.path = path self.old = None def __enter__(self): self.old = os.getcwd() os.chdir(self.path) return self def __exit__(self, *exc): os.chdir(self.old) async def _make_store(files: dict[str, str] | None = None) -> LocalFileStore: """LocalFileStore seeded with files on disk + registered in the graph.""" store = LocalFileStore(name="t", embedding_model="") await store.start() nodes: list[FileNode] = [] for rel, content in (files or {}).items(): abs_path = Path.cwd() / rel abs_path.parent.mkdir(parents=True, exist_ok=True) abs_path.write_text(content, encoding="utf-8") nodes.append( FileNode( path=rel, st_mtime=abs_path.stat().st_mtime, links=WikilinkHandler.extract_links(content, rel), ), ) if nodes: await store.file_graph.upsert_nodes(nodes) return store def _metadata(step) -> dict: return step.context.response.metadata def _run(coro): """Run an async coroutine on a fresh isolated event loop.""" asyncio.run(coro) def _seed_md(vault_dir: Path, rel: str, body: str) -> Path: target = vault_dir / rel target.parent.mkdir(parents=True, exist_ok=True) target.write_text(body, encoding="utf-8") return target # =========================================================================== # Direct unit tests: stat / list / download / move / delete # =========================================================================== # -- stat ---------------------------------------------------------------- def test_stat_indexed_file(): """stat returns size, mime, and frontmatter for an indexed .md file.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store({"topics/n.md": "---\nname: T\n---\nbody"}) step = crud_stat.StatStep(file_store=store) await step(path="topics/n.md") payload = _metadata(step) assert payload["exists"] is True assert payload["type"] == "file" assert "size" in payload and payload["size"] > 0 assert payload["mime"].startswith("text/") assert payload["frontmatter"] == {"name": "T"} await store.close() print("✓ test_stat_indexed_file passed") asyncio.run(run()) def test_stat_directory_fallback(): """stat on a non-indexed directory falls back to a plain join + type=dir.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store() (Path(tmp) / "topics").mkdir(parents=True, exist_ok=True) step = crud_stat.StatStep(file_store=store) await step(path="topics") payload = _metadata(step) assert payload["exists"] is True assert payload["type"] == "dir" await store.close() print("✓ test_stat_directory_fallback passed") asyncio.run(run()) # -- list ---------------------------------------------------------------- def test_list_lists_files(): """list returns paths relative to the vault for files under the given directory.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "topics/a.md": "x", "topics/b.md": "y", "topics/sub/c.md": "z", }, ) step = crud_list.ListStep(file_store=store) await step(path="topics", recursive=True) payload = _metadata(step) assert set(payload["items"]) == {"topics/a.md", "topics/b.md", "topics/sub/c.md"} assert payload["count"] == 3 await store.close() print("✓ test_list_lists_files passed") asyncio.run(run()) def test_list_respects_limit_and_non_recursive(): """Non-recursive list ignores subdirs; limit caps the count.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "topics/a.md": "x", "topics/b.md": "y", "topics/sub/c.md": "z", }, ) step = crud_list.ListStep(file_store=store) await step(path="topics", recursive=False, limit=1) payload = _metadata(step) assert payload["count"] == 1 assert len(payload["items"]) == 1 await store.close() print("✓ test_list_respects_limit_and_non_recursive passed") asyncio.run(run()) # -- download ------------------------------------------------------------ def test_download_to_explicit_path(): """download copies the vault file to dst_path.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store({"topics/a.md": "alpha"}) target = Path(tmp) / "out" / "a.md" step = crud_download.DownloadStep(file_store=store) await step(src_path="topics/a.md", dst_path=str(target)) payload = _metadata(step) assert "error" not in payload assert payload["dst_path"] == str(target) assert target.read_text(encoding="utf-8") == "alpha" await store.close() print("✓ test_download_to_explicit_path passed") asyncio.run(run()) def test_download_to_temp_when_dst_path_empty(): """Without dst_path, download lands the file in a temp file and returns the path.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store({"topics/a.md": "alpha"}) step = crud_download.DownloadStep(file_store=store) await step(src_path="topics/a.md") payload = _metadata(step) assert "error" not in payload assert Path(payload["dst_path"]).read_text(encoding="utf-8") == "alpha" await store.close() print("✓ test_download_to_temp_when_dst_path_empty passed") asyncio.run(run()) # -- move ---------------------------------------------------------------- def test_move_relocates_within_vault(): """move renames / relocates a file in place.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store({"daily/2026-05-18/foo/foo.md": "draft"}) step = crud_move.MoveStep(file_store=store) await step(src_path="daily/2026-05-18/foo/foo.md", dst_path="knowledge/foo/foo.md") payload = _metadata(step) assert "error" not in payload assert not (Path(tmp) / "daily/2026-05-18/foo/foo.md").exists() assert (Path(tmp) / "knowledge/foo/foo.md").read_text(encoding="utf-8") == "draft" await store.close() print("✓ test_move_relocates_within_vault passed") asyncio.run(run()) def test_move_refuses_overwrite_without_flag(): """move refuses to clobber an existing dst_path unless overwrite=True.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "a/x.md": "src", "b/x.md": "dst", }, ) step = crud_move.MoveStep(file_store=store) await step(src_path="a/x.md", dst_path="b/x.md") payload = _metadata(step) assert "destination exists" in payload.get("error", "") assert (Path(tmp) / "a/x.md").exists() await store.close() print("✓ test_move_refuses_overwrite_without_flag passed") asyncio.run(run()) def test_move_default_retargets_inbound_links(): """move with retarget=True (default) rewrites inbound full-path [[src_path]] → [[dst_path]].""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "daily/2026-05-18/draft/draft.md": "spec", # only the literal full-path form is retargeted by design; # short / no-ext forms are intentionally left alone. "knowledge/notes/notes.md": ( "see [[daily/2026-05-18/draft/draft.md]] and again " "[[daily/2026-05-18/draft/draft.md]] twice" ), }, ) step = crud_move.MoveStep(file_store=store) await step( src_path="daily/2026-05-18/draft/draft.md", dst_path="knowledge/draft/draft.md", ) payload = _metadata(step) assert "error" not in payload assert payload["retarget"]["files_touched"] == 1 assert payload["retarget"]["links_changed"] == 2 notes = (Path(tmp) / "knowledge/notes/notes.md").read_text(encoding="utf-8") assert "[[daily/2026-05-18/draft/draft.md]]" not in notes assert notes.count("[[knowledge/draft/draft.md]]") == 2 await store.close() print("✓ test_move_default_retargets_inbound_links passed") asyncio.run(run()) def test_move_opt_out_leaves_links_dangling(): """retarget=False moves the file but leaves inbound references stale.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "daily/2026-05-18/draft/draft.md": "spec", "knowledge/notes/notes.md": "see [[daily/2026-05-18/draft/draft.md]]", }, ) step = crud_move.MoveStep(file_store=store) await step( src_path="daily/2026-05-18/draft/draft.md", dst_path="knowledge/draft/draft.md", retarget=False, ) payload = _metadata(step) assert "error" not in payload assert payload["retarget"] is None notes = (Path(tmp) / "knowledge/notes/notes.md").read_text(encoding="utf-8") # link UNCHANGED — caller opted out of retarget assert "[[daily/2026-05-18/draft/draft.md]]" in notes await store.close() print("✓ test_move_opt_out_leaves_links_dangling passed") asyncio.run(run()) # -- delete -------------------------------------------------------------- def test_delete_removes_file(): """delete hard-removes the file.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store({"knowledge/draft/draft.md": "x"}) step = crud_delete.DeleteStep(file_store=store) await step(path="knowledge/draft/draft.md") payload = _metadata(step) assert payload.get("deleted") is True assert not (Path(tmp) / "knowledge/draft/draft.md").exists() await store.close() print("✓ test_delete_removes_file passed") asyncio.run(run()) def test_delete_missing_returns_error(): """delete on a nonexistent path returns error rather than raising.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store() step = crud_delete.DeleteStep(file_store=store) await step(path="knowledge/nope/nope.md") payload = _metadata(step) assert payload["error"] == "not found" await store.close() print("✓ test_delete_missing_returns_error passed") asyncio.run(run()) def test_delete_reports_inbound_refs(): """delete returns the inbound wikilink list (literal full-path matches only).""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "knowledge/target/target.md": "doomed", "knowledge/a/a.md": "see [[knowledge/target/target.md]]", "knowledge/b/b.md": ( "ref [[knowledge/target/target.md]] and again " "[[knowledge/target/target.md]]" ), # short / no-ext forms are NOT counted by design "knowledge/c/c.md": "[[target]] and [[knowledge/target/target]]", }, ) step = crud_delete.DeleteStep(file_store=store) await step(path="knowledge/target/target.md") payload = _metadata(step) assert payload["deleted"] is True assert not (Path(tmp) / "knowledge/target/target.md").exists() # referencing files are untouched — agent decides what to do assert (Path(tmp) / "knowledge/a/a.md").read_text(encoding="utf-8") == ( "see [[knowledge/target/target.md]]" ) inbound = payload["inbound"] paths = {item["path"] for item in inbound["by_file"]} assert paths == {"knowledge/a/a.md", "knowledge/b/b.md"} # a: 1 full-path ref; b: 2 full-path refs; c: not counted assert inbound["files_touched"] == 2 assert inbound["links_total"] == 3 await store.close() print("✓ test_delete_reports_inbound_refs passed") asyncio.run(run()) def test_delete_folder_removes_tree(): """delete on a directory hard-removes the whole subtree.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "scratch/a.md": "alpha", "scratch/sub/b.md": "beta", "scratch/asset.bin": "blob", "keeper/k.md": "kept", }, ) step = crud_delete.DeleteStep(file_store=store) await step(path="scratch") payload = _metadata(step) assert payload["deleted"] is True assert payload["is_dir"] is True assert set(payload["deleted_files"]) == { "scratch/a.md", "scratch/sub/b.md", "scratch/asset.bin", } assert not (Path(tmp) / "scratch").exists() assert (Path(tmp) / "keeper/k.md").exists() await store.close() print("✓ test_delete_folder_removes_tree passed") asyncio.run(run()) def test_delete_folder_reports_only_external_inbound(): """Inbound from inside the doomed folder is suppressed; outside refs surface.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store( { "doomed/a.md": "see [[doomed/b.md]]", # internal — filtered out "doomed/b.md": "see [[doomed/a.md]]", # internal — filtered out "outside/x.md": ("ext [[doomed/a.md]] and [[doomed/a.md]] plus [[doomed/b.md]]"), "outside/y.md": "another [[doomed/a.md]]", }, ) step = crud_delete.DeleteStep(file_store=store) await step(path="doomed") payload = _metadata(step) assert payload["deleted"] is True assert payload["is_dir"] is True assert not (Path(tmp) / "doomed").exists() # outside files survive untouched assert (Path(tmp) / "outside/x.md").exists() inbound = payload["inbound"] # external sources: x.md, y.md (deduped) → 2 files assert inbound["files_touched"] == 2 # 2 refs to doomed/a.md from x + 1 ref from y + 1 ref to b from x = 4 assert inbound["links_total"] == 4 by_target = {row["target"]: row for row in inbound["by_target"]} assert set(by_target) == {"doomed/a.md", "doomed/b.md"} a_sources = {row["path"]: row["count"] for row in by_target["doomed/a.md"]["by_file"]} assert a_sources == {"outside/x.md": 2, "outside/y.md": 1} b_sources = {row["path"]: row["count"] for row in by_target["doomed/b.md"]["by_file"]} assert b_sources == {"outside/x.md": 1} await store.close() print("✓ test_delete_folder_reports_only_external_inbound passed") asyncio.run(run()) def test_delete_folder_empty_has_no_inbound(): """Empty folder delete reports zero deleted files and zero inbound.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): (Path(tmp) / "empty").mkdir() store = await _make_store() step = crud_delete.DeleteStep(file_store=store) await step(path="empty") payload = _metadata(step) assert payload["deleted"] is True assert payload["is_dir"] is True assert payload["deleted_files"] == [] assert payload["inbound"]["files_touched"] == 0 assert payload["inbound"]["links_total"] == 0 assert not (Path(tmp) / "empty").exists() await store.close() print("✓ test_delete_folder_empty_has_no_inbound passed") asyncio.run(run()) # =========================================================================== # Direct unit tests: read / write / edit # =========================================================================== # -- read ---------------------------------------------------------------- async def _read(store: LocalFileStore, **kwargs): """Run a ReadStep against ``store`` and return its response.""" step = crud_read.ReadStep(file_store=store) await step(**kwargs) return step.context.response async def _write(store: LocalFileStore, **kwargs): """Run a WriteStep against ``store`` and return its response.""" step = crud_write.WriteStep(file_store=store) await step(**kwargs) return step.context.response async def _edit(store: LocalFileStore, **kwargs): """Run an EditStep against ``store`` and return its response.""" step = crud_edit.EditStep(file_store=store) await step(**kwargs) return step.context.response def test_read_relative_path(): """`read path=Templates/Recipe.md` returns the file body from the vault.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): body = "# Recipe\n\nMix flour and water.\n" _seed_md(Path(tmp), "Templates/Recipe.md", body) store = await _make_store() resp = await _read(store, path="Templates/Recipe.md") assert resp.success is True assert "# Recipe" in str(resp.answer) assert "flour and water" in str(resp.answer) await store.close() print("✓ test_read_relative_path passed") _run(run()) def test_read_no_suffix_autoappends_md(): """A bare path with no suffix auto-appends `.md`.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): _seed_md(Path(tmp), "Templates/Recipe.md", "auto-md\n") store = await _make_store() resp = await _read(store, path="Templates/Recipe") assert resp.success is True assert "auto-md" in str(resp.answer) await store.close() print("✓ test_read_no_suffix_autoappends_md passed") _run(run()) def test_read_line_range(): """start_line / end_line slice the file 1-based, inclusive.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): _seed_md(Path(tmp), "Notes.md", "L1\nL2\nL3\nL4\nL5\n") store = await _make_store() resp = await _read(store, path="Notes.md", start_line=2, end_line=4) assert resp.success is True assert "L2" in str(resp.answer) assert "L3" in str(resp.answer) assert "L4" in str(resp.answer) assert "L1" not in str(resp.answer) assert "L5" not in str(resp.answer) await store.close() print("✓ test_read_line_range passed") _run(run()) def test_read_absolute_path_accepted(): """Absolute paths are accepted (a log warning is emitted but the read proceeds).""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): target = _seed_md(Path(tmp), "Abs.md", "x\n") store = await _make_store() resp = await _read(store, path=str(target.resolve())) assert resp.success is True assert "x" in str(resp.answer) await store.close() print("✓ test_read_absolute_path_accepted passed") _run(run()) def test_read_non_md_degraded(): """Paths whose suffix is not `.md` are read in compatibility mode.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): _seed_md(Path(tmp), "data/foo.txt", "plain-text body\n") store = await _make_store() resp = await _read(store, path="data/foo.txt") assert resp.success is True assert "plain-text body" in str(resp.answer) await store.close() print("✓ test_read_non_md_degraded passed") _run(run()) def test_read_missing_file(): """Reading a non-existent file should fail with a clear error.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store() resp = await _read(store, path="NotThere.md") assert resp.success is False assert "does not exist" in str(resp.answer).lower() await store.close() print("✓ test_read_missing_file passed") _run(run()) def test_read_start_after_end(): """start_line > end_line is invalid.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): _seed_md(Path(tmp), "Range.md", "a\nb\nc\n") store = await _make_store() resp = await _read(store, path="Range.md", start_line=3, end_line=1) assert resp.success is False assert "start_line" in str(resp.answer) await store.close() print("✓ test_read_start_after_end passed") _run(run()) def test_read_start_line_exceeds_total(): """start_line beyond total line count is invalid.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): _seed_md(Path(tmp), "Short.md", "only-one-line\n") store = await _make_store() resp = await _read(store, path="Short.md", start_line=99) assert resp.success is False assert "exceeds" in str(resp.answer).lower() await store.close() print("✓ test_read_start_line_exceeds_total passed") _run(run()) def test_read_truncation(): """A file larger than DEFAULT_MAX_BYTES triggers truncation with a continuation notice.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): # Seed > DEFAULT_MAX_BYTES (50 KiB) so the default truncation kicks in. body = "\n".join(f"line {i}" for i in range(8000)) + "\n" _seed_md(Path(tmp), "Big.md", body) store = await _make_store() resp = await _read(store, path="Big.md") assert resp.success is True assert "truncated" in str(resp.answer) assert "start_line=" in str(resp.answer) await store.close() print("✓ test_read_truncation passed") _run(run()) def test_read_empty_path_rejected(): """An empty `path` should be rejected.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store() resp = await _read(store, path="") assert resp.success is False assert "required" in str(resp.answer).lower() await store.close() print("✓ test_read_empty_path_rejected passed") _run(run()) # -- write / edit -------------------------------------------------------- def test_write_basic_with_frontmatter(): """`write path=... name=... description=... content=...` writes a YAML front matter block.""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store() resp = await _write( store, path="Notes/A.md", name="Greetings", description="a friendly hello note", content="# Hello", ) assert resp.success is True assert "Wrote" in str(resp.answer) on_disk = (Path(tmp) / "Notes/A.md").read_text(encoding="utf-8") assert on_disk.startswith("---\n"), on_disk assert "name: Greetings" in on_disk assert "description: a friendly hello note" in on_disk assert "# Hello" in on_disk await store.close() print("✓ test_write_basic_with_frontmatter passed") _run(run()) def test_write_rejects_invalid_path_components(): """`resolve_path` validates each segment with the same rules as daily-note slugs: Windows reserved chars / device names / trailing-dot (also blocks `..` traversal).""" async def run(): with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): store = await _make_store() for bad in ( "CON.md", # Windows-reserved device name (with extension) "Notes/AUX", # device name in a sub-segment "Notes/foo