返回 DeepSeek-TUI-2026
session_manager.rs
根目录 / crates / tui / src / session_manager.rs
1 //! Session management for resuming conversations.
2 //!
3 //! This module provides functionality for:
4 //! - Saving sessions to disk
5 //! - Listing previous sessions
6 //! - Resuming sessions by ID
7 //! - Managing session lifecycle
8
9 use crate::models::{ContentBlock, Message, SystemPrompt};
10 use crate::tui::file_mention::ContextReference;
11 use crate::utils::write_atomic;
12 use chrono::{DateTime, Utc};
13 use serde::{Deserialize, Serialize};
14 use std::fs;
15 use std::path::{Path, PathBuf};
16 use uuid::Uuid;
17
18 /// Maximum number of sessions to retain
19 const MAX_SESSIONS: usize = 50;
20 /// Maximum number of messages to persist per session (#402 P0).
21 /// Beyond this limit, the oldest messages are dropped and a truncation
22 /// note is prepended to the system prompt. Keeps session files bounded
23 /// so save/load remains fast even for long-running conversations.
24 const MAX_PERSISTED_MESSAGES: usize = 500;
25 const CURRENT_SESSION_SCHEMA_VERSION: u32 = 1;
26 const CURRENT_QUEUE_SCHEMA_VERSION: u32 = 1;
27
28 const fn default_session_schema_version() -> u32 {
29 CURRENT_SESSION_SCHEMA_VERSION
30 }
31
32 const fn default_queue_schema_version() -> u32 {
33 CURRENT_QUEUE_SCHEMA_VERSION
34 }
35
36 /// Persisted queued message for offline/degraded mode.
37 #[derive(Debug, Clone, Serialize, Deserialize)]
38 pub struct QueuedSessionMessage {
39 pub display: String,
40 #[serde(default)]
41 pub skill_instruction: Option<String>,
42 }
43
44 /// Persisted queue state for recovery after restart/crash.
45 #[derive(Debug, Clone, Serialize, Deserialize)]
46 pub struct OfflineQueueState {
47 #[serde(default = "default_queue_schema_version")]
48 pub schema_version: u32,
49 /// Session ID this queue belongs to. Queue is only restored when
50 /// resuming the same session to prevent stale messages leaking into new chats.
51 #[serde(default)]
52 pub session_id: Option<String>,
53 #[serde(default)]
54 pub messages: Vec<QueuedSessionMessage>,
55 #[serde(default)]
56 pub draft: Option<QueuedSessionMessage>,
57 }
58
59 impl Default for OfflineQueueState {
60 fn default() -> Self {
61 Self {
62 schema_version: CURRENT_QUEUE_SCHEMA_VERSION,
63 session_id: None,
64 messages: Vec::new(),
65 draft: None,
66 }
67 }
68 }
69
70 /// Durable context-reference metadata attached to a user message.
71 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72 pub struct SessionContextReference {
73 pub message_index: usize,
74 pub reference: ContextReference,
75 }
76
77 /// Session metadata stored with each saved session
78 #[derive(Debug, Clone, Serialize, Deserialize)]
79 pub struct SessionMetadata {
80 /// Unique session identifier
81 pub id: String,
82 /// Human-readable title (derived from first message)
83 pub title: String,
84 /// When the session was created
85 pub created_at: DateTime<Utc>,
86 /// When the session was last updated
87 pub updated_at: DateTime<Utc>,
88 /// Number of messages in the session
89 pub message_count: usize,
90 /// Total tokens used
91 pub total_tokens: u64,
92 /// Model used for the session
93 pub model: String,
94 /// Workspace directory
95 pub workspace: PathBuf,
96 /// Optional mode label (agent/plan/etc.)
97 #[serde(default)]
98 pub mode: Option<String>,
99 }
100
101 /// A saved session containing full conversation history
102 #[derive(Debug, Clone, Serialize, Deserialize)]
103 pub struct SavedSession {
104 /// Schema version for migration compatibility
105 #[serde(default = "default_session_schema_version")]
106 pub schema_version: u32,
107 /// Session metadata
108 pub metadata: SessionMetadata,
109 /// Conversation messages
110 pub messages: Vec<Message>,
111 /// System prompt if any
112 pub system_prompt: Option<String>,
113 /// Compact linked context references for user-visible `@path` and
114 /// `/attach` mentions. Optional for backward-compatible session loads.
115 #[serde(default, skip_serializing_if = "Vec::is_empty")]
116 pub context_references: Vec<SessionContextReference>,
117 }
118
119 /// Manager for session persistence operations
120 pub struct SessionManager {
121 /// Directory where sessions are stored
122 sessions_dir: PathBuf,
123 }
124
125 impl SessionManager {
126 fn validated_session_path(&self, id: &str) -> std::io::Result<PathBuf> {
127 let trimmed = id.trim();
128 if trimmed.is_empty() {
129 return Err(std::io::Error::new(
130 std::io::ErrorKind::InvalidInput,
131 "Session id cannot be empty",
132 ));
133 }
134 if !trimmed
135 .chars()
136 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
137 {
138 return Err(std::io::Error::new(
139 std::io::ErrorKind::InvalidInput,
140 format!("Invalid session id '{id}'"),
141 ));
142 }
143 Ok(self.sessions_dir.join(format!("{trimmed}.json")))
144 }
145
146 /// Create a new `SessionManager` with the specified sessions directory
147 pub fn new(sessions_dir: PathBuf) -> std::io::Result<Self> {
148 // Ensure the sessions directory exists
149 fs::create_dir_all(&sessions_dir)?;
150 Ok(Self { sessions_dir })
151 }
152
153 /// Create a `SessionManager` using the default location (~/.deepseek/sessions)
154 pub fn default_location() -> std::io::Result<Self> {
155 Self::new(default_sessions_dir()?)
156 }
157
158 /// Save a session to disk using atomic write (temp file + fsync + rename).
159 pub fn save_session(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
160 let path = self.validated_session_path(&session.metadata.id)?;
161
162 let content = serde_json::to_string_pretty(session)
163 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
164
165 // Atomic write via write_atomic (NamedTempFile + fsync + persist)
166 write_atomic(&path, content.as_bytes())?;
167
168 // Clean up old sessions if we have too many
169 self.cleanup_old_sessions()?;
170
171 Ok(path)
172 }
173
174 /// Save a crash-recovery checkpoint for in-flight turns.
175 pub fn save_checkpoint(&self, session: &SavedSession) -> std::io::Result<PathBuf> {
176 let checkpoints = self.sessions_dir.join("checkpoints");
177 fs::create_dir_all(&checkpoints)?;
178 let path = checkpoints.join("latest.json");
179 let content = serde_json::to_string_pretty(session)
180 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
181 write_atomic(&path, content.as_bytes())?;
182 Ok(path)
183 }
184
185 /// Load the most recent crash-recovery checkpoint if present.
186 pub fn load_checkpoint(&self) -> std::io::Result<Option<SavedSession>> {
187 let path = self.sessions_dir.join("checkpoints").join("latest.json");
188 if !path.exists() {
189 return Ok(None);
190 }
191 let content = fs::read_to_string(&path)?;
192 let session: SavedSession = serde_json::from_str(&content)
193 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
194 if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
195 return Err(std::io::Error::new(
196 std::io::ErrorKind::InvalidData,
197 format!(
198 "Checkpoint schema v{} is newer than supported v{}",
199 session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
200 ),
201 ));
202 }
203 Ok(Some(session))
204 }
205
206 /// Clear any crash-recovery checkpoint.
207 pub fn clear_checkpoint(&self) -> std::io::Result<()> {
208 let path = self.sessions_dir.join("checkpoints").join("latest.json");
209 if path.exists() {
210 fs::remove_file(path)?;
211 }
212 Ok(())
213 }
214
215 /// Save offline queue state (queued + draft messages).
216 pub fn save_offline_queue_state(
217 &self,
218 state: &OfflineQueueState,
219 session_id: Option<&str>,
220 ) -> std::io::Result<PathBuf> {
221 let checkpoints = self.sessions_dir.join("checkpoints");
222 fs::create_dir_all(&checkpoints)?;
223 let path = checkpoints.join("offline_queue.json");
224 let mut state_with_id = state.clone();
225 state_with_id.session_id = session_id.map(|s| s.to_string());
226 let content = serde_json::to_string_pretty(&state_with_id)
227 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
228 write_atomic(&path, content.as_bytes())?;
229 Ok(path)
230 }
231
232 /// Load offline queue state if present.
233 pub fn load_offline_queue_state(&self) -> std::io::Result<Option<OfflineQueueState>> {
234 let path = self
235 .sessions_dir
236 .join("checkpoints")
237 .join("offline_queue.json");
238 if !path.exists() {
239 return Ok(None);
240 }
241 let content = fs::read_to_string(&path)?;
242 let state: OfflineQueueState = serde_json::from_str(&content)
243 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
244 if state.schema_version > CURRENT_QUEUE_SCHEMA_VERSION {
245 return Err(std::io::Error::new(
246 std::io::ErrorKind::InvalidData,
247 format!(
248 "Offline queue schema v{} is newer than supported v{}",
249 state.schema_version, CURRENT_QUEUE_SCHEMA_VERSION
250 ),
251 ));
252 }
253 Ok(Some(state))
254 }
255
256 /// Remove persisted offline queue state.
257 pub fn clear_offline_queue_state(&self) -> std::io::Result<()> {
258 let path = self
259 .sessions_dir
260 .join("checkpoints")
261 .join("offline_queue.json");
262 if path.exists() {
263 fs::remove_file(path)?;
264 }
265 Ok(())
266 }
267
268 /// Load a session by ID
269 pub fn load_session(&self, id: &str) -> std::io::Result<SavedSession> {
270 let path = self.validated_session_path(id)?;
271
272 let content = fs::read_to_string(&path)?;
273 let session: SavedSession = serde_json::from_str(&content)
274 .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))?;
275 if session.schema_version > CURRENT_SESSION_SCHEMA_VERSION {
276 return Err(std::io::Error::new(
277 std::io::ErrorKind::InvalidData,
278 format!(
279 "Session schema v{} is newer than supported v{}",
280 session.schema_version, CURRENT_SESSION_SCHEMA_VERSION
281 ),
282 ));
283 }
284
285 Ok(session)
286 }
287
288 /// Load a session by partial ID prefix
289 pub fn load_session_by_prefix(&self, prefix: &str) -> std::io::Result<SavedSession> {
290 let sessions = self.list_sessions()?;
291
292 let matches: Vec<_> = sessions
293 .into_iter()
294 .filter(|s| s.id.starts_with(prefix))
295 .collect();
296
297 match matches.len() {
298 0 => Err(std::io::Error::new(
299 std::io::ErrorKind::NotFound,
300 format!("No session found with prefix: {prefix}"),
301 )),
302 1 => self.load_session(&matches[0].id),
303 _ => Err(std::io::Error::new(
304 std::io::ErrorKind::InvalidInput,
305 format!(
306 "Ambiguous prefix '{}' matches {} sessions",
307 prefix,
308 matches.len()
309 ),
310 )),
311 }
312 }
313
314 /// List all saved sessions, sorted by most recently updated
315 pub fn list_sessions(&self) -> std::io::Result<Vec<SessionMetadata>> {
316 let mut sessions = Vec::new();
317
318 for entry in fs::read_dir(&self.sessions_dir)? {
319 let entry = entry?;
320 let path = entry.path();
321
322 if path.extension().is_some_and(|ext| ext == "json")
323 && let Ok(session) = Self::load_session_metadata(&path)
324 {
325 sessions.push(session);
326 }
327 }
328
329 // Sort by updated_at descending (most recent first)
330 sessions.sort_by_key(|s| std::cmp::Reverse(s.updated_at));
331
332 Ok(sessions)
333 }
334
335 /// Load only the metadata from a session file.
336 ///
337 /// Optimization for #337: previously this called
338 /// `serde_json::from_reader` which forces serde to scan every token in
339 /// the file just to validate JSON structure — including the
340 /// (potentially many MB of) `messages` and `tool_log` arrays we're
341 /// going to discard. For a user with hundreds of long sessions, a
342 /// single `list_sessions()` call could chew through tens of MB of
343 /// JSON per startup.
344 ///
345 /// We now read at most 64 KB up front and string-extract the
346 /// top-level `metadata` object, which is invariably tiny (~500 B)
347 /// and appears before any large `messages`/`tool_log` payload. We
348 /// fall back to a full-file read only if the prefix doesn't yield a
349 /// parseable metadata block (e.g. an oddly-formatted legacy file).
350 fn load_session_metadata(path: &Path) -> std::io::Result<SessionMetadata> {
351 use std::io::Read;
352
353 const PREFIX_BYTES: usize = 64 * 1024;
354 let mut file = fs::File::open(path)?;
355 let mut buf = Vec::with_capacity(PREFIX_BYTES);
356 file.by_ref()
357 .take(PREFIX_BYTES as u64)
358 .read_to_end(&mut buf)?;
359
360 if let Some(metadata) = extract_top_level_metadata(&buf) {
361 return Ok(metadata);
362 }
363
364 // Metadata wasn't extractable from the prefix (truncated mid-block,
365 // unusual key ordering, etc.). Read the rest and try again with the
366 // full buffer before giving up.
367 let mut rest = Vec::new();
368 file.read_to_end(&mut rest)?;
369 buf.extend_from_slice(&rest);
370 extract_top_level_metadata(&buf).ok_or_else(|| {
371 std::io::Error::new(
372 std::io::ErrorKind::InvalidData,
373 "session file missing parseable `metadata` block",
374 )
375 })
376 }
377
378 /// Delete a session by ID
379 pub fn delete_session(&self, id: &str) -> std::io::Result<()> {
380 let path = self.validated_session_path(id)?;
381 fs::remove_file(path)
382 }
383
384 /// Clean up old sessions to stay within `MAX_SESSIONS` limit
385 fn cleanup_old_sessions(&self) -> std::io::Result<()> {
386 let sessions = self.list_sessions()?;
387
388 if sessions.len() > MAX_SESSIONS {
389 // Delete oldest sessions
390 for session in sessions.iter().skip(MAX_SESSIONS) {
391 let _ = self.delete_session(&session.id);
392 }
393 }
394
395 Ok(())
396 }
397
398 /// Remove session files whose `updated_at` is older than `max_age`
399 /// from the persisted-sessions directory. Returns the number of
400 /// records pruned. Building block for #406's phase-2 auto-archive
401 /// on boot; today the user-facing entry point is the
402 /// `/sessions prune <days>` slash command.
403 ///
404 /// Crash-recovery safety: skips the running checkpoint
405 /// (`checkpoints/latest.json`) and any file under `checkpoints/`
406 /// — those are owned by the checkpoint subsystem and live with
407 /// stricter durability rules. Only top-level `<session_id>.json`
408 /// files are candidates.
409 ///
410 /// `max_age` is checked against the metadata's `updated_at`
411 /// timestamp embedded in the JSON, not the filesystem mtime — the
412 /// user may have rsynced their `~/.deepseek` between machines and
413 /// fs mtimes can lie.
414 pub fn prune_sessions_older_than(
415 &self,
416 max_age: std::time::Duration,
417 ) -> std::io::Result<usize> {
418 let cutoff = Utc::now()
419 - chrono::Duration::from_std(max_age).unwrap_or(chrono::Duration::days(365 * 10));
420 let sessions = self.list_sessions()?;
421 let mut pruned = 0usize;
422 for session in sessions {
423 if session.updated_at < cutoff {
424 if let Err(err) = self.delete_session(&session.id) {
425 tracing::warn!(
426 target: "session",
427 session = session.id,
428 ?err,
429 "session prune skipped a record",
430 );
431 continue;
432 }
433 pruned += 1;
434 }
435 }
436 Ok(pruned)
437 }
438
439 /// Get the most recent session scoped to the current workspace.
440 pub fn get_latest_session_for_workspace(
441 &self,
442 workspace: &Path,
443 ) -> std::io::Result<Option<SessionMetadata>> {
444 let sessions = self.list_sessions()?;
445 Ok(sessions
446 .into_iter()
447 .find(|session| workspace_scope_matches(&session.workspace, workspace)))
448 }
449
450 /// Search sessions by title
451 pub fn search_sessions(&self, query: &str) -> std::io::Result<Vec<SessionMetadata>> {
452 let query_lower = query.to_lowercase();
453 let sessions = self.list_sessions()?;
454
455 Ok(sessions
456 .into_iter()
457 .filter(|s| s.title.to_lowercase().contains(&query_lower))
458 .collect())
459 }
460 }
461
462 fn workspace_scope_matches(saved_workspace: &Path, current_workspace: &Path) -> bool {
463 if paths_equivalent(saved_workspace, current_workspace) {
464 return true;
465 }
466
467 match (
468 find_git_root(saved_workspace),
469 find_git_root(current_workspace),
470 ) {
471 (Some(saved_root), Some(current_root)) => paths_equivalent(&saved_root, &current_root),
472 _ => false,
473 }
474 }
475
476 fn paths_equivalent(lhs: &Path, rhs: &Path) -> bool {
477 let lhs_canonical = fs::canonicalize(lhs).ok();
478 let rhs_canonical = fs::canonicalize(rhs).ok();
479 match (lhs_canonical, rhs_canonical) {
480 (Some(lhs), Some(rhs)) => lhs == rhs,
481 _ => lhs == rhs,
482 }
483 }
484
485 fn find_git_root(path: &Path) -> Option<PathBuf> {
486 let mut current = fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf());
487 loop {
488 if current.join(".git").exists() {
489 return Some(current);
490 }
491 match current.parent() {
492 Some(parent) if parent != current => current = parent.to_path_buf(),
493 _ => return None,
494 }
495 }
496 }
497
498 /// Resolve the default session directory path (`~/.deepseek/sessions`).
499 pub fn default_sessions_dir() -> std::io::Result<PathBuf> {
500 let home = dirs::home_dir().ok_or_else(|| {
501 std::io::Error::new(std::io::ErrorKind::NotFound, "Home directory not found")
502 })?;
503 Ok(home.join(".deepseek").join("sessions"))
504 }
505
506 /// Prune snapshots older than `max_age` for `workspace`.
507 ///
508 /// Always non-fatal. Returns silently — callers don't need the count
509 /// (the underlying repo logs at WARN if anything blew up).
510 pub fn prune_workspace_snapshots(workspace: &Path, max_age: std::time::Duration) {
511 match crate::snapshot::prune_older_than(workspace, max_age) {
512 Ok(0) => {}
513 Ok(n) => {
514 tracing::debug!(target: "snapshot", "boot prune removed {n} snapshot(s)");
515 }
516 Err(e) => {
517 tracing::warn!(target: "snapshot", "boot prune failed: {e}");
518 }
519 }
520 }
521
522 /// Create a new `SavedSession` from conversation state
523 pub fn create_saved_session(
524 messages: &[Message],
525 model: &str,
526 workspace: &Path,
527 total_tokens: u64,
528 system_prompt: Option<&SystemPrompt>,
529 ) -> SavedSession {
530 create_saved_session_with_mode(
531 messages,
532 model,
533 workspace,
534 total_tokens,
535 system_prompt,
536 None,
537 )
538 }
539
540 /// Create a new `SavedSession` from conversation state with optional mode label
541 pub fn create_saved_session_with_mode(
542 messages: &[Message],
543 model: &str,
544 workspace: &Path,
545 total_tokens: u64,
546 system_prompt: Option<&SystemPrompt>,
547 mode: Option<&str>,
548 ) -> SavedSession {
549 let id = Uuid::new_v4().to_string();
550 let now = Utc::now();
551
552 // Generate title from first user message
553 let title = messages
554 .iter()
555 .find(|m| m.role == "user")
556 .and_then(|m| {
557 m.content.iter().find_map(|block| match block {
558 ContentBlock::Text { text, .. } => Some(truncate_title(text, 50)),
559 _ => None,
560 })
561 })
562 .unwrap_or_else(|| "New Session".to_string());
563
564 let (capped_messages, truncation_note) = cap_messages(messages);
565
566 SavedSession {
567 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
568 metadata: SessionMetadata {
569 id,
570 title,
571 created_at: now,
572 updated_at: now,
573 message_count: messages.len(),
574 total_tokens,
575 model: model.to_string(),
576 workspace: workspace.to_path_buf(),
577 mode: mode.map(str::to_string),
578 },
579 messages: capped_messages,
580 system_prompt: merge_truncation_note(
581 system_prompt_to_string(system_prompt),
582 truncation_note,
583 ),
584 context_references: Vec::new(),
585 }
586 }
587
588 /// Update an existing session with new messages
589 pub fn update_session(
590 mut session: SavedSession,
591 messages: &[Message],
592 total_tokens: u64,
593 system_prompt: Option<&SystemPrompt>,
594 ) -> SavedSession {
595 session.schema_version = CURRENT_SESSION_SCHEMA_VERSION;
596 let (capped_messages, truncation_note) = cap_messages(messages);
597 session.messages = capped_messages;
598 session.metadata.updated_at = Utc::now();
599 session.metadata.message_count = messages.len();
600 session.metadata.total_tokens = total_tokens;
601 session.system_prompt = merge_truncation_note(
602 system_prompt_to_string(system_prompt).or(session.system_prompt),
603 truncation_note,
604 );
605 session
606 }
607
608 /// Cap messages to [`MAX_PERSISTED_MESSAGES`], keeping the most recent.
609 /// Returns the capped slice and an optional truncation note.
610 fn cap_messages(messages: &[Message]) -> (Vec<Message>, Option<String>) {
611 let total = messages.len();
612 if total <= MAX_PERSISTED_MESSAGES {
613 return (messages.to_vec(), None);
614 }
615 let dropped = total - MAX_PERSISTED_MESSAGES;
616 let note = format!(
617 "Note: {dropped} older messages were dropped from the session file \
618 to keep persistence bounded. The full conversation history may \
619 still be recoverable from cycle archives."
620 );
621 (
622 messages[total - MAX_PERSISTED_MESSAGES..].to_vec(),
623 Some(note),
624 )
625 }
626
627 /// Merge an optional truncation note into the system prompt string.
628 fn merge_truncation_note(system_prompt: Option<String>, note: Option<String>) -> Option<String> {
629 match (system_prompt, note) {
630 (None, None) => None,
631 (Some(sp), None) => Some(sp),
632 (None, Some(note)) => Some(format!("[Session note]\n{note}")),
633 (Some(sp), Some(note)) => Some(format!("[Session note]\n{note}\n\n---\n\n{sp}")),
634 }
635 }
636
637 /// String-scan a JSON byte buffer for the top-level `"metadata":{...}`
638 /// block and return it parsed. Returns `None` if no balanced metadata
639 /// object is present in the buffer.
640 ///
641 /// Supports the optimisation in `SessionManager::load_session_metadata`
642 /// (#337). The scanner is brace-balanced and string-aware so a `{` or
643 /// `}` appearing inside a string literal doesn't perturb the depth
644 /// count.
645 fn extract_top_level_metadata(buf: &[u8]) -> Option<SessionMetadata> {
646 let s = std::str::from_utf8(buf).ok()?;
647 let bytes = s.as_bytes();
648
649 // Find the FIRST `"metadata"` key that appears outside of any string
650 // literal. Walking with brace/string awareness costs almost nothing
651 // and avoids matching `metadata` inside an earlier message body.
652 let key_pat = b"\"metadata\"";
653 let mut idx = 0usize;
654 let mut in_string = false;
655 let mut escape = false;
656 let key_offset = loop {
657 if idx >= bytes.len() {
658 return None;
659 }
660 let c = bytes[idx];
661 if escape {
662 escape = false;
663 idx += 1;
664 continue;
665 }
666 if c == b'\\' {
667 escape = true;
668 idx += 1;
669 continue;
670 }
671 if c == b'"' {
672 // If we're already in a string, this closes it; otherwise it
673 // opens one. But before flipping we check for the key match
674 // when we're entering a string at exactly this position.
675 if !in_string && bytes[idx..].starts_with(key_pat) {
676 break idx;
677 }
678 in_string = !in_string;
679 idx += 1;
680 continue;
681 }
682 idx += 1;
683 };
684
685 // Position past the key.
686 let after_key = key_offset + key_pat.len();
687 // Find the colon that separates key from value (skip whitespace).
688 let mut after_colon = after_key;
689 while after_colon < bytes.len() && (bytes[after_colon] as char).is_whitespace() {
690 after_colon += 1;
691 }
692 if after_colon >= bytes.len() || bytes[after_colon] != b':' {
693 return None;
694 }
695 after_colon += 1;
696 while after_colon < bytes.len() && (bytes[after_colon] as char).is_whitespace() {
697 after_colon += 1;
698 }
699 if after_colon >= bytes.len() || bytes[after_colon] != b'{' {
700 return None;
701 }
702
703 // Walk the object, balancing braces.
704 let mut depth = 0i32;
705 let mut in_string = false;
706 let mut escape = false;
707 let mut end = None;
708 for (i, &c) in bytes[after_colon..].iter().enumerate() {
709 let abs = after_colon + i;
710 if escape {
711 escape = false;
712 continue;
713 }
714 if c == b'\\' {
715 escape = true;
716 continue;
717 }
718 if c == b'"' {
719 in_string = !in_string;
720 continue;
721 }
722 if in_string {
723 continue;
724 }
725 match c {
726 b'{' => depth += 1,
727 b'}' => {
728 depth -= 1;
729 if depth == 0 {
730 end = Some(abs + 1);
731 break;
732 }
733 }
734 _ => {}
735 }
736 }
737 let end = end?;
738 serde_json::from_str::<SessionMetadata>(&s[after_colon..end]).ok()
739 }
740
741 fn system_prompt_to_string(system_prompt: Option<&SystemPrompt>) -> Option<String> {
742 match system_prompt {
743 Some(SystemPrompt::Text(text)) => Some(text.clone()),
744 Some(SystemPrompt::Blocks(blocks)) => Some(
745 blocks
746 .iter()
747 .map(|b| b.text.clone())
748 .collect::<Vec<_>>()
749 .join("\n\n---\n\n"),
750 ),
751 None => None,
752 }
753 }
754
755 /// Truncate a session ID to 8 characters for compact display.
756 /// Returns a `&str` borrowing from the input — no allocation.
757 pub fn truncate_id(id: &str) -> &str {
758 id.get(..8).unwrap_or(id)
759 }
760
761 /// Truncate a string to create a title (character-safe for UTF-8)
762 fn truncate_title(s: &str, max_len: usize) -> String {
763 let s = s.trim();
764 let first_line = s.lines().next().unwrap_or(s);
765
766 let char_count = first_line.chars().count();
767 if char_count <= max_len {
768 first_line.to_string()
769 } else {
770 let truncated: String = first_line.chars().take(max_len - 3).collect();
771 format!("{truncated}...")
772 }
773 }
774
775 /// Format a session for display in a picker
776 pub fn format_session_line(meta: &SessionMetadata) -> String {
777 let age = format_age(&meta.updated_at);
778 let truncated_title = truncate_title(&meta.title, 40);
779
780 format!(
781 "{} | {} | {} msgs | {}",
782 truncate_id(&meta.id),
783 truncated_title,
784 meta.message_count,
785 age
786 )
787 }
788
789 /// Format a datetime as relative age
790 fn format_age(dt: &DateTime<Utc>) -> String {
791 let now = Utc::now();
792 let duration = now.signed_duration_since(*dt);
793
794 if duration.num_minutes() < 1 {
795 "just now".to_string()
796 } else if duration.num_hours() < 1 {
797 format!("{}m ago", duration.num_minutes())
798 } else if duration.num_days() < 1 {
799 format!("{}h ago", duration.num_hours())
800 } else if duration.num_weeks() < 1 {
801 format!("{}d ago", duration.num_days())
802 } else {
803 format!("{}w ago", duration.num_weeks())
804 }
805 }
806
807 // === Unit Tests ===
808
809 #[cfg(test)]
810 mod tests {
811 use super::*;
812 use crate::models::ContentBlock;
813 use std::fs;
814 use tempfile::tempdir;
815
816 fn make_test_message(role: &str, text: &str) -> Message {
817 Message {
818 role: role.to_string(),
819 content: vec![ContentBlock::Text {
820 text: text.to_string(),
821 cache_control: None,
822 }],
823 }
824 }
825
826 fn write_session_record(
827 manager: &SessionManager,
828 id: &str,
829 workspace: &Path,
830 updated_at: DateTime<Utc>,
831 ) {
832 let session = SavedSession {
833 schema_version: CURRENT_SESSION_SCHEMA_VERSION,
834 messages: vec![make_test_message("user", "hi")],
835 metadata: SessionMetadata {
836 id: id.to_string(),
837 title: format!("session-{id}"),
838 created_at: updated_at,
839 updated_at,
840 message_count: 1,
841 total_tokens: 0,
842 model: "deepseek-v4-flash".to_string(),
843 workspace: workspace.to_path_buf(),
844 mode: None,
845 },
846 system_prompt: None,
847 context_references: Vec::new(),
848 };
849 manager.save_session(&session).expect("save");
850 }
851
852 #[test]
853 fn test_session_manager_new() {
854 let tmp = tempdir().expect("tempdir");
855 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
856 assert!(tmp.path().join("sessions").exists());
857 let _ = manager;
858 }
859
860 #[test]
861 fn test_save_and_load_session() {
862 let tmp = tempdir().expect("tempdir");
863 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
864
865 let messages = vec![
866 make_test_message("user", "Hello!"),
867 make_test_message("assistant", "Hi there!"),
868 ];
869
870 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
871 let session_id = session.metadata.id.clone();
872
873 manager.save_session(&session).expect("save");
874
875 let loaded = manager.load_session(&session_id).expect("load");
876 assert_eq!(loaded.metadata.id, session_id);
877 assert_eq!(loaded.messages.len(), 2);
878 }
879
880 #[test]
881 fn test_list_sessions() {
882 let tmp = tempdir().expect("tempdir");
883 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
884
885 // Create a few sessions
886 for i in 0..3 {
887 let messages = vec![make_test_message("user", &format!("Session {i}"))];
888 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
889 manager.save_session(&session).expect("save");
890 }
891
892 let sessions = manager.list_sessions().expect("list");
893 assert_eq!(sessions.len(), 3);
894 }
895
896 #[test]
897 fn latest_session_for_workspace_ignores_newer_other_directory() {
898 let tmp = tempdir().expect("tempdir");
899 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
900 let workspace_a = tmp.path().join("aa").join("aaa");
901 let workspace_b = tmp.path().join("bb").join("bbb");
902 fs::create_dir_all(&workspace_a).expect("mkdir workspace a");
903 fs::create_dir_all(&workspace_b).expect("mkdir workspace b");
904
905 write_session_record(
906 &manager,
907 "current-workspace",
908 &workspace_a,
909 Utc::now() - chrono::Duration::minutes(10),
910 );
911 write_session_record(&manager, "other-workspace", &workspace_b, Utc::now());
912
913 let global = manager
914 .list_sessions()
915 .expect("list")
916 .into_iter()
917 .next()
918 .expect("global latest");
919 assert_eq!(global.id, "other-workspace");
920
921 let scoped = manager
922 .get_latest_session_for_workspace(&workspace_a)
923 .expect("latest for workspace")
924 .expect("scoped latest");
925 assert_eq!(scoped.id, "current-workspace");
926 }
927
928 #[test]
929 fn latest_session_for_workspace_matches_same_git_repository() {
930 let tmp = tempdir().expect("tempdir");
931 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
932 let repo = tmp.path().join("repo");
933 let repo_app = repo.join("apps").join("client");
934 let repo_crate = repo.join("crates").join("server");
935 let other_repo = tmp.path().join("other").join("project");
936 fs::create_dir_all(repo.join(".git")).expect("mkdir .git");
937 fs::create_dir_all(&repo_app).expect("mkdir repo app");
938 fs::create_dir_all(&repo_crate).expect("mkdir repo crate");
939 fs::create_dir_all(&other_repo).expect("mkdir other repo");
940
941 write_session_record(
942 &manager,
943 "same-repo",
944 &repo_app,
945 Utc::now() - chrono::Duration::minutes(5),
946 );
947 write_session_record(&manager, "other-repo", &other_repo, Utc::now());
948
949 let scoped = manager
950 .get_latest_session_for_workspace(&repo_crate)
951 .expect("latest for workspace")
952 .expect("same repo latest");
953 assert_eq!(scoped.id, "same-repo");
954 }
955
956 #[test]
957 fn test_load_by_prefix() {
958 let tmp = tempdir().expect("tempdir");
959 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
960
961 let messages = vec![make_test_message("user", "Test session")];
962 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
963 let prefix = truncate_id(&session.metadata.id).to_string();
964 manager.save_session(&session).expect("save");
965
966 let loaded = manager.load_session_by_prefix(&prefix).expect("load");
967 assert_eq!(loaded.messages.len(), 1);
968 }
969
970 #[test]
971 fn test_delete_session() {
972 let tmp = tempdir().expect("tempdir");
973 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
974
975 let messages = vec![make_test_message("user", "To be deleted")];
976 let session = create_saved_session(&messages, "test-model", tmp.path(), 100, None);
977 let session_id = session.metadata.id.clone();
978
979 manager.save_session(&session).expect("save");
980 assert!(manager.load_session(&session_id).is_ok());
981
982 manager.delete_session(&session_id).expect("delete");
983 assert!(manager.load_session(&session_id).is_err());
984 }
985
986 #[test]
987 fn test_session_id_rejects_invalid_characters() {
988 let tmp = tempdir().expect("tempdir");
989 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
990
991 let err = manager
992 .load_session("../outside")
993 .expect_err("invalid id should fail");
994 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
995
996 let err = manager
997 .delete_session("sess bad")
998 .expect_err("invalid id should fail");
999 assert_eq!(err.kind(), std::io::ErrorKind::InvalidInput);
1000 }
1001
1002 #[test]
1003 fn test_truncate_title() {
1004 assert_eq!(truncate_title("Short", 50), "Short");
1005 assert_eq!(
1006 truncate_title("This is a very long title that should be truncated", 20),
1007 "This is a very lo..."
1008 );
1009 assert_eq!(truncate_title("Line 1\nLine 2", 50), "Line 1");
1010 }
1011
1012 #[test]
1013 fn test_format_age() {
1014 let now = Utc::now();
1015 assert_eq!(format_age(&now), "just now");
1016
1017 let hour_ago = now - chrono::Duration::hours(2);
1018 assert_eq!(format_age(&hour_ago), "2h ago");
1019
1020 let day_ago = now - chrono::Duration::days(3);
1021 assert_eq!(format_age(&day_ago), "3d ago");
1022 }
1023
1024 #[test]
1025 fn test_update_session() {
1026 let tmp = tempdir().expect("tempdir");
1027
1028 let messages = vec![make_test_message("user", "Hello")];
1029 let session = create_saved_session(&messages, "test-model", tmp.path(), 50, None);
1030
1031 let new_messages = vec![
1032 make_test_message("user", "Hello"),
1033 make_test_message("assistant", "Hi!"),
1034 ];
1035
1036 let updated = update_session(session, &new_messages, 100, None);
1037 assert_eq!(updated.messages.len(), 2);
1038 assert_eq!(updated.metadata.total_tokens, 100);
1039 }
1040
1041 #[test]
1042 fn test_checkpoint_round_trip_and_clear() {
1043 let tmp = tempdir().expect("tempdir");
1044 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1045 let messages = vec![make_test_message("user", "checkpoint me")];
1046 let session = create_saved_session(&messages, "test-model", tmp.path(), 12, None);
1047
1048 manager.save_checkpoint(&session).expect("save checkpoint");
1049 let loaded = manager
1050 .load_checkpoint()
1051 .expect("load checkpoint")
1052 .expect("checkpoint exists");
1053 assert_eq!(loaded.metadata.id, session.metadata.id);
1054
1055 manager.clear_checkpoint().expect("clear checkpoint");
1056 assert!(
1057 manager
1058 .load_checkpoint()
1059 .expect("load checkpoint")
1060 .is_none()
1061 );
1062 }
1063
1064 #[test]
1065 fn test_offline_queue_round_trip_and_clear() {
1066 let tmp = tempdir().expect("tempdir");
1067 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1068
1069 let state = OfflineQueueState {
1070 messages: vec![QueuedSessionMessage {
1071 display: "queued message".to_string(),
1072 skill_instruction: Some("Use skill".to_string()),
1073 }],
1074 draft: Some(QueuedSessionMessage {
1075 display: "draft message".to_string(),
1076 skill_instruction: None,
1077 }),
1078 ..OfflineQueueState::default()
1079 };
1080
1081 manager
1082 .save_offline_queue_state(&state, Some("test-session"))
1083 .expect("save queue state");
1084 let loaded = manager
1085 .load_offline_queue_state()
1086 .expect("load queue state")
1087 .expect("queue state exists");
1088 assert_eq!(loaded.messages.len(), 1);
1089 assert_eq!(loaded.messages[0].display, "queued message");
1090 assert!(loaded.draft.is_some());
1091
1092 manager
1093 .clear_offline_queue_state()
1094 .expect("clear queue state");
1095 assert!(
1096 manager
1097 .load_offline_queue_state()
1098 .expect("load queue state")
1099 .is_none()
1100 );
1101 }
1102
1103 #[test]
1104 fn test_offline_queue_stamps_session_id_on_save() {
1105 // #487: save_offline_queue_state must stamp the supplied
1106 // session id so the load path's mismatch check has something
1107 // to compare against. A queue persisted without a session id
1108 // is the legacy unscoped form which the load path treats as
1109 // stale-risky and refuses to restore.
1110 let tmp = tempdir().expect("tempdir");
1111 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1112
1113 let state = OfflineQueueState {
1114 messages: vec![QueuedSessionMessage {
1115 display: "first parked".to_string(),
1116 skill_instruction: None,
1117 }],
1118 ..OfflineQueueState::default()
1119 };
1120
1121 manager
1122 .save_offline_queue_state(&state, Some("session-A"))
1123 .expect("save with session id");
1124 let loaded = manager
1125 .load_offline_queue_state()
1126 .expect("ok")
1127 .expect("present");
1128 assert_eq!(loaded.session_id.as_deref(), Some("session-A"));
1129
1130 // Re-saving with a different session id replaces the stamp.
1131 manager
1132 .save_offline_queue_state(&state, Some("session-B"))
1133 .expect("re-save");
1134 let reloaded = manager
1135 .load_offline_queue_state()
1136 .expect("ok")
1137 .expect("present");
1138 assert_eq!(reloaded.session_id.as_deref(), Some("session-B"));
1139
1140 // Saving without a session id explicitly (None) clears the
1141 // stamp — UI's load path treats that as legacy-unscoped and
1142 // fails closed.
1143 manager
1144 .save_offline_queue_state(&state, None)
1145 .expect("save without session id");
1146 let unscoped = manager
1147 .load_offline_queue_state()
1148 .expect("ok")
1149 .expect("present");
1150 assert!(
1151 unscoped.session_id.is_none(),
1152 "save with None must persist a missing session_id, got {:?}",
1153 unscoped.session_id
1154 );
1155 }
1156
1157 #[test]
1158 fn test_session_context_references_round_trip() {
1159 let tmp = tempdir().expect("tempdir");
1160 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1161 let mut session = create_saved_session(
1162 &[make_test_message("user", "read @src/main.rs")],
1163 "deepseek-v4-pro",
1164 tmp.path(),
1165 0,
1166 None,
1167 );
1168 session.context_references.push(SessionContextReference {
1169 message_index: 0,
1170 reference: ContextReference {
1171 kind: crate::tui::file_mention::ContextReferenceKind::File,
1172 source: crate::tui::file_mention::ContextReferenceSource::AtMention,
1173 badge: "file".to_string(),
1174 label: "src/main.rs".to_string(),
1175 target: tmp.path().join("src/main.rs").display().to_string(),
1176 included: true,
1177 expanded: true,
1178 detail: Some("included".to_string()),
1179 },
1180 });
1181
1182 let path = manager.save_session(&session).expect("save session");
1183 let loaded = manager
1184 .load_session(&session.metadata.id)
1185 .expect("load session");
1186 assert!(path.exists());
1187 assert_eq!(loaded.context_references, session.context_references);
1188 }
1189
1190 #[test]
1191 fn test_checkpoint_rejects_newer_schema() {
1192 let tmp = tempdir().expect("tempdir");
1193 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1194 let checkpoints = tmp.path().join("sessions").join("checkpoints");
1195 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
1196 let path = checkpoints.join("latest.json");
1197 fs::write(
1198 &path,
1199 r#"{
1200 "schema_version": 999,
1201 "metadata": {
1202 "id": "sid",
1203 "title": "bad",
1204 "created_at": "2026-01-01T00:00:00Z",
1205 "updated_at": "2026-01-01T00:00:00Z",
1206 "message_count": 0,
1207 "total_tokens": 0,
1208 "model": "m",
1209 "workspace": "/tmp",
1210 "mode": null
1211 },
1212 "messages": [],
1213 "system_prompt": null
1214 }"#,
1215 )
1216 .expect("write checkpoint");
1217
1218 let err = manager.load_checkpoint().expect_err("should reject schema");
1219 assert!(err.to_string().contains("newer than supported"));
1220 }
1221
1222 #[test]
1223 fn test_load_session_rejects_newer_schema() {
1224 let tmp = tempdir().expect("tempdir");
1225 let sessions_dir = tmp.path().join("sessions");
1226 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1227
1228 let id = "future-session";
1229 let path = sessions_dir.join(format!("{id}.json"));
1230 fs::write(
1231 &path,
1232 r#"{
1233 "schema_version": 999,
1234 "metadata": {
1235 "id": "future-session",
1236 "title": "future",
1237 "created_at": "2026-01-01T00:00:00Z",
1238 "updated_at": "2026-01-01T00:00:00Z",
1239 "message_count": 0,
1240 "total_tokens": 0,
1241 "model": "m",
1242 "workspace": "/tmp",
1243 "mode": null
1244 },
1245 "messages": [],
1246 "system_prompt": null
1247 }"#,
1248 )
1249 .expect("write session");
1250
1251 let err = manager.load_session(id).expect_err("should reject schema");
1252 assert!(
1253 err.to_string().contains("newer than supported"),
1254 "unexpected error: {err}"
1255 );
1256 }
1257
1258 /// Regression for #337: metadata extraction skips the (potentially
1259 /// huge) `messages` array — it must succeed even when the messages
1260 /// array is megabytes long, and it must NOT confuse a `"metadata"`
1261 /// substring inside a message body for the real top-level key.
1262 #[test]
1263 fn extract_top_level_metadata_skips_huge_messages_array() {
1264 // Build a session JSON with a large `messages` payload that
1265 // contains the literal string `"metadata"` in a user message —
1266 // a naive `find("\"metadata\"")` would mis-target this.
1267 let big_text = format!(
1268 r#"this message references "metadata" inside it, repeated:{}"#,
1269 "x".repeat(20_000)
1270 );
1271 let json = format!(
1272 r#"{{
1273 "schema_version": 1,
1274 "metadata": {{
1275 "id": "abc-123",
1276 "title": "Real Session",
1277 "created_at": "2026-01-01T00:00:00Z",
1278 "updated_at": "2026-01-02T00:00:00Z",
1279 "message_count": 12,
1280 "total_tokens": 4096,
1281 "model": "deepseek-v4-flash",
1282 "workspace": "/tmp"
1283 }},
1284 "messages": [
1285 {{ "role": "user", "content": [ {{ "Text": {{ "text": {body:?} }} }} ] }}
1286 ]
1287 }}"#,
1288 body = big_text
1289 );
1290
1291 let extracted =
1292 extract_top_level_metadata(json.as_bytes()).expect("metadata extractable from prefix");
1293 assert_eq!(extracted.id, "abc-123");
1294 assert_eq!(extracted.title, "Real Session");
1295 assert_eq!(extracted.message_count, 12);
1296 assert_eq!(extracted.total_tokens, 4096);
1297 }
1298
1299 #[test]
1300 fn extract_top_level_metadata_handles_braces_inside_strings() {
1301 // A title containing `{` and `}` inside the metadata block must
1302 // not throw off the brace counter.
1303 let json = r#"{
1304 "metadata": {
1305 "id": "x",
1306 "title": "weird { title } with braces",
1307 "created_at": "2026-01-01T00:00:00Z",
1308 "updated_at": "2026-01-01T00:00:00Z",
1309 "message_count": 0,
1310 "total_tokens": 0,
1311 "model": "m",
1312 "workspace": "/tmp"
1313 },
1314 "messages": []
1315 }"#;
1316 let extracted = extract_top_level_metadata(json.as_bytes())
1317 .expect("brace-in-string survives the scanner");
1318 assert_eq!(extracted.title, "weird { title } with braces");
1319 }
1320
1321 // ---- #406 prune_sessions_older_than ----
1322 //
1323 // The helper is a building block for the auto-archive design: it
1324 // removes session files older than a threshold while leaving fresh
1325 // ones (and the checkpoint directory) alone. Tests cover the empty
1326 // case, the all-fresh case, the all-stale case, and the mixed case.
1327
1328 fn write_session_with_updated_at(
1329 manager: &SessionManager,
1330 id: &str,
1331 updated_at: DateTime<Utc>,
1332 ) {
1333 // Build a minimal SavedSession by hand so the test isn't tied
1334 // to whatever the helper functions emit; we just need a
1335 // metadata block whose `updated_at` matches the requested
1336 // value.
1337 write_session_record(manager, id, Path::new("/tmp"), updated_at);
1338 }
1339
1340 #[test]
1341 fn prune_sessions_older_than_returns_zero_for_empty_dir() {
1342 let tmp = tempdir().expect("tempdir");
1343 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1344 let pruned = manager
1345 .prune_sessions_older_than(std::time::Duration::from_secs(3600))
1346 .expect("prune");
1347 assert_eq!(pruned, 0);
1348 }
1349
1350 #[test]
1351 fn prune_sessions_older_than_keeps_fresh_records() {
1352 let tmp = tempdir().expect("tempdir");
1353 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1354 // All updated within the last hour.
1355 write_session_with_updated_at(
1356 &manager,
1357 "fresh-1",
1358 Utc::now() - chrono::Duration::minutes(30),
1359 );
1360 write_session_with_updated_at(
1361 &manager,
1362 "fresh-2",
1363 Utc::now() - chrono::Duration::minutes(5),
1364 );
1365 let pruned = manager
1366 .prune_sessions_older_than(std::time::Duration::from_secs(3600))
1367 .expect("prune");
1368 assert_eq!(pruned, 0);
1369 // Both files still on disk.
1370 assert_eq!(manager.list_sessions().expect("list").len(), 2);
1371 }
1372
1373 #[test]
1374 fn prune_sessions_older_than_removes_stale_records() {
1375 let tmp = tempdir().expect("tempdir");
1376 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1377 // Two stale records ≥7 days old.
1378 write_session_with_updated_at(&manager, "stale-1", Utc::now() - chrono::Duration::days(8));
1379 write_session_with_updated_at(&manager, "stale-2", Utc::now() - chrono::Duration::days(30));
1380 let pruned = manager
1381 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
1382 .expect("prune");
1383 assert_eq!(pruned, 2);
1384 assert_eq!(manager.list_sessions().expect("list").len(), 0);
1385 }
1386
1387 #[test]
1388 fn prune_sessions_older_than_only_removes_stale_records_in_mixed_dir() {
1389 let tmp = tempdir().expect("tempdir");
1390 let manager = SessionManager::new(tmp.path().join("sessions")).expect("new");
1391 write_session_with_updated_at(&manager, "fresh", Utc::now() - chrono::Duration::hours(1));
1392 write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
1393 let pruned = manager
1394 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
1395 .expect("prune");
1396 assert_eq!(pruned, 1);
1397 let remaining = manager.list_sessions().expect("list");
1398 assert_eq!(remaining.len(), 1);
1399 assert_eq!(remaining[0].id, "fresh");
1400 }
1401
1402 #[test]
1403 fn prune_sessions_older_than_skips_checkpoint_directory() {
1404 // The checkpoint subsystem owns `<sessions>/checkpoints/` —
1405 // prune must not walk into it. The list_sessions iterator
1406 // already filters to top-level `*.json` files (skipping
1407 // sub-directories), so this test pins that behaviour.
1408 let tmp = tempdir().expect("tempdir");
1409 let sessions_dir = tmp.path().join("sessions");
1410 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1411 let checkpoint_dir = sessions_dir.join("checkpoints");
1412 fs::create_dir_all(&checkpoint_dir).expect("mkdir checkpoints");
1413 // Drop a stale-looking JSON inside the checkpoint dir; prune
1414 // should leave it alone.
1415 let checkpoint_file = checkpoint_dir.join("latest.json");
1416 fs::write(&checkpoint_file, "{}").expect("write checkpoint");
1417
1418 write_session_with_updated_at(&manager, "stale", Utc::now() - chrono::Duration::days(60));
1419 let pruned = manager
1420 .prune_sessions_older_than(std::time::Duration::from_secs(7 * 24 * 3600))
1421 .expect("prune");
1422 assert_eq!(pruned, 1, "the top-level stale session should be removed");
1423 assert!(
1424 checkpoint_file.exists(),
1425 "checkpoint file should be untouched"
1426 );
1427 }
1428
1429 #[test]
1430 fn test_load_offline_queue_rejects_newer_schema() {
1431 let tmp = tempdir().expect("tempdir");
1432 let sessions_dir = tmp.path().join("sessions");
1433 let manager = SessionManager::new(sessions_dir.clone()).expect("new");
1434 let checkpoints = sessions_dir.join("checkpoints");
1435 fs::create_dir_all(&checkpoints).expect("create checkpoints dir");
1436 let path = checkpoints.join("offline_queue.json");
1437 fs::write(
1438 &path,
1439 r#"{
1440 "schema_version": 999,
1441 "messages": [],
1442 "draft": null
1443 }"#,
1444 )
1445 .expect("write queue");
1446
1447 let err = manager
1448 .load_offline_queue_state()
1449 .expect_err("should reject schema");
1450 assert!(
1451 err.to_string().contains("newer than supported"),
1452 "unexpected error: {err}"
1453 );
1454 }
1455 }
1456
1456 lines RUST