1use std::fs::{self, File, OpenOptions};
9use std::io::{BufRead, BufReader, Write};
10use std::path::{Path, PathBuf};
11
12use crate::error::{EngineError, Result};
13use crate::event::Event;
14
15pub trait StateStore: Send + Sync {
19 fn append(&self, session_id: &str, event: &Event) -> Result<()>;
21
22 fn replay(&self, session_id: &str) -> Result<Vec<Event>>;
25
26 fn exists(&self, session_id: &str) -> Result<bool>;
28}
29
30pub fn validate_session_id(session_id: &str) -> Result<()> {
34 if session_id.is_empty() {
35 return Err(EngineError::BadSessionId("empty session id".into()));
36 }
37 let has_illegal_char = session_id
41 .chars()
42 .any(|c| c == '/' || c == '\\' || c.is_control());
43 if has_illegal_char || session_id.contains("..") || session_id.starts_with('.') {
44 return Err(EngineError::BadSessionId(format!(
45 "illegal characters in session id '{session_id}'"
46 )));
47 }
48 Ok(())
49}
50
51#[derive(Debug, Clone)]
54pub struct FilesystemStore {
55 state_dir: PathBuf,
56}
57
58impl FilesystemStore {
59 pub fn new(state_dir: impl Into<PathBuf>) -> Result<Self> {
61 let state_dir = state_dir.into();
62 fs::create_dir_all(&state_dir)
63 .map_err(|e| EngineError::StoreError(format!("create_dir_all {state_dir:?}: {e}")))?;
64 Ok(Self { state_dir })
65 }
66
67 fn path_for(&self, session_id: &str) -> Result<PathBuf> {
69 validate_session_id(session_id)?;
70 Ok(self.state_dir.join(format!("{session_id}.jsonl")))
71 }
72
73 pub fn state_dir(&self) -> &Path {
75 &self.state_dir
76 }
77}
78
79impl StateStore for FilesystemStore {
80 fn append(&self, session_id: &str, event: &Event) -> Result<()> {
81 let path = self.path_for(session_id)?;
82 let line = serde_json::to_string(event)
83 .map_err(|e| EngineError::StoreError(format!("serialize event: {e}")))?;
84 let mut file = OpenOptions::new()
85 .create(true)
86 .append(true)
87 .open(&path)
88 .map_err(|e| EngineError::StoreError(format!("open {path:?}: {e}")))?;
89 writeln!(file, "{line}")
90 .map_err(|e| EngineError::StoreError(format!("write {path:?}: {e}")))?;
91 file.flush()
92 .map_err(|e| EngineError::StoreError(format!("flush {path:?}: {e}")))?;
93 Ok(())
94 }
95
96 fn replay(&self, session_id: &str) -> Result<Vec<Event>> {
97 let path = self.path_for(session_id)?;
98 if !path.exists() {
99 return Err(EngineError::SessionNotFound(session_id.to_string()));
100 }
101 let file = File::open(&path)
102 .map_err(|e| EngineError::StoreError(format!("open {path:?}: {e}")))?;
103 let reader = BufReader::new(file);
104 let mut events = Vec::new();
105 for (idx, line) in reader.lines().enumerate() {
106 let line = line.map_err(|e| EngineError::StoreError(format!("read line: {e}")))?;
107 if line.trim().is_empty() {
108 continue;
109 }
110 let event: Event = serde_json::from_str(&line).map_err(|e| {
111 EngineError::StoreError(format!("parse {path:?} line {}: {e}", idx + 1))
112 })?;
113 events.push(event);
114 }
115 Ok(events)
116 }
117
118 fn exists(&self, session_id: &str) -> Result<bool> {
119 Ok(self.path_for(session_id)?.exists())
120 }
121}
122
123#[cfg(test)]
124mod tests {
125 use super::*;
126 use crate::session::CoverageSchema;
127
128 fn opened(session_id: &str) -> Event {
129 Event::SessionOpened {
130 session_id: session_id.to_string(),
131 coverage_schema: CoverageSchema::default(),
132 }
133 }
134
135 #[test]
136 fn append_then_replay_round_trips() {
137 let dir = tempfile::tempdir().unwrap();
138 let store = FilesystemStore::new(dir.path()).unwrap();
139 let e = opened("s1");
140 store.append("s1", &e).unwrap();
141 let back = store.replay("s1").unwrap();
142 assert_eq!(back, vec![e]);
143 }
144
145 #[test]
146 fn replay_unknown_session_is_not_found() {
147 let dir = tempfile::tempdir().unwrap();
148 let store = FilesystemStore::new(dir.path()).unwrap();
149 let err = store.replay("nope").unwrap_err();
150 assert!(matches!(err, EngineError::SessionNotFound(_)));
151 }
152
153 #[test]
154 fn bad_session_ids_rejected() {
155 for bad in ["", "../escape", "a/b", ".hidden", "with\0nul"] {
156 assert!(validate_session_id(bad).is_err(), "should reject {bad:?}");
157 }
158 for ok in ["s1", "session-2026", "a.b.c", "UUID_1234"] {
159 assert!(validate_session_id(ok).is_ok(), "should accept {ok:?}");
160 }
161 }
162}