use std::path::{Path, PathBuf};
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::config;
use crate::provider::Msg;
#[derive(Serialize, Deserialize)]
pub struct Session {
pub id: String,
pub title: String, pub cwd: String,
pub model: String,
pub created_ms: u128,
pub updated_ms: u128,
pub msgs: Vec<Msg>,
}
fn now_ms() -> u128 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_millis())
.unwrap_or(0)
}
fn dir() -> PathBuf {
config::home().join("sessions")
}
pub fn new_id() -> String {
format!("{:016}", now_ms())
}
fn file(id: &str) -> PathBuf {
dir().join(format!("{id}.json"))
}
fn title_of(msgs: &[Msg]) -> String {
for m in msgs {
if let Msg::User(t) = m {
let t = t.trim();
if !t.is_empty() {
return t.chars().take(80).collect();
}
}
}
"(untitled)".to_string()
}
pub fn save(id: &str, cwd: &Path, model: &str, msgs: &[Msg]) {
if msgs.is_empty() {
return;
}
let _ = std::fs::create_dir_all(dir());
let created = load(id).map(|s| s.created_ms).unwrap_or_else(now_ms);
let s = Session {
id: id.to_string(),
title: title_of(msgs),
cwd: cwd.to_string_lossy().into_owned(),
model: model.to_string(),
created_ms: created,
updated_ms: now_ms(),
msgs: msgs.to_vec(),
};
if let Ok(text) = serde_json::to_string(&s) {
let path = file(id);
let tmp = dir().join(format!("{id}.json.tmp"));
if std::fs::write(&tmp, text).is_ok() && std::fs::rename(&tmp, &path).is_err() {
let _ = std::fs::remove_file(&tmp);
}
}
}
fn parse_session(path: &Path, text: &str) -> Option<Session> {
match serde_json::from_str(text) {
Ok(s) => Some(s),
Err(e) => {
crate::tui::line(&crate::tui::yellow(&format!(
" ⚠ skipping corrupt session file {}: {e}",
path.display()
)));
None
}
}
}
pub fn load(id: &str) -> Option<Session> {
let path = file(id);
let text = std::fs::read_to_string(&path).ok()?;
parse_session(&path, &text)
}
pub fn list() -> Vec<Session> {
let mut v: Vec<Session> = match std::fs::read_dir(dir()) {
Ok(rd) => rd
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|x| x == "json"))
.filter_map(|e| {
let path = e.path();
let text = std::fs::read_to_string(&path).ok()?;
parse_session(&path, &text)
})
.collect(),
Err(_) => Vec::new(),
};
v.sort_by_key(|s| std::cmp::Reverse(s.updated_ms));
v
}
pub fn latest() -> Option<Session> {
list().into_iter().next()
}
#[cfg(test)]
mod tests {
use super::*;
fn with_home<T>(f: impl FnOnce() -> T) -> T {
use std::sync::atomic::{AtomicU64, Ordering};
let _g = crate::config::TEST_ENV_LOCK
.lock()
.unwrap_or_else(|e| e.into_inner());
static N: AtomicU64 = AtomicU64::new(0);
let id = N.fetch_add(1, Ordering::Relaxed);
let home = std::env::temp_dir().join(format!("bwn-sess-{}-{id}", std::process::id()));
let _ = std::fs::remove_dir_all(&home);
std::env::set_var("NEXUS_HOME", &home);
let r = f();
std::env::remove_var("NEXUS_HOME");
let _ = std::fs::remove_dir_all(&home);
r
}
#[test]
fn save_load_roundtrip_and_title() {
with_home(|| {
let msgs = vec![
Msg::System("sys".into()),
Msg::User("fix the parser bug".into()),
];
save("0000000000000001", Path::new("/proj"), "gpt-4o", &msgs);
let s = load("0000000000000001").expect("session loads");
assert_eq!(s.title, "fix the parser bug");
assert_eq!(s.model, "gpt-4o");
assert_eq!(s.msgs.len(), 2);
});
}
#[test]
fn list_orders_newest_first_and_empty_is_noop() {
with_home(|| {
save(
"0000000000000001",
Path::new("/p"),
"m",
&[Msg::User("first".into())],
);
save(
"0000000000000002",
Path::new("/p"),
"m",
&[Msg::User("second".into())],
);
save("0000000000000003", Path::new("/p"), "m", &[]); let ls = list();
assert_eq!(ls.len(), 2);
assert!(ls.iter().any(|s| s.title == "first"));
assert!(ls.iter().any(|s| s.title == "second"));
});
}
#[test]
fn save_leaves_no_tmp_file_and_result_is_loadable() {
with_home(|| {
save(
"0000000000000009",
Path::new("/p"),
"m",
&[Msg::User("atomic".into())],
);
assert!(load("0000000000000009").is_some());
assert!(!dir().join("0000000000000009.json.tmp").exists());
});
}
#[test]
fn list_skips_corrupt_session_files_but_keeps_valid_ones() {
with_home(|| {
save(
"0000000000000001",
Path::new("/p"),
"m",
&[Msg::User("ok".into())],
);
std::fs::write(dir().join("corrupt.json"), "{not valid json").unwrap();
let ls = list();
assert_eq!(ls.len(), 1);
assert_eq!(ls[0].title, "ok");
assert!(dir().join("corrupt.json").exists());
});
}
#[test]
fn load_returns_none_for_corrupt_file() {
with_home(|| {
let _ = std::fs::create_dir_all(dir());
std::fs::write(dir().join("0000000000000042.json"), "garbage").unwrap();
assert!(load("0000000000000042").is_none());
});
}
}