use anyhow::{Context, Result};
use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
use std::io::Write;
use std::path::PathBuf;
use crate::api::types::Message;
use crate::session::encryption::EncryptionManager;
#[derive(Debug, Serialize, Deserialize)]
pub struct SavedChat {
pub name: String,
pub saved_at: DateTime<Utc>,
pub model: String,
pub messages: Vec<Message>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct ChatSummary {
pub name: String,
pub saved_at: DateTime<Utc>,
pub model: String,
pub message_count: usize,
}
pub struct ChatStore {
chats_dir: PathBuf,
}
impl ChatStore {
pub fn new() -> Result<Self> {
let base = dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("selfware")
.join("chats");
std::fs::create_dir_all(&base).context("Failed to create chats directory")?;
Ok(Self { chats_dir: base })
}
pub fn fallback() -> Self {
Self {
chats_dir: std::env::temp_dir().join("selfware_chats"),
}
}
pub fn save(&self, name: &str, messages: &[Message], model: &str) -> Result<()> {
std::fs::create_dir_all(&self.chats_dir).context("Failed to create chats directory")?;
let chat = SavedChat {
name: name.to_string(),
saved_at: Utc::now(),
model: model.to_string(),
messages: messages.to_vec(),
};
let path = self.chat_path(name);
let json = serde_json::to_string_pretty(&chat)?;
let data = if let Some(encryption) = EncryptionManager::get() {
encryption.encrypt(json.as_bytes())?
} else {
json.into_bytes()
};
let tmp_path = path.with_extension(format!("json.tmp.{}", std::process::id()));
{
let mut f = std::fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(&tmp_path)
.context("Failed to create chat temp file")?;
f.write_all(&data)
.context("Failed to write chat temp file")?;
f.sync_all().context("Failed to sync chat temp file")?;
}
if let Err(err) = std::fs::rename(&tmp_path, &path) {
let _ = std::fs::remove_file(&tmp_path);
return Err(err).context("Failed to atomically replace chat file");
}
Ok(())
}
pub fn load(&self, name: &str) -> Result<SavedChat> {
let path = self.chat_path(name);
let data = std::fs::read(&path).with_context(|| format!("Chat '{}' not found", name))?;
let json = if let Some(encryption) = EncryptionManager::get() {
let plaintext = encryption.decrypt(&data).context(
"Decryption failed for chat file. The file may be corrupt or tampered with.",
)?;
String::from_utf8(plaintext).context("Decrypted chat is not valid UTF-8")?
} else {
String::from_utf8(data).context("Chat file is not valid UTF-8")?
};
let chat: SavedChat = serde_json::from_str(&json)?;
Ok(chat)
}
pub fn list(&self) -> Result<Vec<ChatSummary>> {
let mut summaries = Vec::new();
if let Ok(entries) = std::fs::read_dir(&self.chats_dir) {
for entry in entries.filter_map(|e| e.ok()) {
let path = entry.path();
if path.extension().and_then(|e| e.to_str()) == Some("json") {
if let Ok(data) = std::fs::read(&path) {
let json_opt = if let Some(encryption) = EncryptionManager::get() {
match encryption.decrypt(&data) {
Ok(p) => String::from_utf8(p).ok(),
Err(_) => {
tracing::warn!(
"Skipping chat file {:?}: decryption failed (corrupt or tampered)",
path
);
None
}
}
} else {
String::from_utf8(data).ok()
};
if let Some(json) = json_opt {
if let Ok(chat) = serde_json::from_str::<SavedChat>(&json) {
summaries.push(ChatSummary {
name: chat.name,
saved_at: chat.saved_at,
model: chat.model,
message_count: chat.messages.len(),
});
}
}
}
}
}
}
summaries.sort_by(|a, b| b.saved_at.cmp(&a.saved_at));
Ok(summaries)
}
pub fn delete(&self, name: &str) -> Result<()> {
let path = self.chat_path(name);
if path.exists() {
std::fs::remove_file(&path).context("Failed to delete chat file")?;
Ok(())
} else {
anyhow::bail!("Chat '{}' not found", name)
}
}
fn chat_path(&self, name: &str) -> PathBuf {
let safe_name: String = name
.chars()
.map(|c| {
if c.is_alphanumeric() || c == '-' || c == '_' {
c
} else {
'_'
}
})
.collect();
self.chats_dir.join(format!("{}.json", safe_name))
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
fn test_store() -> (ChatStore, TempDir) {
let dir = TempDir::new().unwrap();
let store = ChatStore {
chats_dir: dir.path().to_path_buf(),
};
(store, dir)
}
#[test]
fn test_save_and_load() {
let (store, _dir) = test_store();
let messages = vec![
Message::system("system prompt".to_string()),
Message::user("hello".to_string()),
];
store.save("test-chat", &messages, "test-model").unwrap();
let loaded = store.load("test-chat").unwrap();
assert_eq!(loaded.name, "test-chat");
assert_eq!(loaded.model, "test-model");
assert_eq!(loaded.messages.len(), 2);
}
#[test]
fn test_list_chats() {
let (store, _dir) = test_store();
let messages = vec![Message::user("hello".to_string())];
store.save("chat-a", &messages, "model-1").unwrap();
store.save("chat-b", &messages, "model-2").unwrap();
let list = store.list().unwrap();
assert_eq!(list.len(), 2);
}
#[test]
fn test_delete_chat() {
let (store, _dir) = test_store();
let messages = vec![Message::user("hello".to_string())];
store.save("to-delete", &messages, "model").unwrap();
assert!(store.delete("to-delete").is_ok());
assert!(store.load("to-delete").is_err());
}
#[test]
fn test_delete_nonexistent() {
let (store, _dir) = test_store();
assert!(store.delete("nonexistent").is_err());
}
#[test]
fn test_load_nonexistent() {
let (store, _dir) = test_store();
assert!(store.load("nonexistent").is_err());
}
#[test]
fn test_chat_path_sanitization() {
let (store, _dir) = test_store();
let path = store.chat_path("my chat/with spaces");
assert!(!path.to_string_lossy().contains(' '));
}
}