Skip to main content

pawan/agent/
session.rs

1//! Session persistence — save and resume conversations
2
3use crate::agent::{Message, Role};
4use crate::{PawanError, Result};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8/// A saved conversation session
9#[derive(Debug, Serialize, Deserialize)]
10pub struct Session {
11    /// Unique session ID
12    pub id: String,
13    /// Model used for this session
14    pub model: String,
15    /// When the session was created
16    pub created_at: String,
17    /// When the session was last updated
18    pub updated_at: String,
19    /// Conversation messages
20    pub messages: Vec<Message>,
21    /// Total tokens used in this session
22    #[serde(default)]
23    pub total_tokens: u64,
24    /// Number of iterations completed
25    #[serde(default)]
26    pub iteration_count: u32,
27    /// User-defined tags for this session
28    #[serde(default)]
29    pub tags: Vec<String>,
30    /// User notes/description for this session
31    #[serde(default)]
32    pub notes: String,
33}
34
35impl Session {
36    /// Create a new session
37    pub fn new(model: &str) -> Self {
38        Self::new_with_tags(model, Vec::new())
39    }
40
41    /// Create a new session with a specific ID (e.g. for updates)
42    pub fn new_with_id(id: String, model: &str, tags: Vec<String>) -> Self {
43        let now = chrono::Utc::now().to_rfc3339();
44        Self {
45            id,
46            model: model.to_string(),
47            created_at: now.clone(),
48            updated_at: now,
49            messages: Vec::new(),
50            total_tokens: 0,
51            iteration_count: 0,
52            tags,
53            notes: String::new(),
54        }
55    }
56
57    /// Create a new session with tags
58    pub fn new_with_tags(model: &str, tags: Vec<String>) -> Self {
59        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
60        let now = chrono::Utc::now().to_rfc3339();
61        Self {
62            id,
63            model: model.to_string(),
64            created_at: now.clone(),
65            updated_at: now,
66            messages: Vec::new(),
67            total_tokens: 0,
68            iteration_count: 0,
69            tags,
70            notes: String::new(),
71        }
72    }
73
74    /// Create a new session with notes
75    pub fn new_with_notes(model: &str, notes: String) -> Self {
76        let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
77        let now = chrono::Utc::now().to_rfc3339();
78        Self {
79            id,
80            model: model.to_string(),
81            created_at: now.clone(),
82            updated_at: now,
83            messages: Vec::new(),
84            total_tokens: 0,
85            iteration_count: 0,
86            tags: Vec::new(),
87            notes,
88        }
89    }
90
91    /// Get the sessions directory (~/.pawan/sessions/)
92    pub fn sessions_dir() -> Result<PathBuf> {
93        let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
94        let dir = PathBuf::from(home).join(".pawan").join("sessions");
95        if !dir.exists() {
96            std::fs::create_dir_all(&dir)
97                .map_err(|e| PawanError::Config(format!("Failed to create sessions dir: {}", e)))?;
98        }
99        Ok(dir)
100    }
101
102    /// Save session to disk
103    pub fn save(&mut self) -> Result<PathBuf> {
104        self.updated_at = chrono::Utc::now().to_rfc3339();
105        let dir = Self::sessions_dir()?;
106        let path = dir.join(format!("{}.json", self.id));
107        let json = serde_json::to_string_pretty(self)
108            .map_err(|e| PawanError::Config(format!("Failed to serialize session: {}", e)))?;
109        std::fs::write(&path, json)
110            .map_err(|e| PawanError::Config(format!("Failed to write session: {}", e)))?;
111        Ok(path)
112    }
113
114    /// Load a session from disk by ID
115    pub fn load(id: &str) -> Result<Self> {
116        let dir = Self::sessions_dir()?;
117        let path = dir.join(format!("{}.json", id));
118        if !path.exists() {
119            return Err(PawanError::NotFound(format!("Session not found: {}", id)));
120        }
121        let content = std::fs::read_to_string(&path)
122            .map_err(|e| PawanError::Config(format!("Failed to read session: {}", e)))?;
123        serde_json::from_str(&content)
124            .map_err(|e| PawanError::Config(format!("Failed to parse session: {}", e)))
125    }
126
127    /// Add a tag to the session (validates and prevents duplicates)
128    pub fn add_tag(&mut self, tag: &str) -> Result<()> {
129        let sanitized = Self::sanitize_tag(tag)?;
130        if self.tags.contains(&sanitized) {
131            return Err(PawanError::Config(format!("Tag already exists: {}", sanitized)));
132        }
133        self.tags.push(sanitized);
134        Ok(())
135    }
136
137    /// Remove a tag from the session
138    pub fn remove_tag(&mut self, tag: &str) -> Result<()> {
139        let sanitized = Self::sanitize_tag(tag)?;
140        if let Some(pos) = self.tags.iter().position(|t| t == &sanitized) {
141            self.tags.remove(pos);
142            Ok(())
143        } else {
144            Err(PawanError::NotFound(format!("Tag not found: {}", sanitized)))
145        }
146    }
147
148    /// Clear all tags from the session
149    pub fn clear_tags(&mut self) {
150        self.tags.clear();
151    }
152
153    /// Check if session has a specific tag
154    pub fn has_tag(&self, tag: &str) -> bool {
155        match Self::sanitize_tag(tag) {
156            Ok(sanitized) => self.tags.contains(&sanitized),
157            Err(_) => false,
158        }
159    }
160
161    /// Sanitize and validate a tag name
162    fn sanitize_tag(tag: &str) -> Result<String> {
163        let trimmed = tag.trim();
164        if trimmed.is_empty() {
165            return Err(PawanError::Config("Tag name cannot be empty".to_string()));
166        }
167        if trimmed.len() > 50 {
168            return Err(PawanError::Config("Tag name too long (max 50 characters)".to_string()));
169        }
170        // Allow alphanumeric, hyphen, underscore, and space
171        let sanitized: String = trimmed
172            .chars()
173            .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == ' ')
174            .collect();
175        if sanitized.is_empty() {
176            return Err(PawanError::Config("Tag contains invalid characters".to_string()));
177        }
178        Ok(sanitized)
179    }
180
181    /// Import a session from a JSON file
182    pub fn from_json_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
183        let content = std::fs::read_to_string(path)
184            .map_err(|e| PawanError::Config(format!("Failed to read session file: {}", e)))?;
185        let mut session: Session = serde_json::from_str(&content)
186            .map_err(|e| PawanError::Config(format!("Failed to parse session JSON: {}", e)))?;
187        
188        // Assign a new ID to ensure it doesn't collide with existing sessions
189        // and clearly mark it as a new import in this system.
190        session.id = uuid::Uuid::new_v4().to_string()[..8].to_string();
191        session.updated_at = chrono::Utc::now().to_rfc3339();
192        
193        Ok(session)
194    }
195
196    /// List all saved sessions (sorted by updated_at, newest first)
197    pub fn list() -> Result<Vec<SessionSummary>> {
198        let dir = Self::sessions_dir()?;
199        let mut sessions = Vec::new();
200
201        if let Ok(entries) = std::fs::read_dir(&dir) {
202            for entry in entries.flatten() {
203                let path = entry.path();
204                if path.extension().is_some_and(|ext| ext == "json") {
205                    if let Ok(content) = std::fs::read_to_string(&path) {
206                        if let Ok(session) = serde_json::from_str::<Session>(&content) {
207                            sessions.push(SessionSummary {
208                                id: session.id,
209                                model: session.model,
210                                created_at: session.created_at,
211                                updated_at: session.updated_at,
212                                message_count: session.messages.len(),
213                                tags: session.tags,
214                                notes: session.notes,
215                            });
216                        }
217                    }
218                }
219            }
220        }
221
222        sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
223        Ok(sessions)
224    }
225}
226
227/// Summary of a saved session (for listing)
228#[derive(Debug, Serialize, Deserialize)]
229pub struct SessionSummary {
230    pub id: String,
231    pub model: String,
232    pub created_at: String,
233    pub updated_at: String,
234    pub message_count: usize,
235    /// User-defined tags for this session
236    #[serde(default)]
237    pub tags: Vec<String>,
238    /// User notes for this session
239    #[serde(default)]
240    pub notes: String,
241}
242
243#[cfg(test)]
244mod tests {
245    use super::*;
246    use crate::agent::Role;
247
248    #[test]
249    fn session_new_generates_8_char_id() {
250        let s = Session::new("test-model");
251        assert_eq!(s.id.len(), 8, "session id must be exactly 8 chars");
252        assert_eq!(s.model, "test-model");
253        assert!(s.messages.is_empty());
254        assert_eq!(s.total_tokens, 0);
255        assert_eq!(s.iteration_count, 0);
256    }
257
258    #[test]
259    fn session_new_produces_distinct_ids() {
260        // UUID prefix uniqueness — two fresh sessions in a row must differ.
261        // (At 8 hex chars = 32 bits, birthday paradox says ~65k sessions
262        // before 50% collision chance, so two in a row is safe.)
263        let a = Session::new("m");
264        let b = Session::new("m");
265        assert_ne!(a.id, b.id, "successive Session::new() must produce distinct ids");
266    }
267
268    #[test]
269    fn session_new_timestamps_parse_as_rfc3339() {
270        let s = Session::new("m");
271        // Both timestamps must be valid RFC3339 and equal at creation.
272        assert_eq!(s.created_at, s.updated_at, "at creation created_at == updated_at");
273        chrono::DateTime::parse_from_rfc3339(&s.created_at)
274            .expect("created_at must parse as RFC3339");
275        chrono::DateTime::parse_from_rfc3339(&s.updated_at)
276            .expect("updated_at must parse as RFC3339");
277    }
278
279    #[test]
280    fn session_serde_roundtrip_preserves_all_fields() {
281        let mut original = Session::new("qwen-test");
282        original.total_tokens = 12345;
283        original.iteration_count = 7;
284        original.messages.push(Message {
285            role: Role::User,
286            content: "hello".into(),
287            tool_calls: vec![],
288            tool_result: None,
289        });
290        let json = serde_json::to_string(&original).unwrap();
291        let restored: Session = serde_json::from_str(&json).unwrap();
292        assert_eq!(restored.id, original.id);
293        assert_eq!(restored.model, original.model);
294        assert_eq!(restored.created_at, original.created_at);
295        assert_eq!(restored.updated_at, original.updated_at);
296        assert_eq!(restored.total_tokens, 12345);
297        assert_eq!(restored.iteration_count, 7);
298        assert_eq!(restored.messages.len(), 1);
299    }
300
301    #[test]
302    fn session_deserialize_tolerates_missing_token_fields() {
303        // Old sessions written before total_tokens / iteration_count existed
304        // must still load — they're marked #[serde(default)] so missing
305        // fields deserialize to 0. Regression guard.
306        let json = r#"{
307            "id": "abcd1234",
308            "model": "old-model",
309            "created_at": "2026-01-01T00:00:00Z",
310            "updated_at": "2026-01-01T00:00:00Z",
311            "messages": []
312        }"#;
313        let session: Session = serde_json::from_str(json).unwrap();
314        assert_eq!(session.id, "abcd1234");
315        assert_eq!(session.total_tokens, 0, "missing total_tokens ⇒ default 0");
316        assert_eq!(session.iteration_count, 0, "missing iteration_count ⇒ default 0");
317    }
318
319    #[test]
320    fn session_summary_serde_roundtrip() {
321        let summary = SessionSummary {
322            notes: String::new(),
323            id: "abcdef12".into(),
324            model: "qwen3.5".into(),
325            created_at: "2026-04-10T12:00:00Z".into(),
326            updated_at: "2026-04-10T13:00:00Z".into(),
327            message_count: 42,
328            tags: Vec::new(),
329        };
330        let json = serde_json::to_string(&summary).unwrap();
331        assert!(json.contains("\"id\":\"abcdef12\""));
332        assert!(json.contains("\"message_count\":42"));
333        let restored: SessionSummary = serde_json::from_str(&json).unwrap();
334        assert_eq!(restored.id, "abcdef12");
335        assert_eq!(restored.message_count, 42);
336    }
337
338    // ─── I/O path tests ───────────────────────────────────────────────────
339
340    #[test]
341    fn test_load_nonexistent_id_returns_not_found() {
342        // Use an ID that is guaranteed not to exist on disk.
343        let err = Session::load("__test_nonexistent_id_zzz__").unwrap_err();
344        match err {
345            crate::PawanError::NotFound(msg) => {
346                assert!(msg.contains("Session not found"), "unexpected: {msg}")
347            }
348            other => panic!("expected NotFound, got {:?}", other),
349        }
350    }
351
352    #[test]
353    fn test_save_and_load_roundtrip() {
354        let mut session = Session::new("roundtrip-model");
355        session.total_tokens = 999;
356        session.iteration_count = 3;
357        session.messages.push(Message {
358            role: Role::User,
359            content: "save-load test".into(),
360            tool_calls: vec![],
361            tool_result: None,
362        });
363        let id = session.id.clone();
364
365        let path = session.save().expect("save must succeed");
366        assert!(path.exists(), "saved file must exist at {:?}", path);
367
368        let loaded = Session::load(&id).expect("load by id must succeed");
369        assert_eq!(loaded.id, id);
370        assert_eq!(loaded.model, "roundtrip-model");
371        assert_eq!(loaded.total_tokens, 999);
372        assert_eq!(loaded.iteration_count, 3);
373        assert_eq!(loaded.messages.len(), 1);
374
375        // cleanup
376        let _ = std::fs::remove_file(&path);
377    }
378
379    #[test]
380    fn test_save_updates_updated_at() {
381        let mut session = Session::new("timestamp-model");
382        let original_updated = session.updated_at.clone();
383        // Small sleep to ensure clock advances
384        std::thread::sleep(std::time::Duration::from_millis(10));
385        let path = session.save().expect("save must succeed");
386        // updated_at must be >= created_at (may be equal if sub-ms precision)
387        let updated = chrono::DateTime::parse_from_rfc3339(&session.updated_at)
388            .expect("updated_at must be valid RFC3339");
389        let orig = chrono::DateTime::parse_from_rfc3339(&original_updated)
390            .expect("original_updated must be valid RFC3339");
391        assert!(
392            updated >= orig,
393            "updated_at after save must be >= created_at"
394        );
395        // cleanup
396        let _ = std::fs::remove_file(&path);
397    }
398
399    #[test]
400    fn test_list_includes_saved_session() {
401        let mut session = Session::new("list-test-model");
402        let id = session.id.clone();
403        let path = session.save().expect("save must succeed");
404
405        let summaries = Session::list().expect("list must succeed");
406        let found = summaries.iter().any(|s| s.id == id);
407        assert!(found, "newly saved session must appear in list()");
408
409        // cleanup
410        let _ = std::fs::remove_file(&path);
411    }
412
413    #[test]
414    fn test_list_sorted_newest_first() {
415        // Create two sessions and force different updated_at values.
416        let mut older = Session::new("older-model");
417        older.updated_at = "2020-01-01T00:00:00Z".to_string();
418        let path_older = older.save().expect("save older");
419
420        let mut newer = Session::new("newer-model");
421        newer.updated_at = "2030-01-01T00:00:00Z".to_string();
422        let path_newer = newer.save().expect("save newer");
423
424        let summaries = Session::list().expect("list must succeed");
425
426        // Find positions of our two sessions in the sorted list
427        let pos_older = summaries.iter().position(|s| s.id == older.id);
428        let pos_newer = summaries.iter().position(|s| s.id == newer.id);
429
430        if let (Some(po), Some(pn)) = (pos_older, pos_newer) {
431            assert!(
432                pn < po,
433                "newer session (pos {pn}) must appear before older (pos {po}) in list"
434            );
435        }
436
437        // cleanup
438        let _ = std::fs::remove_file(&path_older);
439        let _ = std::fs::remove_file(&path_newer);
440    }
441}
442
443// ========== Session Search and Pruning ==========
444
445/// Search result for a session
446#[derive(Debug, Serialize, Deserialize)]
447pub struct SearchResult {
448    pub id: String,
449    pub model: String,
450    pub updated_at: String,
451    pub message_count: usize,
452    #[serde(default)]
453    pub tags: Vec<String>,
454    pub matches: Vec<MessageMatch>,
455}
456
457/// A matching message within a search result
458#[derive(Debug, Serialize, Deserialize)]
459pub struct MessageMatch {
460    pub message_index: usize,
461    pub role: Role,
462    pub preview: String,
463    /// Context before the match (for better preview)
464    #[serde(default)]
465    pub context_before: String,
466    /// Context after the match (for better preview)
467    #[serde(default)]
468    pub context_after: String,
469    /// The actual matched text
470    #[serde(default)]
471    pub matched_text: String,
472}
473
474/// Search options for filtering sessions
475#[derive(Debug, Clone, Default)]
476pub struct SearchOptions {
477    /// Filter by role (None = all roles)
478    pub role_filter: Option<Role>,
479    /// Filter by date range (start date in RFC3339 format)
480    pub date_from: Option<String>,
481    /// Filter by date range (end date in RFC3339 format)
482    pub date_to: Option<String>,
483    /// Maximum number of results per session
484    pub max_matches_per_session: Option<usize>,
485    /// Context window size (characters before/after match)
486    pub context_window: usize,
487}
488
489impl SearchOptions {
490    /// Create new search options with defaults
491    pub fn new() -> Self {
492        Self {
493            role_filter: None,
494            date_from: None,
495            date_to: None,
496            max_matches_per_session: Some(5),
497            context_window: 50,
498        }
499    }
500
501    /// Set role filter
502    pub fn with_role(mut self, role: Role) -> Self {
503        self.role_filter = Some(role);
504        self
505    }
506
507    /// Set date range filter
508    pub fn with_date_range(mut self, from: Option<String>, to: Option<String>) -> Self {
509        self.date_from = from;
510        self.date_to = to;
511        self
512    }
513
514    /// Set max matches per session
515    pub fn with_max_matches(mut self, max: usize) -> Self {
516        self.max_matches_per_session = Some(max);
517        self
518    }
519
520    /// Set context window size
521    pub fn with_context_window(mut self, window: usize) -> Self {
522        self.context_window = window;
523        self
524    }
525}
526
527/// Retention policy for session cleanup
528#[derive(Debug, Clone, Default)]
529pub struct RetentionPolicy {
530    /// Maximum age in days (None = no limit)
531    pub max_age_days: Option<u32>,
532    /// Maximum number of sessions to keep (None = no limit)
533    pub max_sessions: Option<usize>,
534    /// Tags to always keep
535    pub keep_tags: Vec<String>,
536}
537
538/// Search sessions by content query with options
539pub fn search_sessions_with_options(query: &str, options: &SearchOptions) -> Result<Vec<SearchResult>> {
540    let dir = Session::sessions_dir()?;
541    let query_lower = query.to_lowercase();
542    let mut results = Vec::new();
543    
544    if let Ok(entries) = std::fs::read_dir(&dir) {
545        for entry in entries.flatten() {
546            let path = entry.path();
547            if path.extension().is_some_and(|ext| ext == "json") {
548                if let Ok(content) = std::fs::read_to_string(&path) {
549                    if let Ok(session) = serde_json::from_str::<Session>(&content) {
550                        // Apply date filter if specified
551                        if let (Some(from), Some(to)) = (&options.date_from, &options.date_to) {
552                            if let Ok(updated) = chrono::DateTime::parse_from_rfc3339(&session.updated_at) {
553                                let updated_utc = updated.with_timezone(&chrono::Utc);
554                                if let (Ok(from_dt), Ok(to_dt)) = (
555                                    chrono::DateTime::parse_from_rfc3339(from),
556                                    chrono::DateTime::parse_from_rfc3339(to)
557                                ) {
558                                    let from_utc = from_dt.with_timezone(&chrono::Utc);
559                                    let to_utc = to_dt.with_timezone(&chrono::Utc);
560                                    if updated_utc < from_utc || updated_utc > to_utc {
561                                        continue; // Skip sessions outside date range
562                                    }
563                                }
564                            }
565                        }
566                        
567                        let mut matches = Vec::new();
568                        for (i, msg) in session.messages.iter().enumerate() {
569                            // Apply role filter if specified
570                            if let Some(ref role_filter) = options.role_filter {
571                                if &msg.role != role_filter {
572                                    continue;
573                                }
574                            }
575                            
576                            if msg.content.to_lowercase().contains(&query_lower) {
577                                // Find the match position for context extraction
578                                let content_lower = msg.content.to_lowercase();
579                                if let Some(pos) = content_lower.find(&query_lower) {
580                                    let start = if pos >= options.context_window {
581                                        pos - options.context_window
582                                    } else {
583                                        0
584                                    };
585                                    let end = std::cmp::min(
586                                        pos + query.len() + options.context_window,
587                                        msg.content.len()
588                                    );
589                                    
590                                    let context_before = msg.content[start..pos].to_string();
591                                    let matched_text = msg.content[pos..pos + query.len()].to_string();
592                                    let context_after = msg.content[pos + query.len()..end].to_string();
593                                    
594                                    // Create preview with context
595                                    let preview = format!(
596                                        "{}{}{}",
597                                        if start > 0 { "..." } else { "" },
598                                        &msg.content[start..end],
599                                        if end < msg.content.len() { "..." } else { "" }
600                                    );
601                                    
602                                    matches.push(MessageMatch {
603                                        message_index: i,
604                                        role: msg.role.clone(),
605                                        preview,
606                                        context_before,
607                                        context_after,
608                                        matched_text,
609                                    });
610                                }
611                            }
612                        }
613                        
614                        if !matches.is_empty() {
615                            // Limit matches per session if specified
616                            let limited_matches = if let Some(max) = options.max_matches_per_session {
617                                matches.into_iter().take(max).collect()
618                            } else {
619                                matches
620                            };
621                            
622                            results.push(SearchResult {
623                                id: session.id,
624                                model: session.model,
625                                updated_at: session.updated_at,
626                                message_count: session.messages.len(),
627                                tags: session.tags,
628                                matches: limited_matches,
629                            });
630                        }
631                    }
632                }
633            }
634        }
635    }
636    
637    results.sort_by(|a, b| b.matches.len().cmp(&a.matches.len()));
638    Ok(results)
639}
640
641/// Search sessions by content query (legacy function for backwards compatibility)
642pub fn search_sessions(query: &str) -> Result<Vec<SearchResult>> {
643    search_sessions_with_options(query, &SearchOptions::new())
644}
645
646/// Prune sessions based on retention policy
647pub fn prune_sessions(policy: &RetentionPolicy) -> Result<usize> {
648    let dir = Session::sessions_dir()?;
649    let mut sessions_data: Vec<(std::path::PathBuf, Session)> = Vec::new();
650    
651    if let Ok(entries) = std::fs::read_dir(&dir) {
652        for entry in entries.flatten() {
653            let path = entry.path();
654            if path.extension().is_some_and(|ext| ext == "json") {
655                if let Ok(content) = std::fs::read_to_string(&path) {
656                    if let Ok(session) = serde_json::from_str::<Session>(&content) {
657                        sessions_data.push((path, session));
658                    }
659                }
660            }
661        }
662    }
663    
664    sessions_data.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at));
665    
666    let mut deleted = 0usize;
667    let now = chrono::Utc::now();
668    
669    // Use enumerate to get index without moving the vector
670    for (i, (path, session)) in sessions_data.into_iter().enumerate() {
671        // Skip sessions with protected tags
672        let has_protected = session.tags.iter()
673            .any(|t| policy.keep_tags.iter().any(|kt| kt == t));
674        if has_protected {
675            continue;
676        }
677        
678        let mut should_delete = false;
679        
680        // Check age limit
681        if let Some(max_days) = policy.max_age_days {
682            if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&session.updated_at) {
683                let age = (now - st.with_timezone(&chrono::Utc)).num_days() as u32;
684                if age > max_days {
685                    should_delete = true;
686                }
687            }
688        }
689        
690        // Check max sessions limit (use index from enumerate)
691        if !should_delete {
692            if let Some(max_sess) = policy.max_sessions {
693                if i >= max_sess {
694                    should_delete = true;
695                }
696            }
697        }
698        
699        if should_delete {
700            std::fs::remove_file(&path)
701                .map_err(|e| PawanError::Config(format!("Delete failed: {}", e)))?;
702            deleted += 1;
703        }
704    }
705    
706    Ok(deleted)
707}
708
709#[cfg(test)]
710mod search_prune_tests {
711    use super::*;
712    use crate::agent::{Message, Role};
713    use serial_test::serial;
714
715    #[test]
716    fn test_role_serialization_is_lowercase() {
717        assert_eq!(serde_json::to_string(&Role::User).unwrap(), "\"user\"");
718        assert_eq!(serde_json::to_string(&Role::Assistant).unwrap(), "\"assistant\"");
719        assert_eq!(serde_json::to_string(&Role::System).unwrap(), "\"system\"");
720        assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), "\"tool\"");
721    }
722
723    #[test]
724    #[serial]
725    fn test_search_sessions_logic() {
726        let tmp = tempfile::tempdir().unwrap();
727        let prev_home = std::env::var("HOME").ok();
728        std::env::set_var("HOME", tmp.path());
729
730        // Create 2 sessions
731        let mut s1 = Session::new("m1");
732        s1.messages.push(Message {
733            role: Role::User,
734            content: "hello world".into(),
735            tool_calls: vec![],
736            tool_result: None,
737        });
738        s1.save().unwrap();
739
740        let mut s2 = Session::new("m2");
741        s2.messages.push(Message {
742            role: Role::User,
743            content: "goodbye world".into(),
744            tool_calls: vec![],
745            tool_result: None,
746        });
747        s2.save().unwrap();
748
749        // Search for "hello"
750        let results = search_sessions("hello").unwrap();
751        assert_eq!(results.len(), 1);
752        assert_eq!(results[0].id, s1.id);
753        assert_eq!(results[0].matches.len(), 1);
754        assert_eq!(results[0].matches[0].preview, "hello world");
755
756        // Search for "world" (both)
757        let results = search_sessions("world").unwrap();
758        assert_eq!(results.len(), 2);
759
760        // Restore HOME
761        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
762    }
763
764    #[test]
765    #[serial]
766    fn test_prune_sessions_logic() {
767        let tmp = tempfile::tempdir().unwrap();
768        let prev_home = std::env::var("HOME").ok();
769        std::env::set_var("HOME", tmp.path());
770
771        // Create 5 sessions with different timestamps manually
772        let dir = Session::sessions_dir().unwrap();
773        for i in 0..5 {
774            let mut s = Session::new("m");
775            s.id = format!("sess{}", i);
776            s.updated_at = format!("2026-04-1{}T12:00:00Z", i);
777            let path = dir.join(format!("{}.json", s.id));
778            let json = serde_json::to_string_pretty(&s).unwrap();
779            std::fs::write(&path, json).unwrap();
780        }
781
782        // Policy: keep 2 most recent
783        let policy = RetentionPolicy {
784            max_age_days: None,
785            max_sessions: Some(2),
786            keep_tags: vec![],
787        };
788        let deleted = prune_sessions(&policy).unwrap();
789        assert_eq!(deleted, 3);
790
791        let list = Session::list().unwrap();
792        assert_eq!(list.len(), 2);
793        // Should be sess4 and sess3 (newest)
794        assert!(list.iter().any(|s| s.id == "sess4"));
795        assert!(list.iter().any(|s| s.id == "sess3"));
796
797        // Restore HOME
798        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
799    }
800
801    #[test]
802    #[serial]
803    fn test_prune_sessions_age_and_tags() {
804        let tmp = tempfile::tempdir().unwrap();
805        let prev_home = std::env::var("HOME").ok();
806        std::env::set_var("HOME", tmp.path());
807
808        let dir = Session::sessions_dir().unwrap();
809
810        // 1. Old session (30 days ago)
811        let mut s1 = Session::new("m");
812        s1.id = "old".into();
813        s1.updated_at = "2020-01-01T00:00:00Z".into();
814        let path1 = dir.join(format!("{}.json", s1.id));
815        std::fs::write(&path1, serde_json::to_string_pretty(&s1).unwrap()).unwrap();
816
817        // 2. Old but protected by tag
818        let mut s2 = Session::new_with_tags("m", vec!["keep".into()]);
819        s2.id = "protected".into();
820        s2.updated_at = "2020-01-01T00:00:00Z".into();
821        let path2 = dir.join(format!("{}.json", s2.id));
822        std::fs::write(&path2, serde_json::to_string_pretty(&s2).unwrap()).unwrap();
823
824        // 3. New session
825        let mut s3 = Session::new("m");
826        s3.id = "new".into();
827        s3.save().unwrap(); // save() is fine for 'new' session
828
829        let policy = RetentionPolicy {
830            max_age_days: Some(7),
831            max_sessions: None,
832            keep_tags: vec!["keep".into()],
833        };
834        let deleted = prune_sessions(&policy).unwrap();
835        assert_eq!(deleted, 1); // Only 'old' deleted
836
837        let list = Session::list().unwrap();
838        assert_eq!(list.len(), 2);
839        assert!(list.iter().any(|s| s.id == "protected"));
840        assert!(list.iter().any(|s| s.id == "new"));
841
842        // Restore HOME
843        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
844    }
845
846    #[test]
847    #[serial]
848    fn test_search_sessions_no_results() {
849        let tmp = tempfile::tempdir().unwrap();
850        let prev_home = std::env::var("HOME").ok();
851        std::env::set_var("HOME", tmp.path());
852
853        let results = search_sessions("anything").unwrap();
854        assert!(results.is_empty());
855
856        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
857    }
858
859    #[test]
860    #[serial]
861    fn test_prune_sessions_zero_limits() {
862        let tmp = tempfile::tempdir().unwrap();
863        let prev_home = std::env::var("HOME").ok();
864        std::env::set_var("HOME", tmp.path());
865
866        let mut s = Session::new("m");
867        s.save().unwrap();
868
869        // Policy: keep 0 sessions
870        let policy = RetentionPolicy {
871            max_age_days: None,
872            max_sessions: Some(0),
873            keep_tags: vec![],
874        };
875        let deleted = prune_sessions(&policy).unwrap();
876        assert_eq!(deleted, 1);
877
878        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
879    }
880
881    #[test]
882    fn test_search_options_builder() {
883        let options = SearchOptions::new()
884            .with_role(Role::User)
885            .with_date_range(Some("2026-01-01T00:00:00Z".to_string()), Some("2026-12-31T23:59:59Z".to_string()))
886            .with_max_matches(10)
887            .with_context_window(100);
888        
889        assert_eq!(options.role_filter, Some(Role::User));
890        assert_eq!(options.date_from, Some("2026-01-01T00:00:00Z".to_string()));
891        assert_eq!(options.date_to, Some("2026-12-31T23:59:59Z".to_string()));
892        assert_eq!(options.max_matches_per_session, Some(10));
893        assert_eq!(options.context_window, 100);
894    }
895
896    #[test]
897    #[serial]
898    fn test_search_sessions_with_role_filter() {
899        let tmp = tempfile::tempdir().unwrap();
900        let prev_home = std::env::var("HOME").ok();
901        std::env::set_var("HOME", tmp.path());
902
903        // Create sessions with different roles
904        let mut s1 = Session::new("m1");
905        s1.messages.push(Message {
906            role: Role::User,
907            content: "hello world".into(),
908            tool_calls: vec![],
909            tool_result: None,
910        });
911        s1.messages.push(Message {
912            role: Role::Assistant,
913            content: "hello there".into(),
914            tool_calls: vec![],
915            tool_result: None,
916        });
917        s1.save().unwrap();
918
919        // Search for "hello" with user role filter
920        let options = SearchOptions::new().with_role(Role::User);
921        let results = search_sessions_with_options("hello", &options).unwrap();
922        assert_eq!(results.len(), 1);
923        assert_eq!(results[0].matches.len(), 1);
924        assert_eq!(results[0].matches[0].role, Role::User);
925
926        // Search for "hello" with assistant role filter
927        let options = SearchOptions::new().with_role(Role::Assistant);
928        let results = search_sessions_with_options("hello", &options).unwrap();
929        assert_eq!(results.len(), 1);
930        assert_eq!(results[0].matches.len(), 1);
931        assert_eq!(results[0].matches[0].role, Role::Assistant);
932
933        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
934    }
935
936    #[test]
937    #[serial]
938    fn test_search_sessions_context_extraction() {
939        let tmp = tempfile::tempdir().unwrap();
940        let prev_home = std::env::var("HOME").ok();
941        std::env::set_var("HOME", tmp.path());
942
943        // Create session with long message
944        let mut s1 = Session::new("m1");
945        s1.messages.push(Message {
946            role: Role::User,
947            content: "This is a long message with the word hello in the middle of the text".into(),
948            tool_calls: vec![],
949            tool_result: None,
950        });
951        s1.save().unwrap();
952
953        // Search with context window
954        let options = SearchOptions::new().with_context_window(10);
955        let results = search_sessions_with_options("hello", &options).unwrap();
956        assert_eq!(results.len(), 1);
957        assert_eq!(results[0].matches.len(), 1);
958        
959        let match_result = &results[0].matches[0];
960        assert!(!match_result.context_before.is_empty());
961        assert!(!match_result.context_after.is_empty());
962        assert_eq!(match_result.matched_text, "hello");
963        assert!(match_result.preview.contains("hello"));
964
965        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
966    }
967
968    #[test]
969    #[serial]
970    fn test_search_sessions_max_matches_limit() {
971        let tmp = tempfile::tempdir().unwrap();
972        let prev_home = std::env::var("HOME").ok();
973        std::env::set_var("HOME", tmp.path());
974
975        // Create session with multiple matches
976        let mut s1 = Session::new("m1");
977        for i in 0..10 {
978            s1.messages.push(Message {
979                role: Role::User,
980                content: format!("Message {} with hello text", i),
981                tool_calls: vec![],
982                tool_result: None,
983            });
984        }
985        s1.save().unwrap();
986
987        // Search with max matches limit
988        let options = SearchOptions::new().with_max_matches(3);
989        let results = search_sessions_with_options("hello", &options).unwrap();
990        assert_eq!(results.len(), 1);
991        assert_eq!(results[0].matches.len(), 3); // Limited to 3
992
993        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
994    }
995
996    #[test]
997    #[serial]
998    fn test_search_sessions_case_insensitive() {
999        let tmp = tempfile::tempdir().unwrap();
1000        let prev_home = std::env::var("HOME").ok();
1001        std::env::set_var("HOME", tmp.path());
1002
1003        // Create session with mixed case
1004        let mut s1 = Session::new("m1");
1005        s1.messages.push(Message {
1006            role: Role::User,
1007            content: "HeLLo WoRLd".into(),
1008            tool_calls: vec![],
1009            tool_result: None,
1010        });
1011        s1.save().unwrap();
1012
1013        // Search with lowercase query
1014        let results = search_sessions("hello").unwrap();
1015        assert_eq!(results.len(), 1);
1016
1017        // Search with uppercase query
1018        let results = search_sessions("HELLO").unwrap();
1019        assert_eq!(results.len(), 1);
1020
1021        if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1022    }
1023
1024    #[test]
1025    fn test_session_new_with_tags() {
1026        let session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1027        assert_eq!(session.tags, vec!["tag1".to_string(), "tag2".to_string()]);
1028        assert_eq!(session.model, "test-model");
1029    }
1030
1031    #[test]
1032    fn test_session_new_with_notes() {
1033        let session = Session::new_with_notes("test-model", "Test notes".to_string());
1034        assert_eq!(session.notes, "Test notes");
1035        assert_eq!(session.model, "test-model");
1036    }
1037
1038    #[test]
1039    fn test_session_add_tag() {
1040        let mut session = Session::new("test-model");
1041        session.add_tag("tag1").unwrap();
1042        assert!(session.tags.contains(&"tag1".to_string()));
1043        assert_eq!(session.tags.len(), 1);
1044    }
1045
1046    #[test]
1047    fn test_session_remove_tag() {
1048        let mut session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1049        session.remove_tag("tag1").unwrap();
1050        assert!(!session.tags.contains(&"tag1".to_string()));
1051        assert!(session.tags.contains(&"tag2".to_string()));
1052        assert_eq!(session.tags.len(), 1);
1053    }
1054
1055    #[test]
1056    fn test_session_clear_tags() {
1057        let mut session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1058        session.clear_tags();
1059        assert!(session.tags.is_empty());
1060    }
1061
1062    #[test]
1063    fn test_session_has_tag() {
1064        let session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1065        assert!(session.has_tag("tag1"));
1066        assert!(session.has_tag("tag2"));
1067        assert!(!session.has_tag("tag3"));
1068    }
1069
1070    #[test]
1071    fn test_session_save_and_load() {
1072        let mut session = Session::new("test-model");
1073        session.messages.push(Message {
1074            role: Role::User,
1075            content: "Test message".to_string(),
1076            tool_calls: vec![],
1077            tool_result: None,
1078        });
1079        session.add_tag("test-tag").unwrap();
1080        session.notes = "Test notes".to_string();
1081
1082        let id = session.id.clone();
1083        let path = session.save().expect("save must succeed");
1084
1085        let loaded = Session::load(&id).expect("load must succeed");
1086        assert_eq!(loaded.id, id);
1087        assert_eq!(loaded.model, "test-model");
1088        assert_eq!(loaded.messages.len(), 1);
1089        assert_eq!(loaded.messages[0].content, "Test message");
1090        assert!(loaded.tags.contains(&"test-tag".to_string()));
1091        assert_eq!(loaded.notes, "Test notes");
1092
1093        // cleanup
1094        let _ = std::fs::remove_file(&path);
1095    }
1096
1097    #[test]
1098    fn test_session_new_with_id() {
1099        let session = Session::new_with_id(
1100            "custom-id".to_string(),
1101            "test-model",
1102            vec!["tag1".to_string()]
1103        );
1104        assert_eq!(session.id, "custom-id");
1105        assert_eq!(session.model, "test-model");
1106        assert_eq!(session.tags, vec!["tag1".to_string()]);
1107    }
1108}