1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
12pub struct SessionLabel {
13 pub name: String,
15 #[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#[derive(Debug, Serialize, Deserialize)]
41pub struct Session {
42 pub id: String,
44 pub model: String,
46 pub created_at: String,
48 pub updated_at: String,
50 pub messages: Vec<Message>,
52 #[serde(default)]
54 pub total_tokens: u64,
55 #[serde(default)]
57 pub iteration_count: u32,
58 #[serde(default)]
60 pub tags: Vec<String>,
61 #[serde(default)]
63 pub notes: String,
64 #[serde(default)]
66 pub parent_id: Option<String>,
67 #[serde(default)]
69 pub root_id: Option<String>,
70 #[serde(default)]
72 pub branch_label: Option<String>,
73 #[serde(default)]
75 pub branch_depth: u32,
76 #[serde(default)]
78 pub labels: Vec<SessionLabel>,
79}
80
81impl Session {
82 pub fn new(model: &str) -> Self {
84 Self::new_with_tags(model, Vec::new())
85 }
86
87 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 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 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 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 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 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 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 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 pub fn clear_tags(&mut self) {
217 self.tags.clear();
218 }
219
220 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 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 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 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 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 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#[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 #[serde(default)]
311 pub tags: Vec<String>,
312 #[serde(default)]
314 pub notes: String,
315 #[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 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 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 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 #[serial(pawan_session_tests)]
429 #[test]
430 fn test_load_nonexistent_id_returns_not_found() {
431 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 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 std::thread::sleep(std::time::Duration::from_millis(10));
475 let path = session.save().expect("save must succeed");
476 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 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 let _ = std::fs::remove_file(&path);
502 }
503
504 #[serial(pawan_session_tests)]
505 #[test]
506 fn test_list_sorted_newest_first() {
507 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 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 let _ = std::fs::remove_file(&path_older);
531 let _ = std::fs::remove_file(&path_newer);
532 }
533}
534
535#[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#[derive(Debug, Serialize, Deserialize)]
551pub struct MessageMatch {
552 pub message_index: usize,
553 pub role: Role,
554 pub preview: String,
555 #[serde(default)]
557 pub context_before: String,
558 #[serde(default)]
560 pub context_after: String,
561 #[serde(default)]
563 pub matched_text: String,
564}
565
566#[derive(Debug, Clone, Default)]
568pub struct SearchOptions {
569 pub role_filter: Option<Role>,
571 pub date_from: Option<String>,
573 pub date_to: Option<String>,
575 pub max_matches_per_session: Option<usize>,
577 pub context_window: usize,
579}
580
581impl SearchOptions {
582 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 pub fn with_role(mut self, role: Role) -> Self {
595 self.role_filter = Some(role);
596 self
597 }
598
599 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 pub fn with_max_matches(mut self, max: usize) -> Self {
608 self.max_matches_per_session = Some(max);
609 self
610 }
611
612 pub fn with_context_window(mut self, window: usize) -> Self {
614 self.context_window = window;
615 self
616 }
617}
618
619#[derive(Debug, Clone, Default)]
621pub struct RetentionPolicy {
622 pub max_age_days: Option<u32>,
624 pub max_sessions: Option<usize>,
626 pub keep_tags: Vec<String>,
628}
629
630pub 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 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; }
660 }
661 }
662 }
663
664 let mut matches = Vec::new();
665 for (i, msg) in session.messages.iter().enumerate() {
666 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 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 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 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
741pub fn search_sessions(query: &str) -> Result<Vec<SearchResult>> {
743 search_sessions_with_options(query, &SearchOptions::new())
744}
745
746pub 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 for (i, (path, session)) in sessions_data.into_iter().enumerate() {
771 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 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 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 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 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 let results = search_sessions("world").unwrap();
863 assert_eq!(results.len(), 2);
864
865 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 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 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 assert!(list.iter().any(|s| s.id == "sess4"));
904 assert!(list.iter().any(|s| s.id == "sess3"));
905
906 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 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 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 let mut s3 = Session::new("m");
939 s3.id = "new".into();
940 s3.save().unwrap(); 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); 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 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 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 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 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 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 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 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 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 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); 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 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 let results = search_sessions("hello").unwrap();
1155 assert_eq!(results.len(), 1);
1156
1157 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 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}