ytil_agents/agent/session_deletion/
claude.rs1use std::path::Path;
2
3use rootcause::prelude::ResultExt;
4use rootcause::report;
5
6use super::DeletionPlan;
7use crate::agent::session::SessionKey;
8
9pub(super) fn build_deletion_plan(
10 root: &Path,
11 key: &SessionKey,
12 selected_path: Option<&Path>,
13) -> rootcause::Result<DeletionPlan> {
14 if let Some(selected_path) = selected_path {
15 return plan_for_selected_path(root, key, selected_path);
16 }
17
18 let paths = crate::agent::session_loader::find_session_paths(
19 root,
20 |entry| claude_session_path(&entry.path(), key.id()),
21 |_| false,
22 )?;
23 let [path] = paths.as_slice() else {
24 return Err(
25 rootcause::report!("selected Claude session was not found uniquely in the session store")
26 .attach(format!("session_id={}", key.id()))
27 .attach(format!("matches={}", paths.len())),
28 );
29 };
30 Ok(DeletionPlan::new(key.clone(), vec![path.clone()], 0, Vec::new()))
31}
32
33fn plan_for_selected_path(root: &Path, key: &SessionKey, selected_path: &Path) -> rootcause::Result<DeletionPlan> {
34 let root = root
35 .canonicalize()
36 .context("failed to resolve Claude session store")
37 .attach_with(|| format!("path={}", root.display()))?;
38 let selected_path = selected_path
39 .canonicalize()
40 .context("failed to resolve selected Claude session path")
41 .attach_with(|| format!("path={}", selected_path.display()))?;
42 if !selected_path.starts_with(&root) {
43 return Err(report!("selected Claude session path is outside the session store")
44 .attach(format!("path={}", selected_path.display()))
45 .attach(format!("root={}", root.display())));
46 }
47 if !selected_path.is_file() || selected_path.extension().is_none_or(|extension| extension != "jsonl") {
48 return Err(report!("selected Claude session path is not a JSONL file")
49 .attach(format!("path={}", selected_path.display()))
50 .attach(format!("session_id={}", key.id())));
51 }
52
53 let content = std::fs::read_to_string(&selected_path)
54 .context("failed to read selected Claude session file")
55 .attach_with(|| format!("path={}", selected_path.display()))?;
56 let session = crate::agent::session_parser::claude::parse(&content)
57 .context("failed to parse selected Claude session file")
58 .attach_with(|| format!("path={}", selected_path.display()))?;
59 if session.id != key.id() {
60 return Err(report!("selected Claude session path does not match the session id")
61 .attach(format!("path={}", selected_path.display()))
62 .attach(format!("session_id={}", key.id()))
63 .attach(format!("metadata_id={}", session.id)));
64 }
65
66 Ok(DeletionPlan::new(key.clone(), vec![selected_path], 0, Vec::new()))
67}
68
69fn claude_session_path(path: &Path, session_id: &str) -> bool {
70 path.extension().is_some_and(|ext| ext == "jsonl")
71 && path
72 .file_name()
73 .and_then(|name| name.to_str())
74 .is_some_and(|name| !matches!(name, "sessions-index.json" | "session.json"))
75 && path.file_stem().and_then(|stem| stem.to_str()) == Some(session_id)
76}
77
78#[cfg(test)]
79mod tests {
80 use tempfile::tempdir;
81 use test_that::prelude::*;
82
83 use super::*;
84 use crate::agent::Agent;
85
86 #[test]
87 fn test_build_deletion_plan_when_selected_path_has_matching_metadata_uses_selected_path() {
88 let dir = tempdir().expect("tempdir should be created");
89 let root = dir.path().join("projects");
90 std::fs::create_dir_all(&root).expect("session root should be created");
91 let selected_path = root.join("selected.jsonl");
92 let matching_filename_path = root.join("target.jsonl");
93 std::fs::write(&selected_path, claude_content("target")).expect("selected session should be written");
94 std::fs::write(&matching_filename_path, claude_content("other")).expect("other session should be written");
95 let key = SessionKey::new(Agent::Claude, "target");
96
97 let plan = build_deletion_plan(&root, &key, Some(&selected_path)).expect("plan should resolve");
98
99 assert_that!(
100 plan.paths,
101 eq([selected_path.canonicalize().expect("path should resolve")])
102 );
103 }
104
105 #[test]
106 fn test_build_deletion_plan_when_selected_path_metadata_differs_from_key_rejects_path() {
107 let dir = tempdir().expect("tempdir should be created");
108 let root = dir.path().join("projects");
109 std::fs::create_dir_all(&root).expect("session root should be created");
110 let selected_path = root.join("selected.jsonl");
111 std::fs::write(&selected_path, claude_content("other")).expect("selected session should be written");
112 let key = SessionKey::new(Agent::Claude, "target");
113
114 let result = build_deletion_plan(&root, &key, Some(&selected_path));
115
116 assert_that!(
117 result,
118 err(displays_as(contains_substring(
119 "selected Claude session path does not match the session id"
120 )))
121 );
122 }
123
124 fn claude_content(id: &str) -> String {
125 format!(
126 "{{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"/tmp\",\"sessionId\":\"{id}\"}}\n"
127 )
128 }
129}