diff --git a/reme/steps/file_io/_path.py b/reme/steps/file_io/_path.py index e426ac34..d5d217b7 100644 --- a/reme/steps/file_io/_path.py +++ b/reme/steps/file_io/_path.py @@ -66,6 +66,17 @@ def is_relative_to(path: Path, parent: Path) -> bool: return False +def display_path(workspace_path: Path, target: Path) -> str: + """Return ``target`` as a workspace-relative POSIX path for user-facing messages. + + Falls back to the absolute string when ``target`` is not under ``workspace_path``. + """ + try: + return target.relative_to(workspace_path).as_posix() + except ValueError: + return str(target) + + # pylint: disable=too-many-return-statements def resolve_path( workspace_path: Path, diff --git a/reme/steps/file_io/frontmatter_delete.py b/reme/steps/file_io/frontmatter_delete.py index 748ac2aa..5a1f1876 100644 --- a/reme/steps/file_io/frontmatter_delete.py +++ b/reme/steps/file_io/frontmatter_delete.py @@ -14,7 +14,7 @@ from pathlib import Path import frontmatter from ._file_io import get_path_lock -from ._path import resolve_path +from ._path import display_path, gate_md, resolve_path from ..base_step import BaseStep from ...components import R @@ -37,11 +37,13 @@ class FrontmatterDeleteStep(BaseStep): if err or target is None: payload: dict = {"path": path, "error": err or "invalid path"} else: + original_target = target + target, is_md = gate_md(target) lock = await get_path_lock(target) async with lock: if not target.is_file(): - payload = {"path": path, "error": "not found"} - elif target.suffix != ".md": + payload = {"path": path, "error": f"{display_path(workspace_dir, target)} not found"} + elif not is_md: payload = {"path": path, "error": "not markdown"} elif not keys: payload = {"path": path, "error": "keys is empty"} @@ -63,6 +65,8 @@ class FrontmatterDeleteStep(BaseStep): "missing": missing, "frontmatter": dict(post.metadata), } + if target != original_target: + payload["resolved_path"] = display_path(workspace_dir, target) if "error" in payload: self.context.response.success = False diff --git a/reme/steps/file_io/frontmatter_read.py b/reme/steps/file_io/frontmatter_read.py index 3da9f4eb..0b127986 100644 --- a/reme/steps/file_io/frontmatter_read.py +++ b/reme/steps/file_io/frontmatter_read.py @@ -13,7 +13,7 @@ from pathlib import Path import frontmatter import yaml -from ._path import resolve_path +from ._path import display_path, gate_md, resolve_path from ..base_step import BaseStep from ...components import R @@ -35,13 +35,19 @@ class FrontmatterReadStep(BaseStep): self.context.response.metadata.update({"path": path, "exists": False, "error": err or "invalid path"}) self.logger.info(f"[{self.name}] path={path} error={err!r}") return + original_target = target + target, is_md = gate_md(target) + resolved: dict = {} + if target != original_target: + resolved["resolved_path"] = display_path(workspace_dir, target) + probed = resolved.get("resolved_path", path) if not target.is_file(): self.context.response.success = False - self.context.response.answer = f"Error: {path} not found" - self.context.response.metadata.update({"path": path, "exists": False}) + self.context.response.answer = f"Error: {probed} not found" + self.context.response.metadata.update({"path": path, "exists": False, **resolved}) self.logger.info(f"[{self.name}] path={path} exists=False") return - if target.suffix != ".md": + if not is_md: self.context.response.success = False self.context.response.answer = "Error: not markdown" self.context.response.metadata.update({"path": path, "error": "not markdown"}) @@ -52,11 +58,11 @@ class FrontmatterReadStep(BaseStep): meta = dict(frontmatter.loads(target.read_text(encoding="utf-8")).metadata) except yaml.YAMLError as exc: self.context.response.success = False - self.context.response.answer = f"Error: failed to parse frontmatter in {path}: {exc}" - self.context.response.metadata.update({"path": path, "exists": True, "error": str(exc)}) + self.context.response.answer = f"Error: failed to parse frontmatter in {probed}: {exc}" + self.context.response.metadata.update({"path": path, "exists": True, "error": str(exc), **resolved}) self.logger.info(f"[{self.name}] path={path} parse_error={exc!r}") return self.context.response.success = True self.context.response.answer = f"Read frontmatter from {path} ({len(meta)} key(s))" - self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta}) + self.context.response.metadata.update({"path": path, "exists": True, "frontmatter": meta, **resolved}) self.logger.info(f"[{self.name}] path={path} keys={len(meta)}") diff --git a/reme/steps/file_io/frontmatter_update.py b/reme/steps/file_io/frontmatter_update.py index 3e9cf910..cc4b8f2d 100644 --- a/reme/steps/file_io/frontmatter_update.py +++ b/reme/steps/file_io/frontmatter_update.py @@ -17,7 +17,7 @@ from pathlib import Path import frontmatter from ._file_io import get_path_lock -from ._path import _check_path_permission, resolve_path +from ._path import _check_path_permission, display_path, gate_md, resolve_path from ..base_step import BaseStep from ...components import R @@ -43,22 +43,27 @@ class FrontmatterUpdateStep(BaseStep): target, err = resolve_path(workspace_dir, path) if err or target is None: payload: dict = {"path": path, "error": err or "invalid path"} - elif not _check_path_permission(workspace_dir, target, self.context.get("_allowed_paths")): - payload = {"path": path, "error": "no permission to update this file"} else: - lock = await get_path_lock(target) - async with lock: - if not target.is_file(): - payload = {"path": path, "error": "not found"} - elif target.suffix != ".md": - payload = {"path": path, "error": "not markdown"} - elif not metadata: - payload = {"path": path, "error": "no fields to update"} - else: - post = frontmatter.loads(target.read_text(encoding="utf-8")) - post.metadata.update(metadata) - target.write_text(frontmatter.dumps(post), encoding="utf-8") - payload = {"path": path, "updated": metadata} + original_target = target + target, is_md = gate_md(target) + if not _check_path_permission(workspace_dir, target, self.context.get("_allowed_paths")): + payload = {"path": path, "error": "no permission to update this file"} + else: + lock = await get_path_lock(target) + async with lock: + if not target.is_file(): + payload = {"path": path, "error": f"{display_path(workspace_dir, target)} not found"} + elif not is_md: + payload = {"path": path, "error": "not markdown"} + elif not metadata: + payload = {"path": path, "error": "no fields to update"} + else: + post = frontmatter.loads(target.read_text(encoding="utf-8")) + post.metadata.update(metadata) + target.write_text(frontmatter.dumps(post), encoding="utf-8") + payload = {"path": path, "updated": metadata} + if target != original_target: + payload["resolved_path"] = display_path(workspace_dir, target) if "error" in payload: self.context.response.success = False diff --git a/reme/steps/file_io/stat.py b/reme/steps/file_io/stat.py index 06ba4d92..4ef31dd4 100644 --- a/reme/steps/file_io/stat.py +++ b/reme/steps/file_io/stat.py @@ -24,7 +24,7 @@ from pathlib import Path import frontmatter -from ._path import resolve_path +from ._path import display_path, gate_md, resolve_path from ..base_step import BaseStep from ...components import R @@ -46,10 +46,24 @@ class StatStep(BaseStep): self.context.response.metadata.update({"path": path, "exists": False, "error": err or "invalid path"}) self.logger.info(f"[{self.name}] path={path} error={err!r}") return + # Auto-append .md for suffix-less paths (consistent with edit). An + # existing directory wins top priority: the daily layout keeps + # // and its /.md index side by + # side, and directory probing is a supported use of this step. + # Otherwise fall back to the original when the .md candidate is + # missing but the original exists (e.g., an extension-less file). + original_target = target + target, _is_md = gate_md(target) + if original_target.is_dir() or (not target.exists() and original_target.exists()): + target = original_target if not target.exists(): + probed = display_path(workspace_dir, target) self.context.response.success = False - self.context.response.answer = f"stat: {path} not found" - self.context.response.metadata.update({"path": path, "exists": False}) + self.context.response.answer = f"stat: {probed} not found" + not_found: dict = {"path": path, "exists": False} + if target != original_target: + not_found["resolved_path"] = probed + self.context.response.metadata.update(not_found) self.logger.info(f"[{self.name}] path={path} exists=False") return @@ -62,6 +76,8 @@ class StatStep(BaseStep): "mtime": datetime.fromtimestamp(st.st_mtime).isoformat(), "ctime": datetime.fromtimestamp(st.st_ctime).isoformat(), } + if target != original_target: + payload["resolved_path"] = display_path(workspace_dir, target) if target.is_file(): payload["size"] = st.st_size is_md = target.suffix.lower() == ".md" diff --git a/tests/unit/test_crud_steps.py b/tests/unit/test_crud_steps.py index b0484aaf..498424bb 100644 --- a/tests/unit/test_crud_steps.py +++ b/tests/unit/test_crud_steps.py @@ -150,6 +150,107 @@ def test_stat_directory_fallback(): asyncio.run(run()) +def test_stat_directory_wins_over_same_name_index(): + """stat prefers an existing directory over its same-name ``.md`` index. + + The daily layout keeps ``//`` and its + ``/.md`` index side by side; probing the bare date + must report the directory, while the explicit ``.md`` still hits the file. + """ + + async def run(): + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + store = await _make_store( + { + "daily/2026-08-10/note.md": "---\nname: n\n---\nbody", + "daily/2026-08-10.md": "---\nname: index\n---\nindex body", + }, + ) + step = crud_stat.StatStep(file_store=store) + await step(path="daily/2026-08-10") + payload = _metadata(step) + assert payload["exists"] is True + assert payload["type"] == "dir" + assert payload["absolute_path"].endswith("2026-08-10") + assert "resolved_path" not in payload + + step = crud_stat.StatStep(file_store=store) + await step(path="daily/2026-08-10.md") + payload = _metadata(step) + assert payload["exists"] is True + assert payload["type"] == "file" + assert payload["frontmatter"] == {"name": "index"} + await store.close() + print("✓ test_stat_directory_wins_over_same_name_index passed") + + asyncio.run(run()) + + +def test_stat_shadowed_extensionless_reports_resolved_path(): + """When both ``LICENSE`` and ``LICENSE.md`` exist, ``stat path=LICENSE`` + resolves to the ``.md`` but discloses the substitution via ``resolved_path``. + """ + + async def run(): + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + (Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8") + (Path(tmp) / "LICENSE.md").write_text("y" * 12, encoding="utf-8") + store = await _make_store() + step = crud_stat.StatStep(file_store=store) + await step(path="LICENSE") + payload = _metadata(step) + assert payload["exists"] is True + assert payload["type"] == "file" + assert payload["path"] == "LICENSE" + assert payload["size"] == 12 + assert payload["mime"] == "text/markdown" + assert payload["absolute_path"].endswith("LICENSE.md") + assert payload["resolved_path"] == "LICENSE.md" + await store.close() + print("✓ test_stat_shadowed_extensionless_reports_resolved_path passed") + + asyncio.run(run()) + + +def test_stat_extensionless_file_without_md_sibling(): + """An extension-less file with no ``.md`` sibling is reported as-is.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + (Path(tmp) / "LICENSE").write_text("x" * 15, encoding="utf-8") + store = await _make_store() + step = crud_stat.StatStep(file_store=store) + await step(path="LICENSE") + payload = _metadata(step) + assert payload["exists"] is True + assert payload["type"] == "file" + assert payload["size"] == 15 + assert payload["absolute_path"].endswith("LICENSE") + assert "resolved_path" not in payload + await store.close() + print("✓ test_stat_extensionless_file_without_md_sibling passed") + + asyncio.run(run()) + + +def test_stat_missing_reports_probed_md_path(): + """stat on a missing suffix-less path reports the probed ``.md`` candidate.""" + + async def run(): + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + store = await _make_store() + step = crud_stat.StatStep(file_store=store) + await step(path="digest/missing") + payload = _metadata(step) + assert payload["exists"] is False + assert payload["resolved_path"] == "digest/missing.md" + assert "digest/missing.md" in str(step.context.response.answer) + await store.close() + print("✓ test_stat_missing_reports_probed_md_path passed") + + asyncio.run(run()) + + # -- list ---------------------------------------------------------------- @@ -1320,6 +1421,10 @@ if __name__ == "__main__": # stat / list / move / delete test_stat_indexed_file() test_stat_directory_fallback() + test_stat_directory_wins_over_same_name_index() + test_stat_shadowed_extensionless_reports_resolved_path() + test_stat_extensionless_file_without_md_sibling() + test_stat_missing_reports_probed_md_path() test_list_lists_files() test_list_respects_limit_and_non_recursive() test_move_relocates_within_workspace() diff --git a/tests/unit/test_frontmatter_steps.py b/tests/unit/test_frontmatter_steps.py new file mode 100644 index 00000000..1ea9a6ef --- /dev/null +++ b/tests/unit/test_frontmatter_steps.py @@ -0,0 +1,194 @@ +"""Tests for the frontmatter-only CRUD steps (``frontmatter_read`` / +``frontmatter_update`` / ``frontmatter_delete``). + +Covers the suffix-less path contract shared with ``read`` / ``write`` / +``edit``: a bare path with no suffix auto-appends ``.md``. All three +siblings must agree on path handling, disclose the substitution via +``resolved_path`` in the response metadata, and report the actually +probed path in not-found errors instead of the caller's raw input. +""" + +# pylint: disable=protected-access + +import os +import tempfile +from pathlib import Path + +import pytest + +from reme.components.file_store import LocalFileStore +from reme.steps.file_io.frontmatter_delete import FrontmatterDeleteStep +from reme.steps.file_io.frontmatter_read import FrontmatterReadStep +from reme.steps.file_io.frontmatter_update import FrontmatterUpdateStep + + +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) + + +def _seed(workspace: Path, rel: str, body: str) -> Path: + target = workspace / rel + target.parent.mkdir(parents=True, exist_ok=True) + target.write_text(body, encoding="utf-8") + return target + + +async def _make_store() -> LocalFileStore: + store = LocalFileStore(name="t_fm", embedding_store="") + await store.start() + return store + + +async def _run(step_cls, store: LocalFileStore, **kwargs): + step = step_cls(file_store=store) + await step(**kwargs) + return step.context.response + + +NOTE = "notes/post.md" +BODY = "---\nname: n\ntags:\n- a\n---\nbody\n" + + +@pytest.mark.asyncio +async def test_read_no_suffix_autoappends_md(): + """frontmatter_read on a suffix-less path resolves to the ``.md`` file.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), NOTE, BODY) + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path="notes/post") + assert resp.success is True + assert resp.metadata["frontmatter"] == {"name": "n", "tags": ["a"]} + assert resp.metadata["path"] == "notes/post" + assert resp.metadata["resolved_path"] == "notes/post.md" + await store.close() + + +@pytest.mark.asyncio +async def test_update_no_suffix_autoappends_md(): + """frontmatter_update on a suffix-less path resolves to the ``.md`` file.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + note = _seed(Path(tmp), NOTE, BODY) + store = await _make_store() + resp = await _run(FrontmatterUpdateStep, store, path="notes/post", metadata={"name": "renamed"}) + assert resp.success is True + assert resp.metadata["updated"] == {"name": "renamed"} + assert resp.metadata["resolved_path"] == "notes/post.md" + assert "name: renamed" in note.read_text(encoding="utf-8") + await store.close() + + +@pytest.mark.asyncio +async def test_delete_no_suffix_autoappends_md(): + """frontmatter_delete on a suffix-less path resolves to the ``.md`` file.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + note = _seed(Path(tmp), NOTE, BODY) + store = await _make_store() + resp = await _run(FrontmatterDeleteStep, store, path="notes/post", keys=["tags"]) + assert resp.success is True + assert resp.metadata["deleted"] == ["tags"] + assert resp.metadata["missing"] == [] + assert resp.metadata["resolved_path"] == "notes/post.md" + assert "tags" not in note.read_text(encoding="utf-8") + await store.close() + + +@pytest.mark.asyncio +async def test_family_consistent_on_suffixless_path(): + """read / update / delete all succeed on the same suffix-less path.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), NOTE, BODY) + store = await _make_store() + + resp = await _run(FrontmatterReadStep, store, path="notes/post") + assert resp.success is True + + resp = await _run(FrontmatterUpdateStep, store, path="notes/post", metadata={"status": "done"}) + assert resp.success is True + + resp = await _run(FrontmatterDeleteStep, store, path="notes/post", keys=["status"]) + assert resp.success is True + assert resp.metadata["deleted"] == ["status"] + await store.close() + + +@pytest.mark.asyncio +async def test_read_missing_reports_probed_path(): + """not-found errors surface the probed ``.md`` path, not the raw input.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path="digest/missing") + assert resp.success is False + assert resp.metadata["exists"] is False + assert resp.metadata["path"] == "digest/missing" + assert resp.metadata["resolved_path"] == "digest/missing.md" + assert "digest/missing.md" in str(resp.answer) + await store.close() + + +@pytest.mark.asyncio +async def test_update_missing_reports_probed_path(): + """frontmatter_update not-found errors mention the probed ``.md`` path.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + store = await _make_store() + resp = await _run(FrontmatterUpdateStep, store, path="digest/missing", metadata={"x": 1}) + assert resp.success is False + assert resp.metadata["error"] == "digest/missing.md not found" + assert resp.metadata["resolved_path"] == "digest/missing.md" + assert "digest/missing.md" in str(resp.answer) + await store.close() + + +@pytest.mark.asyncio +async def test_delete_missing_reports_probed_path(): + """frontmatter_delete not-found errors mention the probed ``.md`` path.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + store = await _make_store() + resp = await _run(FrontmatterDeleteStep, store, path="digest/missing", keys=["x"]) + assert resp.success is False + assert resp.metadata["error"] == "digest/missing.md not found" + assert resp.metadata["resolved_path"] == "digest/missing.md" + assert "digest/missing.md" in str(resp.answer) + await store.close() + + +@pytest.mark.asyncio +async def test_non_md_rejected_without_resolved_path(): + """Non-markdown targets are rejected by all three siblings; no substitution.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), "notes/data.txt", "plain\n") + store = await _make_store() + + for step_cls, kwargs in ( + (FrontmatterReadStep, {}), + (FrontmatterUpdateStep, {"metadata": {"x": 1}}), + (FrontmatterDeleteStep, {"keys": ["x"]}), + ): + resp = await _run(step_cls, store, path="notes/data.txt", **kwargs) + assert resp.success is False + assert "not markdown" in str(resp.answer).lower() + assert "resolved_path" not in resp.metadata + await store.close() + + +@pytest.mark.asyncio +async def test_explicit_md_path_has_no_resolved_path(): + """Explicit ``.md`` paths need no substitution, so no ``resolved_path``.""" + with tempfile.TemporaryDirectory() as tmp, temp_chdir(tmp): + _seed(Path(tmp), NOTE, BODY) + store = await _make_store() + resp = await _run(FrontmatterReadStep, store, path=NOTE) + assert resp.success is True + assert "resolved_path" not in resp.metadata + await store.close()