Skip to main content

synaps_cli/core/
session_index.rs

1use 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}