1use 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#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
13pub struct SessionLabel {
14 pub name: String,
16 #[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#[derive(Debug, Serialize, Deserialize)]
42pub struct Session {
43 pub id: String,
45 pub model: String,
47 pub created_at: String,
49 pub updated_at: String,
51 pub messages: Vec<Message>,
53 #[serde(default)]
55 pub total_tokens: u64,
56 #[serde(default)]
58 pub iteration_count: u32,
59 #[serde(default)]
61 pub tags: Vec<String>,
62 #[serde(default)]
64 pub notes: String,
65 #[serde(default)]
67 pub parent_id: Option<String>,
68 #[serde(default)]
70 pub root_id: Option<String>,
71 #[serde(default)]
73 pub branch_label: Option<String>,
74 #[serde(default)]
76 pub branch_depth: u32,
77 #[serde(default)]
79 pub labels: Vec<SessionLabel>,
80}
81
82impl Session {
83 pub fn new(model: &str) -> Self {
85 Self::new_with_tags(model, Vec::new())
86 }
87
88 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 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 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 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 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 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 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 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 pub fn clear_tags(&mut self) {
218 self.tags.clear();
219 }
220
221 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 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 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 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 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 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#[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 #[serde(default)]
312 pub tags: Vec<String>,
313 #[serde(default)]
315 pub notes: String,
316 #[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 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 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 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 #[serial(pawan_session_tests)]
430 #[test]
431 fn test_load_nonexistent_id_returns_not_found() {
432 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 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 std::thread::sleep(std::time::Duration::from_millis(10));
476 let path = session.save().expect("save must succeed");
477 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 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 let _ = std::fs::remove_file(&path);
503 }
504
505 #[serial(pawan_session_tests)]
506 #[test]
507 fn test_list_sorted_newest_first() {
508 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 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 let _ = std::fs::remove_file(&path_older);
532 let _ = std::fs::remove_file(&path_newer);
533 }
534}
535
536#[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#[derive(Debug, Serialize, Deserialize)]
552pub struct MessageMatch {
553 pub message_index: usize,
554 pub role: Role,
555 pub preview: String,
556 #[serde(default)]
558 pub context_before: String,
559 #[serde(default)]
561 pub context_after: String,
562 #[serde(default)]
564 pub matched_text: String,
565}
566
567#[derive(Debug, Clone, Default)]
569pub struct SearchOptions {
570 pub role_filter: Option<Role>,
572 pub date_from: Option<String>,
574 pub date_to: Option<String>,
576 pub max_matches_per_session: Option<usize>,
578 pub context_window: usize,
580}
581
582impl SearchOptions {
583 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 pub fn with_role(mut self, role: Role) -> Self {
596 self.role_filter = Some(role);
597 self
598 }
599
600 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 pub fn with_max_matches(mut self, max: usize) -> Self {
609 self.max_matches_per_session = Some(max);
610 self
611 }
612
613 pub fn with_context_window(mut self, window: usize) -> Self {
615 self.context_window = window;
616 self
617 }
618}
619
620#[derive(Debug, Clone, Default)]
622pub struct RetentionPolicy {
623 pub max_age_days: Option<u32>,
625 pub max_sessions: Option<usize>,
627 pub keep_tags: Vec<String>,
629}
630
631pub 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 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; }
661 }
662 }
663 }
664
665 let mut matches = Vec::new();
666 for (i, msg) in session.messages.iter().enumerate() {
667 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 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 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 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
738pub fn search_sessions(query: &str) -> Result<Vec<SearchResult>> {
740 search_sessions_with_options(query, &SearchOptions::new())
741}
742
743pub 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 for (i, (path, session)) in sessions_data.into_iter().enumerate() {
768 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 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 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 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 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 let results = search_sessions("world").unwrap();
860 assert_eq!(results.len(), 2);
861
862 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 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 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 assert!(list.iter().any(|s| s.id == "sess4"));
901 assert!(list.iter().any(|s| s.id == "sess3"));
902
903 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 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 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 let mut s3 = Session::new("m");
936 s3.id = "new".into();
937 s3.save().unwrap(); 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); 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 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 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 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 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 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 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 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 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 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); 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 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 let results = search_sessions("hello").unwrap();
1152 assert_eq!(results.len(), 1);
1153
1154 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 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}