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#[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#[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#[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
77pub 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
100pub 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
112pub 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}