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