use std::io;
use std::path::PathBuf;
use crate::memory_store::{MemoryStore, DEFAULT_SESSION_TOPIC};
pub const DEFAULT_TOPIC: &str = DEFAULT_SESSION_TOPIC;
#[derive(Debug, Clone)]
pub struct ExternalMemory {
store: MemoryStore,
}
impl ExternalMemory {
pub fn new(data_dir: impl Into<PathBuf>) -> Self {
Self {
store: MemoryStore::new(data_dir),
}
}
pub fn with_defaults() -> Self {
Self {
store: MemoryStore::with_defaults(),
}
}
pub fn store(&self) -> &MemoryStore {
&self.store
}
fn validate_session_id(session_id: &str) -> io::Result<()> {
crate::memory_store::validate_session_id(session_id).map(|_| ())
}
fn validate_topic(topic: &str) -> io::Result<()> {
crate::memory_store::validate_session_topic(topic).map(|_| ())
}
fn topic_path(&self, session_id: &str, topic: &str) -> io::Result<PathBuf> {
Self::validate_session_id(session_id)?;
Self::validate_topic(topic)?;
Ok(self.store.resolver().session_topic_path(session_id, topic))
}
pub async fn save_topic(
&self,
session_id: &str,
topic: &str,
note: &str,
) -> io::Result<PathBuf> {
self.store
.write_session_topic(session_id, topic, note)
.await
}
pub async fn read_topic(&self, session_id: &str, topic: &str) -> io::Result<Option<String>> {
self.store.read_session_topic(session_id, topic).await
}
pub async fn delete_topic(&self, session_id: &str, topic: &str) -> io::Result<bool> {
self.store.delete_session_topic(session_id, topic).await
}
pub async fn append_topic(
&self,
session_id: &str,
topic: &str,
content: &str,
) -> io::Result<PathBuf> {
self.store
.append_session_topic(session_id, topic, content)
.await
}
pub async fn list_topics(&self, session_id: &str) -> io::Result<Vec<String>> {
self.store.list_session_topics(session_id).await
}
pub async fn save_note(&self, session_id: &str, note: &str) -> io::Result<PathBuf> {
self.save_topic(session_id, DEFAULT_TOPIC, note).await
}
pub async fn read_note(&self, session_id: &str) -> io::Result<Option<String>> {
self.read_topic(session_id, DEFAULT_TOPIC).await
}
pub async fn delete_note(&self, session_id: &str) -> io::Result<bool> {
self.delete_topic(session_id, DEFAULT_TOPIC).await
}
pub async fn append_note(&self, session_id: &str, content: &str) -> io::Result<PathBuf> {
self.append_topic(session_id, DEFAULT_TOPIC, content).await
}
pub async fn list_sessions_with_notes(&self) -> io::Result<Vec<String>> {
let root = self.store.resolver().sessions_root();
let mut sessions = Vec::new();
if !root.exists() {
return Ok(sessions);
}
let mut entries = tokio::fs::read_dir(root).await?;
while let Some(entry) = entries.next_entry().await? {
let path = entry.path();
if path.is_dir() {
let note_dir = path.join("note");
if note_dir.exists() {
if let Some(name) = path.file_name() {
sessions.push(name.to_string_lossy().to_string());
}
}
}
}
sessions.sort();
Ok(sessions)
}
pub fn get_note_path(&self, session_id: &str) -> PathBuf {
self.topic_path(session_id, DEFAULT_TOPIC)
.unwrap_or_else(|_| {
self.store
.resolver()
.sessions_root()
.join("invalid-session-id.md")
})
}
pub async fn has_note(&self, session_id: &str) -> bool {
self.get_note_path(session_id).exists()
}
}
pub fn format_summary_as_note(summary: &str, message_count: usize, token_count: u32) -> String {
let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
format!(
r#"# Conversation Summary
**Generated:** {timestamp}
**Messages Summarized:** {message_count}
**Token Count:** {token_count}
{summary}
"#
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::tempdir;
#[tokio::test]
async fn save_and_read_note() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
let note = "This is a test note.";
memory.save_note("session-1", note).await.unwrap();
let read = memory.read_note("session-1").await.unwrap();
assert_eq!(read, Some(note.to_string()));
}
#[tokio::test]
async fn read_nonexistent_note() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
let read = memory.read_note("nonexistent").await.unwrap();
assert!(read.is_none());
}
#[tokio::test]
async fn delete_note() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory.save_note("session-1", "Note").await.unwrap();
let deleted = memory.delete_note("session-1").await.unwrap();
assert!(deleted);
let deleted_again = memory.delete_note("session-1").await.unwrap();
assert!(!deleted_again);
}
#[tokio::test]
async fn append_to_note() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory.save_note("session-1", "First part").await.unwrap();
memory
.append_note("session-1", "Second part")
.await
.unwrap();
let read = memory.read_note("session-1").await.unwrap();
assert_eq!(read, Some("First part\n\nSecond part".to_string()));
}
#[tokio::test]
async fn list_sessions() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory.save_note("session-1", "Note 1").await.unwrap();
memory.save_note("session-2", "Note 2").await.unwrap();
let sessions = memory.list_sessions_with_notes().await.unwrap();
assert_eq!(sessions.len(), 2);
assert!(sessions.contains(&"session-1".to_string()));
assert!(sessions.contains(&"session-2".to_string()));
}
#[tokio::test]
async fn rejects_invalid_session_id_characters() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
let save = memory.save_note("../escape", "bad").await;
assert!(save.is_err());
let read = memory.read_note("bad/name").await;
assert!(read.is_err());
}
#[test]
fn format_summary_creates_markdown() {
let summary = "User asked about Rust. Assistant explained.";
let note = format_summary_as_note(summary, 10, 500);
assert!(note.contains("# Conversation Summary"));
assert!(note.contains("**Messages Summarized:** 10"));
assert!(note.contains("**Token Count:** 500"));
assert!(note.contains(summary));
}
#[tokio::test]
async fn multi_topic_read_write() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory
.save_topic("s1", "project-a", "Project A notes")
.await
.unwrap();
memory
.save_topic("s1", "project-b", "Project B notes")
.await
.unwrap();
assert_eq!(
memory.read_topic("s1", "project-a").await.unwrap(),
Some("Project A notes".to_string())
);
assert_eq!(
memory.read_topic("s1", "project-b").await.unwrap(),
Some("Project B notes".to_string())
);
}
#[tokio::test]
async fn list_topics_returns_sorted() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory.save_topic("s1", "zebra", "z").await.unwrap();
memory.save_topic("s1", "alpha", "a").await.unwrap();
memory.save_topic("s1", "mid", "m").await.unwrap();
let topics = memory.list_topics("s1").await.unwrap();
assert_eq!(topics, vec!["alpha", "mid", "zebra"]);
}
#[tokio::test]
async fn legacy_migration_moves_file() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
let legacy_notes_dir = dir.path().join("notes");
tokio::fs::create_dir_all(&legacy_notes_dir).await.unwrap();
let legacy_path = legacy_notes_dir.join("session-1.md");
tokio::fs::write(&legacy_path, "legacy content")
.await
.unwrap();
let content = memory.read_note("session-1").await.unwrap();
assert_eq!(content, Some("legacy content".to_string()));
assert!(!legacy_path.exists());
let new_path = dir
.path()
.join("memory")
.join("v1")
.join("sessions")
.join("session-1")
.join("note")
.join("default.md");
assert!(new_path.exists());
}
#[tokio::test]
async fn legacy_migration_preserves_new_over_legacy() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory
.save_topic("session-1", "default", "new content")
.await
.unwrap();
let legacy_notes_dir = dir.path().join("notes");
tokio::fs::create_dir_all(&legacy_notes_dir).await.unwrap();
let legacy_path = legacy_notes_dir.join("session-1.md");
tokio::fs::write(&legacy_path, "old content").await.unwrap();
let content = memory.read_note("session-1").await.unwrap();
assert_eq!(content, Some("new content".to_string()));
assert!(!legacy_path.exists());
}
#[tokio::test]
async fn delete_specific_topic() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory.save_topic("s1", "keep", "keep me").await.unwrap();
memory
.save_topic("s1", "remove", "delete me")
.await
.unwrap();
memory.delete_topic("s1", "remove").await.unwrap();
assert_eq!(
memory.read_topic("s1", "keep").await.unwrap(),
Some("keep me".to_string())
);
assert_eq!(memory.read_topic("s1", "remove").await.unwrap(), None);
}
#[tokio::test]
async fn rejects_invalid_topic_names() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
assert!(memory.save_topic("s1", "../escape", "bad").await.is_err());
assert!(memory.save_topic("s1", "has space", "bad").await.is_err());
assert!(memory.save_topic("s1", "", "bad").await.is_err());
let long_name = "a".repeat(crate::memory_store::MAX_SESSION_TOPIC_LEN + 1);
assert!(memory.save_topic("s1", &long_name, "bad").await.is_err());
}
#[tokio::test]
async fn append_to_topic() {
let dir = tempdir().unwrap();
let memory = ExternalMemory::new(dir.path());
memory.save_topic("s1", "notes", "first").await.unwrap();
memory.append_topic("s1", "notes", "second").await.unwrap();
let content = memory.read_topic("s1", "notes").await.unwrap();
assert_eq!(content, Some("first\n\nsecond".to_string()));
}
}