use std::fs::{self, File, OpenOptions};
use std::io::{BufRead, BufReader, Write};
use std::path::{Path, PathBuf};
use crate::error::{FmecaError, Result};
use crate::event::Event;
pub trait StateStore: Send + Sync {
fn append(&self, session_id: &str, event: &Event) -> Result<()>;
fn replay(&self, session_id: &str) -> Result<Vec<Event>>;
fn exists(&self, session_id: &str) -> Result<bool>;
fn list_sessions(&self) -> Result<Vec<String>> {
Err(FmecaError::StoreError("listing unsupported".into()))
}
}
pub fn validate_session_id(session_id: &str) -> Result<()> {
if session_id.is_empty() {
return Err(FmecaError::BadSessionId("empty session id".into()));
}
let has_illegal_char = session_id
.chars()
.any(|c| c == '/' || c == '\\' || c.is_control());
if has_illegal_char || session_id.contains("..") || session_id.starts_with('.') {
return Err(FmecaError::BadSessionId(format!(
"illegal characters in session id '{session_id}'"
)));
}
Ok(())
}
#[derive(Debug, Clone)]
pub struct FilesystemStore {
state_dir: PathBuf,
}
impl FilesystemStore {
pub fn new(state_dir: impl Into<PathBuf>) -> Result<Self> {
let state_dir = state_dir.into();
fs::create_dir_all(&state_dir)
.map_err(|e| FmecaError::StoreError(format!("create_dir_all {state_dir:?}: {e}")))?;
Ok(Self { state_dir })
}
fn path_for(&self, session_id: &str) -> Result<PathBuf> {
validate_session_id(session_id)?;
Ok(self.state_dir.join(format!("{session_id}.jsonl")))
}
pub fn state_dir(&self) -> &Path {
&self.state_dir
}
}
impl StateStore for FilesystemStore {
fn append(&self, session_id: &str, event: &Event) -> Result<()> {
let path = self.path_for(session_id)?;
let line = serde_json::to_string(event)
.map_err(|e| FmecaError::StoreError(format!("serialize event: {e}")))?;
let mut file = OpenOptions::new()
.create(true)
.append(true)
.open(&path)
.map_err(|e| FmecaError::StoreError(format!("open {path:?}: {e}")))?;
writeln!(file, "{line}")
.map_err(|e| FmecaError::StoreError(format!("write {path:?}: {e}")))?;
file.flush()
.map_err(|e| FmecaError::StoreError(format!("flush {path:?}: {e}")))?;
Ok(())
}
fn replay(&self, session_id: &str) -> Result<Vec<Event>> {
let path = self.path_for(session_id)?;
if !path.exists() {
return Err(FmecaError::SessionNotFound(session_id.to_string()));
}
let file =
File::open(&path).map_err(|e| FmecaError::StoreError(format!("open {path:?}: {e}")))?;
let reader = BufReader::new(file);
let mut events = Vec::new();
for (idx, line) in reader.lines().enumerate() {
let line = line.map_err(|e| FmecaError::StoreError(format!("read line: {e}")))?;
if line.trim().is_empty() {
continue;
}
let event: Event = serde_json::from_str(&line).map_err(|e| {
FmecaError::StoreError(format!("parse {path:?} line {}: {e}", idx + 1))
})?;
events.push(event);
}
Ok(events)
}
fn exists(&self, session_id: &str) -> Result<bool> {
Ok(self.path_for(session_id)?.exists())
}
fn list_sessions(&self) -> Result<Vec<String>> {
let dir = &self.state_dir;
let entries = fs::read_dir(dir)
.map_err(|e| FmecaError::StoreError(format!("read_dir {dir:?}: {e}")))?;
let mut ids = Vec::new();
for entry in entries {
let path = entry
.map_err(|e| FmecaError::StoreError(format!("read_dir {dir:?}: {e}")))?
.path();
if !path.is_file() || path.extension().is_none_or(|ext| ext != "jsonl") {
continue;
}
if let Some(stem) = path.file_stem().and_then(|s| s.to_str())
&& validate_session_id(stem).is_ok()
{
ids.push(stem.to_string());
}
}
ids.sort();
Ok(ids)
}
}
#[cfg(test)]
mod tests {
use super::*;
fn opened(session_id: &str) -> Event {
Event::SessionOpened {
session_id: session_id.to_string(),
matrix_strategy: crate::matrix::MatrixStrategy::default(),
meta: crate::model::SessionMeta::default(),
}
}
#[test]
fn append_then_replay_round_trips() {
let dir = tempfile::tempdir().unwrap();
let store = FilesystemStore::new(dir.path()).unwrap();
let e = opened("s1");
store.append("s1", &e).unwrap();
let back = store.replay("s1").unwrap();
assert_eq!(back, vec![e]);
}
#[test]
fn replay_unknown_session_is_not_found() {
let dir = tempfile::tempdir().unwrap();
let store = FilesystemStore::new(dir.path()).unwrap();
let err = store.replay("nope").unwrap_err();
assert!(matches!(err, FmecaError::SessionNotFound(_)));
}
#[test]
fn bad_session_ids_rejected() {
for bad in ["", "../escape", "a/b", ".hidden", "with\0nul"] {
assert!(validate_session_id(bad).is_err(), "should reject {bad:?}");
}
for ok in ["s1", "session-2026", "a.b.c", "UUID_1234"] {
assert!(validate_session_id(ok).is_ok(), "should accept {ok:?}");
}
}
}