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
248 #[test]
249 fn session_new_generates_8_char_id() {
250 let s = Session::new("test-model");
251 assert_eq!(s.id.len(), 8, "session id must be exactly 8 chars");
252 assert_eq!(s.model, "test-model");
253 assert!(s.messages.is_empty());
254 assert_eq!(s.total_tokens, 0);
255 assert_eq!(s.iteration_count, 0);
256 }
257
258 #[test]
259 fn session_new_produces_distinct_ids() {
260 let a = Session::new("m");
264 let b = Session::new("m");
265 assert_ne!(a.id, b.id, "successive Session::new() must produce distinct ids");
266 }
267
268 #[test]
269 fn session_new_timestamps_parse_as_rfc3339() {
270 let s = Session::new("m");
271 assert_eq!(s.created_at, s.updated_at, "at creation created_at == updated_at");
273 chrono::DateTime::parse_from_rfc3339(&s.created_at)
274 .expect("created_at must parse as RFC3339");
275 chrono::DateTime::parse_from_rfc3339(&s.updated_at)
276 .expect("updated_at must parse as RFC3339");
277 }
278
279 #[test]
280 fn session_serde_roundtrip_preserves_all_fields() {
281 let mut original = Session::new("qwen-test");
282 original.total_tokens = 12345;
283 original.iteration_count = 7;
284 original.messages.push(Message {
285 role: Role::User,
286 content: "hello".into(),
287 tool_calls: vec![],
288 tool_result: None,
289 });
290 let json = serde_json::to_string(&original).unwrap();
291 let restored: Session = serde_json::from_str(&json).unwrap();
292 assert_eq!(restored.id, original.id);
293 assert_eq!(restored.model, original.model);
294 assert_eq!(restored.created_at, original.created_at);
295 assert_eq!(restored.updated_at, original.updated_at);
296 assert_eq!(restored.total_tokens, 12345);
297 assert_eq!(restored.iteration_count, 7);
298 assert_eq!(restored.messages.len(), 1);
299 }
300
301 #[test]
302 fn session_deserialize_tolerates_missing_token_fields() {
303 let json = r#"{
307 "id": "abcd1234",
308 "model": "old-model",
309 "created_at": "2026-01-01T00:00:00Z",
310 "updated_at": "2026-01-01T00:00:00Z",
311 "messages": []
312 }"#;
313 let session: Session = serde_json::from_str(json).unwrap();
314 assert_eq!(session.id, "abcd1234");
315 assert_eq!(session.total_tokens, 0, "missing total_tokens ⇒ default 0");
316 assert_eq!(session.iteration_count, 0, "missing iteration_count ⇒ default 0");
317 }
318
319 #[test]
320 fn session_summary_serde_roundtrip() {
321 let summary = SessionSummary {
322 notes: String::new(),
323 id: "abcdef12".into(),
324 model: "qwen3.5".into(),
325 created_at: "2026-04-10T12:00:00Z".into(),
326 updated_at: "2026-04-10T13:00:00Z".into(),
327 message_count: 42,
328 tags: Vec::new(),
329 };
330 let json = serde_json::to_string(&summary).unwrap();
331 assert!(json.contains("\"id\":\"abcdef12\""));
332 assert!(json.contains("\"message_count\":42"));
333 let restored: SessionSummary = serde_json::from_str(&json).unwrap();
334 assert_eq!(restored.id, "abcdef12");
335 assert_eq!(restored.message_count, 42);
336 }
337
338 #[test]
341 fn test_load_nonexistent_id_returns_not_found() {
342 let err = Session::load("__test_nonexistent_id_zzz__").unwrap_err();
344 match err {
345 crate::PawanError::NotFound(msg) => {
346 assert!(msg.contains("Session not found"), "unexpected: {msg}")
347 }
348 other => panic!("expected NotFound, got {:?}", other),
349 }
350 }
351
352 #[test]
353 fn test_save_and_load_roundtrip() {
354 let mut session = Session::new("roundtrip-model");
355 session.total_tokens = 999;
356 session.iteration_count = 3;
357 session.messages.push(Message {
358 role: Role::User,
359 content: "save-load test".into(),
360 tool_calls: vec![],
361 tool_result: None,
362 });
363 let id = session.id.clone();
364
365 let path = session.save().expect("save must succeed");
366 assert!(path.exists(), "saved file must exist at {:?}", path);
367
368 let loaded = Session::load(&id).expect("load by id must succeed");
369 assert_eq!(loaded.id, id);
370 assert_eq!(loaded.model, "roundtrip-model");
371 assert_eq!(loaded.total_tokens, 999);
372 assert_eq!(loaded.iteration_count, 3);
373 assert_eq!(loaded.messages.len(), 1);
374
375 let _ = std::fs::remove_file(&path);
377 }
378
379 #[test]
380 fn test_save_updates_updated_at() {
381 let mut session = Session::new("timestamp-model");
382 let original_updated = session.updated_at.clone();
383 std::thread::sleep(std::time::Duration::from_millis(10));
385 let path = session.save().expect("save must succeed");
386 let updated = chrono::DateTime::parse_from_rfc3339(&session.updated_at)
388 .expect("updated_at must be valid RFC3339");
389 let orig = chrono::DateTime::parse_from_rfc3339(&original_updated)
390 .expect("original_updated must be valid RFC3339");
391 assert!(
392 updated >= orig,
393 "updated_at after save must be >= created_at"
394 );
395 let _ = std::fs::remove_file(&path);
397 }
398
399 #[test]
400 fn test_list_includes_saved_session() {
401 let mut session = Session::new("list-test-model");
402 let id = session.id.clone();
403 let path = session.save().expect("save must succeed");
404
405 let summaries = Session::list().expect("list must succeed");
406 let found = summaries.iter().any(|s| s.id == id);
407 assert!(found, "newly saved session must appear in list()");
408
409 let _ = std::fs::remove_file(&path);
411 }
412
413 #[test]
414 fn test_list_sorted_newest_first() {
415 let mut older = Session::new("older-model");
417 older.updated_at = "2020-01-01T00:00:00Z".to_string();
418 let path_older = older.save().expect("save older");
419
420 let mut newer = Session::new("newer-model");
421 newer.updated_at = "2030-01-01T00:00:00Z".to_string();
422 let path_newer = newer.save().expect("save newer");
423
424 let summaries = Session::list().expect("list must succeed");
425
426 let pos_older = summaries.iter().position(|s| s.id == older.id);
428 let pos_newer = summaries.iter().position(|s| s.id == newer.id);
429
430 if let (Some(po), Some(pn)) = (pos_older, pos_newer) {
431 assert!(
432 pn < po,
433 "newer session (pos {pn}) must appear before older (pos {po}) in list"
434 );
435 }
436
437 let _ = std::fs::remove_file(&path_older);
439 let _ = std::fs::remove_file(&path_newer);
440 }
441}
442
443#[derive(Debug, Serialize, Deserialize)]
447pub struct SearchResult {
448 pub id: String,
449 pub model: String,
450 pub updated_at: String,
451 pub message_count: usize,
452 #[serde(default)]
453 pub tags: Vec<String>,
454 pub matches: Vec<MessageMatch>,
455}
456
457#[derive(Debug, Serialize, Deserialize)]
459pub struct MessageMatch {
460 pub message_index: usize,
461 pub role: Role,
462 pub preview: String,
463 #[serde(default)]
465 pub context_before: String,
466 #[serde(default)]
468 pub context_after: String,
469 #[serde(default)]
471 pub matched_text: String,
472}
473
474#[derive(Debug, Clone, Default)]
476pub struct SearchOptions {
477 pub role_filter: Option<Role>,
479 pub date_from: Option<String>,
481 pub date_to: Option<String>,
483 pub max_matches_per_session: Option<usize>,
485 pub context_window: usize,
487}
488
489impl SearchOptions {
490 pub fn new() -> Self {
492 Self {
493 role_filter: None,
494 date_from: None,
495 date_to: None,
496 max_matches_per_session: Some(5),
497 context_window: 50,
498 }
499 }
500
501 pub fn with_role(mut self, role: Role) -> Self {
503 self.role_filter = Some(role);
504 self
505 }
506
507 pub fn with_date_range(mut self, from: Option<String>, to: Option<String>) -> Self {
509 self.date_from = from;
510 self.date_to = to;
511 self
512 }
513
514 pub fn with_max_matches(mut self, max: usize) -> Self {
516 self.max_matches_per_session = Some(max);
517 self
518 }
519
520 pub fn with_context_window(mut self, window: usize) -> Self {
522 self.context_window = window;
523 self
524 }
525}
526
527#[derive(Debug, Clone, Default)]
529pub struct RetentionPolicy {
530 pub max_age_days: Option<u32>,
532 pub max_sessions: Option<usize>,
534 pub keep_tags: Vec<String>,
536}
537
538pub fn search_sessions_with_options(query: &str, options: &SearchOptions) -> Result<Vec<SearchResult>> {
540 let dir = Session::sessions_dir()?;
541 let query_lower = query.to_lowercase();
542 let mut results = Vec::new();
543
544 if let Ok(entries) = std::fs::read_dir(&dir) {
545 for entry in entries.flatten() {
546 let path = entry.path();
547 if path.extension().is_some_and(|ext| ext == "json") {
548 if let Ok(content) = std::fs::read_to_string(&path) {
549 if let Ok(session) = serde_json::from_str::<Session>(&content) {
550 if let (Some(from), Some(to)) = (&options.date_from, &options.date_to) {
552 if let Ok(updated) = chrono::DateTime::parse_from_rfc3339(&session.updated_at) {
553 let updated_utc = updated.with_timezone(&chrono::Utc);
554 if let (Ok(from_dt), Ok(to_dt)) = (
555 chrono::DateTime::parse_from_rfc3339(from),
556 chrono::DateTime::parse_from_rfc3339(to)
557 ) {
558 let from_utc = from_dt.with_timezone(&chrono::Utc);
559 let to_utc = to_dt.with_timezone(&chrono::Utc);
560 if updated_utc < from_utc || updated_utc > to_utc {
561 continue; }
563 }
564 }
565 }
566
567 let mut matches = Vec::new();
568 for (i, msg) in session.messages.iter().enumerate() {
569 if let Some(ref role_filter) = options.role_filter {
571 if &msg.role != role_filter {
572 continue;
573 }
574 }
575
576 if msg.content.to_lowercase().contains(&query_lower) {
577 let content_lower = msg.content.to_lowercase();
579 if let Some(pos) = content_lower.find(&query_lower) {
580 let start = if pos >= options.context_window {
581 pos - options.context_window
582 } else {
583 0
584 };
585 let end = std::cmp::min(
586 pos + query.len() + options.context_window,
587 msg.content.len()
588 );
589
590 let context_before = msg.content[start..pos].to_string();
591 let matched_text = msg.content[pos..pos + query.len()].to_string();
592 let context_after = msg.content[pos + query.len()..end].to_string();
593
594 let preview = format!(
596 "{}{}{}",
597 if start > 0 { "..." } else { "" },
598 &msg.content[start..end],
599 if end < msg.content.len() { "..." } else { "" }
600 );
601
602 matches.push(MessageMatch {
603 message_index: i,
604 role: msg.role.clone(),
605 preview,
606 context_before,
607 context_after,
608 matched_text,
609 });
610 }
611 }
612 }
613
614 if !matches.is_empty() {
615 let limited_matches = if let Some(max) = options.max_matches_per_session {
617 matches.into_iter().take(max).collect()
618 } else {
619 matches
620 };
621
622 results.push(SearchResult {
623 id: session.id,
624 model: session.model,
625 updated_at: session.updated_at,
626 message_count: session.messages.len(),
627 tags: session.tags,
628 matches: limited_matches,
629 });
630 }
631 }
632 }
633 }
634 }
635 }
636
637 results.sort_by(|a, b| b.matches.len().cmp(&a.matches.len()));
638 Ok(results)
639}
640
641pub fn search_sessions(query: &str) -> Result<Vec<SearchResult>> {
643 search_sessions_with_options(query, &SearchOptions::new())
644}
645
646pub fn prune_sessions(policy: &RetentionPolicy) -> Result<usize> {
648 let dir = Session::sessions_dir()?;
649 let mut sessions_data: Vec<(std::path::PathBuf, Session)> = Vec::new();
650
651 if let Ok(entries) = std::fs::read_dir(&dir) {
652 for entry in entries.flatten() {
653 let path = entry.path();
654 if path.extension().is_some_and(|ext| ext == "json") {
655 if let Ok(content) = std::fs::read_to_string(&path) {
656 if let Ok(session) = serde_json::from_str::<Session>(&content) {
657 sessions_data.push((path, session));
658 }
659 }
660 }
661 }
662 }
663
664 sessions_data.sort_by(|a, b| b.1.updated_at.cmp(&a.1.updated_at));
665
666 let mut deleted = 0usize;
667 let now = chrono::Utc::now();
668
669 for (i, (path, session)) in sessions_data.into_iter().enumerate() {
671 let has_protected = session.tags.iter()
673 .any(|t| policy.keep_tags.iter().any(|kt| kt == t));
674 if has_protected {
675 continue;
676 }
677
678 let mut should_delete = false;
679
680 if let Some(max_days) = policy.max_age_days {
682 if let Ok(st) = chrono::DateTime::parse_from_rfc3339(&session.updated_at) {
683 let age = (now - st.with_timezone(&chrono::Utc)).num_days() as u32;
684 if age > max_days {
685 should_delete = true;
686 }
687 }
688 }
689
690 if !should_delete {
692 if let Some(max_sess) = policy.max_sessions {
693 if i >= max_sess {
694 should_delete = true;
695 }
696 }
697 }
698
699 if should_delete {
700 std::fs::remove_file(&path)
701 .map_err(|e| PawanError::Config(format!("Delete failed: {}", e)))?;
702 deleted += 1;
703 }
704 }
705
706 Ok(deleted)
707}
708
709#[cfg(test)]
710mod search_prune_tests {
711 use super::*;
712 use crate::agent::{Message, Role};
713 use serial_test::serial;
714
715 #[test]
716 fn test_role_serialization_is_lowercase() {
717 assert_eq!(serde_json::to_string(&Role::User).unwrap(), "\"user\"");
718 assert_eq!(serde_json::to_string(&Role::Assistant).unwrap(), "\"assistant\"");
719 assert_eq!(serde_json::to_string(&Role::System).unwrap(), "\"system\"");
720 assert_eq!(serde_json::to_string(&Role::Tool).unwrap(), "\"tool\"");
721 }
722
723 #[test]
724 #[serial]
725 fn test_search_sessions_logic() {
726 let tmp = tempfile::tempdir().unwrap();
727 let prev_home = std::env::var("HOME").ok();
728 std::env::set_var("HOME", tmp.path());
729
730 let mut s1 = Session::new("m1");
732 s1.messages.push(Message {
733 role: Role::User,
734 content: "hello world".into(),
735 tool_calls: vec![],
736 tool_result: None,
737 });
738 s1.save().unwrap();
739
740 let mut s2 = Session::new("m2");
741 s2.messages.push(Message {
742 role: Role::User,
743 content: "goodbye world".into(),
744 tool_calls: vec![],
745 tool_result: None,
746 });
747 s2.save().unwrap();
748
749 let results = search_sessions("hello").unwrap();
751 assert_eq!(results.len(), 1);
752 assert_eq!(results[0].id, s1.id);
753 assert_eq!(results[0].matches.len(), 1);
754 assert_eq!(results[0].matches[0].preview, "hello world");
755
756 let results = search_sessions("world").unwrap();
758 assert_eq!(results.len(), 2);
759
760 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
762 }
763
764 #[test]
765 #[serial]
766 fn test_prune_sessions_logic() {
767 let tmp = tempfile::tempdir().unwrap();
768 let prev_home = std::env::var("HOME").ok();
769 std::env::set_var("HOME", tmp.path());
770
771 let dir = Session::sessions_dir().unwrap();
773 for i in 0..5 {
774 let mut s = Session::new("m");
775 s.id = format!("sess{}", i);
776 s.updated_at = format!("2026-04-1{}T12:00:00Z", i);
777 let path = dir.join(format!("{}.json", s.id));
778 let json = serde_json::to_string_pretty(&s).unwrap();
779 std::fs::write(&path, json).unwrap();
780 }
781
782 let policy = RetentionPolicy {
784 max_age_days: None,
785 max_sessions: Some(2),
786 keep_tags: vec![],
787 };
788 let deleted = prune_sessions(&policy).unwrap();
789 assert_eq!(deleted, 3);
790
791 let list = Session::list().unwrap();
792 assert_eq!(list.len(), 2);
793 assert!(list.iter().any(|s| s.id == "sess4"));
795 assert!(list.iter().any(|s| s.id == "sess3"));
796
797 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
799 }
800
801 #[test]
802 #[serial]
803 fn test_prune_sessions_age_and_tags() {
804 let tmp = tempfile::tempdir().unwrap();
805 let prev_home = std::env::var("HOME").ok();
806 std::env::set_var("HOME", tmp.path());
807
808 let dir = Session::sessions_dir().unwrap();
809
810 let mut s1 = Session::new("m");
812 s1.id = "old".into();
813 s1.updated_at = "2020-01-01T00:00:00Z".into();
814 let path1 = dir.join(format!("{}.json", s1.id));
815 std::fs::write(&path1, serde_json::to_string_pretty(&s1).unwrap()).unwrap();
816
817 let mut s2 = Session::new_with_tags("m", vec!["keep".into()]);
819 s2.id = "protected".into();
820 s2.updated_at = "2020-01-01T00:00:00Z".into();
821 let path2 = dir.join(format!("{}.json", s2.id));
822 std::fs::write(&path2, serde_json::to_string_pretty(&s2).unwrap()).unwrap();
823
824 let mut s3 = Session::new("m");
826 s3.id = "new".into();
827 s3.save().unwrap(); let policy = RetentionPolicy {
830 max_age_days: Some(7),
831 max_sessions: None,
832 keep_tags: vec!["keep".into()],
833 };
834 let deleted = prune_sessions(&policy).unwrap();
835 assert_eq!(deleted, 1); let list = Session::list().unwrap();
838 assert_eq!(list.len(), 2);
839 assert!(list.iter().any(|s| s.id == "protected"));
840 assert!(list.iter().any(|s| s.id == "new"));
841
842 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
844 }
845
846 #[test]
847 #[serial]
848 fn test_search_sessions_no_results() {
849 let tmp = tempfile::tempdir().unwrap();
850 let prev_home = std::env::var("HOME").ok();
851 std::env::set_var("HOME", tmp.path());
852
853 let results = search_sessions("anything").unwrap();
854 assert!(results.is_empty());
855
856 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
857 }
858
859 #[test]
860 #[serial]
861 fn test_prune_sessions_zero_limits() {
862 let tmp = tempfile::tempdir().unwrap();
863 let prev_home = std::env::var("HOME").ok();
864 std::env::set_var("HOME", tmp.path());
865
866 let mut s = Session::new("m");
867 s.save().unwrap();
868
869 let policy = RetentionPolicy {
871 max_age_days: None,
872 max_sessions: Some(0),
873 keep_tags: vec![],
874 };
875 let deleted = prune_sessions(&policy).unwrap();
876 assert_eq!(deleted, 1);
877
878 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
879 }
880
881 #[test]
882 fn test_search_options_builder() {
883 let options = SearchOptions::new()
884 .with_role(Role::User)
885 .with_date_range(Some("2026-01-01T00:00:00Z".to_string()), Some("2026-12-31T23:59:59Z".to_string()))
886 .with_max_matches(10)
887 .with_context_window(100);
888
889 assert_eq!(options.role_filter, Some(Role::User));
890 assert_eq!(options.date_from, Some("2026-01-01T00:00:00Z".to_string()));
891 assert_eq!(options.date_to, Some("2026-12-31T23:59:59Z".to_string()));
892 assert_eq!(options.max_matches_per_session, Some(10));
893 assert_eq!(options.context_window, 100);
894 }
895
896 #[test]
897 #[serial]
898 fn test_search_sessions_with_role_filter() {
899 let tmp = tempfile::tempdir().unwrap();
900 let prev_home = std::env::var("HOME").ok();
901 std::env::set_var("HOME", tmp.path());
902
903 let mut s1 = Session::new("m1");
905 s1.messages.push(Message {
906 role: Role::User,
907 content: "hello world".into(),
908 tool_calls: vec![],
909 tool_result: None,
910 });
911 s1.messages.push(Message {
912 role: Role::Assistant,
913 content: "hello there".into(),
914 tool_calls: vec![],
915 tool_result: None,
916 });
917 s1.save().unwrap();
918
919 let options = SearchOptions::new().with_role(Role::User);
921 let results = search_sessions_with_options("hello", &options).unwrap();
922 assert_eq!(results.len(), 1);
923 assert_eq!(results[0].matches.len(), 1);
924 assert_eq!(results[0].matches[0].role, Role::User);
925
926 let options = SearchOptions::new().with_role(Role::Assistant);
928 let results = search_sessions_with_options("hello", &options).unwrap();
929 assert_eq!(results.len(), 1);
930 assert_eq!(results[0].matches.len(), 1);
931 assert_eq!(results[0].matches[0].role, Role::Assistant);
932
933 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
934 }
935
936 #[test]
937 #[serial]
938 fn test_search_sessions_context_extraction() {
939 let tmp = tempfile::tempdir().unwrap();
940 let prev_home = std::env::var("HOME").ok();
941 std::env::set_var("HOME", tmp.path());
942
943 let mut s1 = Session::new("m1");
945 s1.messages.push(Message {
946 role: Role::User,
947 content: "This is a long message with the word hello in the middle of the text".into(),
948 tool_calls: vec![],
949 tool_result: None,
950 });
951 s1.save().unwrap();
952
953 let options = SearchOptions::new().with_context_window(10);
955 let results = search_sessions_with_options("hello", &options).unwrap();
956 assert_eq!(results.len(), 1);
957 assert_eq!(results[0].matches.len(), 1);
958
959 let match_result = &results[0].matches[0];
960 assert!(!match_result.context_before.is_empty());
961 assert!(!match_result.context_after.is_empty());
962 assert_eq!(match_result.matched_text, "hello");
963 assert!(match_result.preview.contains("hello"));
964
965 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
966 }
967
968 #[test]
969 #[serial]
970 fn test_search_sessions_max_matches_limit() {
971 let tmp = tempfile::tempdir().unwrap();
972 let prev_home = std::env::var("HOME").ok();
973 std::env::set_var("HOME", tmp.path());
974
975 let mut s1 = Session::new("m1");
977 for i in 0..10 {
978 s1.messages.push(Message {
979 role: Role::User,
980 content: format!("Message {} with hello text", i),
981 tool_calls: vec![],
982 tool_result: None,
983 });
984 }
985 s1.save().unwrap();
986
987 let options = SearchOptions::new().with_max_matches(3);
989 let results = search_sessions_with_options("hello", &options).unwrap();
990 assert_eq!(results.len(), 1);
991 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"); }
994 }
995
996 #[test]
997 #[serial]
998 fn test_search_sessions_case_insensitive() {
999 let tmp = tempfile::tempdir().unwrap();
1000 let prev_home = std::env::var("HOME").ok();
1001 std::env::set_var("HOME", tmp.path());
1002
1003 let mut s1 = Session::new("m1");
1005 s1.messages.push(Message {
1006 role: Role::User,
1007 content: "HeLLo WoRLd".into(),
1008 tool_calls: vec![],
1009 tool_result: None,
1010 });
1011 s1.save().unwrap();
1012
1013 let results = search_sessions("hello").unwrap();
1015 assert_eq!(results.len(), 1);
1016
1017 let results = search_sessions("HELLO").unwrap();
1019 assert_eq!(results.len(), 1);
1020
1021 if let Some(h) = prev_home { std::env::set_var("HOME", h); } else { std::env::remove_var("HOME"); }
1022 }
1023
1024 #[test]
1025 fn test_session_new_with_tags() {
1026 let session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1027 assert_eq!(session.tags, vec!["tag1".to_string(), "tag2".to_string()]);
1028 assert_eq!(session.model, "test-model");
1029 }
1030
1031 #[test]
1032 fn test_session_new_with_notes() {
1033 let session = Session::new_with_notes("test-model", "Test notes".to_string());
1034 assert_eq!(session.notes, "Test notes");
1035 assert_eq!(session.model, "test-model");
1036 }
1037
1038 #[test]
1039 fn test_session_add_tag() {
1040 let mut session = Session::new("test-model");
1041 session.add_tag("tag1").unwrap();
1042 assert!(session.tags.contains(&"tag1".to_string()));
1043 assert_eq!(session.tags.len(), 1);
1044 }
1045
1046 #[test]
1047 fn test_session_remove_tag() {
1048 let mut session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1049 session.remove_tag("tag1").unwrap();
1050 assert!(!session.tags.contains(&"tag1".to_string()));
1051 assert!(session.tags.contains(&"tag2".to_string()));
1052 assert_eq!(session.tags.len(), 1);
1053 }
1054
1055 #[test]
1056 fn test_session_clear_tags() {
1057 let mut session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1058 session.clear_tags();
1059 assert!(session.tags.is_empty());
1060 }
1061
1062 #[test]
1063 fn test_session_has_tag() {
1064 let session = Session::new_with_tags("test-model", vec!["tag1".to_string(), "tag2".to_string()]);
1065 assert!(session.has_tag("tag1"));
1066 assert!(session.has_tag("tag2"));
1067 assert!(!session.has_tag("tag3"));
1068 }
1069
1070 #[test]
1071 fn test_session_save_and_load() {
1072 let mut session = Session::new("test-model");
1073 session.messages.push(Message {
1074 role: Role::User,
1075 content: "Test message".to_string(),
1076 tool_calls: vec![],
1077 tool_result: None,
1078 });
1079 session.add_tag("test-tag").unwrap();
1080 session.notes = "Test notes".to_string();
1081
1082 let id = session.id.clone();
1083 let path = session.save().expect("save must succeed");
1084
1085 let loaded = Session::load(&id).expect("load must succeed");
1086 assert_eq!(loaded.id, id);
1087 assert_eq!(loaded.model, "test-model");
1088 assert_eq!(loaded.messages.len(), 1);
1089 assert_eq!(loaded.messages[0].content, "Test message");
1090 assert!(loaded.tags.contains(&"test-tag".to_string()));
1091 assert_eq!(loaded.notes, "Test notes");
1092
1093 let _ = std::fs::remove_file(&path);
1095 }
1096
1097 #[test]
1098 fn test_session_new_with_id() {
1099 let session = Session::new_with_id(
1100 "custom-id".to_string(),
1101 "test-model",
1102 vec!["tag1".to_string()]
1103 );
1104 assert_eq!(session.id, "custom-id");
1105 assert_eq!(session.model, "test-model");
1106 assert_eq!(session.tags, vec!["tag1".to_string()]);
1107 }
1108}