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