use std::io;
use std::path::{Path, PathBuf};
use swink_agent::atomic_fs::atomic_write;
use swink_agent::{Checkpoint, CheckpointFuture, CheckpointStore};
use crate::store_async::spawn_store_call;
fn checkpoint_path(checkpoints_dir: &Path, id: &str) -> PathBuf {
checkpoints_dir.join(format!("{id}.json"))
}
fn validate_checkpoint_id(id: &str) -> io::Result<()> {
if id.is_empty() {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
"checkpoint ID must not be empty",
));
}
if id.contains('/')
|| id.contains('\\')
|| id.contains("..")
|| id.chars().any(|c| c == ':' || c.is_ascii_control())
{
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
format!("checkpoint ID contains unsafe characters: {id:?}"),
));
}
Ok(())
}
pub struct FileCheckpointStore {
checkpoints_dir: PathBuf,
max_checkpoints: Option<usize>,
}
impl FileCheckpointStore {
pub const DEFAULT_MAX_CHECKPOINTS: usize = 20;
pub fn new(checkpoints_dir: PathBuf) -> io::Result<Self> {
std::fs::create_dir_all(&checkpoints_dir)?;
Ok(Self {
checkpoints_dir,
max_checkpoints: None,
})
}
pub fn default_dir() -> Option<PathBuf> {
dirs::config_dir().map(|dir| dir.join("swink-agent").join("checkpoints"))
}
#[must_use]
pub fn with_max_checkpoints(mut self, n: usize) -> Self {
self.max_checkpoints = Some(n);
self
}
#[must_use]
pub fn unbounded(mut self) -> Self {
self.max_checkpoints = None;
self
}
}
fn prune_to(checkpoints_dir: &Path, keep: usize) {
let entries = match std::fs::read_dir(checkpoints_dir) {
Ok(entries) => entries,
Err(error) => {
tracing::warn!(error = %error, "checkpoint retention: cannot read store dir");
return;
}
};
let mut checkpoints = Vec::new();
for entry in entries.flatten() {
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let Ok(contents) = std::fs::read_to_string(&path) else {
continue;
};
if let Ok(checkpoint) = serde_json::from_str::<Checkpoint>(&contents) {
checkpoints.push((checkpoint.created_at, checkpoint.id, path));
}
}
if checkpoints.len() <= keep {
return;
}
checkpoints.sort_by(|left, right| (right.0, &right.1).cmp(&(left.0, &left.1)));
for (_, id, path) in checkpoints.drain(keep..) {
if let Err(error) = std::fs::remove_file(&path)
&& error.kind() != io::ErrorKind::NotFound
{
tracing::warn!(
checkpoint_id = %id,
path = %path.display(),
error = %error,
"checkpoint retention: failed to prune checkpoint"
);
}
}
}
impl CheckpointStore for FileCheckpointStore {
fn save_checkpoint(&self, checkpoint: Checkpoint) -> CheckpointFuture<'_, ()> {
let checkpoints_dir = self.checkpoints_dir.clone();
let max_checkpoints = self.max_checkpoints;
spawn_store_call(move || {
validate_checkpoint_id(&checkpoint.id)?;
let path = checkpoint_path(&checkpoints_dir, &checkpoint.id);
atomic_write(&path, |writer| {
serde_json::to_writer_pretty(&mut *writer, &checkpoint).map_err(io::Error::other)
})?;
if let Some(keep) = max_checkpoints {
prune_to(&checkpoints_dir, keep);
}
Ok(())
})
}
fn load_checkpoint(&self, id: &str) -> CheckpointFuture<'_, Option<Checkpoint>> {
let checkpoints_dir = self.checkpoints_dir.clone();
let id = id.to_string();
spawn_store_call(move || {
validate_checkpoint_id(&id)?;
let path = checkpoint_path(&checkpoints_dir, &id);
if !path.exists() {
return Ok(None);
}
let contents = std::fs::read_to_string(path)?;
serde_json::from_str(&contents)
.map(Some)
.map_err(io::Error::other)
})
}
fn list_checkpoints(&self) -> CheckpointFuture<'_, Vec<String>> {
let checkpoints_dir = self.checkpoints_dir.clone();
spawn_store_call(move || {
let mut checkpoints = Vec::new();
for entry in std::fs::read_dir(&checkpoints_dir)? {
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|value| value.to_str()) != Some("json") {
continue;
}
let contents = match std::fs::read_to_string(&path) {
Ok(contents) => contents,
Err(error) => {
tracing::warn!(
path = %path.display(),
error = %error,
"skipping unreadable checkpoint file"
);
continue;
}
};
match serde_json::from_str::<Checkpoint>(&contents) {
Ok(checkpoint) => checkpoints.push((checkpoint.created_at, checkpoint.id)),
Err(error) => {
tracing::warn!(
path = %path.display(),
error = %error,
"skipping invalid checkpoint file"
);
}
}
}
checkpoints.sort_by(|left, right| right.cmp(left));
Ok(checkpoints.into_iter().map(|(_, id)| id).collect())
})
}
fn delete_checkpoint(&self, id: &str) -> CheckpointFuture<'_, ()> {
let checkpoints_dir = self.checkpoints_dir.clone();
let id = id.to_string();
spawn_store_call(move || {
validate_checkpoint_id(&id)?;
let path = checkpoint_path(&checkpoints_dir, &id);
match std::fs::remove_file(path) {
Ok(()) => Ok(()),
Err(error) if error.kind() == io::ErrorKind::NotFound => Ok(()),
Err(error) => Err(error),
}
})
}
}
#[cfg(test)]
#[path = "checkpoint_store_tests.rs"]
mod tests;