Skip to main content

warden/commands/
purge.rs

1//! `warden purge --prompts`.
2//!
3//! The store is append-only; purge is one of the very few commands that removes
4//! anything. Privacy therefore asks for two properties, and this module exists to
5//! guarantee them:
6//!
7//! - **Explicit.** Nothing is removed without `--yes` or an interactive `y/N`.
8//! - **It says what it removed.** File count, prompt-record count, and bytes are
9//!   measured *before* the delete, because afterwards they are unknowable.
10//!
11//! Exactly one directory is ever removed: `<data-dir>/prompts`. The path is
12//! re-derived from [`StorePaths`] and re-checked against the store root, so no
13//! argument, config value, or symlink can point the delete elsewhere. `events/`,
14//! `state/`, and `config.toml` are never touched.
15
16use std::io::{self, BufRead, BufReader, IsTerminal, Write};
17use std::path::{Path, PathBuf};
18
19use crate::cli::TimeWindow;
20use crate::output::{emit, Cell, Report, Table};
21use crate::store::StorePaths;
22
23use super::{doctor::human_bytes, thousands, Env};
24
25/// What a purge removed. Measured before the delete.
26#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct PurgeSummary {
28    pub dir: PathBuf,
29    pub files: usize,
30    /// Prompt records, i.e. non-empty JSONL lines.
31    pub records: u64,
32    pub bytes: u64,
33    /// False when the user declined, or when there was nothing there.
34    pub removed: bool,
35}
36
37/// Run the purge, print what happened, and return it.
38pub fn run(env: &Env<'_>, prompts: bool, assume_yes: bool) -> io::Result<PurgeSummary> {
39    if !prompts {
40        return Err(io::Error::new(
41            io::ErrorKind::InvalidInput,
42            "nothing to purge: pass --prompts to delete stored prompt text \
43             (events/ and state/ are never purged)",
44        ));
45    }
46
47    let summary = purge_prompts(
48        env.paths,
49        assume_yes,
50        &mut io::stdin().lock(),
51        &mut io::stderr(),
52        io::stdin().is_terminal(),
53    )?;
54    emit(&report(&summary, env.window), env.json)?;
55    Ok(summary)
56}
57
58/// The testable core: confirm, measure, delete.
59///
60/// `interactive` is passed in rather than probed so a test can exercise both the
61/// prompt and the refusal without a terminal.
62pub fn purge_prompts<R: BufRead, W: Write>(
63    paths: &StorePaths,
64    assume_yes: bool,
65    input: &mut R,
66    prompt_to: &mut W,
67    interactive: bool,
68) -> io::Result<PurgeSummary> {
69    let dir = safe_prompts_dir(paths)?;
70    let mut summary = measure(&dir)?;
71    summary.dir = dir.clone();
72
73    if !dir.exists() {
74        writeln!(
75            prompt_to,
76            "nothing to purge: {} does not exist",
77            dir.display()
78        )?;
79        return Ok(summary);
80    }
81
82    if !assume_yes && !confirm(&summary, input, prompt_to, interactive)? {
83        writeln!(prompt_to, "aborted; nothing was removed")?;
84        return Ok(summary);
85    }
86
87    std::fs::remove_dir_all(&dir)?;
88    summary.removed = true;
89    Ok(summary)
90}
91
92/// Re-derive the one removable path and prove it is the one we mean.
93///
94/// A store root of `/` or a `prompts` entry that is a symlink is refused
95/// outright: this function is the whole safety story for a recursive delete.
96fn safe_prompts_dir(paths: &StorePaths) -> io::Result<PathBuf> {
97    let root = paths.root();
98    let dir = paths.prompts_dir();
99
100    let looks_right = dir.parent() == Some(root)
101        && dir.file_name() == Some(std::ffi::OsStr::new("prompts"))
102        && root.parent().is_some();
103    if !looks_right {
104        return Err(refuse(&dir, "it is not <data-dir>/prompts"));
105    }
106
107    match std::fs::symlink_metadata(&dir) {
108        Ok(meta) if meta.file_type().is_symlink() => Err(refuse(
109            &dir,
110            "it is a symlink, so the delete would follow it elsewhere",
111        )),
112        Ok(meta) if !meta.is_dir() => Err(refuse(&dir, "it is not a directory")),
113        _ => Ok(dir),
114    }
115}
116
117fn refuse(dir: &Path, why: &str) -> io::Error {
118    io::Error::new(
119        io::ErrorKind::InvalidInput,
120        format!("refusing to purge {}: {why}", dir.display()),
121    )
122}
123
124/// Count files, records, and bytes directly under `dir`.
125fn measure(dir: &Path) -> io::Result<PurgeSummary> {
126    let mut summary = PurgeSummary::default();
127    let entries = match std::fs::read_dir(dir) {
128        Ok(entries) => entries,
129        Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(summary),
130        Err(err) => return Err(err),
131    };
132    for entry in entries {
133        let entry = entry?;
134        if !entry.file_type()?.is_file() {
135            continue;
136        }
137        summary.files += 1;
138        summary.bytes += entry.metadata()?.len();
139        let file = std::fs::File::open(entry.path())?;
140        for line in BufReader::new(file).lines() {
141            if !line?.trim().is_empty() {
142                summary.records += 1;
143            }
144        }
145    }
146    Ok(summary)
147}
148
149/// `y/N` on a terminal; a hard refusal when there is nobody to ask.
150fn confirm<R: BufRead, W: Write>(
151    summary: &PurgeSummary,
152    input: &mut R,
153    out: &mut W,
154    interactive: bool,
155) -> io::Result<bool> {
156    if !interactive {
157        return Err(io::Error::new(
158            io::ErrorKind::InvalidInput,
159            "refusing to purge without confirmation: stdin is not a terminal, so pass --yes",
160        ));
161    }
162    write!(
163        out,
164        "delete {} ({} files, {} prompt records, {})? this cannot be undone [y/N] ",
165        summary.dir.display(),
166        summary.files,
167        thousands(summary.records),
168        human_bytes(summary.bytes),
169    )?;
170    out.flush()?;
171
172    let mut answer = String::new();
173    input.read_line(&mut answer)?;
174    Ok(matches!(
175        answer.trim().to_ascii_lowercase().as_str(),
176        "y" | "yes"
177    ))
178}
179
180/// The receipt, through the same renderer every other command uses.
181fn report(summary: &PurgeSummary, window: TimeWindow) -> Report {
182    let table = Table::new(["removed", "files", "prompt records", "bytes"]).with_row(vec![
183        Cell::text(if summary.removed {
184            summary.dir.display().to_string()
185        } else {
186            "(nothing)".to_string()
187        }),
188        Cell::Int(i64::try_from(summary.files).unwrap_or(i64::MAX)),
189        // Exact, not `5.6k`: a receipt for a deletion has to be countable.
190        Cell::text(thousands(summary.records)),
191        Cell::text(human_bytes(if summary.removed { summary.bytes } else { 0 })),
192    ]);
193
194    let rows = vec![serde_json::json!({
195        "removed": summary.removed,
196        "dir": summary.dir.display().to_string(),
197        "files": summary.files,
198        "records": summary.records,
199        "bytes": summary.bytes,
200    })];
201
202    Report::new("purge", window, table)
203        .with_json_rows(rows)
204        .with_notes([
205            "only prompts/ is removed; events/, state/ and config.toml are untouched".to_string(),
206            "prompt text can be kept out of the store in the first place with \
207             `index_prompt_text = false` under [general] in config.toml"
208                .to_string(),
209        ])
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215    use crate::store::{text_hash, Event, Partition, PromptRecord, StoreWriter};
216    use chrono::{TimeZone, Utc};
217
218    /// A store with events and prompts in two different months, plus the state
219    /// file and config that must survive the purge.
220    fn store() -> (tempfile::TempDir, StorePaths) {
221        let dir = tempfile::tempdir().unwrap();
222        let paths = StorePaths::new(dir.path());
223        let mut writer = StoreWriter::open(paths.clone()).unwrap();
224        for (id, month) in [("a", 7u32), ("b", 8u32)] {
225            let ts = Utc
226                .with_ymd_and_hms(2026, month, 2, 9, 0, 0)
227                .unwrap()
228                .timestamp_millis();
229            let mut event = Event::new(id, ts, "claude-code", "anthropic", "user");
230            event.project = Some("acme".into());
231            writer.append_event(&event).unwrap();
232            writer
233                .append_prompt(
234                    ts,
235                    &PromptRecord {
236                        event_id: id.to_string(),
237                        text: Some("hello".into()),
238                        text_hash: text_hash("hello"),
239                    },
240                )
241                .unwrap();
242        }
243        writer
244            .append_cursor(&crate::store::IngestCursor {
245                path: "/src/a.jsonl".into(),
246                mtime: 0,
247                offset: 12,
248                adapter: "claude-code".into(),
249                ts: 0,
250            })
251            .unwrap();
252        std::fs::write(paths.config_file(), "").unwrap();
253        (dir, paths)
254    }
255
256    fn purge(
257        paths: &StorePaths,
258        yes: bool,
259        answer: &str,
260        interactive: bool,
261    ) -> io::Result<PurgeSummary> {
262        let mut input = answer.as_bytes();
263        let mut out = Vec::new();
264        purge_prompts(paths, yes, &mut input, &mut out, interactive)
265    }
266
267    #[test]
268    fn removes_only_prompts_and_reports_what_it_removed() {
269        let (_dir, paths) = store();
270        let events = paths.event_partition(Partition::new(2026, 8));
271        let events_before = std::fs::read(&events).unwrap();
272
273        let summary = purge(&paths, true, "", false).unwrap();
274        assert!(summary.removed);
275        assert_eq!(summary.files, 2, "two monthly prompt partitions");
276        assert_eq!(summary.records, 2);
277        assert!(summary.bytes > 0);
278
279        assert!(!paths.prompts_dir().exists());
280        assert!(paths.events_dir().exists());
281        assert_eq!(std::fs::read(&events).unwrap(), events_before);
282        assert!(paths.state_dir().exists());
283        assert!(paths.config_file().exists());
284    }
285
286    #[test]
287    fn declining_the_prompt_removes_nothing() {
288        let (_dir, paths) = store();
289        let summary = purge(&paths, false, "n\n", true).unwrap();
290        assert!(!summary.removed);
291        assert!(paths.prompts_dir().exists());
292
293        let summary = purge(&paths, false, "y\n", true).unwrap();
294        assert!(summary.removed);
295        assert!(!paths.prompts_dir().exists());
296    }
297
298    #[test]
299    fn refuses_to_purge_unattended_without_yes() {
300        let (_dir, paths) = store();
301        let err = purge(&paths, false, "", false).unwrap_err();
302        assert!(err.to_string().contains("--yes"), "{err}");
303        assert!(paths.prompts_dir().exists());
304    }
305
306    #[test]
307    fn a_missing_prompts_dir_is_not_an_error() {
308        let dir = tempfile::tempdir().unwrap();
309        let paths = StorePaths::new(dir.path());
310        let summary = purge(&paths, true, "", false).unwrap();
311        assert!(!summary.removed);
312        assert_eq!(summary.records, 0);
313    }
314
315    #[test]
316    fn refuses_a_symlinked_prompts_dir() {
317        let (_dir, paths) = store();
318        let elsewhere = tempfile::tempdir().unwrap();
319        std::fs::remove_dir_all(paths.prompts_dir()).unwrap();
320        #[cfg(unix)]
321        std::os::unix::fs::symlink(elsewhere.path(), paths.prompts_dir()).unwrap();
322
323        let err = purge(&paths, true, "", false).unwrap_err();
324        assert!(err.to_string().contains("symlink"), "{err}");
325        assert!(elsewhere.path().exists(), "the symlink target survives");
326    }
327
328    #[test]
329    fn refuses_a_root_that_is_not_a_store() {
330        let paths = StorePaths::new("/");
331        let err = safe_prompts_dir(&paths).unwrap_err();
332        assert!(err.to_string().contains("refusing to purge"), "{err}");
333    }
334
335    #[test]
336    fn the_receipt_names_the_directory_and_the_counts() {
337        let summary = PurgeSummary {
338            dir: PathBuf::from("/store/prompts"),
339            files: 2,
340            records: 1_443,
341            bytes: 14_300_000,
342            removed: true,
343        };
344        let report = report(&summary, TimeWindow::all());
345        assert_eq!(report.json_rows[0]["records"], 1_443);
346        assert!(
347            report
348                .table
349                .render(crate::output::Style::plain())
350                .contains("1,443"),
351            "the record count is exact, not abbreviated"
352        );
353        assert_eq!(report.json_rows[0]["removed"], true);
354        let rendered = report.table.render(crate::output::Style::plain());
355        assert!(rendered.contains("/store/prompts"), "{rendered}");
356        assert!(rendered.contains("13.6 MB"), "{rendered}");
357    }
358}