dream-ini 0.2.0

Import Morrowind.ini settings into OpenMW configuration files
Documentation
// SPDX-License-Identifier: GPL-3.0-only

#![cfg_attr(
    all(feature = "portmaster-gui", not(feature = "gui")),
    allow(dead_code)
)]

use std::path::{Path, PathBuf};

use dream_ini::{
    ImportError, ImportEvent, ImportOptions, ImportResult, ImportWarning, IniImporter,
    PreservedCfgUpdate, TextEncoding, apply_preserved_cfg_update, load_cfg_document,
    save_cfg_output_to_path, save_preserved_cfg_document_to_path,
    save_resolved_configuration_to_path, serialize_cfg_output, serialize_preserved_cfg_document,
    serialize_resolved_configuration,
};

use super::localization::UiText;
use super::path_helpers::{cfg_parent, optional_path, same_cfg_context};

#[derive(Debug, Clone)]
pub(super) struct ImportFormState {
    pub(super) morrowind_ini: String,
    pub(super) existing_cfg: String,
    pub(super) encoding: Option<TextEncoding>,
    pub(super) import_fonts: bool,
    pub(super) import_archives: bool,
    pub(super) import_content_files: bool,
    pub(super) explicit_search_path: String,
    pub(super) data_local: String,
    pub(super) resources: String,
    pub(super) user_data: String,
    pub(super) output_mode: GuiOutputMode,
    pub(super) output_path: String,
}

impl Default for ImportFormState {
    fn default() -> Self {
        Self {
            morrowind_ini: String::new(),
            existing_cfg: String::new(),
            encoding: None,
            import_fonts: false,
            import_archives: true,
            import_content_files: false,
            explicit_search_path: String::new(),
            data_local: String::new(),
            resources: String::new(),
            user_data: String::new(),
            output_mode: GuiOutputMode::PreviewOnly,
            output_path: String::new(),
        }
    }
}

impl ImportFormState {
    pub(super) fn disabled_import_reason(&self) -> Option<UiText> {
        if optional_path(&self.morrowind_ini).is_none() {
            return Some(UiText::SelectMorrowindIniBeforeImporting);
        }
        if self.output_mode == GuiOutputMode::SaveAs && optional_path(&self.output_path).is_none() {
            return Some(UiText::SelectOutputPathBeforeImporting);
        }
        if self.output_mode == GuiOutputMode::UpdateExistingCfg
            && optional_path(&self.existing_cfg).is_none()
        {
            return Some(UiText::SelectExistingCfgBeforeUpdating);
        }
        None
    }

    pub(super) fn run_import(&self) -> GuiImportResult {
        let Some(ini_path) = optional_path(&self.morrowind_ini) else {
            return GuiImportResult::Error {
                error: GuiImportError::MissingMorrowindIni,
            };
        };
        let cfg_path = optional_path(&self.existing_cfg);
        let importer = IniImporter::new(self.import_options());

        match importer.import_optional_cfg_path(&ini_path, cfg_path.as_deref()) {
            Ok(result) => {
                let cfg_text = match self.serialize_result(&result) {
                    Ok(cfg_text) => cfg_text,
                    Err(error) => {
                        return GuiImportResult::Error {
                            error: GuiImportError::Import(error),
                        };
                    }
                };
                match self.write_output(&result) {
                    Ok(output_path) => GuiImportResult::Success {
                        cfg_text,
                        warnings: result.warnings,
                        events: result.events,
                        output_path,
                    },
                    Err(error) => GuiImportResult::Error { error },
                }
            }
            Err(error) => GuiImportResult::Error {
                error: GuiImportError::Import(error),
            },
        }
    }

    pub(super) fn serialize_result(&self, result: &ImportResult) -> Result<String, ImportError> {
        if let Some(cfg_path) = optional_path(&self.existing_cfg) {
            let mut config = load_cfg_document(&cfg_path)?;
            apply_preserved_cfg_update(
                &mut config,
                &result.cfg,
                &self.preserved_update(),
                &result.changed_keys,
            )?;
            if self.relocated_existing_cfg_output() {
                Ok(serialize_resolved_configuration(&config))
            } else {
                Ok(serialize_preserved_cfg_document(
                    &config,
                    &cfg_path,
                    &self.preserved_update(),
                    &result.changed_keys,
                ))
            }
        } else {
            serialize_cfg_output(&result.cfg, &self.output_reference_dir())
        }
    }

    pub(super) fn write_output(
        &self,
        result: &ImportResult,
    ) -> Result<Option<PathBuf>, GuiImportError> {
        match self.output_mode {
            GuiOutputMode::PreviewOnly => Ok(None),
            GuiOutputMode::SaveAs => {
                let Some(output_path) = optional_path(&self.output_path) else {
                    return Err(GuiImportError::MissingOutputPath);
                };
                if let Some(cfg_path) = optional_path(&self.existing_cfg) {
                    let mut config =
                        load_cfg_document(&cfg_path).map_err(GuiImportError::Import)?;
                    apply_preserved_cfg_update(
                        &mut config,
                        &result.cfg,
                        &self.preserved_update(),
                        &result.changed_keys,
                    )
                    .map_err(GuiImportError::Import)?;
                    if same_cfg_context(&cfg_path, &output_path) {
                        save_preserved_cfg_document_to_path(
                            &config,
                            &cfg_path,
                            &output_path,
                            &self.preserved_update(),
                            &result.changed_keys,
                        )
                        .map_err(GuiImportError::Import)?;
                    } else {
                        save_resolved_configuration_to_path(&config, &output_path)
                            .map_err(GuiImportError::Import)?;
                    }
                } else {
                    save_cfg_output_to_path(&result.cfg, &output_path)
                        .map_err(GuiImportError::Import)?;
                }
                Ok(Some(output_path))
            }
            GuiOutputMode::UpdateExistingCfg => {
                let Some(cfg_path) = optional_path(&self.existing_cfg) else {
                    return Err(GuiImportError::MissingExistingCfgForUpdate);
                };
                let mut config = load_cfg_document(&cfg_path).map_err(GuiImportError::Import)?;
                apply_preserved_cfg_update(
                    &mut config,
                    &result.cfg,
                    &self.preserved_update(),
                    &result.changed_keys,
                )
                .map_err(GuiImportError::Import)?;
                save_preserved_cfg_document_to_path(
                    &config,
                    &cfg_path,
                    &cfg_path,
                    &self.preserved_update(),
                    &result.changed_keys,
                )
                .map_err(GuiImportError::Import)?;
                Ok(Some(cfg_path))
            }
        }
    }

    fn preserved_update(&self) -> PreservedCfgUpdate {
        PreservedCfgUpdate {
            import_game_files: self.import_content_files,
            import_archives: self.import_archives,
            data_local: optional_path(&self.data_local),
            resources: optional_path(&self.resources),
            user_data: optional_path(&self.user_data),
        }
    }

    fn output_reference_dir(&self) -> PathBuf {
        let reference = match self.output_mode {
            GuiOutputMode::SaveAs => optional_path(&self.output_path),
            GuiOutputMode::PreviewOnly | GuiOutputMode::UpdateExistingCfg => {
                optional_path(&self.existing_cfg)
            }
        };

        reference
            .and_then(|path| path.parent().map(Path::to_owned))
            .unwrap_or_default()
    }

    pub(super) fn import_options(&self) -> ImportOptions {
        ImportOptions {
            import_game_files: self.import_content_files,
            import_fonts: self.import_fonts,
            import_archives: self.import_archives,
            data_dirs: optional_path(&self.explicit_search_path)
                .into_iter()
                .collect(),
            data_dir_base: self.output_context_dir(),
            write_resolved_data_dirs: self.relocated_existing_cfg_output(),
            data_local: optional_path(&self.data_local),
            resources: optional_path(&self.resources),
            user_data: optional_path(&self.user_data),
            encoding: self.encoding,
            verbose: true,
            ..ImportOptions::default()
        }
    }

    fn output_context_dir(&self) -> Option<PathBuf> {
        match self.output_mode {
            GuiOutputMode::SaveAs => optional_path(&self.output_path),
            GuiOutputMode::PreviewOnly | GuiOutputMode::UpdateExistingCfg => {
                optional_path(&self.existing_cfg)
            }
        }
        .map(|path| cfg_parent(&path).to_owned())
    }

    fn relocated_existing_cfg_output(&self) -> bool {
        if self.output_mode != GuiOutputMode::SaveAs {
            return false;
        }
        optional_path(&self.existing_cfg)
            .zip(optional_path(&self.output_path))
            .is_some_and(|(cfg_path, output_path)| !same_cfg_context(&cfg_path, &output_path))
    }
}

#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub(super) enum GuiOutputMode {
    #[default]
    PreviewOnly,
    SaveAs,
    UpdateExistingCfg,
}

#[derive(Debug)]
pub(super) enum GuiImportResult {
    Success {
        cfg_text: String,
        warnings: Vec<ImportWarning>,
        events: Vec<ImportEvent>,
        output_path: Option<PathBuf>,
    },
    Error {
        error: GuiImportError,
    },
}

#[derive(Debug)]
pub(super) enum GuiImportError {
    MissingMorrowindIni,
    MissingOutputPath,
    MissingExistingCfgForUpdate,
    Import(ImportError),
}