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::{Deserialize, Serialize};
16 use serde_json::Value;
17
18 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`.
19 fn config_dir() -> Option<PathBuf> {
20 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
21 return Some(PathBuf::from(dir));
22 }
23 let home = std::env::var("HOME").ok()?;
24 Some(PathBuf::from(home).join(".config/sigit"))
25 }
26
27 fn sessions_dir() -> Option<PathBuf> {
28 config_dir().map(|dir| dir.join("sessions"))
29 }
30
31 /// Reduce a session id to a filename-safe form: `[A-Za-z0-9._-]` pass through,
32 /// anything else becomes `_`. An empty id maps to `_` so the file name never
33 /// collapses to just the extension.
34 fn sanitize_id(session_id: &str) -> String {
35 if session_id.is_empty() {
36 return "_".to_string();
37 }
38 session_id
39 .chars()
40 .map(|c| {
41 if c.is_ascii_alphanumeric() || matches!(c, '.' | '_' | '-') {
42 c
43 } else {
44 '_'
45 }
46 })
47 .collect()
48 }
49
50 fn session_path(session_id: &str) -> Option<PathBuf> {
51 sessions_dir().map(|dir| dir.join(format!("{}.jsonl", sanitize_id(session_id))))
52 }
53
54 /// `<sessions_dir>/<id>.meta.json`. Note this does not collide with the
55 /// `list()` extension filter (`== Some("jsonl")`): the extension of
56 /// `"a.meta.json"` is `json`, not `jsonl`.
57 fn meta_path(session_id: &str) -> Option<PathBuf> {
58 sessions_dir().map(|dir| dir.join(format!("{}.meta.json", sanitize_id(session_id))))
59 }
60
61 /// ACP-facing metadata for a saved session, kept alongside its transcript so
62 /// `session/list` can be served without touching the model backend.
63 #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64 pub struct SessionMeta {
65 pub cwd: PathBuf,
66 #[serde(default, skip_serializing_if = "Vec::is_empty")]
67 pub additional_directories: Vec<PathBuf>,
68 #[serde(default)]
69 pub title: Option<String>,
70 }
71
72 /// Persist `meta` for `session_id`, replacing any previous sidecar. Same
73 /// atomic temp-file + rename pattern as [`save`].
74 pub fn save_meta(session_id: &str, meta: &SessionMeta) -> Result<(), String> {
75 let path =
76 meta_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
77 let dir = path
78 .parent()
79 .ok_or_else(|| "session meta path has no parent".to_string())?;
80 std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
81
82 let body = serde_json::to_string(meta).map_err(|error| format!("serialize meta: {error}"))?;
83
84 let tmp = dir.join(format!(
85 ".{}.{}.tmp",
86 sanitize_id(session_id),
87 std::process::id()
88 ));
89 std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
90 std::fs::rename(&tmp, &path).map_err(|error| {
91 let _ = std::fs::remove_file(&tmp);
92 format!("rename {tmp:?} -> {path:?}: {error}")
93 })?;
94 Ok(())
95 }
96
97 /// Load the saved metadata for `session_id`, or `None` when no sidecar exists
98 /// (or it cannot be parsed) — matching how [`load`] tolerates junk.
99 pub fn load_meta(session_id: &str) -> Option<SessionMeta> {
100 let path = meta_path(session_id)?;
101 let contents = std::fs::read_to_string(&path).ok()?;
102 serde_json::from_str(&contents).ok()
103 }
104
105 /// Persist a history snapshot for `session_id`, replacing any previous save.
106 /// The write is atomic: a temp file in the same directory is renamed over the
107 /// final path.
108 pub fn save(session_id: &str, history: &[Value]) -> Result<(), String> {
109 let path =
110 session_path(session_id).ok_or_else(|| "cannot resolve config directory".to_string())?;
111 let dir = path
112 .parent()
113 .ok_or_else(|| "session path has no parent".to_string())?;
114 std::fs::create_dir_all(dir).map_err(|error| format!("create {dir:?}: {error}"))?;
115
116 let mut body = String::new();
117 for message in history {
118 body.push_str(&message.to_string());
119 body.push('\n');
120 }
121
122 // Unique temp name so two processes saving the same session can't clobber
123 // each other's half-written file; rename is atomic on the same filesystem.
124 let tmp = dir.join(format!(
125 ".{}.{}.tmp",
126 sanitize_id(session_id),
127 std::process::id()
128 ));
129 std::fs::write(&tmp, body).map_err(|error| format!("write {tmp:?}: {error}"))?;
130 std::fs::rename(&tmp, &path).map_err(|error| {
131 let _ = std::fs::remove_file(&tmp);
132 format!("rename {tmp:?} -> {path:?}: {error}")
133 })?;
134 Ok(())
135 }
136
137 /// Load the saved history for `session_id`, or `None` when no save exists (or
138 /// it cannot be read). Unparseable lines are skipped rather than failing the
139 /// whole restore.
140 pub fn load(session_id: &str) -> Option<Vec<Value>> {
141 let path = session_path(session_id)?;
142 let contents = std::fs::read_to_string(&path).ok()?;
143 Some(
144 contents
145 .lines()
146 .filter(|line| !line.trim().is_empty())
147 .filter_map(|line| serde_json::from_str::<Value>(line).ok())
148 .collect(),
149 )
150 }
151
152 /// Remove the saved history for `session_id`, along with its metadata
153 /// sidecar. Missing files are fine.
154 pub fn delete(session_id: &str) {
155 if let Some(path) = session_path(session_id) {
156 let _ = std::fs::remove_file(path);
157 }
158 if let Some(path) = meta_path(session_id) {
159 let _ = std::fs::remove_file(path);
160 }
161 }
162
163 /// One saved session as seen on disk.
164 ///
165 /// Cross-platform: consumed by the Unix-only TUI (`chat.rs` History tab) and
166 /// by the ACP `session/list` handler (`main.rs`) on every platform.
167 #[derive(Debug, Clone, PartialEq, Eq)]
168 pub struct SessionEntry {
169 /// The sanitized id (the file stem), which `load`/`delete` accept as-is.
170 pub id: String,
171 /// Last-modified time of the session file; `UNIX_EPOCH` when unreadable.
172 pub modified: std::time::SystemTime,
173 /// Number of history messages (non-empty lines) in the file.
174 pub message_count: usize,
175 /// The metadata sidecar, when one was recorded. `None` for sessions saved
176 /// before this existed, or for the singleton `tui`/`headless` entries.
177 pub meta: Option<SessionMeta>,
178 }
179
180 /// List the saved sessions, newest first. Non-`.jsonl` entries (temp files,
181 /// stray junk) are skipped. A missing or unreadable sessions dir yields an
182 /// empty list.
183 pub fn list() -> Vec<SessionEntry> {
184 let Some(dir) = sessions_dir() else {
185 return Vec::new();
186 };
187 let Ok(entries) = std::fs::read_dir(&dir) else {
188 return Vec::new();
189 };
190 let mut sessions: Vec<SessionEntry> = entries
191 .flatten()
192 .filter_map(|entry| {
193 let path = entry.path();
194 if !path.is_file() || path.extension().and_then(|e| e.to_str()) != Some("jsonl") {
195 return None;
196 }
197 let id = path.file_stem()?.to_str()?.to_string();
198 // Cheap line count: no JSON parsing, just non-empty lines.
199 let contents = std::fs::read_to_string(&path).ok()?;
200 let message_count = contents.lines().filter(|l| !l.trim().is_empty()).count();
201 let modified = entry
202 .metadata()
203 .ok()
204 .and_then(|m| m.modified().ok())
205 .unwrap_or(std::time::UNIX_EPOCH);
206 let meta = load_meta(&id);
207 Some(SessionEntry {
208 id,
209 modified,
210 message_count,
211 meta,
212 })
213 })
214 .collect();
215 sessions.sort_by(|a, b| b.modified.cmp(&a.modified).then_with(|| a.id.cmp(&b.id)));
216 sessions
217 }
218
219 #[cfg(test)]
220 mod tests {
221 use super::*;
222
223 #[test]
224 fn sanitize_keeps_safe_chars_and_replaces_the_rest() {
225 assert_eq!(sanitize_id("abc-DEF_123.z"), "abc-DEF_123.z");
226 assert_eq!(sanitize_id("a/b\\c:d e"), "a_b_c_d_e");
227 assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
228 assert_eq!(sanitize_id(""), "_");
229 }
230
231 // One test for the filesystem behavior because it mutates the
232 // process-global `SIGIT_CONFIG_DIR` env var (same pattern as the settings
233 // tests): splitting would race under the parallel test runner.
234 #[test]
235 fn save_load_delete_round_trip() {
236 let _guard = crate::ENV_TEST_LOCK
237 .lock()
238 .unwrap_or_else(|poisoned| poisoned.into_inner());
239 let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
240 let _ = std::fs::remove_dir_all(&dir);
241 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
242 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
243
244 // Missing file → None.
245 assert_eq!(load("nope"), None);
246
247 let history = vec![
248 serde_json::json!({ "role": "system", "content": "sys" }),
249 serde_json::json!({ "role": "user", "content": "hi\nthere" }),
250 serde_json::json!({
251 "role": "assistant", "content": null,
252 "tool_calls": [{ "id": "call_1", "type": "function",
253 "function": { "name": "read_file", "arguments": "{}" } }],
254 }),
255 ];
256 save("sess-1", &history).unwrap();
257 assert_eq!(load("sess-1"), Some(history.clone()));
258
259 // Saving again replaces, not appends.
260 let shorter = vec![serde_json::json!({ "role": "user", "content": "only" })];
261 save("sess-1", &shorter).unwrap();
262 assert_eq!(load("sess-1"), Some(shorter));
263
264 // A hostile id stays inside the sessions dir via sanitization.
265 save("../escape", &history).unwrap();
266 assert!(dir.join("sessions").join(".._escape.jsonl").is_file());
267 assert_eq!(load("../escape"), Some(history));
268 delete("../escape");
269 assert_eq!(load("../escape"), None);
270
271 delete("sess-1");
272 assert_eq!(load("sess-1"), None);
273 // Deleting a missing session is a no-op.
274 delete("sess-1");
275
276 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
277 let _ = std::fs::remove_dir_all(&dir);
278 }
279
280 // Same single-test-per-env-var pattern as `save_load_delete_round_trip`:
281 // this mutates `SIGIT_CONFIG_DIR`, so everything runs under one lock hold.
282 #[test]
283 fn list_returns_sessions_newest_first_and_skips_junk() {
284 let _guard = crate::ENV_TEST_LOCK
285 .lock()
286 .unwrap_or_else(|poisoned| poisoned.into_inner());
287 let dir = std::env::temp_dir().join(format!("sigit_sessions_list_{}", std::process::id()));
288 let _ = std::fs::remove_dir_all(&dir);
289 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
290 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
291
292 // No sessions dir at all → empty list, not an error.
293 assert!(list().is_empty());
294
295 let msg = |text: &str| serde_json::json!({ "role": "user", "content": text });
296 save("older", &[msg("a"), msg("b"), msg("c")]).unwrap();
297 // Distinct mtimes so the newest-first order is deterministic.
298 std::thread::sleep(std::time::Duration::from_millis(50));
299 save("newer", &[msg("x")]).unwrap();
300
301 // Junk the lister must skip: wrong extension, a stray temp file, and a
302 // subdirectory.
303 let sessions = dir.join("sessions");
304 std::fs::write(sessions.join("notes.txt"), "not a session\n").unwrap();
305 std::fs::write(sessions.join(".older.999.tmp"), "half-written\n").unwrap();
306 std::fs::create_dir_all(sessions.join("nested.jsonl")).unwrap();
307
308 let listed = list();
309 assert_eq!(listed.len(), 2);
310 assert_eq!(listed[0].id, "newer");
311 assert_eq!(listed[0].message_count, 1);
312 assert!(
313 listed[0].meta.is_none(),
314 "no sidecar was written for either session"
315 );
316 assert_eq!(listed[1].id, "older");
317 assert_eq!(listed[1].message_count, 3);
318 assert!(listed[0].modified >= listed[1].modified);
319
320 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
321 let _ = std::fs::remove_dir_all(&dir);
322 }
323
324 // Same single-test-per-env-var pattern as the tests above.
325 #[test]
326 fn meta_round_trips_and_is_removed_with_the_session() {
327 let _guard = crate::ENV_TEST_LOCK
328 .lock()
329 .unwrap_or_else(|poisoned| poisoned.into_inner());
330 let dir = std::env::temp_dir().join(format!("sigit_sessions_meta_{}", std::process::id()));
331 let _ = std::fs::remove_dir_all(&dir);
332 // SAFETY: serialized by ENV_TEST_LOCK; restored below.
333 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
334
335 // Missing sidecar → None.
336 assert_eq!(load_meta("sess-1"), None);
337
338 let meta = SessionMeta {
339 cwd: PathBuf::from("/tmp/project"),
340 additional_directories: vec![PathBuf::from("/tmp/other-root")],
341 title: Some("what do the notes say?".to_string()),
342 };
343 save(
344 "sess-1",
345 &[serde_json::json!({ "role": "user", "content": "hi" })],
346 )
347 .unwrap();
348 save_meta("sess-1", &meta).unwrap();
349 assert_eq!(load_meta("sess-1"), Some(meta.clone()));
350
351 // A sidecar with no title round-trips too.
352 let untitled = SessionMeta {
353 cwd: PathBuf::from("/tmp/project"),
354 additional_directories: vec![],
355 title: None,
356 };
357 save_meta("sess-1", &untitled).unwrap();
358 assert_eq!(load_meta("sess-1"), Some(untitled));
359
360 // list() surfaces the sidecar for a session that has one.
361 let listed = list();
362 assert_eq!(listed.len(), 1);
363 assert_eq!(
364 listed[0].meta.as_ref().map(|m| &m.cwd),
365 Some(&PathBuf::from("/tmp/project"))
366 );
367
368 // delete() removes both the transcript and the sidecar.
369 delete("sess-1");
370 assert_eq!(load("sess-1"), None);
371 assert_eq!(load_meta("sess-1"), None);
372 assert!(list().is_empty());
373
374 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
375 let _ = std::fs::remove_dir_all(&dir);
376 }
377 }