use serde::{Deserialize, Serialize};
use std::fs;
use std::io::{self, BufReader, BufWriter};
use std::path::{Path, PathBuf};
use tracing::{debug, info, warn};
pub const DEFAULT_STATE_PATH: &str = "/app/state/data.json";
pub const STATE_PATH_ENV_VAR: &str = "RUDOF_MCP_STATE_PATH";
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct PersistedState {
pub version: u32,
#[serde(default)]
pub rdf_data_ntriples: Option<String>,
#[serde(default)]
pub last_saved: Option<String>,
#[serde(default)]
pub triple_count: Option<usize>,
}
impl PersistedState {
pub const CURRENT_VERSION: u32 = 1;
#[allow(dead_code)]
pub fn new() -> Self {
Self {
version: Self::CURRENT_VERSION,
rdf_data_ntriples: None,
last_saved: None,
triple_count: None,
}
}
pub fn with_rdf_data(rdf_data_ntriples: String, triple_count: usize) -> Self {
Self {
version: Self::CURRENT_VERSION,
rdf_data_ntriples: Some(rdf_data_ntriples),
last_saved: Some(chrono::Utc::now().to_rfc3339()),
triple_count: Some(triple_count),
}
}
#[allow(dead_code)]
pub fn has_rdf_data(&self) -> bool {
self.rdf_data_ntriples.as_ref().is_some_and(|s| !s.is_empty())
}
}
pub fn get_state_path() -> PathBuf {
std::env::var(STATE_PATH_ENV_VAR)
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from(DEFAULT_STATE_PATH))
}
pub fn is_persistence_available() -> bool {
let state_path = get_state_path();
if let Some(parent) = state_path.parent() {
parent.exists() && parent.is_dir()
} else {
false
}
}
pub fn load_state() -> Option<PersistedState> {
let state_path = get_state_path();
if !state_path.exists() {
debug!("State file not found at {:?}, starting with empty state", state_path);
return None;
}
match load_state_from_path(&state_path) {
Ok(state) => {
info!(
"Loaded persisted state from {:?} (version: {}, triples: {:?}, last_saved: {:?})",
state_path, state.version, state.triple_count, state.last_saved
);
Some(state)
},
Err(e) => {
warn!(
"Failed to load state from {:?}: {}. Starting with empty state.",
state_path, e
);
None
},
}
}
pub fn load_state_from_path(path: &Path) -> io::Result<PersistedState> {
let file = fs::File::open(path)?;
let reader = BufReader::new(file);
let state: PersistedState =
serde_json::from_reader(reader).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?;
Ok(state)
}
pub fn save_state(state: &PersistedState) -> io::Result<()> {
let state_path = get_state_path();
save_state_to_path(state, &state_path)
}
pub fn save_state_to_path(state: &PersistedState, path: &Path) -> io::Result<()> {
if let Some(parent) = path.parent()
&& !parent.exists()
{
fs::create_dir_all(parent)?;
}
let file = fs::File::create(path)?;
let writer = BufWriter::new(file);
serde_json::to_writer_pretty(writer, state).map_err(io::Error::other)?;
info!("Saved state to {:?} (triples: {:?})", path, state.triple_count);
Ok(())
}
#[derive(Debug)]
pub enum StatePersistenceError {
Io(io::Error),
Json(String),
RdfSerialization(String),
RdfParse(String),
}
impl std::fmt::Display for StatePersistenceError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::Io(e) => write!(f, "IO error: {}", e),
Self::Json(e) => write!(f, "JSON error: {}", e),
Self::RdfSerialization(e) => write!(f, "RDF serialization error: {}", e),
Self::RdfParse(e) => write!(f, "RDF parse error: {}", e),
}
}
}
impl std::error::Error for StatePersistenceError {}
impl From<io::Error> for StatePersistenceError {
fn from(e: io::Error) -> Self {
Self::Io(e)
}
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::NamedTempFile;
#[test]
fn test_persisted_state_new() {
let state = PersistedState::new();
assert_eq!(state.version, PersistedState::CURRENT_VERSION);
assert!(state.rdf_data_ntriples.is_none());
assert!(!state.has_rdf_data());
}
#[test]
fn test_persisted_state_with_rdf_data() {
let rdf = "<http://example.org/s> <http://example.org/p> <http://example.org/o> .\n";
let state = PersistedState::with_rdf_data(rdf.to_string(), 1);
assert!(state.has_rdf_data());
assert_eq!(state.triple_count, Some(1));
assert!(state.last_saved.is_some());
}
#[test]
fn test_save_and_load_state() {
let temp_file = NamedTempFile::new().unwrap();
let path = temp_file.path().to_path_buf();
let rdf = "<http://example.org/s> <http://example.org/p> <http://example.org/o> .\n";
let state = PersistedState::with_rdf_data(rdf.to_string(), 1);
save_state_to_path(&state, &path).unwrap();
let loaded = load_state_from_path(&path).unwrap();
assert_eq!(loaded.version, state.version);
assert_eq!(loaded.rdf_data_ntriples, state.rdf_data_ntriples);
assert_eq!(loaded.triple_count, state.triple_count);
}
#[test]
fn test_load_nonexistent_file() {
let path = PathBuf::from("/nonexistent/path/state.json");
let result = load_state_from_path(&path);
assert!(result.is_err());
}
}