1use crate::error::MemoryError;
8use crate::memory::MemorySystem;
9use chrono::{DateTime, Utc};
10use serde::{Deserialize, Serialize};
11use std::path::{Path, PathBuf};
12use uuid::Uuid;
13
14#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SessionEntry {
17 pub id: Uuid,
19 pub name: String,
21 pub created_at: DateTime<Utc>,
23 pub updated_at: DateTime<Utc>,
25 pub last_goal: Option<String>,
27 pub summary: Option<String>,
29 pub message_count: usize,
31 pub total_tokens: usize,
33 pub completed: bool,
35 pub file_name: String,
37 #[serde(default, skip_serializing_if = "Vec::is_empty")]
39 pub tags: Vec<String>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
42 pub project_type: Option<String>,
43}
44
45#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct SessionIndex {
48 pub entries: Vec<SessionEntry>,
49}
50
51impl SessionIndex {
52 pub fn load(sessions_dir: &Path) -> Result<Self, MemoryError> {
54 let index_path = sessions_dir.join("index.json");
55 if !index_path.exists() {
56 return Ok(Self::default());
57 }
58 let json =
59 std::fs::read_to_string(&index_path).map_err(|e| MemoryError::PersistenceError {
60 message: format!("Failed to read session index: {}", e),
61 })?;
62 serde_json::from_str(&json).map_err(|e| MemoryError::PersistenceError {
63 message: format!("Failed to parse session index: {}", e),
64 })
65 }
66
67 pub fn save(&self, sessions_dir: &Path) -> Result<(), MemoryError> {
69 std::fs::create_dir_all(sessions_dir).map_err(|e| MemoryError::PersistenceError {
70 message: format!("Failed to create sessions directory: {}", e),
71 })?;
72 let index_path = sessions_dir.join("index.json");
73 let json =
74 serde_json::to_string_pretty(self).map_err(|e| MemoryError::PersistenceError {
75 message: format!("Failed to serialize session index: {}", e),
76 })?;
77 std::fs::write(&index_path, json).map_err(|e| MemoryError::PersistenceError {
78 message: format!("Failed to write session index: {}", e),
79 })
80 }
81
82 pub fn find_by_name(&self, query: &str) -> Option<&SessionEntry> {
84 let query_lower = query.to_lowercase();
85 if let Some(entry) = self
87 .entries
88 .iter()
89 .find(|e| e.name.to_lowercase() == query_lower)
90 {
91 return Some(entry);
92 }
93 self.entries
95 .iter()
96 .find(|e| e.name.to_lowercase().starts_with(&query_lower))
97 }
98
99 pub fn find_by_id(&self, id: Uuid) -> Option<&SessionEntry> {
101 self.entries.iter().find(|e| e.id == id)
102 }
103
104 pub fn most_recent(&self) -> Option<&SessionEntry> {
106 self.entries.iter().max_by_key(|e| e.updated_at)
107 }
108
109 pub fn list_recent(&self, limit: usize) -> Vec<&SessionEntry> {
111 let mut entries: Vec<&SessionEntry> = self.entries.iter().collect();
112 entries.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
113 entries.into_iter().take(limit).collect()
114 }
115}
116
117pub struct SessionManager {
119 sessions_dir: PathBuf,
120 index: SessionIndex,
121 active_session_id: Option<Uuid>,
123 encryptor: Option<crate::encryption::SessionEncryptor>,
125}
126
127impl SessionManager {
128 pub fn new(workspace: &Path) -> Result<Self, MemoryError> {
130 let sessions_dir = workspace.join(".rustant").join("sessions");
131 let index = SessionIndex::load(&sessions_dir)?;
132 Ok(Self {
133 sessions_dir,
134 index,
135 active_session_id: None,
136 encryptor: None,
137 })
138 }
139
140 pub fn with_dir(sessions_dir: PathBuf) -> Result<Self, MemoryError> {
142 let index = SessionIndex::load(&sessions_dir)?;
143 Ok(Self {
144 sessions_dir,
145 index,
146 active_session_id: None,
147 encryptor: None,
148 })
149 }
150
151 pub fn with_encryption(mut self, encryptor: crate::encryption::SessionEncryptor) -> Self {
153 self.encryptor = Some(encryptor);
154 self
155 }
156
157 pub fn start_session(&mut self, name: Option<&str>) -> SessionEntry {
159 let id = Uuid::new_v4();
160 let now = Utc::now();
161 let name = name
162 .map(|n| n.to_string())
163 .unwrap_or_else(|| now.format("%Y-%m-%d_%H%M%S").to_string());
164 let file_name = format!("{}.json", id);
165
166 let entry = SessionEntry {
167 id,
168 name,
169 created_at: now,
170 updated_at: now,
171 last_goal: None,
172 summary: None,
173 message_count: 0,
174 total_tokens: 0,
175 completed: false,
176 file_name,
177 tags: Vec::new(),
178 project_type: None,
179 };
180
181 self.index.entries.push(entry.clone());
182 self.active_session_id = Some(id);
183 let _ = self.index.save(&self.sessions_dir);
184 entry
185 }
186
187 pub fn save_checkpoint(
189 &mut self,
190 memory: &MemorySystem,
191 total_tokens: usize,
192 ) -> Result<(), MemoryError> {
193 let session_id = self
194 .active_session_id
195 .ok_or_else(|| MemoryError::PersistenceError {
196 message: "No active session to save".to_string(),
197 })?;
198
199 let entry = self
201 .index
202 .entries
203 .iter_mut()
204 .find(|e| e.id == session_id)
205 .ok_or_else(|| MemoryError::PersistenceError {
206 message: "Active session not found in index".to_string(),
207 })?;
208
209 entry.updated_at = Utc::now();
211 entry.last_goal = memory.working.current_goal.clone();
212 entry.message_count = memory.short_term.len();
213 entry.total_tokens = total_tokens;
214
215 let session_path = self.sessions_dir.join(&entry.file_name);
217 memory.save_session(&session_path)?;
218
219 if let Some(ref encryptor) = self.encryptor {
221 let plaintext =
222 std::fs::read(&session_path).map_err(|e| MemoryError::PersistenceError {
223 message: format!("Failed to read session for encryption: {}", e),
224 })?;
225 let encrypted =
226 encryptor
227 .encrypt(&plaintext)
228 .map_err(|e| MemoryError::PersistenceError {
229 message: format!("Failed to encrypt session: {}", e),
230 })?;
231 let tmp_path = session_path.with_extension("json.enc.tmp");
232 std::fs::write(&tmp_path, &encrypted).map_err(|e| MemoryError::PersistenceError {
233 message: format!("Failed to write encrypted session: {}", e),
234 })?;
235 std::fs::rename(&tmp_path, &session_path).map_err(|e| {
236 MemoryError::PersistenceError {
237 message: format!("Failed to finalize encrypted session: {}", e),
238 }
239 })?;
240 }
241
242 self.index.save(&self.sessions_dir)
244 }
245
246 pub fn complete_session(&mut self, summary: Option<String>) -> Result<(), MemoryError> {
248 if let Some(session_id) = self.active_session_id {
249 if let Some(entry) = self.index.entries.iter_mut().find(|e| e.id == session_id) {
250 entry.completed = true;
251 entry.updated_at = Utc::now();
252 entry.summary = summary;
253 }
254 self.index.save(&self.sessions_dir)?;
255 }
256 Ok(())
257 }
258
259 pub fn resume_session(&mut self, query: &str) -> Result<(MemorySystem, String), MemoryError> {
262 let entry = if let Ok(id) = Uuid::parse_str(query) {
263 self.index
264 .find_by_id(id)
265 .cloned()
266 .ok_or_else(|| MemoryError::SessionLoadFailed {
267 message: format!("No session found with ID: {}", id),
268 })?
269 } else {
270 self.index.find_by_name(query).cloned().ok_or_else(|| {
271 MemoryError::SessionLoadFailed {
272 message: format!("No session found matching: '{}'", query),
273 }
274 })?
275 };
276
277 let session_path = self.sessions_dir.join(&entry.file_name);
278
279 let memory = if let Some(ref encryptor) = self.encryptor {
281 let encrypted =
282 std::fs::read(&session_path).map_err(|e| MemoryError::SessionLoadFailed {
283 message: format!("Failed to read encrypted session: {}", e),
284 })?;
285 let plaintext =
286 encryptor
287 .decrypt(&encrypted)
288 .map_err(|e| MemoryError::SessionLoadFailed {
289 message: format!("Failed to decrypt session: {}", e),
290 })?;
291 let tmp_path = session_path.with_extension("json.dec.tmp");
293 std::fs::write(&tmp_path, &plaintext).map_err(|e| MemoryError::SessionLoadFailed {
294 message: format!("Failed to write decrypted session: {}", e),
295 })?;
296 let result = MemorySystem::load_session(&tmp_path);
297 let _ = std::fs::remove_file(&tmp_path); result?
299 } else {
300 MemorySystem::load_session(&session_path)?
301 };
302
303 let mut continuation =
305 String::from("You are resuming a previous session. Here is what was accomplished:\n");
306 if let Some(ref goal) = entry.last_goal {
307 continuation.push_str(&format!("- Last goal: {}\n", goal));
308 }
309 if let Some(ref summary) = entry.summary {
310 continuation.push_str(&format!("- Summary: {}\n", summary));
311 }
312 continuation.push_str(&format!("- Messages exchanged: {}\n", entry.message_count));
313 continuation.push_str(&format!(
314 "- Session started: {}\n",
315 entry.created_at.format("%Y-%m-%d %H:%M UTC")
316 ));
317 if entry.completed {
318 continuation.push_str("- Status: Completed\n");
319 } else {
320 continuation.push_str("- Status: In progress (was interrupted)\n");
321 }
322 continuation.push_str("\nContinue from where the session left off.");
323
324 self.active_session_id = Some(entry.id);
326
327 Ok((memory, continuation))
328 }
329
330 pub fn resume_latest(&mut self) -> Result<(MemorySystem, String), MemoryError> {
332 let entry =
333 self.index
334 .most_recent()
335 .cloned()
336 .ok_or_else(|| MemoryError::SessionLoadFailed {
337 message: "No sessions found to resume".to_string(),
338 })?;
339 self.resume_session(&entry.id.to_string())
340 }
341
342 pub fn list_sessions(&self, limit: usize) -> Vec<&SessionEntry> {
344 self.index.list_recent(limit)
345 }
346
347 pub fn rename_session(&mut self, query: &str, new_name: &str) -> Result<(), MemoryError> {
349 let entry = if let Ok(id) = Uuid::parse_str(query) {
350 self.index.entries.iter_mut().find(|e| e.id == id)
351 } else {
352 let query_lower = query.to_lowercase();
353 self.index.entries.iter_mut().find(|e| {
354 e.name.to_lowercase() == query_lower
355 || e.name.to_lowercase().starts_with(&query_lower)
356 })
357 };
358
359 match entry {
360 Some(e) => {
361 e.name = new_name.to_string();
362 self.index.save(&self.sessions_dir)
363 }
364 None => Err(MemoryError::SessionLoadFailed {
365 message: format!("No session found matching: '{}'", query),
366 }),
367 }
368 }
369
370 pub fn delete_session(&mut self, query: &str) -> Result<String, MemoryError> {
372 let (idx, file_name, name) = {
373 let query_lower = query.to_lowercase();
374 let found = if let Ok(id) = Uuid::parse_str(query) {
375 self.index
376 .entries
377 .iter()
378 .enumerate()
379 .find(|(_, e)| e.id == id)
380 } else {
381 self.index.entries.iter().enumerate().find(|(_, e)| {
382 e.name.to_lowercase() == query_lower
383 || e.name.to_lowercase().starts_with(&query_lower)
384 })
385 };
386 match found {
387 Some((i, e)) => (i, e.file_name.clone(), e.name.clone()),
388 None => {
389 return Err(MemoryError::SessionLoadFailed {
390 message: format!("No session found matching: '{}'", query),
391 });
392 }
393 }
394 };
395
396 let session_path = self.sessions_dir.join(&file_name);
398 if session_path.exists() {
399 let _ = std::fs::remove_file(&session_path);
400 }
401
402 self.index.entries.remove(idx);
404 self.index.save(&self.sessions_dir)?;
405
406 Ok(name)
407 }
408
409 pub fn active_session_id(&self) -> Option<Uuid> {
411 self.active_session_id
412 }
413
414 pub fn sessions_dir(&self) -> &Path {
416 &self.sessions_dir
417 }
418
419 pub fn index(&self) -> &SessionIndex {
421 &self.index
422 }
423
424 #[cfg(test)]
426 pub(crate) fn from_index(index: SessionIndex) -> Self {
427 Self {
428 sessions_dir: PathBuf::from("/tmp/rustant-test-sessions"),
429 index,
430 active_session_id: None,
431 encryptor: None,
432 }
433 }
434
435 pub fn find_incomplete_sessions(&self) -> Vec<&SessionEntry> {
438 self.index
439 .entries
440 .iter()
441 .filter(|e| !e.completed && e.message_count > 0)
442 .collect()
443 }
444
445 pub fn search(&self, query: &str) -> Vec<&SessionEntry> {
448 if query.trim().is_empty() {
449 return Vec::new();
450 }
451 let query_lower = query.to_lowercase();
452 self.index
453 .entries
454 .iter()
455 .filter(|e| {
456 e.name.to_lowercase().contains(&query_lower)
457 || e.last_goal
458 .as_ref()
459 .is_some_and(|g| g.to_lowercase().contains(&query_lower))
460 || e.summary
461 .as_ref()
462 .is_some_and(|s| s.to_lowercase().contains(&query_lower))
463 || e.tags
464 .iter()
465 .any(|t| t.to_lowercase().contains(&query_lower))
466 })
467 .collect()
468 }
469
470 pub fn filter_by_tag(&self, tag: &str) -> Vec<&SessionEntry> {
472 let tag_lower = tag.to_lowercase();
473 self.index
474 .entries
475 .iter()
476 .filter(|e| e.tags.iter().any(|t| t.to_lowercase() == tag_lower))
477 .collect()
478 }
479
480 pub fn tag_session(&mut self, query: &str, tag: &str) -> Result<(), MemoryError> {
482 let query_lower = query.to_lowercase();
483 let entry = if let Ok(id) = Uuid::parse_str(query) {
484 self.index.entries.iter_mut().find(|e| e.id == id)
485 } else {
486 self.index.entries.iter_mut().find(|e| {
487 e.name.to_lowercase() == query_lower
488 || e.name.to_lowercase().starts_with(&query_lower)
489 })
490 };
491 match entry {
492 Some(e) => {
493 let tag_str = tag.to_string();
494 if !e.tags.iter().any(|t| t.eq_ignore_ascii_case(&tag_str)) {
495 e.tags.push(tag_str);
496 }
497 self.index.save(&self.sessions_dir)
498 }
499 None => Err(MemoryError::SessionLoadFailed {
500 message: format!("No session found matching: '{}'", query),
501 }),
502 }
503 }
504}
505
506#[cfg(test)]
507mod tests {
508 use super::*;
509 use crate::types::Message;
510
511 fn create_test_manager(dir: &Path) -> SessionManager {
512 SessionManager::with_dir(dir.to_path_buf()).unwrap()
513 }
514
515 #[test]
516 fn test_start_session_default_name() {
517 let dir = tempfile::tempdir().unwrap();
518 let mut mgr = create_test_manager(dir.path());
519
520 let entry = mgr.start_session(None);
521 assert!(!entry.name.is_empty());
522 assert!(!entry.completed);
523 assert_eq!(mgr.active_session_id(), Some(entry.id));
524 }
525
526 #[test]
527 fn test_start_session_with_name() {
528 let dir = tempfile::tempdir().unwrap();
529 let mut mgr = create_test_manager(dir.path());
530
531 let entry = mgr.start_session(Some("refactor-auth"));
532 assert_eq!(entry.name, "refactor-auth");
533 }
534
535 #[test]
536 fn test_save_checkpoint() {
537 let dir = tempfile::tempdir().unwrap();
538 let mut mgr = create_test_manager(dir.path());
539
540 let entry = mgr.start_session(Some("test-save"));
541
542 let mut memory = MemorySystem::new(10);
543 memory.start_new_task("fix the bug");
544 memory.add_message(Message::user("fix bug #42"));
545 memory.add_message(Message::assistant("Looking into it."));
546
547 mgr.save_checkpoint(&memory, 500).unwrap();
548
549 let session_path = dir.path().join(&entry.file_name);
551 assert!(session_path.exists());
552
553 let reloaded = SessionIndex::load(dir.path()).unwrap();
555 let saved = reloaded.find_by_id(entry.id).unwrap();
556 assert_eq!(saved.last_goal.as_deref(), Some("fix the bug"));
557 assert_eq!(saved.message_count, 2);
558 assert_eq!(saved.total_tokens, 500);
559 }
560
561 #[test]
562 fn test_resume_session_by_name() {
563 let dir = tempfile::tempdir().unwrap();
564 let mut mgr = create_test_manager(dir.path());
565
566 mgr.start_session(Some("my-project"));
568 let mut memory = MemorySystem::new(10);
569 memory.start_new_task("implement feature X");
570 memory.add_message(Message::user("implement feature X"));
571 mgr.save_checkpoint(&memory, 200).unwrap();
572
573 let mut mgr2 = create_test_manager(dir.path());
575 let (loaded_mem, continuation) = mgr2.resume_session("my-project").unwrap();
576
577 assert_eq!(
578 loaded_mem.working.current_goal.as_deref(),
579 Some("implement feature X")
580 );
581 assert!(continuation.contains("implement feature X"));
582 assert!(continuation.contains("resuming a previous session"));
583 }
584
585 #[test]
586 fn test_resume_session_by_prefix() {
587 let dir = tempfile::tempdir().unwrap();
588 let mut mgr = create_test_manager(dir.path());
589
590 mgr.start_session(Some("long-project-name"));
591 let mut memory = MemorySystem::new(10);
592 memory.add_message(Message::user("hello"));
593 mgr.save_checkpoint(&memory, 100).unwrap();
594
595 let mut mgr2 = create_test_manager(dir.path());
596 let result = mgr2.resume_session("long");
597 assert!(result.is_ok());
598 }
599
600 #[test]
601 fn test_resume_latest() {
602 let dir = tempfile::tempdir().unwrap();
603 let mut mgr = create_test_manager(dir.path());
604
605 mgr.start_session(Some("old-session"));
607 let mut mem1 = MemorySystem::new(10);
608 mem1.add_message(Message::user("old task"));
609 mgr.save_checkpoint(&mem1, 100).unwrap();
610
611 mgr.start_session(Some("new-session"));
613 let mut mem2 = MemorySystem::new(10);
614 mem2.start_new_task("new task");
615 mem2.add_message(Message::user("new task"));
616 mgr.save_checkpoint(&mem2, 200).unwrap();
617
618 let mut mgr2 = create_test_manager(dir.path());
620 let (loaded, _) = mgr2.resume_latest().unwrap();
621 assert_eq!(loaded.working.current_goal.as_deref(), Some("new task"));
622 }
623
624 #[test]
625 fn test_list_sessions() {
626 let dir = tempfile::tempdir().unwrap();
627 let mut mgr = create_test_manager(dir.path());
628
629 for i in 0..5 {
630 mgr.start_session(Some(&format!("session-{}", i)));
631 let mut mem = MemorySystem::new(10);
632 mem.add_message(Message::user("test"));
633 mgr.save_checkpoint(&mem, 100).unwrap();
634 }
635
636 let sessions = mgr.list_sessions(3);
637 assert_eq!(sessions.len(), 3);
638 }
639
640 #[test]
641 fn test_rename_session() {
642 let dir = tempfile::tempdir().unwrap();
643 let mut mgr = create_test_manager(dir.path());
644
645 let entry = mgr.start_session(Some("old-name"));
646 mgr.rename_session("old-name", "new-name").unwrap();
647
648 let reloaded = SessionIndex::load(dir.path()).unwrap();
649 let found = reloaded.find_by_id(entry.id).unwrap();
650 assert_eq!(found.name, "new-name");
651 }
652
653 #[test]
654 fn test_delete_session() {
655 let dir = tempfile::tempdir().unwrap();
656 let mut mgr = create_test_manager(dir.path());
657
658 let entry = mgr.start_session(Some("to-delete"));
659 let mut mem = MemorySystem::new(10);
660 mem.add_message(Message::user("test"));
661 mgr.save_checkpoint(&mem, 100).unwrap();
662
663 let session_path = dir.path().join(&entry.file_name);
664 assert!(session_path.exists());
665
666 let name = mgr.delete_session("to-delete").unwrap();
667 assert_eq!(name, "to-delete");
668 assert!(!session_path.exists());
669
670 let reloaded = SessionIndex::load(dir.path()).unwrap();
671 assert!(reloaded.find_by_id(entry.id).is_none());
672 }
673
674 #[test]
675 fn test_complete_session() {
676 let dir = tempfile::tempdir().unwrap();
677 let mut mgr = create_test_manager(dir.path());
678
679 let entry = mgr.start_session(Some("completing"));
680 mgr.complete_session(Some("Finished all tasks".to_string()))
681 .unwrap();
682
683 let reloaded = SessionIndex::load(dir.path()).unwrap();
684 let found = reloaded.find_by_id(entry.id).unwrap();
685 assert!(found.completed);
686 assert_eq!(found.summary.as_deref(), Some("Finished all tasks"));
687 }
688
689 #[test]
690 fn test_session_not_found() {
691 let dir = tempfile::tempdir().unwrap();
692 let mut mgr = create_test_manager(dir.path());
693
694 let result = mgr.resume_session("nonexistent");
695 assert!(result.is_err());
696 }
697
698 #[test]
699 fn test_resume_latest_empty() {
700 let dir = tempfile::tempdir().unwrap();
701 let mut mgr = create_test_manager(dir.path());
702
703 let result = mgr.resume_latest();
704 assert!(result.is_err());
705 }
706
707 #[test]
708 fn test_session_index_persistence() {
709 let dir = tempfile::tempdir().unwrap();
710
711 {
712 let mut mgr = create_test_manager(dir.path());
713 mgr.start_session(Some("persistent-session"));
714 let mut mem = MemorySystem::new(10);
715 mem.add_message(Message::user("test"));
716 mgr.save_checkpoint(&mem, 100).unwrap();
717 }
718
719 let mgr2 = create_test_manager(dir.path());
721 let sessions = mgr2.list_sessions(10);
722 assert_eq!(sessions.len(), 1);
723 assert_eq!(sessions[0].name, "persistent-session");
724 }
725
726 #[test]
727 fn test_save_no_active_session_error() {
728 let dir = tempfile::tempdir().unwrap();
729 let mut mgr = create_test_manager(dir.path());
730
731 let mem = MemorySystem::new(10);
732 let result = mgr.save_checkpoint(&mem, 0);
733 assert!(result.is_err());
734 }
735
736 #[test]
737 fn test_session_index_load_empty() {
738 let dir = tempfile::tempdir().unwrap();
739 let index = SessionIndex::load(dir.path()).unwrap();
740 assert!(index.entries.is_empty());
741 }
742
743 #[test]
744 fn test_session_entry_serialization() {
745 let entry = SessionEntry {
746 id: Uuid::new_v4(),
747 name: "test-session".to_string(),
748 created_at: Utc::now(),
749 updated_at: Utc::now(),
750 last_goal: Some("fix bug".to_string()),
751 summary: None,
752 message_count: 5,
753 total_tokens: 1000,
754 completed: false,
755 file_name: "test.json".to_string(),
756 tags: vec!["bugfix".to_string()],
757 project_type: Some("Rust".to_string()),
758 };
759 let json = serde_json::to_string(&entry).unwrap();
760 let restored: SessionEntry = serde_json::from_str(&json).unwrap();
761 assert_eq!(restored.name, "test-session");
762 assert_eq!(restored.message_count, 5);
763 assert_eq!(restored.tags, vec!["bugfix"]);
764 assert_eq!(restored.project_type, Some("Rust".to_string()));
765 }
766
767 #[test]
768 fn test_session_entry_deserialize_without_tags() {
769 let json = r#"{"id":"00000000-0000-0000-0000-000000000001","name":"old-session","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z","last_goal":null,"summary":null,"message_count":3,"total_tokens":500,"completed":false,"file_name":"old.json"}"#;
771 let entry: SessionEntry = serde_json::from_str(json).unwrap();
772 assert_eq!(entry.name, "old-session");
773 assert!(entry.tags.is_empty());
774 assert!(entry.project_type.is_none());
775 }
776
777 #[test]
778 fn test_session_search() {
779 let mut index = SessionIndex::default();
780 let make_entry = |name: &str, goal: Option<&str>, tags: Vec<&str>| SessionEntry {
781 id: Uuid::new_v4(),
782 name: name.to_string(),
783 created_at: Utc::now(),
784 updated_at: Utc::now(),
785 last_goal: goal.map(|g| g.to_string()),
786 summary: None,
787 message_count: 1,
788 total_tokens: 100,
789 completed: false,
790 file_name: format!("{}.json", name),
791 tags: tags.into_iter().map(|s| s.to_string()).collect(),
792 project_type: None,
793 };
794 index.entries.push(make_entry(
795 "debug-auth",
796 Some("fix authentication bug"),
797 vec!["bugfix"],
798 ));
799 index.entries.push(make_entry(
800 "refactor-api",
801 Some("clean up API endpoints"),
802 vec!["refactor"],
803 ));
804 index.entries.push(make_entry(
805 "add-tests",
806 Some("write unit tests"),
807 vec!["testing"],
808 ));
809
810 let mgr = SessionManager::from_index(index);
811
812 let results = mgr.search("auth");
814 assert_eq!(results.len(), 1);
815 assert_eq!(results[0].name, "debug-auth");
816
817 let results = mgr.search("unit tests");
819 assert_eq!(results.len(), 1);
820 assert_eq!(results[0].name, "add-tests");
821
822 let results = mgr.search("bugfix");
824 assert_eq!(results.len(), 1);
825
826 let results = mgr.search("nonexistent");
828 assert_eq!(results.len(), 0);
829
830 let results = mgr.search("");
832 assert!(results.is_empty(), "Empty query should return no results");
833
834 let results = mgr.search(" ");
836 assert!(
837 results.is_empty(),
838 "Whitespace-only query should return no results"
839 );
840 }
841
842 #[test]
843 fn test_session_filter_by_tag() {
844 let mut index = SessionIndex::default();
845 let make_entry = |name: &str, tags: Vec<&str>| SessionEntry {
846 id: Uuid::new_v4(),
847 name: name.to_string(),
848 created_at: Utc::now(),
849 updated_at: Utc::now(),
850 last_goal: None,
851 summary: None,
852 message_count: 1,
853 total_tokens: 100,
854 completed: false,
855 file_name: format!("{}.json", name),
856 tags: tags.into_iter().map(|s| s.to_string()).collect(),
857 project_type: None,
858 };
859 index
860 .entries
861 .push(make_entry("s1", vec!["bugfix", "urgent"]));
862 index.entries.push(make_entry("s2", vec!["refactor"]));
863 index.entries.push(make_entry("s3", vec!["bugfix"]));
864
865 let mgr = SessionManager::from_index(index);
866
867 let results = mgr.filter_by_tag("bugfix");
868 assert_eq!(results.len(), 2);
869
870 let results = mgr.filter_by_tag("urgent");
871 assert_eq!(results.len(), 1);
872
873 let results = mgr.filter_by_tag("nonexistent");
874 assert_eq!(results.len(), 0);
875 }
876
877 #[test]
878 fn test_tag_session_case_insensitive_dedup() {
879 let dir = tempfile::tempdir().unwrap();
880 let mut mgr = create_test_manager(dir.path());
881
882 let entry = mgr.start_session(Some("test-tag-dedup"));
883 mgr.tag_session("test-tag-dedup", "bugfix").unwrap();
884 mgr.tag_session("test-tag-dedup", "BugFix").unwrap();
885 mgr.tag_session("test-tag-dedup", "BUGFIX").unwrap();
886
887 let index = SessionIndex::load(dir.path()).unwrap();
889 let saved = index.find_by_id(entry.id).unwrap();
890 assert_eq!(saved.tags.len(), 1);
891 assert_eq!(saved.tags[0], "bugfix");
892 }
893
894 #[test]
895 fn test_search_special_characters_no_panic() {
896 let mut index = SessionIndex::default();
897 let make_entry = |name: &str, goal: Option<&str>| SessionEntry {
898 id: Uuid::new_v4(),
899 name: name.to_string(),
900 created_at: Utc::now(),
901 updated_at: Utc::now(),
902 last_goal: goal.map(|g| g.to_string()),
903 summary: None,
904 message_count: 1,
905 total_tokens: 100,
906 completed: false,
907 file_name: format!("{}.json", name),
908 tags: vec![],
909 project_type: None,
910 };
911 index.entries.push(make_entry("session-1", Some("fix bug")));
912
913 let mgr = SessionManager::from_index(index);
914
915 let _ = mgr.search(".*+?()[]{}|\\^$");
917 let _ = mgr.search("🦀 Rust emoji");
918 let _ = mgr.search("日本語テスト");
919 let _ = mgr.search("café résumé");
920 }
921
922 #[test]
923 fn test_filter_by_tag_case_insensitive() {
924 let mut index = SessionIndex::default();
925 let make_entry = |name: &str, tags: Vec<&str>| SessionEntry {
926 id: Uuid::new_v4(),
927 name: name.to_string(),
928 created_at: Utc::now(),
929 updated_at: Utc::now(),
930 last_goal: None,
931 summary: None,
932 message_count: 1,
933 total_tokens: 100,
934 completed: false,
935 file_name: format!("{}.json", name),
936 tags: tags.into_iter().map(|s| s.to_string()).collect(),
937 project_type: None,
938 };
939 index.entries.push(make_entry("s1", vec!["BugFix"]));
940 index.entries.push(make_entry("s2", vec!["bugfix"]));
941
942 let mgr = SessionManager::from_index(index);
943
944 let results = mgr.filter_by_tag("BUGFIX");
946 assert_eq!(results.len(), 2);
947 }
948
949 #[test]
950 fn test_encrypted_session_save_load_roundtrip() {
951 let dir = tempfile::TempDir::new().unwrap();
952 let sessions_dir = dir.path().to_path_buf();
953
954 let key = [42u8; 32];
955 let encryptor = crate::encryption::SessionEncryptor::from_key(&key);
956
957 let mut mgr = SessionManager::with_dir(sessions_dir.clone())
958 .unwrap()
959 .with_encryption(encryptor);
960 let _entry = mgr.start_session(Some("encrypted-test"));
961
962 let mut memory = MemorySystem::new(20);
963 memory.short_term.add(Message::user("hello encrypted"));
964 memory.short_term.add(Message::assistant("hi back"));
965
966 mgr.save_checkpoint(&memory, 100).unwrap();
967
968 let entry = mgr.index().entries.last().unwrap();
970 let file_path = sessions_dir.join(&entry.file_name);
971 let raw_bytes = std::fs::read(&file_path).unwrap();
972 assert!(
973 serde_json::from_slice::<serde_json::Value>(&raw_bytes).is_err(),
974 "Encrypted file should not be valid JSON"
975 );
976
977 let (loaded_memory, continuation) = mgr.resume_session("encrypted-test").unwrap();
979 assert_eq!(loaded_memory.short_term.len(), 2);
980 assert!(continuation.contains("resuming"));
981 }
982
983 #[test]
984 fn test_encrypted_session_wrong_key_fails() {
985 let dir = tempfile::TempDir::new().unwrap();
986 let sessions_dir = dir.path().to_path_buf();
987
988 let key1 = [42u8; 32];
989 let encryptor1 = crate::encryption::SessionEncryptor::from_key(&key1);
990
991 let mut mgr = SessionManager::with_dir(sessions_dir.clone())
992 .unwrap()
993 .with_encryption(encryptor1);
994 let _entry = mgr.start_session(Some("wrongkey-test"));
995
996 let mut memory = MemorySystem::new(20);
997 memory.short_term.add(Message::user("secret data"));
998 mgr.save_checkpoint(&memory, 50).unwrap();
999
1000 let key2 = [99u8; 32];
1002 let encryptor2 = crate::encryption::SessionEncryptor::from_key(&key2);
1003 let mut mgr2 = SessionManager::with_dir(sessions_dir)
1004 .unwrap()
1005 .with_encryption(encryptor2);
1006
1007 let result = mgr2.resume_session("wrongkey-test");
1009 assert!(result.is_err(), "Decryption with wrong key should fail");
1010 }
1011
1012 #[test]
1013 fn test_find_incomplete_sessions() {
1014 let mut index = SessionIndex::default();
1015 let now = Utc::now();
1016
1017 index.entries.push(SessionEntry {
1019 id: Uuid::new_v4(),
1020 name: "interrupted".to_string(),
1021 created_at: now,
1022 updated_at: now,
1023 last_goal: Some("fix bug".to_string()),
1024 summary: None,
1025 message_count: 5,
1026 total_tokens: 100,
1027 completed: false,
1028 file_name: "a.json".to_string(),
1029 tags: vec![],
1030 project_type: None,
1031 });
1032
1033 index.entries.push(SessionEntry {
1035 id: Uuid::new_v4(),
1036 name: "done".to_string(),
1037 created_at: now,
1038 updated_at: now,
1039 last_goal: None,
1040 summary: Some("all done".to_string()),
1041 message_count: 10,
1042 total_tokens: 200,
1043 completed: true,
1044 file_name: "b.json".to_string(),
1045 tags: vec![],
1046 project_type: None,
1047 });
1048
1049 index.entries.push(SessionEntry {
1051 id: Uuid::new_v4(),
1052 name: "empty".to_string(),
1053 created_at: now,
1054 updated_at: now,
1055 last_goal: None,
1056 summary: None,
1057 message_count: 0,
1058 total_tokens: 0,
1059 completed: false,
1060 file_name: "c.json".to_string(),
1061 tags: vec![],
1062 project_type: None,
1063 });
1064
1065 let mgr = SessionManager::from_index(index);
1066 let incomplete = mgr.find_incomplete_sessions();
1067 assert_eq!(incomplete.len(), 1);
1068 assert_eq!(incomplete[0].name, "interrupted");
1069 }
1070}