oino_session/manager/
list.rs1use std::fs;
5use std::io::{BufRead, BufReader};
6use std::path::{Path, PathBuf};
7use std::sync::atomic::{AtomicBool, Ordering};
8use std::time::SystemTime;
9
10use oino_ai::JsonValue;
11
12use super::files::{cwd_session_dir, read_header};
13use crate::entry::IsoTimestamp;
14use crate::error::SessionError;
15use crate::time::ms_to_system_time;
16
17#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct SessionInfo {
20 pub path: PathBuf,
22 pub id: String,
24 pub cwd: String,
26 pub name: Option<String>,
28 pub parent_session_path: Option<PathBuf>,
30 pub created: IsoTimestamp,
32 pub modified: SystemTime,
34 pub message_count: usize,
36 pub first_message: String,
38 pub all_messages_text: String,
40}
41
42pub trait ProgressSink {
44 fn progress(&self, done: usize, total: usize);
46}
47
48fn message_text(message: &JsonValue) -> Option<String> {
49 match message.get("content")? {
50 JsonValue::String(text) => Some(text.clone()),
51 JsonValue::Array(blocks) => Some(
52 blocks
53 .iter()
54 .filter(|b| b.get("type").and_then(JsonValue::as_str) == Some("text"))
55 .filter_map(|b| b.get("text").and_then(JsonValue::as_str))
56 .collect::<Vec<_>>()
57 .join(" "),
58 ),
59 _ => None,
60 }
61}
62
63#[must_use]
65pub fn session_info(path: &Path) -> Option<SessionInfo> {
66 let file = fs::File::open(path).ok()?;
67 let mtime = file.metadata().and_then(|m| m.modified()).ok();
68 let mut header: Option<JsonValue> = None;
69 let mut name = None;
70 let mut message_count = 0;
71 let mut first_message = String::new();
72 let mut all_messages = Vec::new();
73 let mut last_activity: Option<i64> = None;
74 for line in BufReader::new(file).lines() {
75 let Ok(line) = line else { break };
76 let Ok(entry) = serde_json::from_str::<JsonValue>(&line) else {
77 continue;
78 };
79 let kind = entry.get("type").and_then(JsonValue::as_str);
80 if header.is_none() {
81 if kind != Some("session") {
82 return None;
83 }
84 header = Some(entry);
85 continue;
86 }
87 if kind == Some("session_info") {
88 name = entry
89 .get("name")
90 .and_then(JsonValue::as_str)
91 .map(str::trim)
92 .filter(|n| !n.is_empty())
93 .map(str::to_owned);
94 }
95 if kind != Some("message") {
96 continue;
97 }
98 message_count += 1;
99 let Some(message) = entry.get("message") else {
100 continue;
101 };
102 let role = message.get("role").and_then(JsonValue::as_str);
103 if !matches!(role, Some("user" | "assistant")) || message.get("content").is_none() {
104 continue;
105 }
106 let activity = message
107 .get("timestamp")
108 .and_then(JsonValue::as_i64)
109 .or_else(|| {
110 entry
111 .get("timestamp")
112 .and_then(JsonValue::as_str)
113 .and_then(|ts| IsoTimestamp::from_text(ts).to_ms())
114 });
115 if let Some(activity) = activity {
116 last_activity = Some(last_activity.map_or(activity, |last| last.max(activity)));
117 }
118 let Some(text) = message_text(message).filter(|t| !t.is_empty()) else {
119 continue;
120 };
121 if first_message.is_empty() && role == Some("user") {
122 first_message.clone_from(&text);
123 }
124 all_messages.push(text);
125 }
126 let header = header?;
127 let id = header.get("id").and_then(JsonValue::as_str)?.to_owned();
128 let created = IsoTimestamp::from_text(
129 header
130 .get("timestamp")
131 .and_then(JsonValue::as_str)
132 .unwrap_or_default(),
133 );
134 let modified = last_activity
135 .filter(|ms| *ms > 0)
136 .or_else(|| created.to_ms())
137 .map(ms_to_system_time)
138 .or(mtime)
139 .unwrap_or(SystemTime::UNIX_EPOCH);
140 Some(SessionInfo {
141 path: path.to_path_buf(),
142 id,
143 cwd: header
144 .get("cwd")
145 .and_then(JsonValue::as_str)
146 .unwrap_or_default()
147 .to_owned(),
148 name,
149 parent_session_path: header
150 .get("parentSession")
151 .and_then(JsonValue::as_str)
152 .map(PathBuf::from),
153 created,
154 modified,
155 message_count,
156 first_message: if first_message.is_empty() {
157 "(no messages)".into()
158 } else {
159 first_message
160 },
161 all_messages_text: all_messages.join(" "),
162 })
163}
164
165pub(crate) fn jsonl_files(dir: &Path) -> Vec<PathBuf> {
167 fs::read_dir(dir)
168 .map(|entries| {
169 entries
170 .filter_map(Result::ok)
171 .map(|entry| entry.path())
172 .filter(|path| path.extension().is_some_and(|ext| ext == "jsonl") && path.is_file())
173 .collect()
174 })
175 .unwrap_or_default()
176}
177
178fn mtime(path: &Path) -> Option<SystemTime> {
179 fs::metadata(path).and_then(|m| m.modified()).ok()
180}
181
182fn build_infos(
183 files: &[PathBuf],
184 progress: Option<&dyn ProgressSink>,
185 cancel: Option<&AtomicBool>,
186) -> Result<Vec<SessionInfo>, SessionError> {
187 let mut infos = Vec::with_capacity(files.len());
188 for (done, path) in files.iter().enumerate() {
189 if cancel.is_some_and(|flag| flag.load(Ordering::Relaxed)) {
190 return Err(SessionError::Cancelled);
191 }
192 infos.extend(session_info(path));
193 if let Some(sink) = progress {
194 sink.progress(done + 1, files.len());
195 }
196 }
197 infos.sort_by_key(|info| std::cmp::Reverse(info.modified));
198 Ok(infos)
199}
200
201pub(super) fn list(
207 cwd: &Path,
208 sessions_root: &Path,
209 progress: Option<&dyn ProgressSink>,
210 cancel: Option<&AtomicBool>,
211) -> Result<Vec<SessionInfo>, SessionError> {
212 let mut files = jsonl_files(&cwd_session_dir(sessions_root, cwd));
213 files.sort_by(|a, b| b.file_name().cmp(&a.file_name()));
214 build_infos(&files, progress, cancel)
215}
216
217pub(super) fn list_all(
224 sessions_root: &Path,
225 progress: Option<&dyn ProgressSink>,
226 cancel: Option<&AtomicBool>,
227) -> Result<Vec<SessionInfo>, SessionError> {
228 let mut files: Vec<(Option<SystemTime>, PathBuf)> = cwd_dirs(sessions_root)
229 .iter()
230 .flat_map(|dir| jsonl_files(dir))
231 .map(|path| (mtime(&path), path))
232 .collect();
233 files.sort_by(|a, b| {
234 b.0.cmp(&a.0)
235 .then_with(|| b.1.file_name().cmp(&a.1.file_name()))
236 });
237 let files: Vec<PathBuf> = files.into_iter().map(|(_, path)| path).collect();
238 build_infos(&files, progress, cancel)
239}
240
241fn cwd_dirs(sessions_root: &Path) -> Vec<PathBuf> {
242 fs::read_dir(sessions_root)
243 .map(|entries| {
244 entries
245 .filter_map(Result::ok)
246 .filter(|entry| !entry.file_name().to_string_lossy().starts_with('.'))
247 .map(|entry| entry.path())
248 .filter(|path| path.is_dir())
249 .collect()
250 })
251 .unwrap_or_default()
252}
253
254#[must_use]
256pub(super) fn find_most_recent(cwd: &Path, sessions_root: &Path) -> Option<PathBuf> {
257 let mut files: Vec<(Option<SystemTime>, PathBuf)> =
258 jsonl_files(&cwd_session_dir(sessions_root, cwd))
259 .into_iter()
260 .map(|path| (mtime(&path), path))
261 .collect();
262 files.sort_by_key(|(time, _)| std::cmp::Reverse(*time));
263 files
264 .into_iter()
265 .map(|(_, path)| path)
266 .find(|path| read_header(path).is_some())
267}
268
269fn search(files: &[PathBuf], id: &str) -> Result<Option<PathBuf>, SessionError> {
270 let headers: Vec<(PathBuf, String)> = files
271 .iter()
272 .filter_map(|path| read_header(path).map(|h| (path.clone(), h.id)))
273 .collect();
274 if let Some((path, _)) = headers.iter().find(|(_, header_id)| header_id == id) {
275 return Ok(Some(path.clone()));
276 }
277 if id.len() < 4 {
278 return Ok(None);
279 }
280 let matches: Vec<PathBuf> = headers
281 .into_iter()
282 .filter(|(_, header_id)| header_id.starts_with(id))
283 .map(|(path, _)| path)
284 .collect();
285 match matches.len() {
286 0 => Ok(None),
287 1 => Ok(matches.into_iter().next()),
288 _ => Err(SessionError::AmbiguousId(matches)),
289 }
290}
291
292pub(super) fn find_by_id(
299 cwd: &Path,
300 id: &str,
301 sessions_root: &Path,
302) -> Result<Option<PathBuf>, SessionError> {
303 let cwd_dir = cwd_session_dir(sessions_root, cwd);
304 if let Some(found) = search(&jsonl_files(&cwd_dir), id)? {
305 return Ok(Some(found));
306 }
307 let all: Vec<PathBuf> = cwd_dirs(sessions_root)
308 .iter()
309 .filter(|dir| **dir != cwd_dir)
310 .flat_map(|dir| jsonl_files(dir))
311 .collect();
312 search(&all, id)
313}