use serde::{Deserialize, Serialize};
use std::error::Error;
use std::io;
use std::time::SystemTime;
use std::path::{Path, PathBuf};
use std::collections::BTreeMap;
#[derive(Serialize, Deserialize)]
#[serde(tag = "version")]
pub(crate) enum FilesStateVersioned {
#[serde(rename = "1")]
V1(FilesState),
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct FilesState {
pub files: BTreeMap<PathBuf, FileState>,
}
#[derive(Debug, PartialEq, Deserialize, Serialize)]
pub struct FileState {
pub modified: SystemTime,
pub source: PathBuf,
}
#[derive(Debug, thiserror::Error)]
#[non_exhaustive]
pub enum StateError {
#[error("IO error")]
IoError(#[from] io::Error),
#[error("error loading file state database {0}")]
LoadFail(PathBuf, Box<dyn Error + Send + Sync>),
#[error("unable to write file state database {0}")]
SaveFail(PathBuf, serde_json::Error),
}
impl FilesState {
pub const EMPTY: FilesState = FilesState {
files: BTreeMap::new(),
};
pub fn load<P: AsRef<Path>>(path: P) -> Result<FilesState, StateError> {
let path = path.as_ref();
let file = std::fs::File::open(path)
.map_err(|e| StateError::LoadFail(path.to_path_buf(), Box::new(e)))?;
let reader = io::BufReader::new(file);
let result: FilesStateVersioned = serde_json::from_reader(reader)
.map_err(|e| StateError::LoadFail(path.to_path_buf(), Box::new(e)))?;
match result {
FilesStateVersioned::V1(r) => Ok(r),
}
}
pub fn save<P: AsRef<Path>>(self, path: P) -> Result<(), StateError> {
let path = path.as_ref();
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let file = std::fs::File::create(path)?;
let writer = io::BufWriter::new(file);
let state = FilesStateVersioned::V1(self);
serde_json::to_writer_pretty(writer, &state)
.map_err(|e| StateError::SaveFail(path.to_path_buf(), e))?;
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use assert_fs::prelude::{FileWriteStr, PathAssert, PathChild};
#[test]
fn load_from_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let state_file = dir.child("state.json");
state_file.write_str(
r#"
{
"version": "1",
"files": {
"/path/to/file": {
"source": "/path/to/source",
"modified": {
"secs_since_epoch": 0,
"nanos_since_epoch": 0
}
}
}
}
"#,
)?;
let state = FilesState::load(state_file)?;
let expected = FilesState {
files: BTreeMap::from([(
PathBuf::from("/path/to/file"),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: PathBuf::from("/path/to/source"),
},
)]),
};
assert_eq!(state, expected);
dir.close()?;
Ok(())
}
#[test]
fn fail_load_from_missing_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let state_file = dir.child("missing-state.json");
let result = FilesState::load(state_file);
assert!(matches!(result, Err(StateError::LoadFail(_, _))));
dir.close()?;
Ok(())
}
#[test]
fn fail_load_from_invalid_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let state_file = dir.child("invalid-state.json");
state_file.write_str("bad json")?;
let result = FilesState::load(state_file);
assert!(matches!(result, Err(StateError::LoadFail(_, _))));
dir.close()?;
Ok(())
}
#[test]
fn save_to_json() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let state_file = dir.child("state.json");
let state = FilesState {
files: BTreeMap::from([(
PathBuf::from("/path/to/file"),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: PathBuf::from("/path/to/source"),
},
)]),
};
state.save(&state_file)?;
state_file.assert(
r#"{
"version": "1",
"files": {
"/path/to/file": {
"modified": {
"secs_since_epoch": 0,
"nanos_since_epoch": 0
},
"source": "/path/to/source"
}
}
}"#,
);
dir.close()?;
Ok(())
}
#[test]
fn save_to_json_missing_parent_dir() -> anyhow::Result<()> {
let dir = assert_fs::TempDir::new()?;
let state_file = dir.child("parent/state.json");
let state = FilesState {
files: BTreeMap::from([(
PathBuf::from("/path/to/file"),
FileState {
modified: SystemTime::UNIX_EPOCH,
source: PathBuf::from("/path/to/source"),
},
)]),
};
state.save(&state_file)?;
state_file.assert(
r#"{
"version": "1",
"files": {
"/path/to/file": {
"modified": {
"secs_since_epoch": 0,
"nanos_since_epoch": 0
},
"source": "/path/to/source"
}
}
}"#,
);
dir.close()?;
Ok(())
}
}