rimrs 0.1.4

RimWorld mod manager
Documentation
use crate::helpers::{fold_lis, strip_bom};
use std::path::Path;
use xml::reader::{EventReader, XmlEvent};

/// Represents the file `ModsConfig.xml` in rimworld's config directory,
/// `.../AppData/LocalLow/Ludeon Studios/RimWorld by Ludeon Studios/Config/`.
///
/// Fields directly represent the XML tag names, so aren't snakecase.
/// The `ModsConfig.xml` file uses `<li>` tags for elements of lists, represented in this struct as [`Vec`]s.
#[allow(non_snake_case)]
#[derive(Debug, Clone, Default)]
pub struct ModsConfig {
    /// RimWorld version.
    /// E.g. `1.4.3613 rev641`
    pub version: Option<String>,

    /// The mods that rimworld will load when launched.
    /// Represented here as a [`Vec`] of package IDs,
    /// represented in-file as a list of package IDs using `<li>{packageid}</li>` for the items.
    pub activeMods: Vec<String>,

    /// List of official RimWorld DLCs.
    /// Stored in the same way as `activeMods`.
    pub knownExpansions: Vec<String>,
}

impl TryFrom<&[u8]> for ModsConfig {
    type Error = xml::reader::Error;

    /// Tries to convert from the given bytes into [`ModsConfig`].
    ///
    /// # Errors
    /// * [`xml::reader::Error`]: invalid XML
    ///
    /// # Panics
    /// * If [`XmlEvent::Characters`] is detected outside of one of the expected tags.
    /// * [`bytes.len() < 3`]
    fn try_from(bytes: &[u8]) -> Result<Self, Self::Error> {
        enum Section {
            Version,
            ActiveMods,
            KnownExpansions,
        }

        let reader = EventReader::new(strip_bom(bytes));
        let mut mods_config = Self::default();

        let mut section: Option<Section> = None;
        for event in reader {
            match event? {
                XmlEvent::StartElement { name, .. } => {
                    if name.local_name == "version" {
                        section = Some(Section::Version);
                    } else if name.local_name == "activeMods" {
                        section = Some(Section::ActiveMods);
                    } else if name.local_name == "knownExpansions" {
                        section = Some(Section::KnownExpansions);
                    }
                }
                XmlEvent::EndElement { name } => {
                    if name.local_name == "version"
                        || name.local_name == "activeMods"
                        || name.local_name == "knownExpansions"
                    {
                        section = None;
                    }
                }
                XmlEvent::Characters(text) => {
                    match section
                        .as_ref()
                        .expect("Unexpected Characters when parsing `ModsConfig`")
                    {
                        Section::Version => mods_config.version = Some(text),
                        Section::ActiveMods => mods_config.activeMods.push(text),
                        Section::KnownExpansions => mods_config.knownExpansions.push(text),
                    }
                }
                _ => {}
            }
        }

        Ok(mods_config)
    }
}

impl TryFrom<&Path> for ModsConfig {
    type Error = super::ParseXMLError;

    /// Tries to read a file, then parse its XML as [`ModsConfig`].
    ///
    /// # Errors
    /// * [`xml::reader::Error`]: invalid XML (see `Self::TryFrom<&[u8]>`)
    /// * [`std::io::Error`]: failed to read the file at the given path
    fn try_from(path: &Path) -> Result<Self, Self::Error> {
        let bytes = std::fs::read(path)?;
        Self::try_from(bytes.as_slice()).map_err(Into::into)
    }
}

impl From<&ModsConfig> for String {
    fn from(mods_config: &ModsConfig) -> Self {
        let mut out =
            String::from("<?xml version=\"1.0\" encoding=\"utf-8\"?>\n<ModsConfigData>\n");

        if let Some(version) = &mods_config.version {
            out.push_str(&format!("    <version>{version}</version>\n"));
        }
        if !mods_config.activeMods.is_empty() {
            out.push_str("    <activeMods>\n");
            out.push_str(&fold_lis(&mods_config.activeMods, 2));
            out.push_str("    </activeMods>\n");
        }
        if !mods_config.knownExpansions.is_empty() {
            out.push_str("    <knownExpansions>\n");
            out.push_str(&fold_lis(&mods_config.knownExpansions, 2));
            out.push_str("    </knownExpansions>\n");
        }

        out.push_str("</ModsConfigData>");

        out
    }
}

impl From<&ModsConfig> for Vec<u8> {
    fn from(mods_config: &ModsConfig) -> Self {
        String::from(mods_config).into_bytes()
    }
}