#![cfg_attr(test, allow(clippy::expect_used, clippy::unwrap_used))]
use std::path::PathBuf;
use motosan_agent_loop::{FileSessionStore, SessionStore};
use crate::error::{AppError, Result};
use crate::session::{paths::SessionPaths, SessionId};
pub struct SessionLookup;
impl SessionLookup {
pub async fn most_recent(paths: &SessionPaths) -> Result<SessionId> {
if !paths.bucket_dir.exists() {
return Err(AppError::Config(format!(
"no sessions yet for this directory (looked in {})",
paths.bucket_dir.display()
)));
}
let store = FileSessionStore::new(paths.bucket_dir.clone());
let metas = store
.list_meta()
.await
.map_err(|err| AppError::Config(format!("list_meta failed: {err}")))?;
let first = metas
.into_iter()
.next()
.ok_or_else(|| AppError::Config("no sessions found".into()))?;
Ok(SessionId::from_string(first.session_id))
}
pub async fn resolve(paths: &SessionPaths, prefix_or_path: &str) -> Result<SessionId> {
if prefix_or_path.starts_with('/')
|| prefix_or_path.starts_with('~')
|| prefix_or_path.starts_with('.')
{
return resolve_path(prefix_or_path);
}
resolve_prefix(paths, prefix_or_path).await
}
}
fn resolve_path(input: &str) -> Result<SessionId> {
let expanded = expand_tilde(input);
if !expanded.exists() {
return Err(AppError::Config(format!(
"session file not found: {}",
expanded.display()
)));
}
let stem = expanded
.file_stem()
.and_then(|s| s.to_str())
.ok_or_else(|| {
AppError::Config(format!("session path has no stem: {}", expanded.display()))
})?;
Ok(SessionId::from_string(stem.to_string()))
}
fn expand_tilde(input: &str) -> PathBuf {
if let Some(rest) = input.strip_prefix("~/") {
if let Ok(home) = std::env::var("HOME") {
return PathBuf::from(home).join(rest);
}
}
PathBuf::from(input)
}
async fn resolve_prefix(paths: &SessionPaths, prefix: &str) -> Result<SessionId> {
if paths.bucket_dir.exists() {
let store = FileSessionStore::new(paths.bucket_dir.clone());
let ids = store
.list()
.await
.map_err(|err| AppError::Config(format!("list failed: {err}")))?;
let matches: Vec<String> = ids
.into_iter()
.filter(|id| id.starts_with(prefix))
.collect();
match matches.len() {
0 => {} 1 => {
let only = matches
.into_iter()
.next()
.ok_or_else(|| AppError::Config("internal: vec drained".into()))?;
return Ok(SessionId::from_string(only));
}
n => {
return Err(AppError::Config(format!(
"session prefix {prefix:?} matches {n} sessions in this directory"
)));
}
}
}
let sessions_root = paths.sessions_root();
if !sessions_root.exists() {
return Err(AppError::Config(format!(
"session prefix {prefix:?}: no sessions exist (root {} missing)",
sessions_root.display()
)));
}
let mut all_matches: Vec<String> = Vec::new();
let iter = std::fs::read_dir(&sessions_root).map_err(|err| {
AppError::Config(format!(
"failed to read sessions root {}: {err}",
sessions_root.display()
))
})?;
for entry in iter {
let entry = entry.map_err(|err| AppError::Config(format!("readdir error: {err}")))?;
let path = entry.path();
if !path.is_dir() {
continue;
}
let store = FileSessionStore::new(path);
let ids = store
.list()
.await
.map_err(|err| AppError::Config(format!("list failed: {err}")))?;
for id in ids {
if id.starts_with(prefix) {
all_matches.push(id);
}
}
}
match all_matches.len() {
0 => Err(AppError::Config(format!(
"session prefix {prefix:?} matches no sessions"
))),
1 => {
let only = all_matches
.into_iter()
.next()
.ok_or_else(|| AppError::Config("internal: vec drained".into()))?;
Ok(SessionId::from_string(only))
}
n => Err(AppError::Config(format!(
"session prefix {prefix:?} matches {n} sessions across all buckets"
))),
}
}
#[cfg(test)]
mod tests {
use super::*;
use motosan_agent_loop::{Message, SessionEntry};
use tempfile::tempdir;
async fn seed_session(paths: &SessionPaths, id: &str) {
paths.ensure_bucket().unwrap();
let store = FileSessionStore::new(paths.bucket_dir.clone());
store
.append_entry(id, &SessionEntry::message(Message::user("hi")))
.await
.unwrap();
store.flush(id).await.unwrap();
}
#[tokio::test]
async fn most_recent_returns_latest_by_updated_at() {
let tmp = tempdir().unwrap();
let agent_dir = tmp.path().to_path_buf();
let cwd = PathBuf::from("/tmp/x");
let paths = SessionPaths::for_cwd(agent_dir, &cwd);
seed_session(&paths, "01OLD000000000000000000000").await;
tokio::time::sleep(std::time::Duration::from_millis(5)).await;
seed_session(&paths, "01NEW000000000000000000000").await;
let most_recent = SessionLookup::most_recent(&paths).await.unwrap();
assert_eq!(most_recent.as_str(), "01NEW000000000000000000000");
}
#[tokio::test]
async fn most_recent_errors_on_empty_bucket() {
let tmp = tempdir().unwrap();
let paths = SessionPaths::for_cwd(tmp.path().to_path_buf(), &PathBuf::from("/x"));
let err = SessionLookup::most_recent(&paths).await.unwrap_err();
assert!(format!("{err}").contains("no sessions"));
}
#[tokio::test]
async fn resolve_prefix_unique_match_in_bucket_succeeds() {
let tmp = tempdir().unwrap();
let paths = SessionPaths::for_cwd(tmp.path().to_path_buf(), &PathBuf::from("/x"));
seed_session(&paths, "01ABCDEFGHIJKLMNOPQRSTUVWX").await;
let id = SessionLookup::resolve(&paths, "01ABC").await.unwrap();
assert_eq!(id.as_str(), "01ABCDEFGHIJKLMNOPQRSTUVWX");
}
#[tokio::test]
async fn resolve_prefix_ambiguous_match_errors() {
let tmp = tempdir().unwrap();
let paths = SessionPaths::for_cwd(tmp.path().to_path_buf(), &PathBuf::from("/x"));
seed_session(&paths, "01ABCD0000000000000000000A").await;
seed_session(&paths, "01ABCD0000000000000000000B").await;
let err = SessionLookup::resolve(&paths, "01ABCD").await.unwrap_err();
assert!(format!("{err}").contains("matches 2 sessions"));
}
#[tokio::test]
async fn resolve_path_starting_with_slash_reads_from_filesystem() {
let tmp = tempdir().unwrap();
let agent_dir = tmp.path().to_path_buf();
let paths = SessionPaths::for_cwd(agent_dir, &PathBuf::from("/x"));
seed_session(&paths, "01XYZWVUTSRQPONMLKJIHGFEDC").await;
let file = paths.bucket_dir.join("01XYZWVUTSRQPONMLKJIHGFEDC.jsonl");
assert!(file.exists());
let id = SessionLookup::resolve(&paths, file.to_str().unwrap())
.await
.unwrap();
assert_eq!(id.as_str(), "01XYZWVUTSRQPONMLKJIHGFEDC");
}
}