ytil_agents/agent/session_loader/
claude.rs1use std::collections::HashSet;
2use std::path::Path;
3use std::path::PathBuf;
4
5use rootcause::prelude::ResultExt;
6
7use crate::agent::Agent;
8use crate::agent::session::Session;
9use crate::agent::session::SessionKey;
10
11pub fn load_sessions() -> rootcause::Result<Vec<Session>> {
17 let root = ytil_sys::dir::build_home_path(Agent::Claude.sessions_root_path())?;
18 let session_paths =
19 crate::agent::session_loader::find_session_paths(&root, |entry| claude_session_path(&entry.path()), |_| false)?;
20
21 load_sessions_from_paths(session_paths, |_| true)
22}
23
24pub fn load_sessions_by_key(keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
29 let root = ytil_sys::dir::build_home_path(Agent::Claude.sessions_root_path())?;
30 load_sessions_from_root_by_key(&root, keys)
31}
32
33fn load_sessions_from_root_by_key(root: &Path, keys: &[SessionKey]) -> rootcause::Result<Vec<Session>> {
34 let requested_ids = crate::agent::session_loader::requested_ids(keys, Agent::Claude);
35 if requested_ids.is_empty() {
36 return Ok(Vec::new());
37 }
38 let session_paths = crate::agent::session_loader::find_session_paths(
39 root,
40 |entry| claude_session_path_matches_requested_id(&entry.path(), &requested_ids),
41 |_| false,
42 )?;
43
44 load_sessions_from_paths(session_paths, |session| requested_ids.contains(session.id.as_str()))
45}
46
47fn load_sessions_from_paths(
48 session_paths: Vec<PathBuf>,
49 keep_session: impl Fn(&Session) -> bool,
50) -> rootcause::Result<Vec<Session>> {
51 let mut sessions = Vec::new();
52 for session_path in session_paths {
53 let content = std::fs::read_to_string(&session_path)
54 .context("failed to read Claude session file")
55 .attach_with(|| format!("path={}", session_path.display()))?;
56 let claude_session = crate::agent::session_parser::claude::parse(&content)
57 .attach_with(|| format!("path={}", session_path.display()))?;
58 let session = claude_session.into_session(session_path);
59 if session.workspace.is_dir() && keep_session(&session) {
60 sessions.push(session);
61 }
62 }
63
64 Ok(sessions)
65}
66
67fn claude_session_path(path: &Path) -> bool {
68 path.extension().is_some_and(|ext| ext == "jsonl")
69 && path
70 .file_name()
71 .and_then(|name| name.to_str())
72 .is_some_and(|name| !matches!(name, "sessions-index.json" | "session.json"))
73}
74
75fn claude_session_path_matches_requested_id(path: &Path, requested_ids: &HashSet<&str>) -> bool {
76 claude_session_path(path)
77 && path
78 .file_stem()
79 .and_then(|name| name.to_str())
80 .is_some_and(|stem| requested_ids.contains(stem))
81}
82
83#[cfg(test)]
84mod tests {
85 use tempfile::tempdir;
86
87 use super::*;
88
89 #[test]
90 fn test_load_sessions_from_root_by_key_only_parses_matching_claude_files() {
91 let dir = tempdir().expect("tempdir should be created");
92 let root = dir.path().join("projects");
93 let workspace = dir.path().join("workspace");
94 std::fs::create_dir_all(&root).expect("session root should be created");
95 std::fs::create_dir_all(&workspace).expect("workspace should be created");
96 std::fs::write(root.join("target.jsonl"), claude_content("target", &workspace))
97 .expect("target session should be written");
98 std::fs::write(root.join("other.jsonl"), "not json\n").expect("nonmatching session should be written");
99
100 assert2::assert!(
101 let Ok(sessions) = load_sessions_from_root_by_key(&root, &[SessionKey::new(Agent::Claude, "target")])
102 );
103
104 pretty_assertions::assert_eq!(sessions.len(), 1);
105 pretty_assertions::assert_eq!(sessions[0].id, "target");
106 }
107
108 fn claude_content(id: &str, workspace: &Path) -> String {
109 format!(
110 "{{\"type\":\"progress\",\"timestamp\":\"2026-03-26T16:51:01.119Z\",\"cwd\":\"{}\",\"sessionId\":\"{id}\"}}\n",
111 workspace.display()
112 )
113 }
114}