1use std::fs;
24use std::io::{self, BufRead, Write};
25use std::path::{Path, PathBuf};
26use thiserror::Error;
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Scope {
32 Out,
34 Backups,
36 All,
38}
39
40impl Scope {
41 pub fn as_str(self) -> &'static str {
42 match self {
43 Scope::Out => "out",
44 Scope::Backups => "backups",
45 Scope::All => "all",
46 }
47 }
48}
49
50impl std::str::FromStr for Scope {
51 type Err = String;
52 fn from_str(s: &str) -> Result<Self, Self::Err> {
53 match s {
54 "out" => Ok(Scope::Out),
55 "backups" => Ok(Scope::Backups),
56 "all" => Ok(Scope::All),
57 other => Err(format!(
58 "--scope must be one of `out`, `backups`, `all` (got `{other}`)"
59 )),
60 }
61 }
62}
63
64#[derive(Debug, Error)]
65pub enum PurgeError {
66 #[error("io error at {path}: {source}")]
67 Io {
68 path: PathBuf,
69 #[source]
70 source: io::Error,
71 },
72 #[error("purge cancelled by operator")]
76 Cancelled,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct PurgeOutcome {
81 pub scope: Scope,
82 pub cleared: Vec<PathBuf>,
86}
87
88pub enum Confirm<'a> {
92 Yes,
93 Prompt {
94 reader: &'a mut dyn BufRead,
95 writer: &'a mut dyn Write,
96 },
97}
98
99pub fn run(
103 state_home: &Path,
104 scope: Scope,
105 confirm: Confirm<'_>,
106 audit: &mut dyn Write,
107) -> Result<PurgeOutcome, PurgeError> {
108 match confirm {
109 Confirm::Yes => {
110 writeln!(
111 audit,
112 "agent-runtime purge-state: --yes scope={} state_home={}",
113 scope.as_str(),
114 state_home.display(),
115 )
116 .ok();
117 }
118 Confirm::Prompt { reader, writer } => {
119 write!(
120 writer,
121 "agent-runtime purge-state: about to clear scope={} under {} — type `y` or `yes` to confirm (anything else cancels): ",
122 scope.as_str(),
123 state_home.display(),
124 )
125 .ok();
126 writer.flush().ok();
127 let mut line = String::new();
128 reader
129 .read_line(&mut line)
130 .map_err(|source| PurgeError::Io {
131 path: PathBuf::from("<confirm-prompt>"),
132 source,
133 })?;
134 let answer = line.trim().to_ascii_lowercase();
135 if answer != "y" && answer != "yes" {
136 return Err(PurgeError::Cancelled);
137 }
138 }
139 }
140
141 let mut cleared = Vec::new();
142 match scope {
143 Scope::Out => {
144 if let Some(p) = clear_dir(state_home, "out")? {
145 cleared.push(p);
146 }
147 }
148 Scope::Backups => {
149 if let Some(p) = clear_dir(state_home, "backups")? {
150 cleared.push(p);
151 }
152 }
153 Scope::All => {
154 if let Some(p) = clear_dir(state_home, "out")? {
155 cleared.push(p);
156 }
157 if let Some(p) = clear_dir(state_home, "backups")? {
158 cleared.push(p);
159 }
160 }
161 }
162 Ok(PurgeOutcome { scope, cleared })
163}
164
165fn clear_dir(state_home: &Path, sub: &str) -> Result<Option<PathBuf>, PurgeError> {
170 let target = state_home.join(sub);
171 match fs::symlink_metadata(&target) {
172 Ok(meta) if meta.file_type().is_dir() => {
173 fs::remove_dir_all(&target).map_err(|source| PurgeError::Io {
174 path: target.clone(),
175 source,
176 })?;
177 fs::create_dir_all(&target).map_err(|source| PurgeError::Io {
182 path: target.clone(),
183 source,
184 })?;
185 Ok(Some(target))
186 }
187 Ok(_) => {
188 Err(PurgeError::Io {
192 path: target,
193 source: io::Error::new(
194 io::ErrorKind::InvalidData,
195 "expected directory under <state_home>",
196 ),
197 })
198 }
199 Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
200 Err(source) => Err(PurgeError::Io {
201 path: target,
202 source,
203 }),
204 }
205}
206
207#[cfg(test)]
208mod tests {
209 use super::*;
210 use std::io::Cursor;
211 use tempfile::TempDir;
212
213 fn seed(state: &Path, sub: &str, file_name: &str, bytes: &str) {
214 let dir = state.join(sub);
215 fs::create_dir_all(&dir).unwrap();
216 fs::write(dir.join(file_name), bytes).unwrap();
217 }
218
219 #[test]
220 fn scope_from_str_accepts_three_values_and_rejects_garbage() {
221 assert_eq!("out".parse::<Scope>().unwrap(), Scope::Out);
222 assert_eq!("backups".parse::<Scope>().unwrap(), Scope::Backups);
223 assert_eq!("all".parse::<Scope>().unwrap(), Scope::All);
224 assert!("OUT".parse::<Scope>().is_err());
225 assert!("everything".parse::<Scope>().is_err());
226 }
227
228 #[test]
229 fn yes_writes_audit_line_and_clears_out_only() {
230 let tmp = TempDir::new().unwrap();
231 let state = tmp.path();
232 seed(state, "out", "render.log", "RENDER");
233 seed(state, "backups/claude/123/entry", "plugin.json", "BACKUP");
234
235 let mut audit = Vec::new();
236 let outcome = run(state, Scope::Out, Confirm::Yes, &mut audit).unwrap();
237 assert_eq!(outcome.scope, Scope::Out);
238 assert_eq!(outcome.cleared, vec![state.join("out")]);
239
240 let audit_text = String::from_utf8(audit).unwrap();
241 assert!(
242 audit_text.contains("--yes"),
243 "audit must mention --yes: {audit_text}"
244 );
245 assert!(
246 audit_text.contains("scope=out"),
247 "audit must name scope: {audit_text}"
248 );
249
250 assert!(state.join("out").is_dir());
252 assert!(state.join("out").read_dir().unwrap().next().is_none());
253 assert_eq!(
254 fs::read_to_string(state.join("backups/claude/123/entry/plugin.json")).unwrap(),
255 "BACKUP"
256 );
257 }
258
259 #[test]
260 fn yes_with_scope_backups_clears_backups_only() {
261 let tmp = TempDir::new().unwrap();
262 let state = tmp.path();
263 seed(state, "out", "render.log", "RENDER");
264 seed(state, "backups/claude/123/entry", "plugin.json", "BACKUP");
265
266 let mut audit = Vec::new();
267 let outcome = run(state, Scope::Backups, Confirm::Yes, &mut audit).unwrap();
268 assert_eq!(outcome.cleared, vec![state.join("backups")]);
269 assert_eq!(
270 fs::read_to_string(state.join("out/render.log")).unwrap(),
271 "RENDER"
272 );
273 assert!(state.join("backups").is_dir());
274 assert!(state.join("backups").read_dir().unwrap().next().is_none());
275 }
276
277 #[test]
278 fn yes_with_scope_all_clears_both() {
279 let tmp = TempDir::new().unwrap();
280 let state = tmp.path();
281 seed(state, "out", "render.log", "RENDER");
282 seed(state, "backups/claude/123/entry", "plugin.json", "BACKUP");
283
284 let mut audit = Vec::new();
285 let outcome = run(state, Scope::All, Confirm::Yes, &mut audit).unwrap();
286 assert_eq!(
287 outcome.cleared,
288 vec![state.join("out"), state.join("backups")]
289 );
290 assert!(state.join("out").read_dir().unwrap().next().is_none());
291 assert!(state.join("backups").read_dir().unwrap().next().is_none());
292 }
293
294 #[test]
295 fn prompt_y_proceeds_and_no_cancels() {
296 let tmp = TempDir::new().unwrap();
297 let state = tmp.path();
298 seed(state, "out", "render.log", "RENDER");
299
300 let mut reader = Cursor::new(b"y\n".to_vec());
302 let mut writer: Vec<u8> = Vec::new();
303 let mut audit = Vec::new();
304 let outcome = run(
305 state,
306 Scope::Out,
307 Confirm::Prompt {
308 reader: &mut reader,
309 writer: &mut writer,
310 },
311 &mut audit,
312 )
313 .unwrap();
314 assert_eq!(outcome.scope, Scope::Out);
315 let prompt = String::from_utf8(writer).unwrap();
317 assert!(
318 prompt.contains("scope=out"),
319 "prompt missing scope: {prompt}"
320 );
321 assert!(
323 audit.is_empty(),
324 "audit must stay empty in prompt path: {audit:?}"
325 );
326
327 seed(state, "out", "render.log", "RENDER-AGAIN");
329 let mut reader = Cursor::new(b"n\n".to_vec());
330 let mut writer: Vec<u8> = Vec::new();
331 let mut audit2 = Vec::new();
332 let err = run(
333 state,
334 Scope::Out,
335 Confirm::Prompt {
336 reader: &mut reader,
337 writer: &mut writer,
338 },
339 &mut audit2,
340 )
341 .unwrap_err();
342 assert!(matches!(err, PurgeError::Cancelled));
343 assert_eq!(
345 fs::read_to_string(state.join("out/render.log")).unwrap(),
346 "RENDER-AGAIN"
347 );
348 }
349
350 #[test]
351 fn missing_subdir_is_clean_noop_not_error() {
352 let tmp = TempDir::new().unwrap();
353 let state = tmp.path();
354 let mut audit = Vec::new();
356 let outcome = run(state, Scope::All, Confirm::Yes, &mut audit).unwrap();
357 assert!(outcome.cleared.is_empty());
358 }
359
360 #[test]
361 fn refuses_non_dir_at_scope_path() {
362 let tmp = TempDir::new().unwrap();
363 let state = tmp.path();
364 fs::create_dir_all(state).unwrap();
365 fs::write(state.join("out"), "regular-file").unwrap();
367
368 let mut audit = Vec::new();
369 let err = run(state, Scope::Out, Confirm::Yes, &mut audit).unwrap_err();
370 match err {
371 PurgeError::Io { path, .. } => assert_eq!(path, state.join("out")),
372 other => panic!("expected Io shape-violation error, got {other:?}"),
373 }
374 assert!(String::from_utf8(audit).unwrap().contains("scope=out"));
376 assert_eq!(
378 fs::read_to_string(state.join("out")).unwrap(),
379 "regular-file"
380 );
381 }
382}