hex-patch 1.12.6

HexPatch is a binary patcher and editor with terminal user interface (TUI), it's capable of disassembling instructions and assembling patches. It supports a variety of architectures and file formats. Also, it can edit remote files via SSH.
Documentation
#![allow(clippy::module_inception)]
use std::{
    collections::HashMap,
    io,
    path::{Path, PathBuf},
};

use ratatui::style::Style;
use serde::de::Visitor;
use termbg::Theme;

use super::{
    app_settings::AppSettings, color_settings::ColorSettings, key_settings::KeySettings,
    settings_value::SettingsValue,
};

#[derive(Debug, Clone, PartialEq, serde::Serialize)]
pub struct Settings {
    pub color: ColorSettings,
    pub key: KeySettings,
    pub app: AppSettings,
    pub custom: HashMap<String, SettingsValue>,
}

impl Settings {
    pub fn load(path: Option<&Path>, terminal_theme: Theme) -> Result<Settings, io::Error> {
        let path = match path {
            Some(path) => path.to_path_buf(),
            None => Self::get_default_settings_path()
                .ok_or(io::Error::other(t!("errors.get_default_settings_path")))?,
        };

        if !path.exists() {
            return Err(io::Error::new(
                io::ErrorKind::NotFound,
                t!("app.messages.settings_not_found"),
            ));
        }

        let settings = std::fs::read_to_string(&path)?;

        let mut deserializer = serde_json::Deserializer::from_str(&settings);

        Ok(
            match Settings::custom_deserialize(&mut deserializer, terminal_theme) {
                Ok(settings) => settings,
                Err(e) => {
                    return Err(io::Error::new(
                        io::ErrorKind::InvalidData,
                        t!("app.messages.parse_settings_error", e = e),
                    ))
                }
            },
        )
    }

    fn get_default_settings_path() -> Option<PathBuf> {
        let config = dirs::config_dir()?;
        Some(config.join("HexPatch").join("settings.json"))
    }

    pub fn load_or_create(path: Option<&Path>, terminal_theme: Theme) -> Result<Settings, String> {
        match Self::load(path, terminal_theme) {
            Ok(settings) => Ok(settings),
            Err(e) => {
                if e.kind() != io::ErrorKind::NotFound {
                    Err(t!("app.messages.load_settings_error", e = e).to_string())
                } else {
                    let settings = Settings::empty(terminal_theme);
                    if path.is_some() {
                        settings
                            .save(path)
                            .ok_or(t!("app.messages.save_default_settings_error", e = e))?;
                    }
                    Ok(settings)
                }
            }
        }
    }

    pub fn save(&self, path: Option<&Path>) -> Option<()> {
        let path = match path {
            Some(path) => path.to_path_buf(),
            None => Self::get_default_settings_path()?,
        };

        let settings = serde_json::to_string_pretty(self).ok()?;
        std::fs::create_dir_all(path.parent()?).ok()?;
        std::fs::write(&path, settings).ok()?;
        Some(())
    }

    pub fn empty(terminal_theme: Theme) -> Self {
        Self {
            color: ColorSettings::get_default_theme(terminal_theme),
            key: KeySettings::default(),
            app: AppSettings::default(),
            custom: HashMap::new(),
        }
    }
}

struct SettingsVisitor {
    theme: Theme,
}

impl<'de> Visitor<'de> for SettingsVisitor {
    type Value = Settings;

    fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
        formatter.write_str("a valid Settings struct")
    }

    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
    where
        A: serde::de::MapAccess<'de>,
    {
        let mut color_settings: Option<HashMap<String, Style>> = None;
        let mut key_settings: Option<KeySettings> = None;
        let mut app_settings: Option<AppSettings> = None;
        let mut custom_settings: Option<HashMap<String, SettingsValue>> = None;

        while let Some(key) = map.next_key()? {
            match key {
                "color" => {
                    if color_settings.is_some() {
                        return Err(serde::de::Error::duplicate_field("color"));
                    }
                    color_settings = Some(map.next_value()?);
                }
                "key" => {
                    if key_settings.is_some() {
                        return Err(serde::de::Error::duplicate_field("key"));
                    }
                    key_settings = Some(map.next_value()?);
                }
                "app" => {
                    if app_settings.is_some() {
                        return Err(serde::de::Error::duplicate_field("app"));
                    }
                    app_settings = Some(map.next_value()?);
                }
                "custom" => {
                    if custom_settings.is_some() {
                        return Err(serde::de::Error::duplicate_field("custom"));
                    }
                    custom_settings = Some(map.next_value()?);
                }
                _ => {
                    return Err(serde::de::Error::unknown_field(
                        key,
                        &["color", "key", "app", "custom"],
                    ));
                }
            }
        }
        let key_settings = key_settings.unwrap_or_default();
        let app_settings = app_settings.unwrap_or_default();
        let custom_settings = custom_settings.unwrap_or_default();
        let color_settings = ColorSettings::from_map(
            &color_settings.unwrap_or_default(),
            &app_settings,
            self.theme,
        )
        .map_err(serde::de::Error::custom)?;

        Ok(Self::Value {
            color: color_settings,
            key: key_settings,
            app: app_settings,
            custom: custom_settings,
        })
    }
}

impl Settings {
    fn custom_deserialize<'de, D>(deserializer: D, theme: Theme) -> Result<Self, D::Error>
    where
        D: serde::Deserializer<'de>,
    {
        deserializer.deserialize_map(SettingsVisitor { theme })
    }
}

impl Default for Settings {
    fn default() -> Self {
        Self {
            color: ColorSettings::get_default_theme(Theme::Dark),
            key: KeySettings::default(),
            app: AppSettings::default(),
            custom: HashMap::new(),
        }
    }
}

#[cfg(test)]
mod test {
    use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
    use ratatui::style::{Color, Style};

    use super::*;

    #[test]
    fn test_settings_load() {
        let settings = Settings::load(Some(Path::new("test/default_settings.json")), Theme::Dark);
        if let Err(e) = settings {
            panic!("Could not load settings: {e}");
        }
        assert_eq!(settings.unwrap(), Settings::default());
    }

    #[test]
    fn test_settings_partial_load() {
        let settings = Settings::load(
            Some(Path::new("test/partial_default_settings.json")),
            Theme::Dark,
        );
        if let Err(e) = settings {
            panic!("Could not load settings: {e}");
        }
        assert_eq!(settings.unwrap(), Settings::default());
    }

    #[test]
    fn test_settings_load_custom() {
        let settings = Settings::load(Some(Path::new("test/custom_settings.json")), Theme::Dark);
        if let Err(e) = settings {
            panic!("Could not load settings: {e}");
        }
        let mut expected = Settings::default();
        expected
            .custom
            .insert("plugin1.value1".to_string(), SettingsValue::from("value1"));
        expected
            .custom
            .insert("plugin1.value2".to_string(), SettingsValue::from(2));
        expected
            .custom
            .insert("plugin2.value1".to_string(), SettingsValue::from(3.0));
        expected
            .custom
            .insert("plugin2.value2".to_string(), SettingsValue::from(true));
        expected.custom.insert(
            "plugin3.value1".to_string(),
            SettingsValue::from(Style::default().fg(Color::Red)),
        );
        expected.custom.insert(
            "plugin3.value2".to_string(),
            SettingsValue::from(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE)),
        );
    }
}