Skip to main content

agent_runtime/
purge_state.rs

1//! `agent-runtime purge-state` body. Plan 04 Sprint 2 Task 2.3.
2//!
3//! Removes writable state under `<state_home>` per a required `--scope`:
4//!
5//! - `out` — clears everything under `<state_home>/out/` (renderer
6//!   artifacts, dry-run output captures, agent-output cache).
7//! - `backups` — clears everything under `<state_home>/backups/`
8//!   (every product's per-run backup tree).
9//! - `all` — both of the above.
10//!
11//! The runtime home is **never** touched (no `--live-home` arg even
12//! accepted). `auth*`, `history*`, `sessions*`, `cache*`, and
13//! `projects*` live under the product runtime home — not under
14//! `<state_home>` — and are therefore outside this command's scope by
15//! construction.
16//!
17//! Confirmation is required by default. `--yes` bypasses the prompt
18//! and is logged to stderr in a single audit line containing the scope
19//! value — the CLI is the only sanctioned way to set it, and the
20//! library writes the audit line at the start of every invocation so
21//! the trace is present even on partial failure.
22
23use std::fs;
24use std::io::{self, BufRead, Write};
25use std::path::{Path, PathBuf};
26use thiserror::Error;
27
28/// Required-scope selector. There is **no default**: missing `--scope`
29/// exits non-zero at the CLI before reaching this module.
30#[derive(Debug, Clone, Copy, PartialEq, Eq)]
31pub enum Scope {
32    /// `<state_home>/out/` only.
33    Out,
34    /// `<state_home>/backups/` only.
35    Backups,
36    /// Both `out/` and `backups/`.
37    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    /// The operator answered the confirmation prompt with anything
73    /// other than `y` / `yes`. Treated as a clean refusal — not an
74    /// error to log as a stack trace.
75    #[error("purge cancelled by operator")]
76    Cancelled,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq)]
80pub struct PurgeOutcome {
81    pub scope: Scope,
82    /// Absolute paths of the top-level dirs the executor cleared
83    /// (`<state_home>/out`, `<state_home>/backups`, or both). Empty
84    /// when the requested dir did not exist — that is a no-op success.
85    pub cleared: Vec<PathBuf>,
86}
87
88/// Confirmation policy. `Yes` bypasses the prompt and triggers the
89/// `--yes` audit line on stderr; `Prompt` reads a single line from
90/// the supplied reader and accepts `y` / `yes` (case-insensitive).
91pub enum Confirm<'a> {
92    Yes,
93    Prompt {
94        reader: &'a mut dyn BufRead,
95        writer: &'a mut dyn Write,
96    },
97}
98
99/// Execute one purge cycle. Writes the `--yes` audit line (or runs
100/// the prompt) before any filesystem mutation, so the operator
101/// trace lands even when the subsequent `fs::remove_dir_all` fails.
102pub 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
165/// Remove `<state_home>/<sub>` if present, then recreate it as an
166/// empty directory. Returns `Some(path)` when the dir existed before
167/// the call (so the outcome can record what was cleared), `None`
168/// when it was absent (no-op success).
169fn 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            // Recreate the empty dir so subsequent install / render
178            // calls do not have to create it themselves. Mirrors how
179            // the install pipeline assumes `<state_home>` shape on
180            // entry.
181            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            // A non-dir at this path (file, symlink, socket) is a
189            // shape violation — refuse to destroy it. The CLI maps
190            // this to a clear error message.
191            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        // out/ is now empty, backups/ is untouched.
251        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        // First call: operator answers "y\n" — purge proceeds.
301        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        // Prompt was rendered to the writer.
316        let prompt = String::from_utf8(writer).unwrap();
317        assert!(
318            prompt.contains("scope=out"),
319            "prompt missing scope: {prompt}"
320        );
321        // No audit line for the prompt path.
322        assert!(
323            audit.is_empty(),
324            "audit must stay empty in prompt path: {audit:?}"
325        );
326
327        // Second call: re-seed and answer "n" — purge cancelled.
328        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        // Content survived the refusal.
344        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        // Neither out/ nor backups/ exists.
355        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        // Plant a regular file where the dir should be.
366        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        // The audit line still landed before the IO error.
375        assert!(String::from_utf8(audit).unwrap().contains("scope=out"));
376        // The operator's file survived — we refused to destroy it.
377        assert_eq!(
378            fs::read_to_string(state.join("out")).unwrap(),
379            "regular-file"
380        );
381    }
382}