1 //! Local persistence of ACP chat conversations.
2 //!
3 //! Editors such as Zed remember a thread's `SessionId` across restarts and call
4 //! `session/load` to reopen it. Before this module siGit cleared history on
5 //! load, so every reopened thread started blank. Here we store a compact
6 //! transcript — the user prompts and the assistant's visible replies — per
7 //! session under `$SIGIT_CONFIG_DIR/sessions/<id>.json`. On reload the
8 //! transcript is replayed to the editor and pushed back into the active backend
9 //! so the model keeps its context.
10 //!
11 //! Only finished user/assistant turns are stored: no tool-call plumbing and no
12 //! system context (that is rebuilt fresh from the cwd on every load). This keeps
13 //! the format backend-agnostic — the same file restores whether the session
14 //! resumes on-device or on a siGit Code Cloud tier.
15
16 use std::path::{Path, PathBuf};
17
18 use serde::{Deserialize, Serialize};
19
20 /// Author of a stored message.
21 #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
22 #[serde(rename_all = "lowercase")]
23 pub enum Role {
24 User,
25 Assistant,
26 }
27
28 /// One persisted turn in a conversation.
29 #[derive(Debug, Clone, Serialize, Deserialize)]
30 pub struct StoredMessage {
31 pub role: Role,
32 pub text: String,
33 }
34
35 /// A persisted conversation, keyed on disk by its ACP `SessionId`.
36 #[derive(Debug, Clone, Default, Serialize, Deserialize)]
37 pub struct StoredSession {
38 /// The session's working directory, kept for reference/debugging.
39 #[serde(default)]
40 pub cwd: Option<String>,
41 #[serde(default)]
42 pub messages: Vec<StoredMessage>,
43 }
44
45 impl StoredSession {
46 pub fn is_empty(&self) -> bool {
47 self.messages.is_empty()
48 }
49 }
50
51 /// Config directory: `$SIGIT_CONFIG_DIR` or `~/.config/sigit`. Mirrors
52 /// [`crate::settings`] and [`crate::credentials`].
53 fn config_dir() -> Option<PathBuf> {
54 if let Ok(dir) = std::env::var("SIGIT_CONFIG_DIR") {
55 return Some(PathBuf::from(dir));
56 }
57 let home = std::env::var("HOME").ok()?;
58 Some(PathBuf::from(home).join(".config/sigit"))
59 }
60
61 fn sessions_dir() -> Option<PathBuf> {
62 config_dir().map(|dir| dir.join("sessions"))
63 }
64
65 /// Reduce a `SessionId` to a single, traversal-safe file-name stem. Editors
66 /// pick the id (usually a UUID); keep `[A-Za-z0-9._-]` and map anything else to
67 /// `_` so it can never escape the sessions directory.
68 fn sanitize_id(id: &str) -> String {
69 id.chars()
70 .map(|c| {
71 if c.is_ascii_alphanumeric() || matches!(c, '-' | '_' | '.') {
72 c
73 } else {
74 '_'
75 }
76 })
77 .collect()
78 }
79
80 /// Path to the transcript file for `id`, or `None` if the id can't form a valid
81 /// file name (empty, or only dots/separators after sanitizing).
82 fn session_path(id: &str) -> Option<PathBuf> {
83 let stem = sanitize_id(id);
84 if stem.trim_matches(['.', '_', '-']).is_empty() {
85 return None;
86 }
87 sessions_dir().map(|dir| dir.join(format!("{stem}.json")))
88 }
89
90 /// Load the stored transcript for `id`, or an empty session if none exists or
91 /// the file can't be read or parsed.
92 pub fn load(id: &str) -> StoredSession {
93 let Some(path) = session_path(id) else {
94 return StoredSession::default();
95 };
96 match std::fs::read_to_string(&path) {
97 Ok(contents) => serde_json::from_str(&contents).unwrap_or_else(|error| {
98 log::warn!("sessions: ignoring unreadable {}: {error}", path.display());
99 StoredSession::default()
100 }),
101 Err(_) => StoredSession::default(),
102 }
103 }
104
105 /// Persist `session` for `id`, creating the sessions directory if needed.
106 pub fn save(id: &str, session: &StoredSession) -> Result<(), String> {
107 let path = session_path(id).ok_or_else(|| format!("invalid session id: {id:?}"))?;
108 if let Some(parent) = path.parent() {
109 std::fs::create_dir_all(parent).map_err(|error| format!("create {parent:?}: {error}"))?;
110 }
111 let body =
112 serde_json::to_string_pretty(session).map_err(|error| format!("serialize: {error}"))?;
113 std::fs::write(&path, body).map_err(|error| format!("write {path:?}: {error}"))
114 }
115
116 /// Append a completed turn to `id`'s transcript. The user text is always
117 /// recorded; the assistant reply only when non-empty. Best-effort: a write
118 /// failure is logged, never surfaced, so persistence can't break a live turn.
119 pub fn append_turn(id: &str, cwd: Option<&Path>, user_text: &str, assistant_text: &str) {
120 let mut session = load(id);
121 if session.cwd.is_none() {
122 session.cwd = cwd.map(|path| path.display().to_string());
123 }
124 session.messages.push(StoredMessage {
125 role: Role::User,
126 text: user_text.to_string(),
127 });
128 let assistant = assistant_text.trim();
129 if !assistant.is_empty() {
130 session.messages.push(StoredMessage {
131 role: Role::Assistant,
132 text: assistant.to_string(),
133 });
134 }
135 if let Err(error) = save(id, &session) {
136 log::warn!("sessions: could not persist turn for {id}: {error}");
137 }
138 }
139
140 /// Forget `id`'s transcript (used by `/clear`). A missing file is not an error.
141 pub fn clear(id: &str) {
142 if let Some(path) = session_path(id)
143 && let Err(error) = std::fs::remove_file(&path)
144 && error.kind() != std::io::ErrorKind::NotFound
145 {
146 log::warn!("sessions: could not clear {}: {error}", path.display());
147 }
148 }
149
150 /// Copy `from`'s transcript onto `to` when a session is forked, so the fork
151 /// opens with the parent's history instead of blank. Best-effort.
152 pub fn fork(from: &str, to: &str) {
153 let session = load(from);
154 if session.is_empty() {
155 return;
156 }
157 if let Err(error) = save(to, &session) {
158 log::warn!("sessions: could not fork {from} -> {to}: {error}");
159 }
160 }
161
162 #[cfg(test)]
163 mod tests {
164 use super::*;
165
166 #[test]
167 fn append_load_clear_round_trip() {
168 let _guard = crate::ENV_TEST_LOCK
169 .lock()
170 .unwrap_or_else(|poisoned| poisoned.into_inner());
171 let dir = std::env::temp_dir().join(format!("sigit_sessions_{}", std::process::id()));
172 let _ = std::fs::remove_dir_all(&dir);
173 // SAFETY: single-threaded test guarded by ENV_TEST_LOCK; restored below.
174 unsafe { std::env::set_var("SIGIT_CONFIG_DIR", &dir) };
175
176 let id = "11111111-2222-3333-4444-555555555555";
177 assert!(load(id).is_empty(), "unknown session starts empty");
178
179 append_turn(id, Some(Path::new("/tmp/project")), "hello", "hi there");
180 append_turn(id, None, "second", "");
181
182 let session = load(id);
183 assert_eq!(session.cwd.as_deref(), Some("/tmp/project"));
184 // user, assistant, user — the empty assistant reply is dropped.
185 assert_eq!(session.messages.len(), 3);
186 assert_eq!(session.messages[0].role, Role::User);
187 assert_eq!(session.messages[0].text, "hello");
188 assert_eq!(session.messages[1].role, Role::Assistant);
189 assert_eq!(session.messages[1].text, "hi there");
190 assert_eq!(session.messages[2].role, Role::User);
191 assert_eq!(session.messages[2].text, "second");
192
193 let forked = "99999999-2222-3333-4444-555555555555";
194 fork(id, forked);
195 assert_eq!(load(forked).messages.len(), 3, "fork copies the transcript");
196
197 clear(id);
198 assert!(load(id).is_empty(), "clear forgets the transcript");
199 clear(id); // clearing a missing session is a no-op
200
201 let _ = std::fs::remove_dir_all(&dir);
202 // SAFETY: single-threaded test guarded by ENV_TEST_LOCK.
203 unsafe { std::env::remove_var("SIGIT_CONFIG_DIR") };
204 }
205
206 #[test]
207 fn sanitize_id_blocks_path_traversal() {
208 // Dots are kept, but every path separator becomes `_`, so the result is
209 // always a single, non-traversing path component.
210 assert_eq!(sanitize_id("../../etc/passwd"), ".._.._etc_passwd");
211 assert_eq!(sanitize_id("a/b\\c"), "a_b_c");
212 assert_eq!(sanitize_id("uuid-1234_AB.cd"), "uuid-1234_AB.cd");
213 // Ids that sanitize to nothing usable yield no path.
214 assert!(session_path("..").is_none());
215 assert!(session_path("/").is_none());
216 }
217 }