1use crate::agent::{Message, Role};
4use crate::{PawanError, Result};
5use serde::{Deserialize, Serialize};
6use std::path::PathBuf;
7
8#[derive(Debug, Serialize, Deserialize)]
10pub struct Session {
11 pub id: String,
13 pub model: String,
15 pub created_at: String,
17 pub updated_at: String,
19 pub messages: Vec<Message>,
21 #[serde(default)]
23 pub total_tokens: u64,
24 #[serde(default)]
26 pub iteration_count: u32,
27 #[serde(default)]
29 pub tags: Vec<String>,
30 #[serde(default)]
32 pub notes: String,
33}
34
35impl Session {
36 pub fn new(model: &str) -> Self {
38 Self::new_with_tags(model, Vec::new())
39 }
40
41 pub fn new_with_id(id: String, model: &str, tags: Vec<String>) -> Self {
43 let now = chrono::Utc::now().to_rfc3339();
44 Self {
45 id,
46 model: model.to_string(),
47 created_at: now.clone(),
48 updated_at: now,
49 messages: Vec::new(),
50 total_tokens: 0,
51 iteration_count: 0,
52 tags,
53 notes: String::new(),
54 }
55 }
56
57 pub fn new_with_tags(model: &str, tags: Vec<String>) -> Self {
59 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
60 let now = chrono::Utc::now().to_rfc3339();
61 Self {
62 id,
63 model: model.to_string(),
64 created_at: now.clone(),
65 updated_at: now,
66 messages: Vec::new(),
67 total_tokens: 0,
68 iteration_count: 0,
69 tags,
70 notes: String::new(),
71 }
72 }
73
74 pub fn new_with_notes(model: &str, notes: String) -> Self {
76 let id = uuid::Uuid::new_v4().to_string()[..8].to_string();
77 let now = chrono::Utc::now().to_rfc3339();
78 Self {
79 id,
80 model: model.to_string(),
81 created_at: now.clone(),
82 updated_at: now,
83 messages: Vec::new(),
84 total_tokens: 0,
85 iteration_count: 0,
86 tags: Vec::new(),
87 notes,
88 }
89 }
90
91 pub fn sessions_dir() -> Result<PathBuf> {
93 let home = std::env::var("HOME").unwrap_or_else(|_| "/root".to_string());
94 let dir = PathBuf::from(home).join(".pawan").join("sessions");
95 if !dir.exists() {
96 std::fs::create_dir_all(&dir)
97 .map_err(|e| PawanError::Config(format!("Failed to create sessions dir: {}", e)))?;
98 }
99 Ok(dir)
100 }
101
102 pub fn save(&mut self) -> Result<PathBuf> {
104 self.updated_at = chrono::Utc::now().to_rfc3339();
105 let dir = Self::sessions_dir()?;
106 let path = dir.join(format!("{}.json", self.id));
107 let json = serde_json::to_string_pretty(self)
108 .map_err(|e| PawanError::Config(format!("Failed to serialize session: {}", e)))?;
109 std::fs::write(&path, json)
110 .map_err(|e| PawanError::Config(format!("Failed to write session: {}", e)))?;
111 Ok(path)
112 }
113
114 pub fn load(id: &str) -> Result<Self> {
116 let dir = Self::sessions_dir()?;
117 let path = dir.join(format!("{}.json", id));
118 if !path.exists() {
119 return Err(PawanError::NotFound(format!("Session not found: {}", id)));
120 }
121 let content = std::fs::read_to_string(&path)
122 .map_err(|e| PawanError::Config(format!("Failed to read session: {}", e)))?;
123 serde_json::from_str(&content)
124 .map_err(|e| PawanError::Config(format!("Failed to parse session: {}", e)))
125 }
126
127 pub fn add_tag(&mut self, tag: &str) -> Result<()> {
129 let sanitized = Self::sanitize_tag(tag)?;
130 if self.tags.contains(&sanitized) {
131 return Err(PawanError::Config(format!("Tag already exists: {}", sanitized)));
132 }
133 self.tags.push(sanitized);
134 Ok(())
135 }
136
137 pub fn remove_tag(&mut self, tag: &str) -> Result<()> {
139 let sanitized = Self::sanitize_tag(tag)?;
140 if let Some(pos) = self.tags.iter().position(|t| t == &sanitized) {
141 self.tags.remove(pos);
142 Ok(())
143 } else {
144 Err(PawanError::NotFound(format!("Tag not found: {}", sanitized)))
145 }
146 }
147
148 pub fn clear_tags(&mut self) {
150 self.tags.clear();
151 }
152
153 pub fn has_tag(&self, tag: &str) -> bool {
155 match Self::sanitize_tag(tag) {
156 Ok(sanitized) => self.tags.contains(&sanitized),
157 Err(_) => false,
158 }
159 }
160
161 fn sanitize_tag(tag: &str) -> Result<String> {
163 let trimmed = tag.trim();
164 if trimmed.is_empty() {
165 return Err(PawanError::Config("Tag name cannot be empty".to_string()));
166 }
167 if trimmed.len() > 50 {
168 return Err(PawanError::Config("Tag name too long (max 50 characters)".to_string()));
169 }
170 let sanitized: String = trimmed
172 .chars()
173 .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_' || *c == ' ')
174 .collect();
175 if sanitized.is_empty() {
176 return Err(PawanError::Config("Tag contains invalid characters".to_string()));
177 }
178 Ok(sanitized)
179 }
180
181 pub fn from_json_file(path: impl AsRef<std::path::Path>) -> Result<Self> {
183 let content = std::fs::read_to_string(path)
184 .map_err(|e| PawanError::Config(format!("Failed to read session file: {}", e)))?;
185 let mut session: Session = serde_json::from_str(&content)
186 .map_err(|e| PawanError::Config(format!("Failed to parse session JSON: {}", e)))?;
187
188 session.id = uuid::Uuid::new_v4().to_string()[..8].to_string();
191 session.updated_at = chrono::Utc::now().to_rfc3339();
192
193 Ok(session)
194 }
195
196 pub fn list() -> Result<Vec<SessionSummary>> {
198 let dir = Self::sessions_dir()?;
199 let mut sessions = Vec::new();
200
201 if let Ok(entries) = std::fs::read_dir(&dir) {
202 for entry in entries.flatten() {
203 let path = entry.path();
204 if path.extension().is_some_and(|ext| ext == "json") {
205 if let Ok(content) = std::fs::read_to_string(&path) {
206 if let Ok(session) = serde_json::from_str::<Session>(&content) {
207 sessions.push(SessionSummary {
208 id: session.id,
209 model: session.model,
210 created_at: session.created_at,
211 updated_at: session.updated_at,
212 message_count: session.messages.len(),
213 tags: session.tags,
214 notes: session.notes,
215 });
216 }
217 }
218 }
219 }
220 }
221
222 sessions.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
223 Ok(sessions)
224 }
225}
226
227#[derive(Debug, Serialize, Deserialize)]
229pub struct SessionSummary {
230 pub id: String,
231 pub model: String,
232 pub created_at: String,
233 pub updated_at: String,
234 pub message_count: usize,
235 #[serde(default)]
237 pub tags: Vec<String>,
238 #[serde(default)]
240 pub notes: String,
241}
242
243#[cfg(test)]
244mod tests {
245 use super::*;
246 use crate::agent::Role;
247 use serial_test::serial;
248
249 #[test]
250 fn session_new_generates_8_char_id() {
251 let s = Session::new("test-model");
252 assert_eq!(s.id.len(), 8, "session id must be exactly 8 chars");
253 assert_eq!(s.model, "test-model");
254 assert!(s.messages.is_empty());
255 assert_eq!(s.total_tokens, 0);
256 assert_eq!(s.iteration_count, 0);
257 }
258
259 #[test]
260 fn session_new_produces_distinct_ids() {
261 let a = Session::new("m");
265 let b = Session::new("m");
266 assert_ne!(a.id, b.id, "successive Session::new() must produce distinct ids");
267 }
268
269 #[test]
270 fn session_new_timestamps_parse_as_rfc3339() {
271 let s = Session::new("m");
272 assert_eq!(s.created_at, s.updated_at, "at creation created_at == updated_at");
274 chrono::DateTime::parse_from_rfc3339(&s.created_at)
275 .expect("created_at must parse as RFC3339");
276 chrono::DateTime::parse_from_rfc3339(&s.updated_at)
277 .expect("updated_at must parse as RFC3339");
278 }
279
280 #[test]
281 fn session_serde_roundtrip_preserves_all_fields() {
282 let mut original = Session::new("qwen-test");
283 original.total_tokens = 12345;
284 original.iteration_count = 7;
285 original.messages.push(Message {
286 role: Role::User,
287 content: "hello".into(),
288 tool_calls: vec![],
289 tool_result: None,
290 });
291 let json = serde_json::to_string(&original).unwrap();
292 let restored: Session = serde_json::from_str(&json).unwrap();
293 assert_eq!(restored.id, original.id);
294 assert_eq!(restored.model, original.model);
295 assert_eq!(restored.created_at, original.created_at);
296 assert_eq!(restored.updated_at, original.updated_at);
297 assert_eq!(restored.total_tokens, 12345);
298 assert_eq!(restored.iteration_count, 7);
299 assert_eq!(restored.messages.len(), 1);
300 }
301
302 #[test]
303 fn session_deserialize_tolerates_missing_token_fields() {
304 let json = r#"{
308 "id": "abcd1234",
309 "model": "old-model",
310 "created_at": "2026-01-01T00:00:00Z",
311 "updated_at": "2026-01-01T00:00:00Z",
312 "messages": []
313 }"#;
314 let session: Session = serde_json::from_str(json).unwrap();
315 assert_eq!(session.id, "abcd1234");
316 assert_eq!(session.total_tokens, 0, "missing total_tokens ⇒ default 0");
317 assert_eq!(session.iteration_count, 0, "missing iteration_count ⇒ default 0");
318 }
319
320 #[test]
321 fn session_summary_serde_roundtrip() {
322 let summary = SessionSummary {
323 notes: String::new(),
324 id: "abcdef12".into(),
325 model: "qwen3.5".into(),
326 created_at: "2026-04-10T12:00:00Z".into(),
327 updated_at: "2026-04-10T13:00:00Z".into(),
328 message_count: 42,
329 tags: Vec::new(),
330 };
331 let json = serde_json::to_string(&summary).unwrap();
332 assert!(json.contains("\"id\":\"abcdef12\""));
333 assert!(json.contains("\"message_count\":42"));
334 let restored: SessionSummary = serde_json::from_str(&json).unwrap();
335 assert_eq!(restored.id, "abcdef12");
336 assert_eq!(restored.message_count, 42);
337 }
338
339 #[serial(pawan_session_tests)]
342 #[test]
343 fn test_load_nonexistent_id_returns_not_found() {
344 let err = Session::load("__test_nonexistent_id_zzz__").unwrap_err();
346 match err {
347 crate::PawanError::NotFound(msg) => {
348 assert!(msg.contains("Session not found"), "unexpected: {msg}")
349 }
350 other => panic!("expected NotFound, got {:?}", other),
351 }
352 }
353
354 #[serial(pawan_session_tests)]
355 #[test]
356 fn test_save_and_load_roundtrip() {
357 let mut session = Session::new("roundtrip-model");
358 session.total_tokens = 999;
359 session.iteration_count = 3;
360 session.messages.push(Message {
361 role: Role::User,
362 content: "save-load test".into(),
363 tool_calls: vec![],
364 tool_result: None,
365 });
366 let id = session.id.clone();
367
368 let path = session.save().expect("save must succeed");
369 assert!(path.exists(), "saved file must exist at {:?}", path);
370
371 let loaded = Session::load(&id).expect("load by id must succeed");
372 assert_eq!(loaded.id, id);
373 assert_eq!(loaded.model, "roundtrip-model");
374 assert_eq!(loaded.total_tokens, 999);
375 assert_eq!(loaded.iteration_count, 3);
376 assert_eq!(loaded.messages.len(), 1);
377
378 let _ = std::fs::remove_file(&path);
380 }
381
382 #[test]
383 fn test_save_updates_updated_at() {
384 let mut session = Session::new("timestamp-model");
385 let original_updated = session.updated_at.clone();
386 std::thread::sleep(std::time::Duration::from_millis(10));
388 let path = session.save().expect("save must succeed");
389 let updated = chrono::DateTime::parse_from_rfc3339(&session.updated_at)
391 .expect("updated_at must be valid RFC3339");
392 let orig = chrono::DateTime::parse_from_rfc3339(&original_updated)
393 .expect("original_updated must be valid RFC3339");
394 assert!(
395 updated >= orig,
396 "updated_at after save must be >= created_at"
397 );
398 let _ = std::fs::remove_file(&path);
400 }
401
402 #[serial(pawan_session_tests)]
403 #[test]
404 fn test_list_includes_saved_session() {
405 let mut session = Session::new("list-test-model");
406 let id = session.id.clone();
407 let path = session.save().expect("save must succeed");
408
409 let summaries = Session::list().expect("list must succeed");
410 let found = summaries.iter().any(|s| s.id == id);
411 assert!(found, "newly saved session must appear in list()");
412
413 let _ = std::fs::remove_file(&path);
415 }
416
417 #[serial(pawan_session_tests)]
418 #[test]
419 fn test_list_sorted_newest_first() {
420 let mut older = Session::new("older-model");
422 older.updated_at = "2020-01-01T00:00:00Z".to_string();
423 let path_older = older.save().expect("save older");
424
425 let mut newer = Session::new("newer-model");
426 newer.updated_at = "2030-01-01T00:00:00Z".to_string();
427 let path_newer = newer.save().expect("save newer");
428
429 let summaries = Session::list().expect("list must succeed");
430
431 let pos_older = summaries.iter().position(|s| s.id == older.id);
433 let pos_newer = summaries.iter().position(|s| s.id == newer.id);
434
435 if let (Some(po), Some(pn)) = (pos_older, pos_newer) {
436 assert!(
437 pn < po,
438 "newer session (pos {pn}) must appear before older (pos {po}) in list"
439 );
440 }
441
442 let _ = std::fs::remove_file(&path_older);
444 let _ = std::fs::remove_file(&path_newer);
445 }
446}
447
448#[derive(Debug, Serialize, Deserialize)]
452pub struct SearchResult {
453 pub id: String,
454 pub model: String,
455 pub updated_at: String,
456 pub message_count: usize,
457 #[serde(default)]
458 pub tags: Vec<String>,
459 pub matches: Vec<MessageMatch>,
460}
461
462#[derive(Debug, Serialize, Deserialize)]
464pub struct MessageMatch {
465 pub message_index: usize,
466 pub role: Role,
467 pub preview: String,
468 #[serde(default)]
470 pub context_before: String,
471 #[serde(default)]
473 pub context_after: String,
474 #[serde(default)]
476 pub matched_text: String,
477}
478
479#[derive(Debug, Clone, Default)]
481pub struct SearchOptions {
482 pub role_filter: Option<Role>,
484 pub date_from: Option<String>,
486 pub date_to: Option<String>,
488 pub max_matches_per_session: Option<usize>,
490 pub context_window: usize,
492}
493
494impl SearchOptions {
495 pub fn new() -> Self {
497 Self {
498 role_filter: None,
499 date_from: None,
500 date_to: None,
501 max_matches_per_session: Some(5),
502 context_window: 50,
503 }
504 }
505
506 pub fn with_role(mut self, role: Role) -> Self {
508 self.role_filter = Some(role);
509 self
510 }
511
512 pub fn with_date_range(mut self, from: Option<String>, to: Option<String>) -> Self {
514 self.date_from = from;
515 self.date_to = to;
516 self
517 }
518
519 pub fn with_max_matches(mut self, max: usize) -> Self {
521 self.max_matches_per_session = Some(max);
522 self
523 }
524
525 pub fn with_context_window(mut self, window: usize) -> Self {
527 self.context_window = window;
528 self
529 }
530}
531
532#[derive(Debug, Clone, Default)]
534pub struct RetentionPolicy {
535 pub max_age_days: Option<u32>,
537 pub max_sessions: Option<usize>,
539 pub keep_tags: Vec<String>,
541}
542
543pub fn search_sessions_with_options(query: &str, options: &SearchOptions) -> Result<Vec<SearchResult>> {
545 let dir = Session::sessions_dir()?;
546 let query_lower = query.to_lowercase();
547 let mut results = Vec::new();
548
549 if let Ok(entries) = std::fs::read_dir(&dir) {
550 for entry in entries.flatten() {
551 let path = entry.path();
552 if path.extension().is_some_and(|ext| ext == "json") {
553 if let Ok(content) = std::fs::read_to_string(&path) {
554 if let Ok(session) = serde_json::from_str::<Session>(&content) {
555 if let (Some(from), Some(to)) = (&options.date_from, &options.date_to) {
557 if let Ok(updated) = chrono::DateTime::parse_from_rfc3339(&session.updated_at) {
558 let updated_utc = updated.with_timezone(&chrono::Utc);
559 if let (Ok(from_dt), Ok(to_dt)) = (
560 chrono::DateTime::parse_from_rfc3339(from),
561 chrono::DateTime::parse_from_rfc3339(to)
562 ) {
563 let from_utc = from_dt.with_timezone(&chrono::Utc);
564 let to_utc = to_dt.with_timezone(&chrono::Utc);
565 if updated_utc < from_utc || updated_utc > to_utc {
566 continue; }
568 }
569 }
570 }
571
572 let mut matches = Vec::new();
573 for (i, msg) in session.messages.iter().enumerate() {
574 if let Some(ref role_filter) = options.role_filter {
576 if &msg.role != role_filter {
577 continue;
578 }
579 }
580
581 if msg.content.to_lowercase().contains(&query_lower) {
582 let content_lower = msg.content.to_lowercase();
584 if let Some(pos) = content_lower.find(&query_lower) {
585 let start = if pos >= options.context_window {
586 pos - options.context_window
587 } else {
588 0
589 };
590 let end = std::cmp::min(
591 pos + query.len() + options.context_window,
592 msg.content.len()
593 );
594
595 let context_before = msg.content[start..pos].to_string();
596 let matched_text = msg.content[pos..pos + query.len()].to_string();
597 let context_after = msg.content[pos + query.len()..end].to_string();
598
599 let preview = format!(
601 "{}{}{}",
602 if start > 0 { "..." } else { "" },
603 &msg.content[start..end],
604 if end < msg.content.len() { "..." } else { "" }
605 );
606
607 matches.push(MessageMatch {
608 message_index: i,
609 role: msg.role.clone(),
610 preview,
611 context_before,
612 context_after,
613 matched_text,
614 });
615 }
616 }
617 }
618
619 if !matches.is_empty() {
620 let limited_matches = if let Some(max) = options.max_matches_per_session {
622 matches.into_iter().take(max).collect()
623 } else {
624 matches
625 };
626
627 results.push(SearchResult {
628 id: session.id,
629 model: session.model,
630 updated_at: session.updated_at,
631 message_count: session.messages.len(),
632 tags: session.tags,
633 matches: limited_matches,
634 });
635 }
636 }
637 }
638 }
639 }
640 }
641
642 results.sort_by(|a, b| b.matches.len().cmp(&a.matches.len()));
643 Ok(results)
644}
645
646pub fn search_sessions(query: &str) -> Result<Vec<SearchResult>> {
648 search_sessions_with_options(query, &SearchOptions::new())
649}
650
651pub fn prune_sessions(policy: &RetentionPolicy) -> Result<usize> {
653 let dir = Session::sessions_dir()?;
654 let mut sessions_data: Vec<(std::path::PathBuf, Session)> = Vec::new();
655
656 if let Ok(entries) = std::fs::read_dir(&dir) {
657 for entry in entries.flatten() {
658 let path = entry.path();
659 if path.extension().is_some_and(|ext| ext == "json") {
660 if let Ok(content) = std::fs::read_to_string(&path) {
661 if let Ok(session) = serde_json::from_str::<Session>(&content) {
662 sessions_data.push((path, session));
663 }
664 }
665 }
666 }
667 }
668
669 sessions_data.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at));
670
671 let mut deleted = 0usize;
672 let now = chrono::Utc::now();
673
674 for (i, (path, session)) in sessions_data.into_iter().enumerate() {
676 let has_protected = session.tags.iter()
678 .any(|t| policy.keep_tags.iter().any(|kt| kt == t));
679 if has_protected {
680 continue;
681 }
682
683 let mut should_delete = false;
684
685 if let Some(max_days) = policy.max_age_days {
687 if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&session.updated_at) {
688 let age = (now - st.with_timezone(&chrono::Utc)).num_days() as u32;
689 if age > max_days {
690 should_delete = true;
691 }
692 }
693 }
694
695 if !should_delete {
697 if let Some(max_sess) = policy.max_sessions {
698 if i >= max_sess {
699 should_delete = true;
700 }
701 }
702 }
703
704 if should_delete {
705 std::fs::remove_file(&path)
706 .map_err(|e| PawanError::Config(format!("Delete failed: {}", e)))?;
707 deleted += 1;
708 }
709 }
710
711 Ok(deleted)
712}
713
714#[cfg(test)]
715mod search_prune_tests {
716 use super::*;
717 use crate::agent::{Message, Role};
718 use serial_test::serial;
719
720 #[test]
721 fn test_role_serialization_is_lowercase() {
722 assert_eq!(serde_json::to_string(&Role::User).unwrap(), "\"user\"");
723 assert_eq!(serde_json::to_string(&Role::Assistant).unwrap(), "\"assistant\"");
724 assert_eq!(serde_json::to_string(&Role::System).unwrap(), "\"system\"");
725 assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), "\"tool\"");
726 }
727
728 #[test]
729 #[serial]
730 fn test_search_sessions_logic() {
731 let tmp = tempfile::tempdir().unwrap();
732 let prev_home = std::env::var("HOME").ok();
733 std::env::set_var("HOME", tmp.path());
734
735 let mut s1 = Session::new("m1");
737 s1.messages.push(Message {
738 role: Role::User,
739 content: "hello world".into(),
740 tool_calls: vec![],
741 tool_result: None,
742 });
743 s1.save().unwrap();
744
745 let mut s2 = Session::new("m2");
746 s2.messages.push(Message {
747 role: Role::User,
748 content: "goodbye world".into(),
749 tool_calls: vec![],
750 tool_result: None,
751 });
752 s2.save().unwrap();
753
754 let results = search_sessions("hello").unwrap();
756 assert_eq!(results.len(), 1);
757 assert_eq!(results[0].id, s1.id);
758 assert_eq!(results[0].matches.len(), 1);
759 assert_eq!(results[0].matches[0].preview, "hello world");
760
761 let results = search_sessions("world").unwrap();
763 assert_eq!(results.len(), 2);
764
765 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
767 }
768
769 #[test]
770 #[serial]
771 fn test_prune_sessions_logic() {
772 let tmp = tempfile::tempdir().unwrap();
773 let prev_home = std::env::var("HOME").ok();
774 std::env::set_var("HOME", tmp.path());
775
776 let dir = Session::sessions_dir().unwrap();
778 for i in 0..5 {
779 let mut s = Session::new("m");
780 s.id = format!("sess{}", i);
781 s.updated_at = format!("2026-04-1{}T12:00:00Z", i);
782 let path = dir.join(format!("{}.json", s.id));
783 let json = serde_json::to_string_pretty(&s).unwrap();
784 std::fs::write(&path, json).unwrap();
785 }
786
787 let policy = RetentionPolicy {
789 max_age_days: None,
790 max_sessions: Some(2),
791 keep_tags: vec![],
792 };
793 let deleted = prune_sessions(&policy).unwrap();
794 assert_eq!(deleted, 3);
795
796 let list = Session::list().unwrap();
797 assert_eq!(list.len(), 2);
798 assert!(list.iter().any(|s| s.id == "sess4"));
800 assert!(list.iter().any(|s| s.id == "sess3"));
801
802 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
804 }
805
806 #[test]
807 #[serial]
808 fn test_prune_sessions_age_and_tags() {
809 let tmp = tempfile::tempdir().unwrap();
810 let prev_home = std::env::var("HOME").ok();
811 std::env::set_var("HOME", tmp.path());
812
813 let dir = Session::sessions_dir().unwrap();
814
815 let mut s1 = Session::new("m");
817 s1.id = "old".into();
818 s1.updated_at = "2020-01-01T00:00:00Z".into();
819 let path1 = dir.join(format!("{}.json", s1.id));
820 std::fs::write(&path1, serde_json::to_string_pretty(&s1).unwrap()).unwrap();
821
822 let mut s2 = Session::new_with_tags("m", vec!["keep".into()]);
824 s2.id = "protected".into();
825 s2.updated_at = "2020-01-01T00:00:00Z".into();
826 let path2 = dir.join(format!("{}.json", s2.id));
827 std::fs::write(&path2, serde_json::to_string_pretty(&s2).unwrap()).unwrap();
828
829 let mut s3 = Session::new("m");
831 s3.id = "new".into();
832 s3.save().unwrap(); let policy = RetentionPolicy {
835 max_age_days: Some(7),
836 max_sessions: None,
837 keep_tags: vec!["keep".into()],
838 };
839 let deleted = prune_sessions(&policy).unwrap();
840 assert_eq!(deleted, 1); let list = Session::list().unwrap();
843 assert_eq!(list.len(), 2);
844 assert!(list.iter().any(|s| s.id == "protected"));
845 assert!(list.iter().any(|s| s.id == "new"));
846
847 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
849 }
850
851 #[test]
852 #[serial]
853 fn test_search_sessions_no_results() {
854 let tmp = tempfile::tempdir().unwrap();
855 let prev_home = std::env::var("HOME").ok();
856 std::env::set_var("HOME", tmp.path());
857
858 let results = search_sessions("anything").unwrap();
859 assert!(results.is_empty());
860
861 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
862 }
863
864 #[test]
865 #[serial]
866 fn test_prune_sessions_zero_limits() {
867 let tmp = tempfile::tempdir().unwrap();
868 let prev_home = std::env::var("HOME").ok();
869 std::env::set_var("HOME", tmp.path());
870
871 let mut s = Session::new("m");
872 s.save().unwrap();
873
874 let policy = RetentionPolicy {
876 max_age_days: None,
877 max_sessions: Some(0),
878 keep_tags: vec![],
879 };
880 let deleted = prune_sessions(&policy).unwrap();
881 assert_eq!(deleted, 1);
882
883 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
884 }
885
886 #[test]
887 fn test_search_options_builder() {
888 let options = SearchOptions::new()
889 .with_role(Role::User)
890 .with_date_range(Some("2026-01-01T00:00:00Z".to_string()), Some("2026-12-31T23:59:59Z".to_string()))
891 .with_max_matches(10)
892 .with_context_window(100);
893
894 assert_eq!(options.role_filter, Some(Role::User));
895 assert_eq!(options.date_from, Some("2026-01-01T00:00:00Z".to_string()));
896 assert_eq!(options.date_to, Some("2026-12-31T23:59:59Z".to_string()));
897 assert_eq!(options.max_matches_per_session, Some(10));
898 assert_eq!(options.context_window, 100);
899 }
900
901 #[test]
902 #[serial]
903 fn test_search_sessions_with_role_filter() {
904 let tmp = tempfile::tempdir().unwrap();
905 let prev_home = std::env::var("HOME").ok();
906 std::env::set_var("HOME", tmp.path());
907
908 let mut s1 = Session::new("m1");
910 s1.messages.push(Message {
911 role: Role::User,
912 content: "hello world".into(),
913 tool_calls: vec![],
914 tool_result: None,
915 });
916 s1.messages.push(Message {
917 role: Role::Assistant,
918 content: "hello there".into(),
919 tool_calls: vec![],
920 tool_result: None,
921 });
922 s1.save().unwrap();
923
924 let options = SearchOptions::new().with_role(Role::User);
926 let results = search_sessions_with_options("hello", &options).unwrap();
927 assert_eq!(results.len(), 1);
928 assert_eq!(results[0].matches.len(), 1);
929 assert_eq!(results[0].matches[0].role, Role::User);
930
931 let options = SearchOptions::new().with_role(Role::Assistant);
933 let results = search_sessions_with_options("hello", &options).unwrap();
934 assert_eq!(results.len(), 1);
935 assert_eq!(results[0].matches.len(), 1);
936 assert_eq!(results[0].matches[0].role, Role::Assistant);
937
938 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
939 }
940
941 #[test]
942 #[serial]
943 fn test_search_sessions_context_extraction() {
944 let tmp = tempfile::tempdir().unwrap();
945 let prev_home = std::env::var("HOME").ok();
946 std::env::set_var("HOME", tmp.path());
947
948 let mut s1 = Session::new("m1");
950 s1.messages.push(Message {
951 role: Role::User,
952 content: "This is a long message with the word hello in the middle of the text".into(),
953 tool_calls: vec![],
954 tool_result: None,
955 });
956 s1.save().unwrap();
957
958 let options = SearchOptions::new().with_context_window(10);
960 let results = search_sessions_with_options("hello", &options).unwrap();
961 assert_eq!(results.len(), 1);
962 assert_eq!(results[0].matches.len(), 1);
963
964 let match_result = &results[0].matches[0];
965 assert!(!match_result.context_before.is_empty());
966 assert!(!match_result.context_after.is_empty());
967 assert_eq!(match_result.matched_text, "hello");
968 assert!(match_result.preview.contains("hello"));
969
970 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
971 }
972
973 #[test]
974 #[serial]
975 fn test_search_sessions_max_matches_limit() {
976 let tmp = tempfile::tempdir().unwrap();
977 let prev_home = std::env::var("HOME").ok();
978 std::env::set_var("HOME", tmp.path());
979
980 let mut s1 = Session::new("m1");
982 for i in 0..10 {
983 s1.messages.push(Message {
984 role: Role::User,
985 content: format!("Message {} with hello text", i),
986 tool_calls: vec![],
987 tool_result: None,
988 });
989 }
990 s1.save().unwrap();
991
992 let options = SearchOptions::new().with_max_matches(3);
994 let results = search_sessions_with_options("hello", &options).unwrap();
995 assert_eq!(results.len(), 1);
996 assert_eq!(results[0].matches.len(), 3); if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
999 }
1000
1001 #[test]
1002 #[serial]
1003 fn test_search_sessions_case_insensitive() {
1004 let tmp = tempfile::tempdir().unwrap();
1005 let prev_home = std::env::var("HOME").ok();
1006 std::env::set_var("HOME", tmp.path());
1007
1008 let mut s1 = Session::new("m1");
1010 s1.messages.push(Message {
1011 role: Role::User,
1012 content: "HeLLo WoRLd".into(),
1013 tool_calls: vec![],
1014 tool_result: None,
1015 });
1016 s1.save().unwrap();
1017
1018 let results = search_sessions("hello").unwrap();
1020 assert_eq!(results.len(), 1);
1021
1022 let results = search_sessions("HELLO").unwrap();
1024 assert_eq!(results.len(), 1);
1025
1026 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1027 }
1028
1029 #[test]
1030 fn test_session_new_with_tags() {
1031 let session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1032 assert_eq!(session.tags, vec!["tag1".to_string(), "tag2".to_string()]);
1033 assert_eq!(session.model, "test-model");
1034 }
1035
1036 #[test]
1037 fn test_session_new_with_notes() {
1038 let session = Session::new_with_notes("test-model", "Test notes".to_string());
1039 assert_eq!(session.notes, "Test notes");
1040 assert_eq!(session.model, "test-model");
1041 }
1042
1043 #[test]
1044 fn test_session_add_tag() {
1045 let mut session = Session::new("test-model");
1046 session.add_tag("tag1").unwrap();
1047 assert!(session.tags.contains(&"tag1".to_string()));
1048 assert_eq!(session.tags.len(), 1);
1049 }
1050
1051 #[test]
1052 fn test_session_remove_tag() {
1053 let mut session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1054 session.remove_tag("tag1").unwrap();
1055 assert!(!session.tags.contains(&"tag1".to_string()));
1056 assert!(session.tags.contains(&"tag2".to_string()));
1057 assert_eq!(session.tags.len(), 1);
1058 }
1059
1060 #[test]
1061 fn test_session_clear_tags() {
1062 let mut session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1063 session.clear_tags();
1064 assert!(session.tags.is_empty());
1065 }
1066
1067 #[test]
1068 fn test_session_has_tag() {
1069 let session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1070 assert!(session.has_tag("tag1"));
1071 assert!(session.has_tag("tag2"));
1072 assert!(!session.has_tag("tag3"));
1073 }
1074
1075 #[serial(pawan_session_tests)]
1076 #[test]
1077 fn test_session_save_and_load() {
1078 let mut session = Session::new("test-model");
1079 session.messages.push(Message {
1080 role: Role::User,
1081 content: "Test message".to_string(),
1082 tool_calls: vec![],
1083 tool_result: None,
1084 });
1085 session.add_tag("test-tag").unwrap();
1086 session.notes = "Test notes".to_string();
1087
1088 let id = session.id.clone();
1089 let path = session.save().expect("save must succeed");
1090
1091 let loaded = Session::load(&id).expect("load must succeed");
1092 assert_eq!(loaded.id, id);
1093 assert_eq!(loaded.model, "test-model");
1094 assert_eq!(loaded.messages.len(), 1);
1095 assert_eq!(loaded.messages[0].content, "Test message");
1096 assert!(loaded.tags.contains(&"test-tag".to_string()));
1097 assert_eq!(loaded.notes, "Test notes");
1098
1099 let _ = std::fs::remove_file(&path);
1101 }
1102
1103 #[test]
1104 fn test_session_new_with_id() {
1105 let session = Session::new_with_id(
1106 "custom-id".to_string(),
1107 "test-model",
1108 vec!["tag1".to_string()]
1109 );
1110 assert_eq!(session.id, "custom-id");
1111 assert_eq!(session.model, "test-model");
1112 assert_eq!(session.tags, vec!["tag1".to_string()]);
1113 }
1114}