openstranded-common-helpers 0.1.0

OpenStranded shared helper functions for .inf field parsing
Documentation
//! # openstranded-common-helpers
//!
//! Shared helper functions for parsing `.inf` file fields across
//! all `openstranded-common-*` domain type crates.
//!
//! These functions were previously duplicated in every common-* crate.
//! They operate on the `HashMap<String, Vec<String>>` structure produced
//! by the `inf2ron` parser.
//!
//! ## Functions
//!
//! | Function | Returns | Description |
//! |----------|---------|-------------|
//! | [`first_str`] | `Option<&str>` | Get first value of a field as `&str` |
//! | [`first_string`] | `Option<String>` | Get first value as owned `String` |
//! | [`first_u32`] | `Option<u32>` | Get first value parsed as `u32` |
//! | [`first_f32`] | `Option<f32>` | Get first value parsed as `f32` |
//! | [`parse_color`] | `Option<[u8; 3]>` | Parse `"R,G,B"` colour string |
//! | [`parse_optional_u32`] | `Option<u32>` | Parse a field that may be `"none"` or a number |

use std::collections::HashMap;

/// Parse a colour string `"R,G,B"` into `[R, G, B]`.
///
/// Each component must be a valid integer between 0 and 255.
/// Returns `None` if the string does not have exactly 3 comma-separated values.
///
/// # Examples
///
/// ```
/// use openstranded_common_helpers::parse_color;
///
/// assert_eq!(parse_color("255,0,128"), Some([255, 0, 128]));
/// assert_eq!(parse_color("invalid"), None);
/// ```
#[must_use]
pub fn parse_color(s: &str) -> Option<[u8; 3]> {
    let parts: Vec<&str> = s.split(',').collect();
    if parts.len() != 3 {
        return None;
    }
    Some([
        parts[0].trim().parse().ok()?,
        parts[1].trim().parse().ok()?,
        parts[2].trim().parse().ok()?,
    ])
}

/// Get the first value of a field as `&str`, if present.
///
/// # Examples
///
/// ```
/// use std::collections::HashMap;
/// use openstranded_common_helpers::first_str;
///
/// let mut fields = HashMap::new();
/// fields.insert("name".into(), vec!["Wood".into()]);
/// assert_eq!(first_str(&fields, "name"), Some("Wood"));
/// assert_eq!(first_str(&fields, "missing"), None);
/// ```
#[must_use]
pub fn first_str<'a>(fields: &'a HashMap<String, Vec<String>>, key: &str) -> Option<&'a str> {
    fields.get(key)?.first().map(String::as_str)
}

/// Get the first value of a field parsed as `u32`.
///
/// # Examples
///
/// ```
/// use std::collections::HashMap;
/// use openstranded_common_helpers::first_u32;
///
/// let mut fields = HashMap::new();
/// fields.insert("id".into(), vec!["42".into()]);
/// assert_eq!(first_u32(&fields, "id"), Some(42));
/// assert_eq!(first_u32(&fields, "missing"), None);
/// ```
#[must_use]
pub fn first_u32(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<u32> {
    first_str(fields, key)?.parse().ok()
}

/// Get the first value of a field parsed as `f32`.
///
/// # Examples
///
/// ```
/// use std::collections::HashMap;
/// use openstranded_common_helpers::first_f32;
///
/// let mut fields = HashMap::new();
/// fields.insert("weight".into(), vec!["3.5".into()]);
/// let val = first_f32(&fields, "weight");
/// assert!((val.unwrap() - 3.5).abs() < 1e-6);
/// ```
#[must_use]
pub fn first_f32(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<f32> {
    first_str(fields, key)?.parse().ok()
}

/// Get the first value of a field as an owned `String`.
///
/// # Examples
///
/// ```
/// use std::collections::HashMap;
/// use openstranded_common_helpers::first_string;
///
/// let mut fields = HashMap::new();
/// fields.insert("name".into(), vec!["Wood".into()]);
/// assert_eq!(first_string(&fields, "name"), Some("Wood".into()));
/// ```
#[must_use]
pub fn first_string(fields: &HashMap<String, Vec<String>>, key: &str) -> Option<String> {
    first_str(fields, key).map(ToOwned::to_owned)
}

/// Parse a field that may be `"none"` (returns `None`) or a number.
///
/// Used for optional numeric fields where a sentinel string indicates absence.
///
/// # Examples
///
/// ```
/// use openstranded_common_helpers::parse_optional_u32;
///
/// assert_eq!(parse_optional_u32("42"), Some(Some(42)));
/// assert_eq!(parse_optional_u32("none"), Some(None));
/// assert_eq!(parse_optional_u32("invalid"), None);
/// ```
#[must_use]
pub fn parse_optional_u32(s: &str) -> Option<Option<u32>> {
    if s.eq_ignore_ascii_case("none") {
        return Some(None);
    }
    s.parse::<u32>().ok().map(Some)
}

// ── Tests ──────────────────────────────────────────────────────────

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_parse_color_rgb() {
        assert_eq!(parse_color("255,0,128"), Some([255, 0, 128]));
    }

    #[test]
    fn test_parse_color_whitespace() {
        assert_eq!(parse_color(" 10 , 20 , 30 "), Some([10, 20, 30]));
    }

    #[test]
    fn test_parse_color_too_few() {
        assert_eq!(parse_color("255,0"), None);
    }

    #[test]
    fn test_parse_color_too_many() {
        assert_eq!(parse_color("1,2,3,4"), None);
    }

    #[test]
    fn test_parse_color_invalid() {
        assert_eq!(parse_color("abc,def,ghi"), None);
    }

    #[test]
    fn test_parse_color_empty() {
        assert_eq!(parse_color(""), None);
    }

    #[test]
    fn test_first_str_found() {
        let mut fields = HashMap::new();
        fields.insert("name".into(), vec!["Wood".into()]);
        assert_eq!(first_str(&fields, "name"), Some("Wood"));
    }

    #[test]
    fn test_first_str_not_found() {
        let fields = HashMap::new();
        assert_eq!(first_str(&fields, "missing"), None);
    }

    #[test]
    fn test_first_str_multiple_values() {
        let mut fields = HashMap::new();
        fields.insert("key".into(), vec!["first".into(), "second".into()]);
        assert_eq!(first_str(&fields, "key"), Some("first"));
    }

    #[test]
    fn test_first_u32_ok() {
        let mut fields = HashMap::new();
        fields.insert("id".into(), vec!["42".into()]);
        assert_eq!(first_u32(&fields, "id"), Some(42));
    }

    #[test]
    fn test_first_u32_invalid() {
        let mut fields = HashMap::new();
        fields.insert("id".into(), vec!["not_a_number".into()]);
        assert_eq!(first_u32(&fields, "id"), None);
    }

    #[test]
    fn test_first_u32_not_found() {
        let fields = HashMap::new();
        assert_eq!(first_u32(&fields, "missing"), None);
    }

    #[test]
    fn test_first_f32_ok() {
        let mut fields = HashMap::new();
        fields.insert("weight".into(), vec!["3.5".into()]);
        let val = first_f32(&fields, "weight").unwrap();
        assert!((val - 3.5).abs() < 1e-6);
    }

    #[test]
    fn test_first_f32_integer_string() {
        let mut fields = HashMap::new();
        fields.insert("val".into(), vec!["7".into()]);
        let val = first_f32(&fields, "val").unwrap();
        assert!((val - 7.0).abs() < 1e-6);
    }

    #[test]
    fn test_first_f32_not_found() {
        let fields = HashMap::new();
        assert_eq!(first_f32(&fields, "missing"), None);
    }

    #[test]
    fn test_first_string_ok() {
        let mut fields = HashMap::new();
        fields.insert("name".into(), vec!["Wood".into()]);
        assert_eq!(first_string(&fields, "name"), Some("Wood".into()));
    }

    #[test]
    fn test_first_string_not_found() {
        let fields = HashMap::new();
        assert_eq!(first_string(&fields, "missing"), None);
    }

    #[test]
    fn test_parse_optional_u32_number() {
        assert_eq!(parse_optional_u32("42"), Some(Some(42)));
    }

    #[test]
    fn test_parse_optional_u32_none() {
        assert_eq!(parse_optional_u32("none"), Some(None));
    }

    #[test]
    fn test_parse_optional_u32_none_uppercase() {
        assert_eq!(parse_optional_u32("NONE"), Some(None));
    }

    #[test]
    fn test_parse_optional_u32_invalid() {
        assert_eq!(parse_optional_u32("abc"), None);
    }
}