From c4a287c464fdc78b4bf08401cc21a75bf748b91e Mon Sep 17 00:00:00 2001 From: worldmozara Date: Wed, 21 Oct 2026 20:19:54 +0800 Subject: [PATCH] core/config: add the [trash] section `[trash]` owns the one policy `delete_path` needs from the human: whether a delete is a move to the desktop's trash (`enabled = true`, the default) or a removal that cannot be undone (`false`, which is what the tool did before). Unknown keys degrade to the defaults, the way `[mcp]` does, so a typo cannot silently switch a destructive policy on. `reload()` re-reads the section. --- src/plyngent/config/__init__.py | 1 + src/plyngent/config/models.py | 15 +++++++++ src/plyngent/config/store.py | 27 ++++++++++++++++ tests/test_config/test_trash_section.py | 42 +++++++++++++++++++++++++ 4 files changed, 85 insertions(+) create mode 100644 tests/test_config/test_trash_section.py diff --git a/src/plyngent/config/__init__.py b/src/plyngent/config/__init__.py index e35ea07..fc1013b 100644 --- a/src/plyngent/config/__init__.py +++ b/src/plyngent/config/__init__.py @@ -25,6 +25,7 @@ from .models import ProviderConfig as ProviderConfig from .models import ReasoningEffortConfig as ReasoningEffortConfig from .models import SkillDiscoverySource as SkillDiscoverySource from .models import SkillsConfig as SkillsConfig +from .models import TrashConfig as TrashConfig from .models import compose_agent_system_content as compose_agent_system_content from .path import get_default_path as _get_default_path from .reasoning import ReasoningConfig as ReasoningConfig diff --git a/src/plyngent/config/models.py b/src/plyngent/config/models.py index 9d471a4..68cb3f9 100644 --- a/src/plyngent/config/models.py +++ b/src/plyngent/config/models.py @@ -304,6 +304,21 @@ class NotifyConfig(Struct, omit_defaults=True): protocol: str = "" +class TrashConfig(Struct, omit_defaults=True): + """Whether a delete lands in the system trash or is final. + + ``enabled`` (the default) sends every path ``delete_path`` removes to the + desktop's own trash — the freedesktop trash on Linux, the Finder trash on + macOS, the Recycle Bin on Windows — so a deletion can be undone there. + ``false`` restores the old behaviour of removing the path outright. + + Scratch paths under the OS temporary directory, and a call that passes + ``permanent=true``, are deleted for good either way. + """ + + enabled: bool = True + + class ModelConfig(Struct, omit_defaults=True): """Capability flags and optional routing overrides for a model. diff --git a/src/plyngent/config/store.py b/src/plyngent/config/store.py index f252cf2..fa5603e 100644 --- a/src/plyngent/config/store.py +++ b/src/plyngent/config/store.py @@ -16,6 +16,7 @@ from .models import ( PluginsConfig, Provider, SkillsConfig, + TrashConfig, ) if TYPE_CHECKING: @@ -83,6 +84,22 @@ def _parse_notify(raw: dict[str, object]) -> NotifyConfig: return config +def _parse_trash(raw: dict[str, object]) -> TrashConfig: + """Parse the ``[trash]`` section, falling back to defaults. + + Unknown keys are rejected like ``[mcp]`` does: a typo leaves deletes going + to the trash instead of half-applying a policy about destructive ones. + """ + try: + config = msgspec.convert(raw, TrashConfig) + except msgspec.ValidationError: + return TrashConfig() + known = set(TrashConfig.__struct_fields__) + if set(raw) - known: + return TrashConfig() + return config + + def _parse_mcp(raw: dict[str, object]) -> McpConfig: """Parse the ``[mcp]`` section, falling back to defaults. @@ -178,6 +195,7 @@ class ConfigStore: _mcp: McpConfig _skills: SkillsConfig _notify: NotifyConfig + _trash: TrashConfig _providers: dict[str, Provider] _bad_providers: dict[str, object] _recoverable_providers: dict[str, Provider] @@ -193,6 +211,7 @@ class ConfigStore: self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {}))) self._skills = _parse_skills(cast("dict[str, object]", raw.get("skills", {}))) self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {}))) + self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(document) @property @@ -264,6 +283,13 @@ class ConfigStore: """Typed notifications section (grace, channels, actions).""" return self._notify + # -- trash (read-only) -- + + @property + def trash_config(self) -> TrashConfig: + """Typed trash section (whether a delete can be undone).""" + return self._trash + def set_plugins_enable(self, names: Sequence[str]) -> PluginsConfig: """Replace the plugin enable list (in-memory; call :meth:`write` to persist).""" cleaned = [name.strip() for name in names if name.strip()] @@ -469,6 +495,7 @@ class ConfigStore: self._networking = _parse_networking(cast("dict[str, object]", raw.get("networking", {}))) self._mcp = _parse_mcp(cast("dict[str, object]", raw.get("mcp", {}))) self._notify = _parse_notify(cast("dict[str, object]", raw.get("notify", {}))) + self._trash = _parse_trash(cast("dict[str, object]", raw.get("trash", {}))) self._providers, self._bad_providers, self._recoverable_providers = _parse_providers(self._document) # -- internal sync helpers -- diff --git a/tests/test_config/test_trash_section.py b/tests/test_config/test_trash_section.py new file mode 100644 index 0000000..0b4ab36 --- /dev/null +++ b/tests/test_config/test_trash_section.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from plyngent.config import load + +if TYPE_CHECKING: + from pathlib import Path + + +def test_trash_section_defaults(tmp_path: Path) -> None: + """Deletes go to the system trash unless the section says otherwise.""" + path = tmp_path / "c.toml" + _ = path.write_text("", encoding="utf-8") + assert load(path).trash_config.enabled is True + + +def test_trash_section_parse(tmp_path: Path) -> None: + path = tmp_path / "c.toml" + _ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8") + assert load(path).trash_config.enabled is False + + +def test_trash_section_typo_falls_back(tmp_path: Path) -> None: + """An unknown key or a bad type degrades to the defaults, like ``[mcp]``.""" + path = tmp_path / "c.toml" + _ = path.write_text("[trash]\nenable = false\n", encoding="utf-8") + assert load(path).trash_config.enabled is True + + path2 = tmp_path / "d.toml" + _ = path2.write_text('[trash]\nenabled = "no"\n', encoding="utf-8") + assert load(path2).trash_config.enabled is True + + +def test_trash_section_survives_reload(tmp_path: Path) -> None: + path = tmp_path / "c.toml" + _ = path.write_text("[trash]\nenabled = false\n", encoding="utf-8") + store = load(path) + assert store.trash_config.enabled is False + _ = path.write_text("[trash]\nenabled = true\n", encoding="utf-8") + store.reload() + assert store.trash_config.enabled is True