Skip to main content

ytil_agents/agent/
session_deletion.rs

1use std::path::Path;
2use std::path::PathBuf;
3
4use rootcause::prelude::ResultExt;
5use rootcause::report;
6
7use crate::agent::Agent;
8use crate::agent::session::SessionKey;
9
10mod claude;
11mod codex;
12mod cursor;
13
14/// A storage-specific deletion target resolved for one selected session.
15#[derive(Debug)]
16pub struct DeletionPlan {
17    key: SessionKey,
18    paths: Vec<PathBuf>,
19    related_session_count: usize,
20    skipped_paths: Vec<PathBuf>,
21}
22
23impl DeletionPlan {
24    pub(crate) const fn new(
25        key: SessionKey,
26        paths: Vec<PathBuf>,
27        related_session_count: usize,
28        skipped_paths: Vec<PathBuf>,
29    ) -> Self {
30        Self {
31            key,
32            paths,
33            related_session_count,
34            skipped_paths,
35        }
36    }
37
38    pub const fn key(&self) -> &SessionKey {
39        &self.key
40    }
41
42    pub const fn related_session_count(&self) -> usize {
43        self.related_session_count
44    }
45}
46
47/// The result of resolving and deleting one selected session.
48#[derive(Debug)]
49pub enum DeletionOutcome {
50    Deleted {
51        key: SessionKey,
52        related_session_count: usize,
53    },
54    Failed {
55        key: SessionKey,
56        error: rootcause::Report,
57    },
58}
59
60/// Reports from files that could not safely participate in session discovery.
61#[derive(Debug, Default)]
62pub struct DeletionReport {
63    outcomes: Vec<DeletionOutcome>,
64    skipped_paths: Vec<PathBuf>,
65}
66
67impl DeletionReport {
68    pub fn outcomes(&self) -> &[DeletionOutcome] {
69        &self.outcomes
70    }
71
72    pub fn skipped_paths(&self) -> &[PathBuf] {
73        &self.skipped_paths
74    }
75}
76
77/// A listed session chosen for deletion, including its store path.
78pub struct DeletionTarget {
79    key: SessionKey,
80    path: PathBuf,
81}
82
83impl DeletionTarget {
84    #[must_use]
85    pub const fn new(key: SessionKey, path: PathBuf) -> Self {
86        Self { key, path }
87    }
88
89    #[must_use]
90    pub const fn key(&self) -> &SessionKey {
91        &self.key
92    }
93
94    #[must_use]
95    pub fn path(&self) -> &Path {
96        &self.path
97    }
98}
99
100/// Delete selected sessions from their owning agent stores.
101///
102/// Every selected key is resolved and deleted independently. A failure for one
103/// key does not prevent attempts for the remaining keys.
104pub fn delete_sessions(home_dir: &Path, keys: &[SessionKey]) -> DeletionReport {
105    let mut report = DeletionReport::default();
106    for key in keys {
107        apply_deletion(&mut report, home_dir, key, None);
108    }
109    report
110}
111
112/// Delete listed sessions using each session's already resolved store path.
113pub fn delete_session_targets(home_dir: &Path, targets: &[DeletionTarget]) -> DeletionReport {
114    let mut report = DeletionReport::default();
115    for target in targets {
116        apply_deletion(&mut report, home_dir, target.key(), Some(target.path()));
117    }
118    report
119}
120
121fn apply_deletion(report: &mut DeletionReport, home_dir: &Path, key: &SessionKey, selected_path: Option<&Path>) {
122    match build_deletion_plan(home_dir, key, selected_path) {
123        Ok(plan) => {
124            let DeletionPlan {
125                key,
126                paths,
127                related_session_count,
128                skipped_paths,
129            } = plan;
130            report.skipped_paths.extend(skipped_paths);
131            match delete_paths_in_order(&paths, delete_session_path) {
132                Ok(()) => report.outcomes.push(DeletionOutcome::Deleted {
133                    key,
134                    related_session_count,
135                }),
136                Err(error) => report.outcomes.push(DeletionOutcome::Failed { key, error }),
137            }
138        }
139        Err(error) => report.outcomes.push(DeletionOutcome::Failed {
140            key: key.clone(),
141            error,
142        }),
143    }
144}
145
146fn build_deletion_plan(
147    home_dir: &Path,
148    key: &SessionKey,
149    selected_path: Option<&Path>,
150) -> rootcause::Result<DeletionPlan> {
151    match key.agent() {
152        Agent::Claude => claude::build_deletion_plan(&session_root(home_dir, Agent::Claude), key, selected_path),
153        Agent::Codex => codex::build_deletion_plan(&session_root(home_dir, Agent::Codex), key, selected_path),
154        Agent::Cursor => cursor::build_deletion_plan(&session_root(home_dir, Agent::Cursor), key, selected_path),
155        Agent::Gemini | Agent::Opencode => {
156            Err(report!("session deletion is not supported").attach(format!("agent={}", key.agent())))
157        }
158    }
159}
160
161fn session_root(home_dir: &Path, agent: Agent) -> PathBuf {
162    agent
163        .sessions_root_path()
164        .iter()
165        .fold(home_dir.to_path_buf(), |path, component| path.join(component))
166}
167
168fn delete_paths_in_order(
169    paths: &[PathBuf],
170    mut delete_path: impl FnMut(&Path) -> rootcause::Result<()>,
171) -> rootcause::Result<()> {
172    for path in paths {
173        delete_path(path)?;
174    }
175    Ok(())
176}
177
178fn delete_session_path(path: &Path) -> rootcause::Result<()> {
179    if path.is_dir() {
180        std::fs::remove_dir_all(path)
181            .context("failed to delete session directory")
182            .attach_with(|| format!("path={}", path.display()))?;
183    } else {
184        std::fs::remove_file(path)
185            .context("failed to delete session file")
186            .attach_with(|| format!("path={}", path.display()))?;
187    }
188    Ok(())
189}
190
191#[cfg(test)]
192mod tests {
193    use std::path::PathBuf;
194
195    use rootcause::report;
196    use tempfile::tempdir;
197    use test_that::prelude::*;
198
199    use super::*;
200
201    #[test]
202    fn test_delete_paths_in_order_when_one_plan_fails_stops_that_plan() {
203        let paths = vec![PathBuf::from("child"), PathBuf::from("parent")];
204        let mut deleted = Vec::new();
205
206        let result = delete_paths_in_order(&paths, |path| {
207            if path == Path::new("child") {
208                Err(report!("child deletion failed"))
209            } else {
210                deleted.push(path.to_path_buf());
211                Ok(())
212            }
213        });
214
215        assert_that!(result, err(displays_as(contains_substring("child deletion failed"))));
216        assert_that!(deleted, eq(Vec::<PathBuf>::new()));
217    }
218
219    #[test]
220    fn test_delete_sessions_when_one_selection_fails_deletes_valid_agent_sessions() {
221        let dir = tempdir().expect("tempdir should be created");
222        let home_dir = dir.path();
223        let claude_path = home_dir.join(".claude/projects/claude.jsonl");
224        let codex_path = home_dir.join(".codex/sessions/codex.jsonl");
225        let cursor_path = home_dir.join(".cursor/chats/hash/cursor/meta.json");
226        write_file(&claude_path, "session");
227        write_file(
228            &codex_path,
229            "{\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"type\":\"session_meta\",\"payload\":{\"id\":\"codex\",\"timestamp\":\"2026-03-20T06:30:20.312Z\",\"cwd\":\"/tmp/workspace\"}}\n",
230        );
231        write_file(&cursor_path, r#"{"cwd":"/tmp"}"#);
232        let keys = vec![
233            SessionKey::new(Agent::Claude, "claude"),
234            SessionKey::new(Agent::Codex, "codex"),
235            SessionKey::new(Agent::Cursor, "cursor"),
236            SessionKey::new(Agent::Claude, "missing"),
237        ];
238
239        let report = delete_sessions(home_dir, &keys);
240
241        assert_that!(claude_path.exists(), eq(false));
242        assert_that!(codex_path.exists(), eq(false));
243        assert_that!(cursor_path.parent().is_some_and(Path::exists), eq(false));
244        assert_that!(
245            report
246                .outcomes()
247                .iter()
248                .filter(|outcome| matches!(outcome, DeletionOutcome::Deleted { .. }))
249                .count(),
250            eq(3)
251        );
252    }
253
254    #[test]
255    fn test_delete_sessions_when_cursor_session_is_meta_json_deletes_session_dir() {
256        let dir = tempdir().expect("tempdir should be created");
257        let home_dir = dir.path();
258        let meta_path = home_dir.join(".cursor/chats/hash/session-id/meta.json");
259        write_file(&meta_path, r#"{"cwd":"/tmp"}"#);
260        let keys = vec![SessionKey::new(Agent::Cursor, "session-id")];
261
262        let report = delete_sessions(home_dir, &keys);
263
264        assert_that!(meta_path.parent().is_some_and(Path::exists), eq(false));
265        assert_that!(
266            report
267                .outcomes()
268                .iter()
269                .filter(|outcome| matches!(outcome, DeletionOutcome::Deleted { .. }))
270                .count(),
271            eq(1)
272        );
273    }
274
275    #[test]
276    fn test_delete_session_targets_when_cursor_id_is_duplicated_deletes_only_selected_path() {
277        let dir = tempdir().expect("tempdir should be created");
278        let home_dir = dir.path();
279        let session_id = "24ba5086-7cca-419c-85c7-e9d636670fbe";
280        let selected_dir = home_dir.join(".cursor/chats/hash-a").join(session_id);
281        let other_dir = home_dir.join(".cursor/chats/hash-b").join(session_id);
282        write_file(&selected_dir.join("meta.json"), r#"{"cwd":"/tmp/selected"}"#);
283        write_file(&other_dir.join("meta.json"), r#"{"cwd":"/tmp/other"}"#);
284        let target = DeletionTarget::new(SessionKey::new(Agent::Cursor, session_id), selected_dir.clone());
285
286        let report = delete_session_targets(home_dir, &[target]);
287
288        assert_that!(selected_dir.exists(), eq(false));
289        assert_that!(other_dir.exists(), eq(true));
290        assert_that!(
291            report
292                .outcomes()
293                .iter()
294                .filter(|outcome| matches!(outcome, DeletionOutcome::Deleted { .. }))
295                .count(),
296            eq(1)
297        );
298    }
299
300    fn write_file(path: &Path, content: &str) {
301        let parent = path.parent().expect("fixture path should have a parent");
302        std::fs::create_dir_all(parent).expect("fixture parent should be created");
303        std::fs::write(path, content).expect("fixture should be written");
304    }
305}