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