use std::fs::{self, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use super::format::{make_persisted, parse_entry};
use super::time::{truncate_topic, uuid_v7_timestamp};
use super::traits::EntryType;
#[derive(Debug, Clone)]
pub struct SessionMeta {
pub path: PathBuf,
pub created: u64,
pub message_count: usize,
pub topic: String,
pub size_bytes: u64,
pub session_id: Option<String>,
}
impl SessionMeta {
pub(crate) fn from_path(path: &Path) -> Option<Self> {
let meta = fs::metadata(path).ok()?;
let filename = path.file_stem()?.to_str()?;
let file = fs::File::open(path).ok()?;
let reader = BufReader::new(file);
let mut message_count = 0;
let mut topic = String::new();
let mut session_id = None;
let mut first_uuid = None;
for line in reader.lines().map_while(Result::ok) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
message_count += 1;
if session_id.is_none() {
session_id = value["sessionId"].as_str().map(String::from);
}
if first_uuid.is_none() {
first_uuid = value["uuid"].as_str().map(String::from);
}
if topic.is_empty()
&& let Some((EntryType::User, content)) = parse_entry(&value)
{
topic = truncate_topic(&content);
}
}
let created = first_uuid
.as_deref()
.and_then(uuid_v7_timestamp)
.or_else(|| filename.strip_prefix("session_")?.parse::<u64>().ok())
.or_else(|| {
meta.modified()
.ok()
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs())
})
.unwrap_or(0);
Some(Self {
path: path.to_path_buf(),
created,
message_count,
topic,
size_bytes: meta.len(),
session_id,
})
}
}
pub fn list_sessions(session_dir: &str) -> Vec<SessionMeta> {
let Ok(entries) = fs::read_dir(session_dir) else {
return vec![];
};
let mut sessions: Vec<SessionMeta> = entries
.filter_map(|e| e.ok())
.filter(|e| e.path().extension().is_some_and(|ext| ext == "jsonl"))
.filter_map(|e| SessionMeta::from_path(&e.path()))
.collect();
sessions.sort_by(|a, b| b.created.cmp(&a.created));
sessions
}
#[cfg(feature = "search")]
pub fn search_sessions(session_dir: &str, query: &str) -> Vec<(u32, SessionMeta)> {
use nucleo_matcher::pattern::{CaseMatching, Normalization, Pattern};
use nucleo_matcher::{Config, Matcher, Utf32Str};
let sessions = list_sessions(session_dir);
if sessions.is_empty() || query.is_empty() {
return sessions.into_iter().map(|s| (0, s)).collect();
}
let pattern = Pattern::parse(query, CaseMatching::Ignore, Normalization::Smart);
let mut matcher = Matcher::new(Config::DEFAULT);
let mut matches: Vec<(u32, SessionMeta)> = sessions
.into_iter()
.filter_map(|s| {
let haystack = Utf32Str::Ascii(s.topic.as_bytes());
pattern
.score(haystack, &mut matcher)
.map(|score| (score, s))
})
.collect();
matches.sort_by(|a, b| b.0.cmp(&a.0));
matches
}
pub fn import_claude_session(claude_path: &Path, output_dir: &str) -> Option<PathBuf> {
let file = fs::File::open(claude_path).ok()?;
let reader = BufReader::new(file);
let session_id = uuid::Uuid::now_v7().to_string();
let mut entries: Vec<String> = Vec::new();
let mut last_uuid = None;
for line in reader.lines().map_while(Result::ok) {
let Ok(value) = serde_json::from_str::<serde_json::Value>(&line) else {
continue;
};
let type_str = value["type"].as_str().unwrap_or("");
if EntryType::parse(type_str).is_none() {
continue;
}
if value.get("message").is_some() && value.get("uuid").is_some() {
let mut entry = value.clone();
entry["sessionId"] = serde_json::Value::String(session_id.clone());
if let Some(uid) = entry["uuid"].as_str() {
last_uuid = Some(uid.to_string());
}
if let Ok(json) = serde_json::to_string(&entry) {
entries.push(json);
}
continue;
}
if let Some((entry_type, content)) = parse_entry(&value) {
let persisted = make_persisted(entry_type, &content, &session_id, last_uuid.as_deref());
last_uuid = Some(persisted.uuid.clone());
if let Ok(json) = serde_json::to_string(&persisted) {
entries.push(json);
}
}
}
if entries.is_empty() {
return None;
}
fs::create_dir_all(output_dir).ok()?;
let output_path = Path::new(output_dir).join(format!("{}.jsonl", session_id));
let mut file = OpenOptions::new()
.create(true)
.truncate(true)
.write(true)
.open(&output_path)
.ok()?;
for json in &entries {
let _ = writeln!(file, "{}", json);
}
Some(output_path)
}