Skip to main content

ytil_agents/agent/session_deletion/
claude.rs

1use 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}