1use std::path::{Path, PathBuf};
8
9use serde::{Deserialize, Serialize};
10
11use crate::error::ClassifiedError;
12use crate::session::ChatSession;
13use crate::types::Role;
14
15#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct SessionMeta {
18 pub id: String,
20 #[serde(default, skip_serializing_if = "Option::is_none")]
22 pub title: Option<String>,
23 pub source: String,
25 pub provider: String,
27 pub model: String,
29 pub created_at: String,
31 pub updated_at: String,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub parent_session_id: Option<String>,
36 #[serde(default)]
38 pub message_count: usize,
39 #[serde(default)]
41 pub prompt_count: u32,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub first_user_message: Option<String>,
45}
46
47pub struct SessionManager {
49 home: PathBuf,
50}
51
52impl SessionManager {
53 pub fn new(config_home: &Path) -> Self {
55 Self {
56 home: config_home.join("sessions"),
57 }
58 }
59
60 #[must_use]
62 pub fn with_home(home: PathBuf) -> Self {
63 Self { home }
64 }
65
66 #[must_use]
68 pub fn root(&self) -> &Path {
69 &self.home
70 }
71
72 fn ensure_home(&self) -> Result<(), ClassifiedError> {
74 std::fs::create_dir_all(&self.home)?;
75 Ok(())
76 }
77
78 #[must_use]
80 pub fn session_dir(&self, id: &str) -> PathBuf {
81 self.home.join(id)
82 }
83
84 pub fn save(&self, session: &ChatSession, meta: &SessionMeta) -> Result<(), ClassifiedError> {
90 if meta.id != session.session_id.to_string() {
91 return Err(ClassifiedError::Config(format!(
92 "session ID mismatch: meta.id={}, session.session_id={}",
93 meta.id, session.session_id
94 )));
95 }
96 self.ensure_home()?;
97 let dir = self.session_dir(&meta.id);
98 std::fs::create_dir_all(&dir)?;
99
100 let session_path = dir.join("session.json");
101 let json = serde_json::to_string_pretty(session)
102 .map_err(|e| ClassifiedError::Parse(format!("save session: {e}")))?;
103 atomic_write(&session_path, &json)?;
104
105 let meta = if meta.first_user_message.is_none() {
107 let first_msg = session
108 .messages
109 .iter()
110 .find(|m| m.role == Role::User)
111 .map(|m| {
112 let text = m.text_content();
113 let end = crate::floor_char_boundary(&text, text.len().min(200));
114 text[..end].to_string()
115 });
116 SessionMeta {
117 first_user_message: first_msg,
118 ..meta.clone()
119 }
120 } else {
121 meta.clone()
122 };
123
124 let meta_path = dir.join("metadata.json");
125 let json = serde_json::to_string_pretty(&meta)
126 .map_err(|e| ClassifiedError::Parse(format!("save metadata: {e}")))?;
127 atomic_write(&meta_path, &json)?;
128
129 Ok(())
130 }
131
132 pub fn load(&self, id: &str) -> Result<ChatSession, ClassifiedError> {
134 let path = self.session_dir(id).join("session.json");
135 ChatSession::load(&path)
136 }
137
138 pub fn load_meta(&self, id: &str) -> Result<SessionMeta, ClassifiedError> {
140 let path = self.session_dir(id).join("metadata.json");
141 let text = std::fs::read_to_string(&path)?;
142 serde_json::from_str(&text)
143 .map_err(|e| ClassifiedError::Parse(format!("load metadata: {e}")))
144 }
145
146 pub fn list(&self) -> Result<Vec<SessionMeta>, ClassifiedError> {
148 if !self.home.exists() {
149 return Ok(Vec::new());
150 }
151 let mut metas: Vec<SessionMeta> = Vec::new();
152 for entry in std::fs::read_dir(&self.home)? {
153 let entry = entry?;
154 if !entry.file_type()?.is_dir() {
155 continue;
156 }
157 let meta_path = entry.path().join("metadata.json");
158 if !meta_path.exists() {
159 continue;
160 }
161 match Self::read_meta_file(&meta_path) {
162 Ok(meta) => metas.push(meta),
163 Err(e) => {
164 tracing::warn!(path = %meta_path.display(), "skipping corrupt session metadata: {e}");
165 }
166 }
167 }
168 metas.sort_by(|a, b| b.updated_at.cmp(&a.updated_at));
169 Ok(metas)
170 }
171
172 pub fn delete(&self, id: &str) -> Result<(), ClassifiedError> {
177 if id.contains('/') || id.contains('\\') || id.contains("..") || id.contains('\0') {
178 return Err(ClassifiedError::Config(format!("invalid session id: {id}")));
179 }
180 let dir = self.session_dir(id);
181 if dir.exists() {
182 std::fs::remove_dir_all(&dir)?;
183 }
184 Ok(())
185 }
186
187 #[must_use]
189 pub fn exists(&self, id: &str) -> bool {
190 self.session_dir(id).join("session.json").exists()
191 }
192
193 pub fn search(&self, query: &str) -> Result<Vec<SessionMeta>, ClassifiedError> {
196 let lower = query.to_lowercase();
197 let all = self.list()?;
198 let results: Vec<SessionMeta> = all
199 .into_iter()
200 .filter(|m| {
201 let haystack = format!(
202 "{} {} {}",
203 m.title.as_deref().unwrap_or(""),
204 m.id,
205 m.first_user_message.as_deref().unwrap_or("")
206 )
207 .to_lowercase();
208 haystack.contains(&lower)
209 })
210 .collect();
211 Ok(results)
212 }
213
214 fn read_meta_file(path: &Path) -> Result<SessionMeta, ClassifiedError> {
215 let text = std::fs::read_to_string(path)?;
216 serde_json::from_str(&text)
217 .map_err(|e| ClassifiedError::Parse(format!("read metadata: {e}")))
218 }
219}
220
221pub fn timestamp_now() -> String {
223 chrono::Utc::now().format("%Y-%m-%dT%H:%M:%SZ").to_string()
224}
225
226fn atomic_write(path: &Path, data: &str) -> Result<(), ClassifiedError> {
228 let tmp = path.with_extension(format!("tmp.{}", uuid::Uuid::new_v4()));
229 std::fs::write(&tmp, data)?;
230 std::fs::rename(&tmp, path)?;
231 Ok(())
232}
233
234#[cfg(test)]
235#[allow(clippy::unwrap_used)]
236mod tests {
237 use super::*;
238 use crate::session::ChatSession;
239
240 #[test]
241 fn save_and_load_roundtrip() {
242 let dir = tempfile::tempdir().unwrap();
243 let mgr = SessionManager::with_home(dir.path().join("sessions"));
244
245 let mut session = ChatSession::new();
246 session.push_user("hello");
247 let id = session.session_id.to_string();
248
249 let meta = SessionMeta {
250 id: id.clone(),
251 title: Some("test session".into()),
252 source: "repl".into(),
253 provider: "mock".into(),
254 model: "mock-model".into(),
255 created_at: timestamp_now(),
256 updated_at: timestamp_now(),
257 parent_session_id: None,
258 message_count: session.len(),
259 prompt_count: 1,
260 first_user_message: None,
261 };
262
263 mgr.save(&session, &meta).unwrap();
264 assert!(mgr.exists(&id));
265
266 let loaded = mgr.load(&id).unwrap();
267 assert_eq!(loaded.session_id.to_string(), id);
268 assert_eq!(loaded.len(), 1);
269
270 let loaded_meta = mgr.load_meta(&id).unwrap();
271 assert_eq!(loaded_meta.title.as_deref(), Some("test session"));
272 assert_eq!(loaded_meta.source, "repl");
273 }
274
275 #[test]
276 fn list_sessions_sorted_by_updated_at() {
277 let dir = tempfile::tempdir().unwrap();
278 let mgr = SessionManager::with_home(dir.path().join("sessions"));
279
280 for i in 0..3 {
281 let mut session = ChatSession::new();
282 session.push_user(format!("msg {i}"));
283 let id = session.session_id.to_string();
284 let ts = format!("2025-01-0{}T00:00:00Z", i + 1);
285 let meta = SessionMeta {
286 id: id.clone(),
287 title: None,
288 source: "repl".into(),
289 provider: "mock".into(),
290 model: "m".into(),
291 created_at: ts.clone(),
292 updated_at: ts,
293 parent_session_id: None,
294 message_count: 1,
295 prompt_count: 1,
296 first_user_message: None,
297 };
298 mgr.save(&session, &meta).unwrap();
299 }
300
301 let list = mgr.list().unwrap();
302 assert_eq!(list.len(), 3);
303 assert!(list[0].updated_at > list[2].updated_at);
305 }
306
307 #[test]
308 fn delete_removes_session() {
309 let dir = tempfile::tempdir().unwrap();
310 let mgr = SessionManager::with_home(dir.path().join("sessions"));
311
312 let session = ChatSession::new();
313 let id = session.session_id.to_string();
314 let meta = SessionMeta {
315 id: id.clone(),
316 title: None,
317 source: "repl".into(),
318 provider: "mock".into(),
319 model: "m".into(),
320 created_at: timestamp_now(),
321 updated_at: timestamp_now(),
322 parent_session_id: None,
323 message_count: 0,
324 prompt_count: 0,
325 first_user_message: None,
326 };
327 mgr.save(&session, &meta).unwrap();
328 assert!(mgr.exists(&id));
329
330 mgr.delete(&id).unwrap();
331 assert!(!mgr.exists(&id));
332 }
333
334 #[test]
335 fn list_empty_when_no_sessions() {
336 let dir = tempfile::tempdir().unwrap();
337 let mgr = SessionManager::with_home(dir.path().join("sessions"));
338 let list = mgr.list().unwrap();
339 assert!(list.is_empty());
340 }
341
342 #[test]
343 fn search_finds_by_title() {
344 let dir = tempfile::tempdir().unwrap();
345 let mgr = SessionManager::with_home(dir.path().join("sessions"));
346
347 let mut session = ChatSession::new();
348 session.push_user("hello");
349 let id = session.session_id.to_string();
350 let meta = SessionMeta {
351 id: id.clone(),
352 title: Some("Rust debugging".into()),
353 source: "repl".into(),
354 provider: "mock".into(),
355 model: "m".into(),
356 created_at: timestamp_now(),
357 updated_at: timestamp_now(),
358 parent_session_id: None,
359 message_count: 1,
360 prompt_count: 1,
361 first_user_message: None,
362 };
363 mgr.save(&session, &meta).unwrap();
364
365 let results = mgr.search("rust").unwrap();
366 assert_eq!(results.len(), 1);
367 assert_eq!(results[0].id, id);
368 }
369
370 #[test]
371 fn search_finds_by_first_user_message() {
372 let dir = tempfile::tempdir().unwrap();
373 let mgr = SessionManager::with_home(dir.path().join("sessions"));
374
375 let mut session = ChatSession::new();
376 session.push_user("debug the parser error");
377 let id = session.session_id.to_string();
378 let meta = SessionMeta {
379 id: id.clone(),
380 title: None,
381 source: "repl".into(),
382 provider: "mock".into(),
383 model: "m".into(),
384 created_at: timestamp_now(),
385 updated_at: timestamp_now(),
386 parent_session_id: None,
387 message_count: 1,
388 prompt_count: 1,
389 first_user_message: None,
390 };
391 mgr.save(&session, &meta).unwrap();
392
393 let loaded = mgr.load_meta(&id).unwrap();
395 assert!(loaded.first_user_message.is_some());
396
397 let results = mgr.search("parser").unwrap();
398 assert_eq!(results.len(), 1);
399 }
400
401 #[test]
402 fn search_is_case_insensitive() {
403 let dir = tempfile::tempdir().unwrap();
404 let mgr = SessionManager::with_home(dir.path().join("sessions"));
405
406 let mut session = ChatSession::new();
407 session.push_user("test");
408 let id = session.session_id.to_string();
409 let meta = SessionMeta {
410 id: id.clone(),
411 title: Some("Rust Debugging".into()),
412 source: "repl".into(),
413 provider: "mock".into(),
414 model: "m".into(),
415 created_at: timestamp_now(),
416 updated_at: timestamp_now(),
417 parent_session_id: None,
418 message_count: 1,
419 prompt_count: 1,
420 first_user_message: None,
421 };
422 mgr.save(&session, &meta).unwrap();
423
424 let results = mgr.search("RUST").unwrap();
425 assert_eq!(results.len(), 1);
426 }
427
428 #[test]
429 fn search_returns_empty_when_no_match() {
430 let dir = tempfile::tempdir().unwrap();
431 let mgr = SessionManager::with_home(dir.path().join("sessions"));
432
433 let mut session = ChatSession::new();
434 session.push_user("hello");
435 let id = session.session_id.to_string();
436 let meta = SessionMeta {
437 id,
438 title: Some("Python scripting".into()),
439 source: "repl".into(),
440 provider: "mock".into(),
441 model: "m".into(),
442 created_at: timestamp_now(),
443 updated_at: timestamp_now(),
444 parent_session_id: None,
445 message_count: 1,
446 prompt_count: 1,
447 first_user_message: None,
448 };
449 mgr.save(&session, &meta).unwrap();
450
451 let results = mgr.search("rust").unwrap();
452 assert!(results.is_empty());
453 }
454
455 #[test]
456 fn save_resume_save_preserves_full_conversation() {
457 let dir = tempfile::tempdir().unwrap();
458 let mgr = SessionManager::with_home(dir.path().join("sessions"));
459
460 let mut session = ChatSession::new();
461 session.push_user("first question");
462 let id = session.session_id.to_string();
463 let meta1 = SessionMeta {
464 id: id.clone(),
465 title: Some("debugging".into()),
466 source: "repl".into(),
467 provider: "mock".into(),
468 model: "m".into(),
469 created_at: timestamp_now(),
470 updated_at: timestamp_now(),
471 parent_session_id: None,
472 message_count: session.len(),
473 prompt_count: 1,
474 first_user_message: None,
475 };
476 mgr.save(&session, &meta1).unwrap();
477
478 let mut loaded = mgr.load(&id).unwrap();
480 loaded.push_user("follow-up question");
481 let meta2 = SessionMeta {
482 id: id.clone(),
483 title: Some("debugging".into()),
484 source: "repl".into(),
485 provider: "mock".into(),
486 model: "m".into(),
487 created_at: meta1.created_at.clone(),
488 updated_at: timestamp_now(),
489 parent_session_id: None,
490 message_count: loaded.len(),
491 prompt_count: 2,
492 first_user_message: None,
493 };
494 mgr.save(&loaded, &meta2).unwrap();
495
496 let final_session = mgr.load(&id).unwrap();
498 assert_eq!(final_session.messages.len(), 2);
499 assert_eq!(final_session.messages[0].text_content(), "first question");
500 assert_eq!(
501 final_session.messages[1].text_content(),
502 "follow-up question"
503 );
504
505 let final_meta = mgr.load_meta(&id).unwrap();
506 assert_eq!(final_meta.message_count, 2);
507 assert_eq!(final_meta.prompt_count, 2);
508 }
509
510 #[test]
511 fn auto_populated_first_user_message_on_load() {
512 let dir = tempfile::tempdir().unwrap();
513 let mgr = SessionManager::with_home(dir.path().join("sessions"));
514
515 let mut session = ChatSession::new();
516 session.push_user("debug the rust build error");
517 let id = session.session_id.to_string();
518 let meta = SessionMeta {
519 id,
520 title: None,
521 source: "repl".into(),
522 provider: "mock".into(),
523 model: "m".into(),
524 created_at: timestamp_now(),
525 updated_at: timestamp_now(),
526 parent_session_id: None,
527 message_count: 1,
528 prompt_count: 1,
529 first_user_message: None,
530 };
531 mgr.save(&session, &meta).unwrap();
532
533 let loaded_meta = mgr.load_meta(&session.session_id.to_string()).unwrap();
534 assert_eq!(
535 loaded_meta.first_user_message.as_deref(),
536 Some("debug the rust build error")
537 );
538 }
539
540 #[test]
541 fn save_rejects_id_mismatch() {
542 let dir = tempfile::tempdir().unwrap();
543 let mgr = SessionManager::with_home(dir.path().join("sessions"));
544
545 let session = ChatSession::new();
546 let bad_meta = SessionMeta {
547 id: "wrong-id".into(),
548 title: None,
549 source: "repl".into(),
550 provider: "mock".into(),
551 model: "m".into(),
552 created_at: timestamp_now(),
553 updated_at: timestamp_now(),
554 parent_session_id: None,
555 message_count: 0,
556 prompt_count: 0,
557 first_user_message: None,
558 };
559 let err = mgr.save(&session, &bad_meta).unwrap_err();
560 assert!(
561 err.to_string().contains("mismatch"),
562 "expected ID mismatch error, got: {err}"
563 );
564 }
565}