1 //! Durable session storage.
2 //!
3 //! One JSON-lines file per session at `$SIGIT_CONFIG_DIR/sessions/<id>.jsonl`
4 //! (config dir resolution matches [`crate::settings`] / [`crate::credentials`]:
5 //! `$SIGIT_CONFIG_DIR` or `~/.config/sigit`). Each line is one history message
6 //! as produced by `InferenceBackend::history_snapshot`, so a saved file can be
7 //! restored into either backend.
8 //!
9 //! Writes are atomic (temp file + rename) so a crash mid-save never leaves a
10 //! truncated session behind. Session ids are sanitized to a filename-safe
11 //! alphabet before touching the filesystem.
12
13 use std::path::PathBuf;
14
15 use serde_json::Value;
16
17 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
18 fn config_dir() -> Option<PathBuf> {
19 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
20 return Some(PathBuf::from(dir));
21 }
22 let home = std::env::var("HOME").ok()?;
23 Some(PathBuf::from(home).join(".config/sigit"))
24 }
25
26 fn sessions_dir() -> Option<PathBuf> {
27 config_dir().map(|dir| dir.join("sessions"))
28 }
29
30 /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through,
31 /// anything else becomes `_`. An empty id maps to `_` so the file name never
32 /// collapses to just the extension.
33 fn sanitize_id(session_id: &str) -> String {
34 if session_id.is_empty() {
35 return "_".to_string();
36 }
37 session_id
38 .chars()
39 .map(|c| {
40 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
41 c
42 } else {
43 '_'
44 }
45 })
46 .collect()
47 }
48
49 fn session_path(session_id: &str) -> Option<PathBuf> {
50 sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
51 }
52
53 /// Persist a history snapshot for `session_id`, replacing any previous save.
54 /// The write is atomic: a temp file in the same directory is renamed over the
55 /// final path.
56 pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
57 let path =
58 session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
59 let dir = path
60 .parent()
61 .ok_or_else(|| "session path has no parent".to_string())?;
62 std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
63
64 let mut body = String::new();
65 for message in history {
66 body.push_str(&message.to_string());
67 body.push('\n');
68 }
69
70 // Unique temp name so two processes saving the same session can't clobber
71 // each other's half-written file; rename is atomic on the same filesystem.
72 let tmp = dir.join(format!(
73 ".{}.{}.tmp",
74 sanitize_id(session_id),
75 std::process::id()
76 ));
77 std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
78 std::fs::rename(&tmp, &path).map_err(|error| {
79 let _ = std::fs::remove_file(&tmp);
80 format!("rename {tmp:?} -> {path:?}: {error}")
81 })?;
82 Ok(())
83 }
84
85 /// Load the saved history for `session_id`, or `None` when no save exists (or
86 /// it cannot be read). Unparseable lines are skipped rather than failing the
87 /// whole restore.
88 pub fn load(session_id: &str) -> Option<Vec<Value>> {
89 let path = session_path(session_id)?;
90 let contents = std::fs::read_to_string(&path).ok()?;
91 Some(
92 contents
93 .lines()
94 .filter(|line| !line.trim().is_empty())
95 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
96 .collect(),
97 )
98 }
99
100 /// Remove the saved history for `session_id`. Missing files are fine.
101 pub fn delete(session_id: &str) {
102 if let Some(path) = session_path(session_id) {
103 let _ = std::fs::remove_file(path);
104 }
105 }
106
107 /// One saved session as seen on disk.
108 ///
109 /// Cross-platform like the rest of the store, though today only the Unix-only
110 /// TUI (`chat.rs` History tab) consumes it — hence the non-Unix dead-code gate,
111 /// mirroring `permissions::TUI_SESSION`.
112 #[cfg_attr(not(unix), allow(dead_code))]
113 #[derive(Debug, Clone, PartialEq, Eq)]
114 pub struct SessionEntry {
115 /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
116 pub id: String,
117 /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable.
118 pub modified: std::time::SystemTime,
119 /// Number of history messages (non-empty lines) in the file.
120 pub message_count: usize,
121 }
122
123 /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
124 /// stray junk) are skipped. A missing or unreadable sessions dir yields an
125 /// empty list.
126 #[cfg_attr(not(unix), allow(dead_code))]
127 pub fn list() -> Vec<SessionEntry> {
128 let Some(dir) = sessions_dir() else {
129 return Vec::new();
130 };
131 let Ok(entries) = std::fs::read_dir(&dir) else {
132 return Vec::new();
133 };
134 let mut sessions: Vec<SessionEntry> = entries
135 .flatten()
136 .filter_map(|entry| {
137 let path = entry.path();
138 if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
139 return None;
140 }
141 let id = path.file_stem()?.to_str()?.to_string();
142 // Cheap line count: no JSON parsing, just non-empty lines.
143 let contents = std::fs::read_to_string(&path).ok()?;
144 let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
145 let modified = entry
146 .metadata()
147 .ok()
148 .and_then(|m| m.modified().ok())
149 .unwrap_or(std::time::UNIX_EPOCH);
150 Some(SessionEntry {
151 id,
152 modified,
153 message_count,
154 })
155 })
156 .collect();
157 sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id)));
158 sessions
159 }
160
161 #[cfg(test)]
162 mod tests {
163 use super::*;
164
165 #[test]
166 fn sanitize_keeps_safe_chars_and_replaces_the_rest() {
167 assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z");
168 assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e");
169 assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
170 assert_eq!(sanitize_id(""), "_");
171 }
172
173 // One test for the filesystem behavior because it mutates the
174 // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings
175 // tests): splitting would race under the parallel test runner.
176 #[test]
177 fn save_load_delete_round_trip() {
178 let _guard = crate::ENV_TEST_LOCK
179 .lock()
180 .unwrap_or_else(|poisoned| poisoned.into_inner());
181 let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
182 let _ = std::fs::remove_dir_all(&dir);
183 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
184 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
185
186 // Missing file → None.
187 assert_eq!(load("nope"), None);
188
189 let history = vec![
190 serde_json::json!({ "role": "system", "content": "sys" }),
191 serde_json::json!({ "role": "user", "content": "hi\nthere" }),
192 serde_json::json!({
193 "role": "assistant", "content": null,
194 "tool_calls": [{ "id": "call_1", "type": "function",
195 "function": { "name": "read_file", "arguments": "{}" } }],
196 }),
197 ];
198 save("sess-1", &history).unwrap();
199 assert_eq!(load("sess-1"), Some(history.clone()));
200
201 // Saving again replaces, not appends.
202 let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })];
203 save("sess-1", &shorter).unwrap();
204 assert_eq!(load("sess-1"), Some(shorter));
205
206 // A hostile id stays inside the sessions dir via sanitization.
207 save("../escape", &history).unwrap();
208 assert!(dir.join("sessions").join(".._escape.jsonl").is_file());
209 assert_eq!(load("../escape"), Some(history));
210 delete("../escape");
211 assert_eq!(load("../escape"), None);
212
213 delete("sess-1");
214 assert_eq!(load("sess-1"), None);
215 // Deleting a missing session is a no-op.
216 delete("sess-1");
217
218 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
219 let _ = std::fs::remove_dir_all(&dir);
220 }
221
222 // Same single-test-per-env-var pattern as `save_load_delete_round_trip`:
223 // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold.
224 #[test]
225 fn list_returns_sessions_newest_first_and_skips_junk() {
226 let _guard = crate::ENV_TEST_LOCK
227 .lock()
228 .unwrap_or_else(|poisoned| poisoned.into_inner());
229 let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id()));
230 let _ = std::fs::remove_dir_all(&dir);
231 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
232 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
233
234 // No sessions dir at all → empty list, not an error.
235 assert!(list().is_empty());
236
237 let msg = |text: &str| serde_json::json!({ "role": "user", "content": text });
238 save("older", &[msg("a"), msg("b"), msg("c")]).unwrap();
239 // Distinct mtimes so the newest-first order is deterministic.
240 std::thread::sleep(std::time::Duration::from_millis(50));
241 save("newer", &[msg("x")]).unwrap();
242
243 // Junk the lister must skip: wrong extension, a stray temp file, and a
244 // subdirectory.
245 let sessions = dir.join("sessions");
246 std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap();
247 std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap();
248 std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap();
249
250 let listed = list();
251 assert_eq!(listed.len(), 2);
252 assert_eq!(listed[0].id, "newer");
253 assert_eq!(listed[0].message_count, 1);
254 assert_eq!(listed[1].id, "older");
255 assert_eq!(listed[1].message_count, 3);
256 assert!(listed[0].modified >= listed[1].modified);
257
258 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
259 let _ = std::fs::remove_dir_all(&dir);
260 }
261 }