sculptor 0.0.2

(Placeholder of) an app framework that uses configuration files.
Documentation
use directories::ProjectDirs;
use once_cell::sync::Lazy;
use serde::{Deserialize, Serialize};
use std::{fs, io, path::PathBuf};

/// Implement this trait to get ProjectInfo, which provides directories for the project
pub trait AppAuthor {
    fn app_name() -> &'static str;
    fn author() -> &'static str;
}

pub trait LazyProjectDirs {
    fn lazy_project_dirs() -> Lazy<ProjectDirs>;
}

impl<T: AppAuthor> LazyProjectDirs for T {
    fn lazy_project_dirs() -> Lazy<ProjectDirs> {
        Lazy::new(|| {
            ProjectDirs::from("", Self::author(), Self::app_name())
                .expect("No valid config directory fomulated")
        })
    }
}

/// Provides directories for the project
pub trait ProjectInfo: LazyProjectDirs {
    fn project_dirs() -> ProjectDirs {
        Self::lazy_project_dirs().to_owned()
    }
    fn config_dir() -> PathBuf {
        Self::lazy_project_dirs().config_dir().to_path_buf()
    }
    fn data_dir() -> PathBuf {
        Self::lazy_project_dirs().data_dir().to_path_buf()
    }
    fn cache_dir() -> PathBuf {
        Self::lazy_project_dirs().cache_dir().to_path_buf()
    }
    fn state_dir() -> Option<PathBuf> {
        Some(Self::lazy_project_dirs().state_dir()?.to_path_buf())
    }
}
impl<T: LazyProjectDirs> ProjectInfo for T {}

/// Easy access to the a file (configuration file, data file, etc.)
/// Provides (safe?) load and save operations
pub struct FileIO<T: Serialize + for<'de> Deserialize<'de>> {
    _phantom: std::marker::PhantomData<T>,
    pub path: PathBuf,
}

impl<T> FileIO<T>
where
    T: Serialize + for<'de> Deserialize<'de>,
{
    pub fn new(path: PathBuf) -> Self {
        Self {
            _phantom: 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, from_string: impl Fn(&str) -> Result<T, io::Error>) -> io::Result<T> {
        self.ensure_parent()?;
        let string = fs::read_to_string(&self.path.canonicalize()?)?;
        let conf = from_string(&string)?;
        Ok(conf)
    }
    pub fn save(
        &self,
        conf: &T,
        to_string: impl Fn(&T) -> Result<String, io::Error>,
    ) -> io::Result<()> {
        self.ensure_parent()?;
        // back up the old file
        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 = to_string(conf)?;
        fs::write(&self.path, s)?;
        Ok(())
    }
    pub fn load_or_init(
        &self,
        from_string: impl Fn(&str) -> Result<T, io::Error>,
        to_string: impl Fn(&T) -> Result<String, io::Error>,
        init: impl Fn() -> T,
    ) -> io::Result<T> {
        match self.load_str(from_string) {
            Ok(conf) => Ok(conf),
            Err(_) => {
                let conf = init();
                self.save(&conf, to_string)?;
                Ok(conf)
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[derive(Serialize, Deserialize, PartialEq, Eq, Debug)]
    struct Conf {
        pub name: String,
    }

    #[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(|string| {
            println!("{:?}", string);
            let json = serde_json::from_str(&string)?;
            Ok(json)
        });
        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, |conf| {
                let s = serde_json::to_string(conf)?;
                Ok(s)
            })
            .unwrap();
        let loaded_conf = file_io.load_str(|string| {
            let json = serde_json::from_str(&string)?;
            Ok(json)
        });
        // fs::remove_file(&path).unwrap();
        assert_eq!(loaded_conf.unwrap(), conf);
    }
}