systemdaemon 1.0.0

System daemon building blocks. Designed for but not limited to systemd.
// SPDX-FileCopyrightText: 2025 Simon Brummer
//
// SPDX-License-Identifier: MPL-2.0

/// [`ConfigFormat`](crate::ConfigFormat) storing a configuration in JSON.
#[cfg(feature = "json")]
#[derive(Default)]
pub struct Json<T> {
    _phantom: std::marker::PhantomData<T>,
}

#[cfg(feature = "json")]
impl<T> crate::ConfigFormat for Json<T>
where
    T: serde::Serialize + serde::de::DeserializeOwned,
{
    type Data = T;

    fn serialize(&self, data: &Self::Data) -> Result<String, crate::error::SaveError> {
        Ok(serde_json::to_string_pretty(data)?)
    }
    fn deserialize(&self, data: &str) -> Result<Self::Data, crate::error::LoadError> {
        Ok(serde_json::from_str(data)?)
    }
}

/// [`ConfigFormat`](crate::ConfigFormat) storing a configuration in TOML.
#[cfg(feature = "toml")]
#[derive(Default)]
pub struct Toml<T> {
    _phantom: std::marker::PhantomData<T>,
}

#[cfg(feature = "toml")]
impl<T> crate::ConfigFormat for Toml<T>
where
    T: serde::Serialize + serde::de::DeserializeOwned,
{
    type Data = T;

    fn serialize(&self, data: &Self::Data) -> Result<String, crate::error::SaveError> {
        Ok(toml::to_string_pretty(data)?)
    }

    fn deserialize(&self, data: &str) -> Result<Self::Data, crate::error::LoadError> {
        Ok(toml::from_str(data)?)
    }
}

/// [`ConfigFormat`](crate::ConfigFormat) storing a configuration in YAML.
#[cfg(feature = "yaml")]
#[derive(Default)]
pub struct Yaml<T> {
    _phantom: std::marker::PhantomData<T>,
}

#[cfg(feature = "yaml")]
impl<T> crate::ConfigFormat for Yaml<T>
where
    T: serde::Serialize + serde::de::DeserializeOwned,
{
    type Data = T;

    fn serialize(&self, data: &Self::Data) -> Result<String, crate::error::SaveError> {
        Ok(serde_saphyr::to_string(data)?)
    }

    fn deserialize(&self, data: &str) -> Result<Self::Data, crate::error::LoadError> {
        Ok(serde_saphyr::from_str(data)?)
    }
}

#[cfg(test)]
mod tests {
    use crate::ConfigFormat;
    use crate::error::{LoadError, SaveError};
    use anyhow::anyhow;
    use serde::{Deserialize, Serialize};
    use std::fs::File;
    use std::io::Write;
    use temp_testdir::TempDir;

    #[derive(Default, Serialize, Deserialize, PartialEq, Debug)]
    pub struct TestData {
        test: bool,
    }

    // Local mock implementation testing the traits default implementation
    mockall::mock! {
        pub Format {}

        impl ConfigFormat for Format {
            type Data = TestData;

            fn serialize(&self, data: &TestData) -> Result<String, SaveError>;
            fn deserialize(&self, data: &str) -> Result<TestData, LoadError>;
        }
    }

    /// Expected behavior:
    /// Given path does not exist: An io error is returned
    #[test]
    fn load_file_is_non_existent_returns_io_error() {
        let path = TempDir::default().join("non-exist");
        let format = MockFormat::new();
        assert!(matches!(format.try_load(&path), Err(LoadError::Io(_))))
    }

    /// Expected behavior:
    /// Given path exists but is a directory: An io error is returned
    #[test]
    fn load_file_is_dir_returns_io_error() {
        let path = TempDir::default();
        let format = MockFormat::new();
        assert!(matches!(format.try_load(&path), Err(LoadError::Io(_))))
    }

    /// Expected behavior:
    /// Given path exists but it contains garbage: Return serde error
    #[test]
    fn load_file_exists_contains_garbage_returns_serde_error() {
        let path = TempDir::default();
        let path = path.join("test.txt");
        {
            let mut file = File::create(&path).unwrap();
            file.write_all("garbage data".as_bytes()).unwrap();
        }
        let mut format = MockFormat::new();
        format
            .expect_deserialize()
            .times(1)
            .returning(|_| Err(LoadError::Deserialize(anyhow!(""))));

        assert!(matches!(
            format.try_load(&path),
            Err(LoadError::Deserialize(_))
        ))
    }

    /// Expected behavior:
    /// Given path exists but is a directory: An Io error is returned
    #[test]
    fn save_file_is_dir_returns_io_error() {
        let path = TempDir::default();
        let data = TestData::default();
        let mut format = MockFormat::default();
        format
            .expect_serialize()
            .times(1)
            .returning(|_| Ok(String::from("")));

        assert!(matches!(
            format.try_save(&path, &data),
            Err(SaveError::Io(_))
        ))
    }

    /// Expected behavior:
    /// Given path does not exist: Recreate a new file and store the objects contents
    #[test]
    fn save_file_non_existent_file_is_created() {
        let path = TempDir::default();
        let path = path.join("test.txt");
        assert!(!path.exists());

        let data = TestData::default();
        let mut format = MockFormat::default();
        format
            .expect_serialize()
            .times(1)
            .returning(|_| Ok(String::from("")));
        format
            .expect_deserialize()
            .times(1)
            .returning(|_| Ok(TestData::default()));

        assert!(matches!(format.try_save(&path, &data), Ok(())));
        assert!(path.exists());
        assert_eq!(data, format.try_load(&path).unwrap());
    }

    /// Expected behavior:
    /// Given path does exist: Recreate a new file and store the objects contents
    #[test]
    fn save_file_existent_file_is_created() {
        let path = TempDir::default();
        let path = path.join("test.txt");
        {
            let mut file = File::create(&path).unwrap();
            file.write_all("for_such_invalid_yml:{]".as_bytes())
                .unwrap();
        }
        assert!(path.exists());

        let data = TestData::default();
        let mut format = MockFormat::default();
        format
            .expect_serialize()
            .times(1)
            .returning(|_| Ok(String::from("")));
        format
            .expect_deserialize()
            .times(1)
            .returning(|_| Ok(TestData::default()));

        assert!(matches!(format.try_save(&path, &data), Ok(())));
        assert!(path.exists());
        assert_eq!(data, format.try_load(&path).unwrap());
    }
}