synaps_cli/core/
session_index.rs1use chrono::{DateTime, Utc};
2use serde::{Deserialize, Serialize};
3use std::path::PathBuf;
4
5#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
6#[serde(rename_all = "snake_case")]
7pub enum SessionIndexEventKind {
8 Start,
9 End,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
13pub struct SessionIndexRecord {
14 pub schema_version: u8,
15 pub session_id: String,
16 pub event: SessionIndexEventKind,
17 pub timestamp: DateTime<Utc>,
18
19 #[serde(skip_serializing_if = "Option::is_none")]
20 pub cwd: Option<PathBuf>,
21 #[serde(skip_serializing_if = "Option::is_none")]
22 pub profile: Option<String>,
23 #[serde(skip_serializing_if = "Option::is_none")]
24 pub model: Option<String>,
25 #[serde(skip_serializing_if = "Option::is_none")]
26 pub duration_ms: Option<u64>,
27 #[serde(skip_serializing_if = "Option::is_none")]
28 pub turns: Option<usize>,
29 #[serde(default, skip_serializing_if = "Vec::is_empty")]
30 pub tags: Vec<String>,
31 #[serde(skip_serializing_if = "Option::is_none")]
32 pub note: Option<String>,
33}
34
35impl SessionIndexRecord {
36 pub fn start(session_id: impl Into<String>) -> Self {
37 Self::new(session_id, SessionIndexEventKind::Start)
38 }
39
40 pub fn end(session_id: impl Into<String>) -> Self {
41 Self::new(session_id, SessionIndexEventKind::End)
42 }
43
44 fn new(session_id: impl Into<String>, event: SessionIndexEventKind) -> Self {
45 Self {
46 schema_version: 1,
47 session_id: session_id.into(),
48 event,
49 timestamp: Utc::now(),
50 cwd: None,
51 profile: None,
52 model: None,
53 duration_ms: None,
54 turns: None,
55 tags: Vec::new(),
56 note: None,
57 }
58 }
59}
60
61pub fn index_path() -> PathBuf {
62 crate::core::config::base_dir().join("sessions").join("index.jsonl")
63}
64
65pub fn append_record(record: &SessionIndexRecord) -> crate::Result<()> {
66 append_record_to_path(&index_path(), record)
67}
68
69pub fn read_recent(limit: usize) -> crate::Result<Vec<SessionIndexRecord>> {
70 read_recent_from_path(&index_path(), limit)
71}
72
73fn append_record_to_path(path: &std::path::Path, record: &SessionIndexRecord) -> crate::Result<()> {
74 if let Some(parent) = path.parent() {
75 std::fs::create_dir_all(parent)
76 .map_err(|err| crate::core::error::RuntimeError::Session(format!("create session index directory: {err}")))?;
77 }
78
79 let mut file = std::fs::OpenOptions::new()
80 .create(true)
81 .append(true)
82 .open(path)
83 .map_err(|err| crate::core::error::RuntimeError::Session(format!("open session index: {err}")))?;
84 let mut line = serde_json::to_string(record)
85 .map_err(|err| crate::core::error::RuntimeError::Session(format!("serialize session index record: {err}")))?;
86 line.push('\n');
87 use std::io::Write;
88 file.write_all(line.as_bytes())
89 .map_err(|err| crate::core::error::RuntimeError::Session(format!("write session index record: {err}")))?;
90 Ok(())
91}
92
93fn read_recent_from_path(path: &std::path::Path, limit: usize) -> crate::Result<Vec<SessionIndexRecord>> {
94 if limit == 0 || !path.exists() {
95 return Ok(Vec::new());
96 }
97
98 let contents = std::fs::read_to_string(path)
99 .map_err(|err| crate::core::error::RuntimeError::Session(format!("read session index: {err}")))?;
100 let mut records = Vec::new();
101 for line in contents.lines().rev().take(limit) {
102 if line.trim().is_empty() {
103 continue;
104 }
105 match serde_json::from_str::<SessionIndexRecord>(line) {
106 Ok(record) => records.push(record),
107 Err(err) => {
108 tracing::warn!("skipping malformed session index line: {err}");
109 continue;
110 }
111 }
112 }
113 records.reverse();
114 Ok(records)
115}
116
117#[cfg(test)]
118mod tests {
119 use super::*;
120 use serial_test::serial;
121 use serde_json::Value;
122 use std::sync::Mutex;
123
124 static ENV_LOCK: Mutex<()> = Mutex::new(());
125
126 struct EnvGuard {
127 old_base_dir: Option<String>,
128 }
129
130 impl EnvGuard {
131 fn set_base_dir(path: &std::path::Path) -> Self {
132 let old_base_dir = std::env::var("SYNAPS_BASE_DIR").ok();
133 std::env::set_var("SYNAPS_BASE_DIR", path);
134 Self { old_base_dir }
135 }
136 }
137
138 impl Drop for EnvGuard {
139 fn drop(&mut self) {
140 if let Some(value) = self.old_base_dir.take() {
141 std::env::set_var("SYNAPS_BASE_DIR", value);
142 } else {
143 std::env::remove_var("SYNAPS_BASE_DIR");
144 }
145 }
146 }
147
148 fn temp_base_dir(test_name: &str) -> PathBuf {
149 std::env::temp_dir().join(format!(
150 "synaps-session-index-{test_name}-{}",
151 uuid::Uuid::new_v4()
152 ))
153 }
154
155 #[test]
156 #[serial]
157 fn append_record_creates_jsonl_under_base_dir() {
158 let _lock = ENV_LOCK.lock().unwrap();
159 let base = temp_base_dir("creates-jsonl");
160 let _guard = EnvGuard::set_base_dir(&base);
161
162 let record = SessionIndexRecord::start("sess-1");
163 append_record(&record).unwrap();
164
165 let path = base.join("sessions").join("index.jsonl");
166 assert!(path.exists());
167 let contents = std::fs::read_to_string(path).unwrap();
168 let line: Value = serde_json::from_str(contents.trim()).unwrap();
169 assert_eq!(line["schema_version"], 1);
170 assert_eq!(line["session_id"], "sess-1");
171 assert_eq!(line["event"], "start");
172 assert!(line.get("timestamp").is_some());
173 assert!(line.get("cwd").is_none());
174 }
175
176 #[test]
177 #[serial]
178 fn append_start_and_end_are_valid_json_lines() {
179 let _lock = ENV_LOCK.lock().unwrap();
180 let base = temp_base_dir("start-end-lines");
181 let _guard = EnvGuard::set_base_dir(&base);
182
183 append_record(&SessionIndexRecord::start("sess-1")).unwrap();
184 append_record(&SessionIndexRecord::end("sess-1")).unwrap();
185
186 let contents = std::fs::read_to_string(index_path()).unwrap();
187 let lines: Vec<&str> = contents.lines().collect();
188 assert_eq!(lines.len(), 2);
189 assert_eq!(serde_json::from_str::<Value>(lines[0]).unwrap()["event"], "start");
190 assert_eq!(serde_json::from_str::<Value>(lines[1]).unwrap()["event"], "end");
191 }
192
193 #[test]
194 #[serial]
195 fn read_recent_returns_newest_records_in_chronological_order() {
196 let _lock = ENV_LOCK.lock().unwrap();
197 let base = temp_base_dir("read-recent");
198 let _guard = EnvGuard::set_base_dir(&base);
199
200 append_record(&SessionIndexRecord::start("sess-1")).unwrap();
201 append_record(&SessionIndexRecord::start("sess-2")).unwrap();
202 append_record(&SessionIndexRecord::end("sess-2")).unwrap();
203
204 let records = read_recent(2).unwrap();
205 assert_eq!(records.len(), 2);
206 assert_eq!(records[0].session_id, "sess-2");
207 assert_eq!(records[0].event, SessionIndexEventKind::Start);
208 assert_eq!(records[1].session_id, "sess-2");
209 assert_eq!(records[1].event, SessionIndexEventKind::End);
210 }
211
212 #[test]
213 #[serial]
214 fn read_recent_missing_index_returns_empty() {
215 let _lock = ENV_LOCK.lock().unwrap();
216 let base = temp_base_dir("missing-index");
217 let _guard = EnvGuard::set_base_dir(&base);
218
219 assert!(read_recent(10).unwrap().is_empty());
220 }
221
222 #[test]
223 #[serial]
224 fn read_recent_limit_zero_returns_empty() {
225 let _lock = ENV_LOCK.lock().unwrap();
226 let base = temp_base_dir("limit-zero");
227 let _guard = EnvGuard::set_base_dir(&base);
228
229 append_record(&SessionIndexRecord::start("sess-1")).unwrap();
230
231 assert!(read_recent(0).unwrap().is_empty());
232 }
233}