diff --git a/docs/tickets/features/FR-00002-TD.md b/docs/tickets/features/FR-00002-TD.md new file mode 100644 index 0000000..35ab3f2 --- /dev/null +++ b/docs/tickets/features/FR-00002-TD.md @@ -0,0 +1,45 @@ +# Technical Document: FR-00002 — Add self-contained CLI to cache package + +## Overview + +The cache package currently has no CLI and its `CacheSettings` model only carries `namespace` and `strict` — it has no awareness of which storage backend to use or where it lives. Any consumer must manually resolve storage settings, create a backend, and wire it to `Cache`. This ticket introduces a `core-cache` CLI with a single `--config` flag that accepts a self-contained settings file covering both cache behaviour and the storage backend. The key design change is embedding `StorageSettings` directly inside `CacheSettings`, making the cache's configuration model fully self-contained. + +## Approach + +`CacheSettings` gains a `storage: StorageSettings` field (imported from `core_storage`). This means a single resolved `CacheSettings` instance carries everything needed to build a wired `Cache`: the namespace, the strict flag, and the full backend definition. No second resolver call, no second config file. + +A `defaults.toml` is added to the cache package (currently missing) to establish the baseline layer. The cache `get_settings()` resolver gains a `config_file: Path | None` parameter — identical to what was added to storage in FR-00001 — which threads through to `LayerDiscovery.discover_layers(explicit_config=...)`. + +The cache CLI callback resolves `CacheSettings` once, then wires: +``` +backend = create_backend(cache_settings.storage) +store = Store(backend).namespace(cache_settings.namespace) +cache = Cache(store, strict=cache_settings.strict) +``` + +The resulting `Cache` is placed on the Typer context and consumed by each subcommand. + +## Architecture / Design Decisions + +- **Embed `StorageSettings` in `CacheSettings`, not a path pointer**: a path pointer (`storage_config: Path`) would require a second file and a second resolver call at runtime. Embedding the model keeps the config surface to a single file and a single resolved object. +- **No per-invocation backend override flags on the cache CLI**: the cache CLI is intentionally simpler than the storage CLI. Backend configuration belongs in the config file; the only CLI-level knob is which config file to use. +- **Reuse `LayerDiscovery` / `ConfigBuilder` from storage**: no duplication. The cache resolver passes its own `APP_NAME`, `ENV_PREFIX`, and `CacheSettings` model type into the same machinery. +- **`config_file` param mirrors FR-00001 exactly**: consistent API across both packages. When provided, project-root discovery is skipped; explicit config sits above XDG user config but below env vars. + +## Implementation Plan + +1. Add `storage: StorageSettings = Field(default_factory=StorageSettings)` to `CacheSettings` in `packages/cache/src/core_cache/settings/models.py`. +2. Create `packages/cache/src/core_cache/settings/defaults.toml` with SQLite backend defaults pointing to `~/.local/share/core-cache/cache.db`. +3. Add `config_file: Path | None = None` to `get_settings()` in `packages/cache/src/core_cache/settings/resolver.py`; pass it as `explicit_config` to `LayerDiscovery.discover_layers()`. +4. Create `packages/cache/src/core_cache/cli/__init__.py` with a Typer app, a `configure_cache` callback (single `--config`/`-c` option), and subcommands: `lookup`, `invalidate`, `clear`. +5. Register the `core-cache` entry point in `packages/cache/pyproject.toml`. + +## Testing Strategy + +- Unit tests for the updated `CacheSettings`: `storage` field present, defaults resolve to SQLite, embedded storage settings are validated by Pydantic. +- Unit tests for the updated `get_settings()`: `config_file` is applied, beats project-root, env vars still override it. +- CLI tests: `--config` with a valid file exits 0 and uses embedded storage settings; `--config` with a nonexistent path exits non-zero; omitting `--config` falls back to layered resolution. + +## Risks / Unknowns + +- None. The change is additive: `CacheSettings` gains a new field with a default, so existing library consumers that construct `CacheSettings()` directly are unaffected. diff --git a/docs/tickets/features/FR-00002-US.md b/docs/tickets/features/FR-00002-US.md new file mode 100644 index 0000000..9742c5b --- /dev/null +++ b/docs/tickets/features/FR-00002-US.md @@ -0,0 +1,28 @@ +# User Story: FR-00002 — Add self-contained CLI to cache package + +## Story + +As a developer using the core-cache package, I want a `core-cache` CLI with a single `--config` flag pointing to a cache-owned settings file so that I can operate the cache (lookup, invalidate, clear) without needing to manage storage configuration separately — everything the cache needs, including the backend path, lives in one file. + +## Acceptance Criteria + +- [ ] The `core-cache` CLI is available as an entry point after installing the package. +- [ ] The CLI accepts a `--config ` / `-c ` flag pointing to a TOML settings file. +- [ ] The settings file is self-contained: it holds both cache settings (`namespace`, `strict`) and storage backend settings (`backend.type`, `backend.sqlite.path`, etc.). +- [ ] `CacheSettings` embeds `StorageSettings` so the full configuration is expressed in a single model. +- [ ] A `defaults.toml` is shipped with the package providing sensible defaults (SQLite backend, `~/.local/share/core-cache/cache.db`). +- [ ] When `--config` is provided, project-root auto-discovery is skipped and the explicit file is used above the XDG user config (`~/.config/core-cache/settings.toml`). +- [ ] When `--config` is not provided, the existing layered resolution (package defaults → project root → XDG user config → env vars) is used. +- [ ] If the path passed to `--config` does not exist, the CLI exits with a non-zero code and a clear error message. +- [ ] All existing `Cache` / `CacheSettings` public API remains unchanged for library consumers. + +## Out of Scope + +- Supporting multiple `--config` files in a single invocation. +- Adding per-invocation backend override flags (e.g. `--backend`, `--sqlite-path`) to the cache CLI. +- A `core-cache start` / daemon command. + +## Notes + +- `StorageSettings` is imported from `core_storage` — no duplication of backend models. +- The layered settings infrastructure (`LayerDiscovery`, `ConfigBuilder`) is already shared from `core_storage`; this ticket follows the same pattern established by FR-00001. diff --git a/packages/cache/pyproject.toml b/packages/cache/pyproject.toml index ae9201e..755e933 100644 --- a/packages/cache/pyproject.toml +++ b/packages/cache/pyproject.toml @@ -9,8 +9,12 @@ license = { text = "MIT" } dependencies = [ "core-storage", "pydantic>=2.0", + "typer>=0.9", ] +[project.scripts] +core-cache = "core_cache.cli:app" + [tool.uv.sources] core-storage = { workspace = true } diff --git a/packages/cache/src/core_cache/cli/__init__.py b/packages/cache/src/core_cache/cli/__init__.py new file mode 100644 index 0000000..c711cef --- /dev/null +++ b/packages/cache/src/core_cache/cli/__init__.py @@ -0,0 +1,54 @@ +"""CLI entry point for core-cache.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from core_cache.cache import Cache +from core_cache.cli.commands.clear import clear_cache +from core_cache.cli.commands.invalidate import invalidate_entry +from core_cache.cli.commands.lookup import lookup_entry +from core_cache.settings.resolver import get_settings +from core_storage.exceptions import StorageError +from core_storage.factory import create_backend +from core_storage.store import Store + +app = typer.Typer( + help="core-cache — file-path-keyed cache CLI", + no_args_is_help=True, +) + +app.command("lookup")(lookup_entry) +app.command("invalidate")(invalidate_entry) +app.command("clear")(clear_cache) + + +@app.callback() +def configure_cache( + ctx: typer.Context, + config: Path | None = typer.Option( + None, + "--config", + "-c", + help="Path to a settings.toml file. Overrides project-root and user config.", + exists=True, + file_okay=True, + dir_okay=False, + readable=True, + ), +) -> None: + """Resolve settings then build and wire the cache.""" + ctx.ensure_object(dict) + try: + cache_settings = get_settings(config_file=config) + backend = create_backend(cache_settings.storage) + store = Store(backend).namespace(cache_settings.namespace) + ctx.obj = Cache(store, strict=cache_settings.strict) + except StorageError as storage_error: + typer.echo(f"Error: {storage_error}", err=True) + raise typer.Exit(code=1) from None + + +__all__ = ["app"] diff --git a/packages/cache/src/core_cache/cli/commands/__init__.py b/packages/cache/src/core_cache/cli/commands/__init__.py new file mode 100644 index 0000000..f65da80 --- /dev/null +++ b/packages/cache/src/core_cache/cli/commands/__init__.py @@ -0,0 +1 @@ +"""CLI subcommands for core-cache.""" diff --git a/packages/cache/src/core_cache/cli/commands/clear.py b/packages/cache/src/core_cache/cli/commands/clear.py new file mode 100644 index 0000000..5875118 --- /dev/null +++ b/packages/cache/src/core_cache/cli/commands/clear.py @@ -0,0 +1,29 @@ +"""CLI command: clear all entries from the cache namespace.""" + +from __future__ import annotations + +import typer + +from core_cache.cache import Cache +from core_storage.exceptions import StorageError + + +def clear_cache( + ctx: typer.Context, + yes: bool = typer.Option( + False, + "--yes", + "-y", + help="Skip confirmation prompt.", + ), +) -> None: + """Delete all entries in the cache namespace.""" + cache: Cache = ctx.obj + if not yes: + typer.confirm("Clear all entries in the cache?", abort=True) + try: + deleted_count = cache.clear() + typer.echo(f"Cleared {deleted_count} entry/entries.") + except StorageError as storage_error: + typer.echo(f"Error: {storage_error}", err=True) + raise typer.Exit(code=1) from None diff --git a/packages/cache/src/core_cache/cli/commands/invalidate.py b/packages/cache/src/core_cache/cli/commands/invalidate.py new file mode 100644 index 0000000..8c82a61 --- /dev/null +++ b/packages/cache/src/core_cache/cli/commands/invalidate.py @@ -0,0 +1,47 @@ +"""CLI command: invalidate a cache entry by file path and params.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from core_cache.cache import Cache +from core_cache.exceptions import CacheError + + +def invalidate_entry( + ctx: typer.Context, + path: Path = typer.Argument(..., help="Source file path to invalidate."), + param: list[str] = typer.Option( + [], + "--param", + "-p", + help="Generation parameter as key=value. Repeatable.", + ), +) -> None: + """Remove the cache entry for the given path and params.""" + cache: Cache = ctx.obj + params = _parse_params(param) + try: + removed = cache.invalidate(path, params) + except CacheError as cache_error: + typer.echo(f"Error: {cache_error}", err=True) + raise typer.Exit(code=1) from None + + if removed: + typer.echo(f"Invalidated: {path}") + else: + typer.echo(f"Not found: {path}", err=True) + raise typer.Exit(code=1) + + +def _parse_params(raw: list[str]) -> dict[str, object]: + params: dict[str, object] = {} + for item in raw: + if "=" not in item: + typer.echo(f"Error: param {item!r} must be in key=value format.", err=True) + raise typer.Exit(code=1) + key, _, value = item.partition("=") + params[key] = value + return params diff --git a/packages/cache/src/core_cache/cli/commands/lookup.py b/packages/cache/src/core_cache/cli/commands/lookup.py new file mode 100644 index 0000000..2ae1205 --- /dev/null +++ b/packages/cache/src/core_cache/cli/commands/lookup.py @@ -0,0 +1,48 @@ +"""CLI command: look up a cache entry by file path and params.""" + +from __future__ import annotations + +from pathlib import Path + +import typer + +from core_cache.cache import Cache +from core_cache.exceptions import CacheError +from core_cache.result import CacheHit + + +def lookup_entry( + ctx: typer.Context, + path: Path = typer.Argument(..., help="Source file path to look up."), + param: list[str] = typer.Option( + [], + "--param", + "-p", + help="Generation parameter as key=value. Repeatable.", + ), +) -> None: + """Check whether a file path is cached. Exits 0 on hit, 1 on miss.""" + cache: Cache = ctx.obj + params = _parse_params(param) + try: + result = cache.lookup(path, params) + except CacheError as cache_error: + typer.echo(f"Error: {cache_error}", err=True) + raise typer.Exit(code=1) from None + + if isinstance(result, CacheHit): + typer.echo(result.value.decode()) + else: + typer.echo(f"Miss: {result.reason}", err=True) + raise typer.Exit(code=1) + + +def _parse_params(raw: list[str]) -> dict[str, object]: + params: dict[str, object] = {} + for item in raw: + if "=" not in item: + typer.echo(f"Error: param {item!r} must be in key=value format.", err=True) + raise typer.Exit(code=1) + key, _, value = item.partition("=") + params[key] = value + return params diff --git a/packages/cache/src/core_cache/settings/defaults.toml b/packages/cache/src/core_cache/settings/defaults.toml new file mode 100644 index 0000000..3385655 --- /dev/null +++ b/packages/cache/src/core_cache/settings/defaults.toml @@ -0,0 +1,20 @@ +# core-cache package defaults +# Lowest priority — overridden by project settings.toml, user settings.toml, +# environment variables, and CLI flags. + +namespace = "core-cache" +strict = false + +[storage.backend] +type = "sqlite" + +[storage.backend.sqlite] +path = "~/.local/share/core-cache/cache.db" + +[storage.backend.json] +path = "~/.local/share/core-cache/cache.json" + +[storage.backend.redis] +host = "localhost" +port = 6379 +db = 0 diff --git a/packages/cache/src/core_cache/settings/models.py b/packages/cache/src/core_cache/settings/models.py index 6bc44ba..9052a47 100644 --- a/packages/cache/src/core_cache/settings/models.py +++ b/packages/cache/src/core_cache/settings/models.py @@ -4,6 +4,8 @@ from pydantic import BaseModel, Field +from core_storage.settings.models import StorageSettings + class CacheSettings(BaseModel): """Settings for core_cache. @@ -13,7 +15,10 @@ class CacheSettings(BaseModel): this to avoid cross-generator collisions (e.g. "color-scheme-cache"). strict: When True, storage errors propagate instead of degrading to CacheMiss. Default False (transparent degradation). + storage: Full storage backend configuration. Embedded so a single + config file is self-contained. """ namespace: str = Field(default="core-cache", min_length=1) strict: bool = False + storage: StorageSettings = Field(default_factory=StorageSettings) diff --git a/packages/cache/src/core_cache/settings/resolver.py b/packages/cache/src/core_cache/settings/resolver.py index 55674b6..0f575a8 100644 --- a/packages/cache/src/core_cache/settings/resolver.py +++ b/packages/cache/src/core_cache/settings/resolver.py @@ -3,7 +3,7 @@ from __future__ import annotations from pathlib import Path -from typing import Any +from typing import Any, cast from core_cache.settings.constants import APP_NAME, ENV_PREFIX from core_cache.settings.models import CacheSettings @@ -13,17 +13,22 @@ def get_settings( project_root: Path | None = None, + config_file: Path | None = None, cli_overrides: dict[str, Any] | None = None, _environ: dict[str, str] | None = None, ) -> CacheSettings: """Return resolved CacheSettings by applying all layers in priority order. Priority: package defaults < project settings.toml < - ~/.config/core-cache/settings.toml < env vars < cli_overrides. + ~/.config/core-cache/settings.toml < config_file < + env vars < cli_overrides. Args: project_root: Override the project root used to find ``settings.toml``. - Defaults to ``Path.cwd()``. + Defaults to ``Path.cwd()``. Ignored when ``config_file`` is provided. + config_file: Explicit path to a settings file (e.g. from ``--config``). + When provided, project-root auto-discovery is skipped and this file + is loaded above the XDG user config. cli_overrides: Dotted-path overrides, e.g. ``{"namespace": "my-ns"}``. _environ: Override ``os.environ`` (for testing). """ @@ -31,7 +36,8 @@ def get_settings( app_name=APP_NAME, project_root=project_root, env_prefix=ENV_PREFIX, + explicit_config=config_file, _environ=_environ, ) validated = ConfigBuilder.build(CacheSettings, layers, cli_overrides=cli_overrides) - return validated # type: ignore[return-value] + return cast(CacheSettings, validated) diff --git a/packages/cache/tests/unit/test_cli.py b/packages/cache/tests/unit/test_cli.py new file mode 100644 index 0000000..ea4ff96 --- /dev/null +++ b/packages/cache/tests/unit/test_cli.py @@ -0,0 +1,267 @@ +"""Unit tests for the core-cache CLI.""" + +from __future__ import annotations + +import unittest.mock +from pathlib import Path + +from typer.testing import CliRunner + +from core_cache.cli import app +from core_cache.exceptions import CacheError +from core_cache.result import CacheHit, CacheMiss +from core_storage.exceptions import StorageOperationError + +runner = CliRunner() + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_config(tmp_path: Path, db_name: str = "cache.db") -> Path: + """Write a minimal cache settings.toml pointing to a SQLite backend.""" + config = tmp_path / "settings.toml" + db_path = tmp_path / db_name + config.write_text( + f'namespace = "test-ns"\n' + f"[storage.backend]\n" + f'type = "sqlite"\n' + f"[storage.backend.sqlite]\n" + f'path = "{db_path}"\n' + ) + return config + + +# --------------------------------------------------------------------------- +# configure_cache callback +# --------------------------------------------------------------------------- + + +def test_config_flag_loads_settings(tmp_path: Path) -> None: + config = _make_config(tmp_path) + result = runner.invoke(app, ["--config", str(config), "clear", "--yes"]) + assert result.exit_code == 0 + + +def test_config_flag_missing_file_exits_nonzero() -> None: + result = runner.invoke( + app, ["--config", "/nonexistent/settings.toml", "clear", "--yes"] + ) + assert result.exit_code != 0 + + +def test_storage_error_on_backend_creation_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.create_backend", + side_effect=StorageOperationError("boom", operation="open"), + ): + result = runner.invoke(app, ["--config", str(config), "clear", "--yes"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# clear +# --------------------------------------------------------------------------- + + +def test_clear_with_yes_flag(tmp_path: Path) -> None: + config = _make_config(tmp_path) + result = runner.invoke(app, ["--config", str(config), "clear", "--yes"]) + assert result.exit_code == 0 + assert "Cleared" in result.output + + +def test_clear_confirms_before_deleting(tmp_path: Path) -> None: + config = _make_config(tmp_path) + result = runner.invoke(app, ["--config", str(config), "clear"], input="y\n") + assert result.exit_code == 0 + assert "Cleared" in result.output + + +def test_clear_aborted_on_no(tmp_path: Path) -> None: + config = _make_config(tmp_path) + result = runner.invoke(app, ["--config", str(config), "clear"], input="n\n") + assert result.exit_code != 0 + + +def test_clear_storage_error_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.clear.Cache.clear", + side_effect=StorageOperationError("boom", operation="clear"), + ): + result = runner.invoke(app, ["--config", str(config), "clear", "--yes"]) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# lookup +# --------------------------------------------------------------------------- + + +def test_lookup_hit_prints_value(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.lookup.Cache.lookup", + return_value=CacheHit( + value=b"hello", cached_at=__import__("datetime").datetime.now() + ), + ): + result = runner.invoke( + app, + ["--config", str(config), "lookup", str(tmp_path / "img.jpg")], + ) + assert result.exit_code == 0 + assert "hello" in result.output + + +def test_lookup_miss_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.lookup.Cache.lookup", + return_value=CacheMiss(reason="not_found"), + ): + result = runner.invoke( + app, + ["--config", str(config), "lookup", str(tmp_path / "img.jpg")], + ) + assert result.exit_code == 1 + + +def test_lookup_with_params(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.lookup.Cache.lookup", + return_value=CacheMiss(reason="not_found"), + ) as mock_lookup: + runner.invoke( + app, + [ + "--config", + str(config), + "lookup", + str(tmp_path / "img.jpg"), + "--param", + "mode=dark", + "--param", + "size=1920", + ], + ) + mock_lookup.assert_called_once() + _, params = mock_lookup.call_args[0] + assert params == {"mode": "dark", "size": "1920"} + + +def test_lookup_bad_param_format_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + result = runner.invoke( + app, + [ + "--config", + str(config), + "lookup", + str(tmp_path / "img.jpg"), + "--param", + "badvalue", + ], + ) + assert result.exit_code == 1 + + +def test_lookup_cache_error_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.lookup.Cache.lookup", + side_effect=CacheError("bad params"), + ): + result = runner.invoke( + app, + ["--config", str(config), "lookup", str(tmp_path / "img.jpg")], + ) + assert result.exit_code == 1 + + +# --------------------------------------------------------------------------- +# invalidate +# --------------------------------------------------------------------------- + + +def test_invalidate_found_exits_0(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.invalidate.Cache.invalidate", + return_value=True, + ): + result = runner.invoke( + app, + ["--config", str(config), "invalidate", str(tmp_path / "img.jpg")], + ) + assert result.exit_code == 0 + assert "Invalidated" in result.output + + +def test_invalidate_not_found_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.invalidate.Cache.invalidate", + return_value=False, + ): + result = runner.invoke( + app, + ["--config", str(config), "invalidate", str(tmp_path / "img.jpg")], + ) + assert result.exit_code == 1 + + +def test_invalidate_with_params(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.invalidate.Cache.invalidate", + return_value=True, + ) as mock_inv: + runner.invoke( + app, + [ + "--config", + str(config), + "invalidate", + str(tmp_path / "img.jpg"), + "--param", + "mode=dark", + ], + ) + mock_inv.assert_called_once() + _, params = mock_inv.call_args[0] + assert params == {"mode": "dark"} + + +def test_invalidate_bad_param_format_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + result = runner.invoke( + app, + [ + "--config", + str(config), + "invalidate", + str(tmp_path / "img.jpg"), + "--param", + "bad", + ], + ) + assert result.exit_code == 1 + + +def test_invalidate_cache_error_exits_1(tmp_path: Path) -> None: + config = _make_config(tmp_path) + with unittest.mock.patch( + "core_cache.cli.commands.invalidate.Cache.invalidate", + side_effect=CacheError("bad params"), + ): + result = runner.invoke( + app, + ["--config", str(config), "invalidate", str(tmp_path / "img.jpg")], + ) + assert result.exit_code == 1 diff --git a/packages/cache/tests/unit/test_settings.py b/packages/cache/tests/unit/test_settings.py index 0311687..9f55528 100644 --- a/packages/cache/tests/unit/test_settings.py +++ b/packages/cache/tests/unit/test_settings.py @@ -6,6 +6,7 @@ from core_cache.settings.constants import APP_NAME, ENV_PREFIX from core_cache.settings.models import CacheSettings from core_cache.settings.resolver import get_settings +from core_storage.settings.models import StorageSettings def test_constants_have_expected_values() -> None: @@ -30,6 +31,22 @@ def test_cache_settings_rejects_empty_namespace() -> None: CacheSettings(namespace="") +def test_cache_settings_has_storage_field() -> None: + s = CacheSettings() + assert isinstance(s.storage, StorageSettings) + + +def test_cache_settings_storage_defaults_to_sqlite() -> None: + s = CacheSettings() + assert s.storage.backend.type.value == "sqlite" + + +def test_cache_settings_accepts_storage_override() -> None: + storage = StorageSettings() + s = CacheSettings(storage=storage) + assert s.storage is storage + + def test_get_settings_returns_defaults_with_no_config(tmp_path: Path) -> None: settings = get_settings(project_root=tmp_path, _environ={}) assert settings.namespace == "core-cache" @@ -46,3 +63,33 @@ def test_get_settings_env_var_overrides_strict(tmp_path: Path) -> None: environ = {"CORE_CACHE_STRICT": "true"} settings = get_settings(project_root=tmp_path, _environ=environ) assert settings.strict is True + + +def test_get_settings_config_file_overrides_namespace(tmp_path: Path) -> None: + config = tmp_path / "settings.toml" + config.write_text('namespace = "custom-ns"\n') + settings = get_settings(config_file=config, _environ={}) + assert settings.namespace == "custom-ns" + + +def test_get_settings_config_file_skips_project_root(tmp_path: Path) -> None: + project_root = tmp_path / "project" + project_root.mkdir() + (project_root / "settings.toml").write_text('namespace = "project-ns"\n') + + config = tmp_path / "explicit.toml" + config.write_text('namespace = "explicit-ns"\n') + + settings = get_settings(project_root=project_root, config_file=config, _environ={}) + assert settings.namespace == "explicit-ns" + + +def test_get_settings_config_file_sets_storage_backend(tmp_path: Path) -> None: + db_path = tmp_path / "cache.db" + config = tmp_path / "settings.toml" + config.write_text( + f'[storage.backend]\ntype = "sqlite"\n' + f'[storage.backend.sqlite]\npath = "{db_path}"\n' + ) + settings = get_settings(config_file=config, _environ={}) + assert settings.storage.backend.sqlite.path == db_path