Coverage for src/lilbee/app/reset.py: 100%
41 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-09-28 17:20 +0000
1"""Knowledge base reset (delete all documents and data)."""
3from __future__ import annotations
5import logging
6import shutil
7from pathlib import Path
9from pydantic import BaseModel
11from lilbee.core import settings
12from lilbee.core.config import cfg
13from lilbee.data.ingest.skip_marker import clear_skip_markers
14from lilbee.runtime.lock import no_sync_running
17class ResetResult(BaseModel):
18 """Result of a full knowledge base reset."""
20 command: str = "reset"
21 deleted_docs: int
22 deleted_data: int
23 skipped: list[str] = []
24 documents_dir: str
25 data_dir: str
28def _clear_dir(base_dir: Path, skipped: list[str]) -> int:
29 """Delete all items in *base_dir*, appending undeletable paths to *skipped*."""
30 log = logging.getLogger(__name__)
31 deleted = 0
32 if not base_dir.exists():
33 return deleted
34 for item in list(base_dir.iterdir()):
35 try:
36 # iterdir yields direct children only, so the entry is within
37 # base_dir by construction. Registered source roots live outside this
38 # dir (only their config entry is here), so reset never reaches a
39 # user's corpus: it deletes owned files, and perform_reset
40 # un-registers the roots from config.toml separately.
41 if item.is_dir():
42 shutil.rmtree(item)
43 else:
44 item.unlink()
45 except OSError as exc:
46 # best-effort: reset is "delete as much as you can", and the
47 # caller surfaces the skipped list to the user verbatim.
48 log.warning("Could not delete %s: %s", item, exc)
49 skipped.append(str(item))
50 continue
51 deleted += 1
52 return deleted
55def perform_reset() -> ResetResult:
56 """Delete all documents and data and un-register every linked source.
58 Raises ``ResetRefusedError`` while a sync or import, in this process or another,
59 runs against the same data root, or when the lock that shows one cannot be taken:
60 a running sync or import would write back what the reset removed.
61 """
62 skipped: list[str] = []
63 with no_sync_running(cfg.data_root):
64 deleted_docs = _clear_dir(cfg.documents_dir, skipped)
65 deleted_data = _clear_dir(cfg.data_dir, skipped)
67 # config.toml and the skip sidecars live at data_root, next to (not inside)
68 # the two cleared dirs. Without un-registering the roots here, in the file
69 # AND in this process, the next sync walks every linked root and re-indexes
70 # it, so content reappears after a "factory reset". Other settings survive:
71 # reset deletes data, not configuration.
72 settings.delete_value(cfg.data_root, "linked_roots")
73 cfg.linked_roots = {}
74 clear_skip_markers(cfg.data_root)
76 return ResetResult(
77 deleted_docs=deleted_docs,
78 deleted_data=deleted_data,
79 skipped=skipped,
80 documents_dir=str(cfg.documents_dir),
81 data_dir=str(cfg.data_dir),
82 )