use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf};
pub trait SerdeStr<Proof>: Serialize + for<'de> Deserialize<'de> {
fn de_from_str(string: &str) -> Result<Self, io::Error>
where
Self: Sized;
fn ser_to_string(&self) -> Result<String, io::Error>;
}
pub struct SerdeViaJson;
impl<T> SerdeStr<SerdeViaJson> for T
where
T: Serialize + for<'de> Deserialize<'de>,
{
fn de_from_str(string: &str) -> Result<Self, io::Error> {
Ok(serde_json::from_str(string)?)
}
fn ser_to_string(&self) -> Result<String, io::Error> {
Ok(serde_json::to_string(self)?)
}
}
pub struct SerdeViaToml;
impl<T> SerdeStr<SerdeViaToml> for T
where
T: Serialize + for<'de> Deserialize<'de>,
{
fn de_from_str(string: &str) -> Result<Self, io::Error> {
Ok(toml::from_str(string).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?)
}
fn ser_to_string(&self) -> Result<String, io::Error> {
Ok(toml::to_string(self).map_err(|e| io::Error::new(io::ErrorKind::InvalidData, e))?)
}
}
pub struct FileIO<T, S = ()> {
_content: std::marker::PhantomData<T>,
_serde: std::marker::PhantomData<S>,
pub path: PathBuf,
}
impl<T, S> FileIO<T, S>
where
T: SerdeStr<S>,
{
pub fn new(path: PathBuf) -> Self {
Self {
_content: std::marker::PhantomData,
_serde: std::marker::PhantomData,
path,
}
}
fn ensure_parent(&self) -> io::Result<()> {
let parent = self.path.parent().ok_or_else(|| {
io::Error::new(
io::ErrorKind::InvalidInput,
"The path has no parent directory",
)
})?;
fs::create_dir_all(parent)?;
Ok(())
}
pub fn load_str(&self) -> io::Result<T> {
self.ensure_parent()?;
let string = fs::read_to_string(&self.path.canonicalize()?)?;
let conf = SerdeStr::de_from_str(&string)?;
Ok(conf)
}
pub fn save(&self, conf: &T) -> io::Result<()> {
self.ensure_parent()?;
if self.path.exists() {
let mut ext = self
.path
.extension()
.unwrap_or_default()
.to_owned()
.into_string()
.unwrap_or_default();
ext += ".";
ext += &time::OffsetDateTime::now_utc().unix_timestamp().to_string();
ext += ".bak";
let backup_path = self.path.with_extension(ext);
fs::rename(&self.path, &backup_path)?;
}
let s = SerdeStr::ser_to_string(conf)?;
fs::write(&self.path, s)?;
Ok(())
}
pub fn load_or_init(&self, init: impl Fn() -> T) -> io::Result<T> {
match self.load_str() {
Ok(conf) => Ok(conf),
Err(_) => {
let conf = init();
self.save(&conf)?;
Ok(conf)
}
}
}
pub fn edit(&self) -> io::Result<()> {
let editor = std::env::var("EDITOR")
.map_err(|_| io::Error::new(io::ErrorKind::NotFound, "$EDITOR envvar not set"))?;
let status = std::process::Command::new(editor)
.arg(&self.path)
.status()?;
if !status.success() {
Err(io::Error::new(
io::ErrorKind::Other,
"failed to edit config file and exit gracefully",
))?;
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
struct Conf {
pub name: String,
}
impl SerdeStr<()> for Conf {
fn de_from_str(string: &str) -> Result<Self, io::Error> {
let json = serde_json::from_str(&string)?;
Ok(json)
}
fn ser_to_string(&self) -> Result<String, io::Error> {
let s = serde_json::to_string(self)?;
Ok(s)
}
}
#[test]
fn file_io_str() {
let path = PathBuf::from("test_file_io_str.json");
let conf = Conf {
name: "test".to_string(),
};
let s = serde_json::to_string(&conf).unwrap();
fs::write(&path, s).unwrap();
let file_io = FileIO::<Conf>::new(path.clone());
let loaded_conf = file_io.load_str();
fs::remove_file(&path).unwrap();
assert_eq!(loaded_conf.unwrap(), conf);
}
#[test]
fn file_io_save() {
let path = PathBuf::from("test_file_io_save.json");
let conf = Conf {
name: "test".to_string(),
};
let file_io = FileIO::<Conf>::new(path.clone());
file_io.save(&conf).unwrap();
let loaded_conf = file_io.load_str();
fs::remove_file(&path).unwrap();
assert_eq!(loaded_conf.unwrap(), conf);
}
}