Skip to main content

rustant_core/
session_manager.rs

1//! Session management for persistent, resumable agent sessions.
2//!
3//! Maintains a session index in the sessions directory with metadata (name,
4//! last task, timestamp, token usage, completion status). Supports auto-save,
5//! listing, resume, rename, and delete operations.
6
7use 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/// Metadata for a session entry in the session index.
15#[derive(Debug, Clone, Serialize, Deserialize)]
16pub struct SessionEntry {
17    /// Unique session identifier.
18    pub id: Uuid,
19    /// Human-readable session name.
20    pub name: String,
21    /// When the session was first created.
22    pub created_at: DateTime<Utc>,
23    /// When the session was last saved.
24    pub updated_at: DateTime<Utc>,
25    /// The last goal/task the agent was working on.
26    pub last_goal: Option<String>,
27    /// Summary of what was accomplished.
28    pub summary: Option<String>,
29    /// Total messages in the session.
30    pub message_count: usize,
31    /// Total tokens used in the session.
32    pub total_tokens: usize,
33    /// Whether the session completed its task.
34    pub completed: bool,
35    /// File path to the session data (relative to sessions directory).
36    pub file_name: String,
37    /// User-defined tags for categorization.
38    #[serde(default, skip_serializing_if = "Vec::is_empty")]
39    pub tags: Vec<String>,
40    /// Auto-detected project type at save time.
41    #[serde(default, skip_serializing_if = "Option::is_none")]
42    pub project_type: Option<String>,
43}
44
45/// The session index stored as a JSON file.
46#[derive(Debug, Clone, Default, Serialize, Deserialize)]
47pub struct SessionIndex {
48    pub entries: Vec<SessionEntry>,
49}
50
51impl SessionIndex {
52    /// Load the session index from a directory.
53    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    /// Save the session index to a directory.
68    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    /// Find an entry by name (case-insensitive, fuzzy prefix match).
83    pub fn find_by_name(&self, query: &str) -> Option<&SessionEntry> {
84        let query_lower = query.to_lowercase();
85        // Exact match first
86        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        // Prefix match
94        self.entries
95            .iter()
96            .find(|e| e.name.to_lowercase().starts_with(&query_lower))
97    }
98
99    /// Find an entry by ID.
100    pub fn find_by_id(&self, id: Uuid) -> Option<&SessionEntry> {
101        self.entries.iter().find(|e| e.id == id)
102    }
103
104    /// Get the most recent session (by updated_at).
105    pub fn most_recent(&self) -> Option<&SessionEntry> {
106        self.entries.iter().max_by_key(|e| e.updated_at)
107    }
108
109    /// List entries sorted by most recently updated.
110    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
117/// Manages session persistence, indexing, and resume.
118pub struct SessionManager {
119    sessions_dir: PathBuf,
120    index: SessionIndex,
121    /// ID of the currently active session (if any).
122    active_session_id: Option<Uuid>,
123    /// Optional encryptor for session data at rest.
124    encryptor: Option<crate::encryption::SessionEncryptor>,
125}
126
127impl SessionManager {
128    /// Create a new session manager for the given workspace.
129    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    /// Create a new session manager with a custom sessions directory.
141    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    /// Enable encryption for session data using the provided encryptor.
152    pub fn with_encryption(mut self, encryptor: crate::encryption::SessionEncryptor) -> Self {
153        self.encryptor = Some(encryptor);
154        self
155    }
156
157    /// Start a new session with an optional name.
158    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    /// Save the current state of a memory system to the active session.
188    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        // Find the entry
200        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        // Update metadata
210        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        // Save session data
216        let session_path = self.sessions_dir.join(&entry.file_name);
217        memory.save_session(&session_path)?;
218
219        // Encrypt session file if encryption is enabled
220        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        // Save updated index
243        self.index.save(&self.sessions_dir)
244    }
245
246    /// Mark the active session as completed.
247    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    /// Resume a session by name or ID. Returns the loaded MemorySystem and
260    /// a continuation prompt to inject into the agent.
261    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        // Decrypt session file if encryption is enabled
280        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            // Write decrypted data to a temp file for loading
292            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); // Clean up temp file
298            result?
299        } else {
300            MemorySystem::load_session(&session_path)?
301        };
302
303        // Build continuation prompt
304        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        // Set this as the active session
325        self.active_session_id = Some(entry.id);
326
327        Ok((memory, continuation))
328    }
329
330    /// Resume the most recent session.
331    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    /// List recent sessions.
343    pub fn list_sessions(&self, limit: usize) -> Vec<&SessionEntry> {
344        self.index.list_recent(limit)
345    }
346
347    /// Rename a session.
348    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    /// Delete a session (removes data file and index entry).
371    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        // Remove session data file
397        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        // Remove from index
403        self.index.entries.remove(idx);
404        self.index.save(&self.sessions_dir)?;
405
406        Ok(name)
407    }
408
409    /// Get the active session ID.
410    pub fn active_session_id(&self) -> Option<Uuid> {
411        self.active_session_id
412    }
413
414    /// Get the sessions directory path.
415    pub fn sessions_dir(&self) -> &Path {
416        &self.sessions_dir
417    }
418
419    /// Get a reference to the session index.
420    pub fn index(&self) -> &SessionIndex {
421        &self.index
422    }
423
424    /// Create a SessionManager from an in-memory index (for testing).
425    #[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    /// Find incomplete sessions (not completed, with at least one message).
436    /// Useful for crash recovery — shows sessions that were interrupted.
437    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    /// Search sessions by matching query against name, goal, summary, and tags.
446    /// Returns empty vec for empty/whitespace-only queries.
447    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    /// Filter sessions by tag.
471    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    /// Add a tag to a session.
481    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        // Verify file was created
550        let session_path = dir.path().join(&entry.file_name);
551        assert!(session_path.exists());
552
553        // Verify index was updated
554        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        // Create and save a session
567        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        // Create a new manager (simulating restart)
574        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        // First session
606        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        // Second session (more recent)
612        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        // Resume latest
619        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        // New manager should find the persisted session
720        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        // Ensure backward compatibility: old entries without tags/project_type still deserialize
770        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        // Search by name
813        let results = mgr.search("auth");
814        assert_eq!(results.len(), 1);
815        assert_eq!(results[0].name, "debug-auth");
816
817        // Search by goal
818        let results = mgr.search("unit tests");
819        assert_eq!(results.len(), 1);
820        assert_eq!(results[0].name, "add-tests");
821
822        // Search by tag
823        let results = mgr.search("bugfix");
824        assert_eq!(results.len(), 1);
825
826        // No matches
827        let results = mgr.search("nonexistent");
828        assert_eq!(results.len(), 0);
829
830        // Empty query returns empty (not all sessions)
831        let results = mgr.search("");
832        assert!(results.is_empty(), "Empty query should return no results");
833
834        // Whitespace-only query returns empty
835        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        // Reload and verify only one tag was added
888        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        // Special regex-like characters should not panic
916        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        // Case-insensitive filtering should find both
945        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        // Verify the saved file is NOT valid JSON (it's encrypted binary data)
969        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        // Resume should decrypt and load correctly
978        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        // Create a new manager with a different key
1001        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        // Resume should fail because the key is wrong
1008        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        // Incomplete session with messages
1018        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        // Completed session
1034        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        // Empty session (no messages) — should NOT be included
1050        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}