redguard-preservation 0.10.0

A CLI tool to preserve Redguard files
Documentation
use crate::{Result, error::Error};
use std::collections::BTreeMap;

pub(super) struct Section<'a> {
    pub(super) file: &'static str,
    pub(super) name: String,
    pub(super) values: BTreeMap<String, (&'a str, usize)>,
}

pub(super) fn sections<'a>(
    text: &'a str,
    file: &'static str,
) -> Result<BTreeMap<String, Section<'a>>> {
    let mut sections = BTreeMap::new();
    let mut active: Option<String> = None;
    for (index, line) in text.trim_start_matches('\u{feff}').lines().enumerate() {
        let line_number = index + 1;
        let line = line.split(';').next().unwrap_or_default().trim();
        if line.is_empty() || line.starts_with('#') {
            continue;
        }
        let invalid = || Error::Parse(format!("{file} line {line_number}: invalid INI record"));
        if line.starts_with('[') {
            let name = line
                .strip_prefix('[')
                .and_then(|line| line.strip_suffix(']'))
                .filter(|name| !name.trim().is_empty())
                .ok_or_else(invalid)?
                .trim()
                .to_ascii_lowercase();
            sections.entry(name.clone()).or_insert_with(|| Section {
                file,
                name: name.clone(),
                values: BTreeMap::new(),
            });
            active = Some(name);
            continue;
        }
        let (key, value) = line.split_once('=').ok_or_else(invalid)?;
        let key = key.trim().to_ascii_lowercase();
        if key.is_empty() {
            return Err(invalid());
        }
        let section = active.as_ref().ok_or_else(invalid)?;
        sections
            .get_mut(section)
            .unwrap()
            .values
            .entry(key)
            .or_insert((value.trim(), line_number));
    }
    Ok(sections)
}

pub(super) fn required<'a, 'b>(
    sections: &'b BTreeMap<String, Section<'a>>,
    file: &str,
    name: &str,
) -> Result<&'b Section<'a>> {
    sections
        .get(name)
        .ok_or_else(|| Error::Parse(format!("{file}: missing [{name}]")))
}

impl Section<'_> {
    pub(super) fn get<T: IniValue>(&self, key: &str) -> Result<Option<T>> {
        let Some(&(value, line)) = self.values.get(key) else {
            return Ok(None);
        };
        if value.is_empty() {
            return Ok(None);
        }
        T::parse(value).map(Some).ok_or_else(|| {
            Error::Parse(format!(
                "{} [{}] {key}, line {line}: expected {}, got {value:?}",
                self.file,
                self.name,
                T::EXPECTED
            ))
        })
    }
}

pub(super) trait IniValue: Sized {
    const EXPECTED: &'static str;
    fn parse(value: &str) -> Option<Self>;
}

impl IniValue for i32 {
    const EXPECTED: &'static str = "a signed 32-bit integer";
    fn parse(value: &str) -> Option<Self> {
        value.parse().ok()
    }
}

impl IniValue for u32 {
    const EXPECTED: &'static str = "an unsigned 32-bit integer";
    fn parse(value: &str) -> Option<Self> {
        value.parse().ok()
    }
}

impl IniValue for f32 {
    const EXPECTED: &'static str = "a finite 32-bit float";
    fn parse(value: &str) -> Option<Self> {
        value.parse::<f32>().ok().filter(|value| value.is_finite())
    }
}

impl IniValue for bool {
    const EXPECTED: &'static str = "0/1, no/yes, off/on, or false/true";
    fn parse(value: &str) -> Option<Self> {
        match value.to_ascii_lowercase().as_str() {
            "1" | "yes" | "on" | "true" => Some(true),
            "0" | "no" | "off" | "false" => Some(false),
            _ => None,
        }
    }
}

impl IniValue for String {
    const EXPECTED: &'static str = "text";
    fn parse(value: &str) -> Option<Self> {
        Some(value.to_owned())
    }
}

impl IniValue for Vec<u32> {
    const EXPECTED: &'static str = "comma-separated unsigned 32-bit integers";
    fn parse(value: &str) -> Option<Self> {
        value
            .split(',')
            .map(|token| token.trim().parse().ok())
            .collect()
    }
}

impl IniValue for [i32; 2] {
    const EXPECTED: &'static str = "two comma-separated signed 32-bit integers";
    fn parse(value: &str) -> Option<Self> {
        let (first, second) = value.split_once(',')?;
        Some([first.trim().parse().ok()?, second.trim().parse().ok()?])
    }
}

macro_rules! ini_section {
    ($(#[$meta:meta])* indexed $name:ident { $($field:ident: $ty:ty => $key:literal),* $(,)? }) => {
        ini_section! { $(#[$meta])* $name { $($field: $ty => $key,)* } }
        impl $name {
            fn is_field(key: &str) -> bool {
                matches!(key, $($key)|*)
            }
        }
    };
    ($(#[$meta:meta])* $name:ident { $($field:ident: $ty:ty => $key:literal),* $(,)? }) => {
        $(#[$meta])*
        #[derive(Debug, Clone, Default, PartialEq, serde::Serialize)]
        pub struct $name {
            $(#[serde(rename = $key)] pub $field: Option<$ty>,)*
        }

        impl $name {
            pub(super) fn parse(section: Option<&super::ini::Section<'_>>) -> crate::Result<Self> {
                let Some(section) = section else { return Ok(Self::default()); };
                Ok(Self { $($field: section.get($key)?,)* })
            }
        }
    };
}

pub(super) use ini_section;

macro_rules! ini_enum {
    ($(#[$meta:meta])* $name:ident { $($variant:ident = $code:literal),* $(,)? }) => {
        $(#[$meta])*
        #[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize)]
        pub enum $name {
            $($variant,)*
            Unknown(i32),
        }

        impl super::ini::IniValue for $name {
            const EXPECTED: &'static str = "a signed 32-bit type code";
            fn parse(value: &str) -> Option<Self> {
                Some(match value.parse::<i32>().ok()? {
                    $($code => Self::$variant,)*
                    code => Self::Unknown(code),
                })
            }
        }
    };
}

pub(super) use ini_enum;