use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Meta {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub origin: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub parent_session_id: Option<String>,
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub gone: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub last_line: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub provider: Option<String>,
#[serde(default)]
pub updated_ms: u64,
}
static LOCK: Mutex<()> = Mutex::new(());
pub fn path(state_dir: &Path) -> PathBuf {
state_dir.join("session-meta.json")
}
fn load_map(file: &Path) -> BTreeMap<String, Meta> {
std::fs::read(file)
.ok()
.and_then(|b| serde_json::from_slice(&b).ok())
.unwrap_or_default()
}
fn save_map(file: &Path, map: &BTreeMap<String, Meta>) {
let tmp = file.with_extension("json.tmp");
if let Ok(b) = serde_json::to_vec_pretty(map) {
let _ = std::fs::write(&tmp, b).and_then(|_| std::fs::rename(&tmp, file));
}
}
fn key(instance_id: u64) -> String {
instance_id.to_string()
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_millis() as u64)
.unwrap_or(0)
}
pub fn upsert(state_dir: &Path, instance_id: u64, patch: Meta) {
let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
let file = path(state_dir);
let mut m = load_map(&file);
let e = m.entry(key(instance_id)).or_default();
if let Some(o) = patch.origin {
e.origin = Some(o);
}
if let Some(p) = patch.parent_session_id {
e.parent_session_id = Some(p);
}
if patch.gone {
e.gone = true;
}
if let Some(l) = patch.last_line {
e.last_line = Some(l);
}
if let Some(p) = patch.provider {
e.provider = Some(p);
}
e.updated_ms = now_ms();
save_map(&file, &m);
}
pub fn mark_gone(state_dir: &Path, instance_id: u64) {
upsert(
state_dir,
instance_id,
Meta {
gone: true,
updated_ms: now_ms(),
..Meta::default()
},
);
}
pub fn revive(state_dir: &Path, instance_id: u64) {
let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
let file = path(state_dir);
let mut m = load_map(&file);
if let Some(e) = m.get_mut(&key(instance_id)) {
e.gone = false;
e.updated_ms = now_ms();
save_map(&file, &m);
}
}
pub fn get(state_dir: &Path, instance_id: u64) -> Option<Meta> {
let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
load_map(&path(state_dir)).remove(&key(instance_id))
}
pub fn gone_ids(state_dir: &Path) -> Vec<u64> {
let _g = LOCK.lock().unwrap_or_else(|p| p.into_inner());
load_map(&path(state_dir))
.into_iter()
.filter(|(_, v)| v.gone)
.filter_map(|(k, _)| k.parse().ok())
.collect()
}
pub fn set_last_line(state_dir: &Path, instance_id: u64, line: &str) {
let line = line.trim();
if line.is_empty() {
return;
}
let clipped: String = line.chars().take(160).collect();
upsert(
state_dir,
instance_id,
Meta {
last_line: Some(clipped),
..Meta::default()
},
);
}
#[cfg(test)]
mod tests {
use super::*;
use std::time::{SystemTime, UNIX_EPOCH};
#[test]
fn gone_and_origin_roundtrip() {
let dir = std::env::temp_dir().join(format!(
"session-meta-test-{}",
SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_nanos()
));
let _ = std::fs::create_dir_all(&dir);
upsert(
&dir,
7,
Meta {
origin: Some("human".into()),
parent_session_id: Some("claude#3".into()),
provider: Some("claude".into()),
..Meta::default()
},
);
let m = get(&dir, 7).unwrap();
assert_eq!(m.origin.as_deref(), Some("human"));
assert_eq!(m.parent_session_id.as_deref(), Some("claude#3"));
mark_gone(&dir, 7);
assert!(get(&dir, 7).unwrap().gone);
assert!(gone_ids(&dir).contains(&7));
revive(&dir, 7);
assert!(!get(&dir, 7).unwrap().gone);
let _ = std::fs::remove_dir_all(&dir);
}
}