Skip to main content

oino_session/manager/
list.rs

1//! Session discovery: listing, most-recent lookup and id search (`list`, `listAll`,
2//! `findMostRecentSession`, `findById`).
3
4use 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/// Summary of one session file.
18#[derive(Clone, Debug, PartialEq, Eq)]
19pub struct SessionInfo {
20    /// File path.
21    pub path: PathBuf,
22    /// Session id.
23    pub id: String,
24    /// Header cwd (empty when missing).
25    pub cwd: String,
26    /// Latest `session_info` name.
27    pub name: Option<String>,
28    /// Header `parentSession`.
29    pub parent_session_path: Option<PathBuf>,
30    /// Header timestamp.
31    pub created: IsoTimestamp,
32    /// Last user/assistant activity, else header time, else file mtime.
33    pub modified: SystemTime,
34    /// Number of `message` entries.
35    pub message_count: usize,
36    /// First user text, or `"(no messages)"`.
37    pub first_message: String,
38    /// All user/assistant texts joined with spaces (search corpus).
39    pub all_messages_text: String,
40}
41
42/// Receives `(done, total)` while listing.
43pub trait ProgressSink {
44    /// Called after each file.
45    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/// Builds the summary of one file; `None` when it is not a session file.
64#[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
165/// `*.jsonl` files directly inside `dir` (missing dir ⇒ empty).
166pub(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
201/// Sessions of `cwd` (`<root>/--<cwd>--/`), newest modified first.
202///
203/// # Errors
204///
205/// [`SessionError::Cancelled`] when `cancel` is set.
206pub(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
217/// Sessions of every cwd directory under the root (dot-directories such as `.legacy` are
218/// skipped), newest modified first.
219///
220/// # Errors
221///
222/// [`SessionError::Cancelled`] when `cancel` is set.
223pub(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/// Newest (by mtime) valid session file of `cwd`.
255#[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
292/// Finds a session by id: exact header id, then a unique prefix (≥ 4 chars) in the cwd
293/// directory, then the same over all directories.
294///
295/// # Errors
296///
297/// [`SessionError::AmbiguousId`] when a prefix matches several files.
298pub(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}