1use std::io;
20use std::path::PathBuf;
21
22use crate::memory_store::{MemoryStore, DEFAULT_SESSION_TOPIC};
23
24pub const DEFAULT_TOPIC: &str = DEFAULT_SESSION_TOPIC;
26
27#[derive(Debug, Clone)]
29pub struct ExternalMemory {
30 store: MemoryStore,
31}
32
33impl ExternalMemory {
34 pub fn new(data_dir: impl Into<PathBuf>) -> Self {
36 Self {
37 store: MemoryStore::new(data_dir),
38 }
39 }
40
41 pub fn with_defaults() -> Self {
45 Self {
46 store: MemoryStore::with_defaults(),
47 }
48 }
49
50 pub fn store(&self) -> &MemoryStore {
51 &self.store
52 }
53
54 fn validate_session_id(session_id: &str) -> io::Result<()> {
57 crate::memory_store::validate_session_id(session_id).map(|_| ())
58 }
59
60 fn validate_topic(topic: &str) -> io::Result<()> {
61 crate::memory_store::validate_session_topic(topic).map(|_| ())
62 }
63
64 fn topic_path(&self, session_id: &str, topic: &str) -> io::Result<PathBuf> {
68 Self::validate_session_id(session_id)?;
69 Self::validate_topic(topic)?;
70 Ok(self.store.resolver().session_topic_path(session_id, topic))
71 }
72
73 pub async fn save_topic(
77 &self,
78 session_id: &str,
79 topic: &str,
80 note: &str,
81 ) -> io::Result<PathBuf> {
82 self.store
83 .write_session_topic(session_id, topic, note)
84 .await
85 }
86
87 pub async fn read_topic(&self, session_id: &str, topic: &str) -> io::Result<Option<String>> {
89 self.store.read_session_topic(session_id, topic).await
90 }
91
92 pub async fn delete_topic(&self, session_id: &str, topic: &str) -> io::Result<bool> {
94 self.store.delete_session_topic(session_id, topic).await
95 }
96
97 pub async fn append_topic(
99 &self,
100 session_id: &str,
101 topic: &str,
102 content: &str,
103 ) -> io::Result<PathBuf> {
104 self.store
105 .append_session_topic(session_id, topic, content)
106 .await
107 }
108
109 pub async fn list_topics(&self, session_id: &str) -> io::Result<Vec<String>> {
111 self.store.list_session_topics(session_id).await
112 }
113
114 pub async fn save_note(&self, session_id: &str, note: &str) -> io::Result<PathBuf> {
118 self.save_topic(session_id, DEFAULT_TOPIC, note).await
119 }
120
121 pub async fn read_note(&self, session_id: &str) -> io::Result<Option<String>> {
123 self.read_topic(session_id, DEFAULT_TOPIC).await
124 }
125
126 pub async fn delete_note(&self, session_id: &str) -> io::Result<bool> {
128 self.delete_topic(session_id, DEFAULT_TOPIC).await
129 }
130
131 pub async fn append_note(&self, session_id: &str, content: &str) -> io::Result<PathBuf> {
133 self.append_topic(session_id, DEFAULT_TOPIC, content).await
134 }
135
136 pub async fn list_sessions_with_notes(&self) -> io::Result<Vec<String>> {
138 let root = self.store.resolver().sessions_root();
139 let mut sessions = Vec::new();
140
141 if !root.exists() {
142 return Ok(sessions);
143 }
144
145 let mut entries = tokio::fs::read_dir(root).await?;
146 while let Some(entry) = entries.next_entry().await? {
147 let path = entry.path();
148 if path.is_dir() {
149 let note_dir = path.join("note");
150 if note_dir.exists() {
151 if let Some(name) = path.file_name() {
152 sessions.push(name.to_string_lossy().to_string());
153 }
154 }
155 }
156 }
157
158 sessions.sort();
159 Ok(sessions)
160 }
161
162 pub fn get_note_path(&self, session_id: &str) -> PathBuf {
164 self.topic_path(session_id, DEFAULT_TOPIC)
165 .unwrap_or_else(|_| {
166 self.store
167 .resolver()
168 .sessions_root()
169 .join("invalid-session-id.md")
170 })
171 }
172
173 pub async fn has_note(&self, session_id: &str) -> bool {
175 self.get_note_path(session_id).exists()
176 }
177}
178
179pub fn format_summary_as_note(summary: &str, message_count: usize, token_count: u32) -> String {
181 let timestamp = chrono::Utc::now().format("%Y-%m-%d %H:%M:%S UTC");
182
183 format!(
184 r#"# Conversation Summary
185
186**Generated:** {timestamp}
187**Messages Summarized:** {message_count}
188**Token Count:** {token_count}
189
190{summary}
191"#
192 )
193}
194
195#[cfg(test)]
196mod tests {
197 use super::*;
198 use tempfile::tempdir;
199
200 #[tokio::test]
201 async fn save_and_read_note() {
202 let dir = tempdir().unwrap();
203 let memory = ExternalMemory::new(dir.path());
204
205 let note = "This is a test note.";
206 memory.save_note("session-1", note).await.unwrap();
207
208 let read = memory.read_note("session-1").await.unwrap();
209 assert_eq!(read, Some(note.to_string()));
210 }
211
212 #[tokio::test]
213 async fn read_nonexistent_note() {
214 let dir = tempdir().unwrap();
215 let memory = ExternalMemory::new(dir.path());
216
217 let read = memory.read_note("nonexistent").await.unwrap();
218 assert!(read.is_none());
219 }
220
221 #[tokio::test]
222 async fn delete_note() {
223 let dir = tempdir().unwrap();
224 let memory = ExternalMemory::new(dir.path());
225
226 memory.save_note("session-1", "Note").await.unwrap();
227 let deleted = memory.delete_note("session-1").await.unwrap();
228 assert!(deleted);
229
230 let deleted_again = memory.delete_note("session-1").await.unwrap();
231 assert!(!deleted_again);
232 }
233
234 #[tokio::test]
235 async fn append_to_note() {
236 let dir = tempdir().unwrap();
237 let memory = ExternalMemory::new(dir.path());
238
239 memory.save_note("session-1", "First part").await.unwrap();
240 memory
241 .append_note("session-1", "Second part")
242 .await
243 .unwrap();
244
245 let read = memory.read_note("session-1").await.unwrap();
246 assert_eq!(read, Some("First part\n\nSecond part".to_string()));
247 }
248
249 #[tokio::test]
250 async fn list_sessions() {
251 let dir = tempdir().unwrap();
252 let memory = ExternalMemory::new(dir.path());
253
254 memory.save_note("session-1", "Note 1").await.unwrap();
255 memory.save_note("session-2", "Note 2").await.unwrap();
256
257 let sessions = memory.list_sessions_with_notes().await.unwrap();
258 assert_eq!(sessions.len(), 2);
259 assert!(sessions.contains(&"session-1".to_string()));
260 assert!(sessions.contains(&"session-2".to_string()));
261 }
262
263 #[tokio::test]
264 async fn rejects_invalid_session_id_characters() {
265 let dir = tempdir().unwrap();
266 let memory = ExternalMemory::new(dir.path());
267
268 let save = memory.save_note("../escape", "bad").await;
269 assert!(save.is_err());
270
271 let read = memory.read_note("bad/name").await;
272 assert!(read.is_err());
273 }
274
275 #[test]
276 fn format_summary_creates_markdown() {
277 let summary = "User asked about Rust. Assistant explained.";
278 let note = format_summary_as_note(summary, 10, 500);
279
280 assert!(note.contains("# Conversation Summary"));
281 assert!(note.contains("**Messages Summarized:** 10"));
282 assert!(note.contains("**Token Count:** 500"));
283 assert!(note.contains(summary));
284 }
285
286 #[tokio::test]
289 async fn multi_topic_read_write() {
290 let dir = tempdir().unwrap();
291 let memory = ExternalMemory::new(dir.path());
292
293 memory
294 .save_topic("s1", "project-a", "Project A notes")
295 .await
296 .unwrap();
297 memory
298 .save_topic("s1", "project-b", "Project B notes")
299 .await
300 .unwrap();
301
302 assert_eq!(
303 memory.read_topic("s1", "project-a").await.unwrap(),
304 Some("Project A notes".to_string())
305 );
306 assert_eq!(
307 memory.read_topic("s1", "project-b").await.unwrap(),
308 Some("Project B notes".to_string())
309 );
310 }
311
312 #[tokio::test]
313 async fn list_topics_returns_sorted() {
314 let dir = tempdir().unwrap();
315 let memory = ExternalMemory::new(dir.path());
316
317 memory.save_topic("s1", "zebra", "z").await.unwrap();
318 memory.save_topic("s1", "alpha", "a").await.unwrap();
319 memory.save_topic("s1", "mid", "m").await.unwrap();
320
321 let topics = memory.list_topics("s1").await.unwrap();
322 assert_eq!(topics, vec!["alpha", "mid", "zebra"]);
323 }
324
325 #[tokio::test]
326 async fn legacy_migration_moves_file() {
327 let dir = tempdir().unwrap();
328 let memory = ExternalMemory::new(dir.path());
329
330 let legacy_notes_dir = dir.path().join("notes");
332 tokio::fs::create_dir_all(&legacy_notes_dir).await.unwrap();
333 let legacy_path = legacy_notes_dir.join("session-1.md");
334 tokio::fs::write(&legacy_path, "legacy content")
335 .await
336 .unwrap();
337
338 let content = memory.read_note("session-1").await.unwrap();
340 assert_eq!(content, Some("legacy content".to_string()));
341
342 assert!(!legacy_path.exists());
344
345 let new_path = dir
347 .path()
348 .join("memory")
349 .join("v1")
350 .join("sessions")
351 .join("session-1")
352 .join("note")
353 .join("default.md");
354 assert!(new_path.exists());
355 }
356
357 #[tokio::test]
358 async fn legacy_migration_preserves_new_over_legacy() {
359 let dir = tempdir().unwrap();
360 let memory = ExternalMemory::new(dir.path());
361
362 memory
364 .save_topic("session-1", "default", "new content")
365 .await
366 .unwrap();
367
368 let legacy_notes_dir = dir.path().join("notes");
370 tokio::fs::create_dir_all(&legacy_notes_dir).await.unwrap();
371 let legacy_path = legacy_notes_dir.join("session-1.md");
372 tokio::fs::write(&legacy_path, "old content").await.unwrap();
373
374 let content = memory.read_note("session-1").await.unwrap();
376 assert_eq!(content, Some("new content".to_string()));
377 assert!(!legacy_path.exists());
378 }
379
380 #[tokio::test]
381 async fn delete_specific_topic() {
382 let dir = tempdir().unwrap();
383 let memory = ExternalMemory::new(dir.path());
384
385 memory.save_topic("s1", "keep", "keep me").await.unwrap();
386 memory
387 .save_topic("s1", "remove", "delete me")
388 .await
389 .unwrap();
390
391 memory.delete_topic("s1", "remove").await.unwrap();
392
393 assert_eq!(
394 memory.read_topic("s1", "keep").await.unwrap(),
395 Some("keep me".to_string())
396 );
397 assert_eq!(memory.read_topic("s1", "remove").await.unwrap(), None);
398 }
399
400 #[tokio::test]
401 async fn rejects_invalid_topic_names() {
402 let dir = tempdir().unwrap();
403 let memory = ExternalMemory::new(dir.path());
404
405 assert!(memory.save_topic("s1", "../escape", "bad").await.is_err());
406 assert!(memory.save_topic("s1", "has space", "bad").await.is_err());
407 assert!(memory.save_topic("s1", "", "bad").await.is_err());
408
409 let long_name = "a".repeat(crate::memory_store::MAX_SESSION_TOPIC_LEN + 1);
410 assert!(memory.save_topic("s1", &long_name, "bad").await.is_err());
411 }
412
413 #[tokio::test]
414 async fn append_to_topic() {
415 let dir = tempdir().unwrap();
416 let memory = ExternalMemory::new(dir.path());
417
418 memory.save_topic("s1", "notes", "first").await.unwrap();
419 memory.append_topic("s1", "notes", "second").await.unwrap();
420
421 let content = memory.read_topic("s1", "notes").await.unwrap();
422 assert_eq!(content, Some("first\n\nsecond".to_string()));
423 }
424}