use std::path::PathBuf;
use std::time::{SystemTime, UNIX_EPOCH};
use serde::{Deserialize, Serialize};
use crate::config::Config;
use crate::error::{Result, ZeptoError};
use crate::session::{Message, Role, Session};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConversationEntry {
pub session_key: String,
pub title: String,
pub message_count: usize,
pub last_updated: String,
pub file_size: u64,
}
pub struct ConversationHistory {
storage_path: PathBuf,
}
impl ConversationHistory {
pub fn new() -> Result<Self> {
let storage_path = Config::dir().join("sessions");
std::fs::create_dir_all(&storage_path)?;
Ok(Self { storage_path })
}
pub fn with_path(path: PathBuf) -> Result<Self> {
std::fs::create_dir_all(&path)?;
Ok(Self { storage_path: path })
}
pub fn list_conversations(&self) -> Result<Vec<ConversationEntry>> {
let mut entries = Vec::new();
let dir_entries = std::fs::read_dir(&self.storage_path)?;
for entry in dir_entries {
let entry = entry?;
let path = entry.path();
let file_name = match path.file_name().and_then(|n| n.to_str()) {
Some(name) => name.to_string(),
None => continue,
};
if !file_name.ends_with(".json") || !file_name.starts_with("cli%3A") {
continue;
}
let content = match std::fs::read_to_string(&path) {
Ok(c) => c,
Err(_) => continue,
};
let session: Session = match serde_json::from_str(&content) {
Ok(s) => s,
Err(_) => continue,
};
if !session.key.starts_with("cli:") {
continue;
}
let file_size = entry.metadata().map(|m| m.len()).unwrap_or(0);
entries.push(ConversationEntry {
session_key: session.key.clone(),
title: Self::extract_title(&session.messages),
message_count: session.messages.len(),
last_updated: session.updated_at.to_rfc3339(),
file_size,
});
}
entries.sort_by(|a, b| b.last_updated.cmp(&a.last_updated));
Ok(entries)
}
pub fn latest_conversation(&self) -> Result<Option<ConversationEntry>> {
let conversations = self.list_conversations()?;
Ok(conversations.into_iter().next())
}
pub fn find_conversation(&self, query: &str) -> Result<Option<ConversationEntry>> {
let conversations = self.list_conversations()?;
if let Some(entry) = conversations.iter().find(|e| e.session_key == query) {
return Ok(Some(entry.clone()));
}
let query_lower = query.to_lowercase();
Ok(conversations
.into_iter()
.find(|e| e.title.to_lowercase().contains(&query_lower)))
}
pub fn generate_session_key() -> String {
let timestamp = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
format!("cli:{}", timestamp)
}
pub fn extract_title(messages: &[Message]) -> String {
if messages.is_empty() {
return "(empty conversation)".to_string();
}
match messages.iter().find(|m| m.role == Role::User) {
Some(msg) => {
let content = msg.content.trim();
if content.len() <= 80 {
content.to_string()
} else {
let mut truncated: String = content.chars().take(80).collect();
truncated.push_str("...");
truncated
}
}
None => "(no user messages)".to_string(),
}
}
pub fn cleanup_old(&self, keep_count: usize) -> Result<usize> {
let conversations = self.list_conversations()?;
if conversations.len() <= keep_count {
return Ok(0);
}
let to_delete = &conversations[keep_count..];
let mut deleted = 0;
for entry in to_delete {
let sanitized = Self::sanitize_key(&entry.session_key);
let file_path = self.storage_path.join(format!("{}.json", sanitized));
if file_path.exists() {
std::fs::remove_file(&file_path).map_err(|e| {
ZeptoError::Session(format!(
"Failed to delete session file {}: {}",
file_path.display(),
e
))
})?;
deleted += 1;
}
}
Ok(deleted)
}
fn sanitize_key(key: &str) -> String {
let mut result = String::with_capacity(key.len() * 3);
for c in key.chars() {
match c {
'/' => result.push_str("%2F"),
'\\' => result.push_str("%5C"),
':' => result.push_str("%3A"),
'*' => result.push_str("%2A"),
'?' => result.push_str("%3F"),
'"' => result.push_str("%22"),
'<' => result.push_str("%3C"),
'>' => result.push_str("%3E"),
'|' => result.push_str("%7C"),
'%' => result.push_str("%25"),
c => result.push(c),
}
}
result
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::Path;
use tempfile::TempDir;
fn write_test_session(dir: &Path, key: &str, user_msg: &str, updated_at: &str) {
let session_json = serde_json::json!({
"key": key,
"messages": [{
"role": "user",
"content": user_msg
}],
"summary": null,
"created_at": updated_at,
"updated_at": updated_at
});
let sanitized = key.replace(':', "%3A");
let path = dir.join(format!("{}.json", sanitized));
std::fs::write(path, serde_json::to_string(&session_json).unwrap()).unwrap();
}
#[test]
fn test_conversation_entry_creation() {
let entry = ConversationEntry {
session_key: "cli:1700000000".to_string(),
title: "Hello world".to_string(),
message_count: 3,
last_updated: "2025-11-14T22:13:20+00:00".to_string(),
file_size: 256,
};
assert_eq!(entry.session_key, "cli:1700000000");
assert_eq!(entry.title, "Hello world");
assert_eq!(entry.message_count, 3);
assert_eq!(entry.last_updated, "2025-11-14T22:13:20+00:00");
assert_eq!(entry.file_size, 256);
}
#[test]
fn test_generate_session_key() {
let key = ConversationHistory::generate_session_key();
assert!(
key.starts_with("cli:"),
"Key should start with 'cli:', got: {}",
key
);
let timestamp_part = &key[4..];
let parsed: u64 = timestamp_part
.parse()
.expect("Timestamp part should be a valid u64");
assert!(parsed > 0, "Timestamp should be positive");
}
#[test]
fn test_extract_title_from_messages() {
let messages = vec![
Message::system("You are helpful"),
Message::user("Tell me about Rust programming language and its memory safety features"),
];
let title = ConversationHistory::extract_title(&messages);
assert_eq!(
title,
"Tell me about Rust programming language and its memory safety features"
);
let long_msg = "a".repeat(120);
let messages = vec![Message::user(&long_msg)];
let title = ConversationHistory::extract_title(&messages);
assert!(
title.len() <= 83,
"Title should be at most 80 chars + '...'"
);
assert!(title.ends_with("..."), "Long title should end with '...'");
let title_prefix: String = title.chars().take(80).collect();
let long_msg_prefix: String = long_msg.chars().take(80).collect();
assert_eq!(title_prefix, long_msg_prefix);
}
#[test]
fn test_extract_title_empty_messages() {
let messages: Vec<Message> = vec![];
let title = ConversationHistory::extract_title(&messages);
assert_eq!(title, "(empty conversation)");
}
#[test]
fn test_extract_title_no_user_messages() {
let messages = vec![
Message::system("System prompt"),
Message::assistant("Hello there"),
];
let title = ConversationHistory::extract_title(&messages);
assert_eq!(title, "(no user messages)");
}
#[test]
fn test_list_conversations_empty_dir() {
let temp_dir = TempDir::new().unwrap();
let history = ConversationHistory::with_path(temp_dir.path().to_path_buf()).unwrap();
let conversations = history.list_conversations().unwrap();
assert!(conversations.is_empty());
}
#[test]
fn test_list_conversations_with_sessions() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(
dir,
"cli:1000",
"First conversation",
"2025-01-01T00:00:00Z",
);
write_test_session(
dir,
"cli:2000",
"Second conversation",
"2025-06-15T12:00:00Z",
);
write_test_session(
dir,
"cli:3000",
"Third conversation",
"2025-03-10T06:30:00Z",
);
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
let conversations = history.list_conversations().unwrap();
assert_eq!(conversations.len(), 3);
assert_eq!(conversations[0].session_key, "cli:2000");
assert_eq!(conversations[1].session_key, "cli:3000");
assert_eq!(conversations[2].session_key, "cli:1000");
assert_eq!(conversations[0].title, "Second conversation");
assert_eq!(conversations[1].title, "Third conversation");
assert_eq!(conversations[2].title, "First conversation");
for conv in &conversations {
assert_eq!(conv.message_count, 1);
}
}
#[test]
fn test_list_conversations_ignores_non_cli() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(dir, "cli:1000", "CLI session", "2025-01-01T00:00:00Z");
write_test_session(
dir,
"telegram:chat123",
"Telegram session",
"2025-01-02T00:00:00Z",
);
write_test_session(
dir,
"slack:channel456",
"Slack session",
"2025-01-03T00:00:00Z",
);
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
let conversations = history.list_conversations().unwrap();
assert_eq!(conversations.len(), 1);
assert_eq!(conversations[0].session_key, "cli:1000");
assert_eq!(conversations[0].title, "CLI session");
}
#[test]
fn test_latest_conversation() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(dir, "cli:1000", "Old conversation", "2025-01-01T00:00:00Z");
write_test_session(
dir,
"cli:2000",
"Newest conversation",
"2025-12-31T23:59:59Z",
);
write_test_session(
dir,
"cli:3000",
"Middle conversation",
"2025-06-15T12:00:00Z",
);
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
let latest = history.latest_conversation().unwrap();
assert!(latest.is_some());
let latest = latest.unwrap();
assert_eq!(latest.session_key, "cli:2000");
assert_eq!(latest.title, "Newest conversation");
}
#[test]
fn test_latest_conversation_empty() {
let temp_dir = TempDir::new().unwrap();
let history = ConversationHistory::with_path(temp_dir.path().to_path_buf()).unwrap();
let latest = history.latest_conversation().unwrap();
assert!(latest.is_none());
}
#[test]
fn test_find_conversation_by_title() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(
dir,
"cli:1000",
"Discuss Rust memory safety",
"2025-01-01T00:00:00Z",
);
write_test_session(
dir,
"cli:2000",
"Python web framework comparison",
"2025-06-15T12:00:00Z",
);
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
let found = history.find_conversation("rust memory").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().session_key, "cli:1000");
let found = history.find_conversation("PYTHON WEB").unwrap();
assert!(found.is_some());
assert_eq!(found.unwrap().session_key, "cli:2000");
let found = history.find_conversation("nonexistent topic").unwrap();
assert!(found.is_none());
}
#[test]
fn test_find_conversation_by_key() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(dir, "cli:1000", "First session", "2025-01-01T00:00:00Z");
write_test_session(dir, "cli:2000", "Second session", "2025-06-15T12:00:00Z");
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
let found = history.find_conversation("cli:1000").unwrap();
assert!(found.is_some());
let entry = found.unwrap();
assert_eq!(entry.session_key, "cli:1000");
assert_eq!(entry.title, "First session");
}
#[test]
fn test_cleanup_old() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(dir, "cli:1000", "Session one", "2025-01-01T00:00:00Z");
write_test_session(dir, "cli:2000", "Session two", "2025-02-01T00:00:00Z");
write_test_session(dir, "cli:3000", "Session three", "2025-03-01T00:00:00Z");
write_test_session(dir, "cli:4000", "Session four", "2025-04-01T00:00:00Z");
write_test_session(dir, "cli:5000", "Session five", "2025-05-01T00:00:00Z");
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
assert_eq!(history.list_conversations().unwrap().len(), 5);
let deleted = history.cleanup_old(2).unwrap();
assert_eq!(deleted, 3);
let remaining = history.list_conversations().unwrap();
assert_eq!(remaining.len(), 2);
assert_eq!(remaining[0].session_key, "cli:5000");
assert_eq!(remaining[1].session_key, "cli:4000");
assert!(!dir.join("cli%3A1000.json").exists());
assert!(!dir.join("cli%3A2000.json").exists());
assert!(!dir.join("cli%3A3000.json").exists());
assert!(dir.join("cli%3A4000.json").exists());
assert!(dir.join("cli%3A5000.json").exists());
}
#[test]
fn test_cleanup_old_nothing_to_delete() {
let temp_dir = TempDir::new().unwrap();
let dir = temp_dir.path();
write_test_session(dir, "cli:1000", "Session one", "2025-01-01T00:00:00Z");
write_test_session(dir, "cli:2000", "Session two", "2025-02-01T00:00:00Z");
let history = ConversationHistory::with_path(dir.to_path_buf()).unwrap();
let deleted = history.cleanup_old(5).unwrap();
assert_eq!(deleted, 0);
assert_eq!(history.list_conversations().unwrap().len(), 2);
}
}