1use 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#[derive(Debug, Clone, PartialEq, Eq, Default)]
27pub struct PurgeSummary {
28 pub dir: PathBuf,
29 pub files: usize,
30 pub records: u64,
32 pub bytes: u64,
33 pub removed: bool,
35}
36
37pub 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
58pub 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
92fn 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
124fn 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
149fn 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
180fn 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 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 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}