iforgor 0.3.3

The CLI tool for all those commands you forget about
Documentation
use {
    anyhow::anyhow,
    serde::{de::DeserializeOwned, Serialize},
    std::{
        fs::File,
        io::{Read, Write},
        ops::{Deref, DerefMut},
        path::PathBuf,
    },
};

#[derive(Debug)]
pub struct OnDisk<T> {
    inner: T,
    path: PathBuf,
}

impl<T: Serialize + DeserializeOwned + Default> OnDisk<T> {
    pub fn open(path: PathBuf) -> anyhow::Result<Self> {
        let mut file = File::open(&path)?;
        let mut content = String::new();
        file.read_to_string(&mut content)?;
        let inner = toml::from_str(&content)?;

        Ok(Self { path, inner })
    }

    pub fn open_or_default(path: PathBuf) -> anyhow::Result<Self> {
        let mut file = match File::open(&path) {
            Ok(file) => file,
            Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
                return Ok(Self {
                    path,
                    inner: Default::default(),
                })
            }
            Err(e) => Err(e)?,
        };

        let mut content = String::new();
        file.read_to_string(&mut content)?;

        if content.trim().is_empty() {
            return Ok(Self {
                path,
                inner: Default::default(),
            });
        }

        let inner = toml::from_str(&content)?;

        Ok(Self { path, inner })
    }

    pub fn new_from_default(path: PathBuf) -> Self {
        Self {
            path,
            inner: Default::default(),
        }
    }

    pub fn save(&self) -> anyhow::Result<()> {
        let folder = self
            .path
            .parent()
            .ok_or(anyhow!("expected file to be in a folder"))?;
        std::fs::create_dir_all(folder)?;

        let mut file = File::create(&self.path)?;
        let content = toml::to_string(&self.inner)?;
        file.write_all(content.as_bytes())?;

        Ok(())
    }

    pub fn into_inner(self) -> T {
        self.inner
    }
}

impl<T> Deref for OnDisk<T> {
    type Target = T;

    fn deref(&self) -> &Self::Target {
        &self.inner
    }
}

impl<T> DerefMut for OnDisk<T> {
    fn deref_mut(&mut self) -> &mut Self::Target {
        &mut self.inner
    }
}

#[cfg(test)]
mod tests {
    use {
        super::*,
        serde::{Deserialize, Serialize},
    };

    #[derive(Debug, Default, PartialEq, Serialize, Deserialize)]
    struct TestData {
        #[serde(default)]
        value: String,
    }

    #[test]
    fn roundtrip_save_and_open() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        let path = tmp.path().to_path_buf();

        let mut disk = OnDisk::<TestData>::new_from_default(path.clone());
        disk.value = "hello".into();
        disk.save().unwrap();

        let loaded = OnDisk::<TestData>::open(path).unwrap();
        assert_eq!(loaded.value, "hello");
    }

    #[test]
    fn open_missing_file_errors() {
        let result = OnDisk::<TestData>::open("/nonexistent/path.toml".into());
        assert!(result.is_err());
    }

    #[test]
    fn open_or_default_missing_file_returns_default() {
        let disk = OnDisk::<TestData>::open_or_default("/nonexistent/path.toml".into()).unwrap();
        assert_eq!(disk.value, "");
    }

    #[test]
    fn open_or_default_empty_file_returns_default() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(tmp.path(), "").unwrap();
        let disk = OnDisk::<TestData>::open_or_default(tmp.path().to_path_buf()).unwrap();
        assert_eq!(disk.value, "");
    }

    #[test]
    fn open_invalid_toml_errors() {
        let tmp = tempfile::NamedTempFile::new().unwrap();
        std::fs::write(tmp.path(), "not valid [[[ toml").unwrap();
        let result = OnDisk::<TestData>::open(tmp.path().to_path_buf());
        assert!(result.is_err());
    }

    #[test]
    fn into_inner_returns_data() {
        let mut disk = OnDisk::<TestData>::new_from_default("/tmp/test.toml".into());
        disk.value = "inner".into();
        let data = disk.into_inner();
        assert_eq!(data.value, "inner");
    }
}