lunar-lib 0.9.0

Common utilities for lunar applications
Documentation
use std::{
    fs, io,
    path::{Path, PathBuf},
    sync::OnceLock,
};

use serde::{Deserialize, Serialize};
use toml_edit::{DocumentMut, Item, Table};

#[derive(Debug, thiserror::Error)]
pub enum ConfigError {
    #[error("io error: {0}")]
    Io(#[from] std::io::Error),

    #[error("toml_edit error: {0}")]
    TomlEdit(#[from] toml_edit::TomlError),

    #[error("toml error: {0}")]
    Toml(#[from] toml::de::Error),

    #[error("Config directory for '{0}' could not be found")]
    MissingConfigDir(&'static str),
}

pub trait Config: Sized + Serialize + for<'de> Deserialize<'de> + Default {
    /// The name of the config file, excluding its extension
    const CONFIG_FILE_NAME: &'static str;

    /// The config dir the file is stored in
    ///
    /// This function should return [`None`] if the config dir cannot be found
    fn config_dir() -> PathBuf;

    fn __config_dir() -> &'static Path {
        static PATH: OnceLock<PathBuf> = OnceLock::new();
        PATH.get_or_init(Self::config_dir)
    }

    /// Loads [`Self`] from the system config directory using [`Self::CONFIG_FILE_NAME`], creating a default config if one does not already exist
    fn load() -> Result<Self, ConfigError> {
        let file = Self::__config_dir().join(format!("{}.toml", Self::CONFIG_FILE_NAME));

        let config = match std::fs::read_to_string(&file) {
            Ok(toml) => toml::from_str(&toml)?,
            Err(err) if matches![err.kind(), io::ErrorKind::NotFound] => Self::default(),
            Err(err) => return Err(ConfigError::Io(err)),
        };

        Ok(config)
    }

    fn from_toml(toml: impl AsRef<str>) -> Result<Self, ConfigError> {
        Ok(toml::from_str::<Self>(toml.as_ref())?)
    }

    /// Saves [`Self`] to the system config directory using [`Self::CONFIG_FILE_NAME`]
    ///
    /// # Errors
    ///
    /// Errors when the program cannot create necessary directories or write to file
    /// See [`std::fs::create_dir_all()`] and [`std::fs::write()`] for more information
    fn save(&self) -> Result<(), ConfigError> {
        let config_dir = Self::config_dir();
        let file = config_dir.join(format!("{}.toml", Self::CONFIG_FILE_NAME));

        let config_doc = toml_edit::ser::to_document(self).unwrap();
        let mut disk_doc: DocumentMut = if file.exists() {
            fs::read_to_string(&file)?.parse()?
        } else {
            DocumentMut::new()
        };

        let default_table = toml_edit::ser::to_document(&Self::default()).unwrap();

        write_needed_config_values(disk_doc.as_table_mut(), &config_doc, &default_table);

        fs::create_dir_all(config_dir)?;
        fs::write(file, disk_doc.to_string())?;

        Ok(())
    }

    #[must_use] 
    fn print_default() -> String {
        toml::to_string_pretty(&Self::default()).unwrap()
    }
}

fn write_needed_config_values(disk: &mut Table, current_table: &Table, default_table: &Table) {
    for (key, mem_value) in current_table {
        match mem_value {
            Item::Table(table) => {
                let empty = Table::new();
                let default_sub = default_table
                    .get(key)
                    .and_then(Item::as_table)
                    .unwrap_or(&empty);

                let disk_table = disk
                    .entry(key)
                    .or_insert(Item::Table(Table::new()))
                    .as_table_mut()
                    .unwrap();
                write_needed_config_values(disk_table, table, default_sub);
            }
            _ => {
                if let Some(disk_value) = disk.get(key)
                    && !items_equal(mem_value, disk_value)
                {
                    disk.insert(key, mem_value.clone());
                } else if let Some(default_value) = default_table.get(key)
                    && !items_equal(mem_value, default_value)
                {
                    disk.insert(key, mem_value.clone());
                }
            }
        }
    }
}

fn items_equal(a: &Item, b: &Item) -> bool {
    match (a, b) {
        (Item::Value(a), Item::Value(b)) => a.to_string() == b.to_string(),
        _ => false,
    }
}