rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use indexmap::IndexMap;
use serde_json::{Map, Value};
use std::collections::HashMap;

/// An ordered options hash with dotted-path access.
#[derive(Debug, Clone, Default, PartialEq)]
pub struct OrderedOptions {
    values: IndexMap<String, Value>,
}

impl OrderedOptions {
    /// Creates an empty ordered options hash.
    #[must_use]
    pub fn new() -> Self {
        Self::default()
    }

    /// Returns the value stored at `key`.
    #[must_use]
    pub fn get(&self, key: &str) -> Option<&Value> {
        get_path(&self.values, key)
    }

    /// Sets `value` at `key`, creating nested objects as needed.
    pub fn set(&mut self, key: impl Into<String>, value: Value) {
        set_path(&mut self.values, &key.into(), value);
    }

    /// Returns a merged copy with values from `other` overriding values in `self`.
    #[must_use]
    pub fn merge(&self, other: &Self) -> Self {
        let mut merged = self.clone();
        for (key, value) in &other.values {
            match merged.values.get_mut(key) {
                Some(existing) => merge_value(existing, value),
                None => {
                    merged.values.insert(key.clone(), value.clone());
                }
            }
        }
        merged
    }

    /// Converts the ordered options hash into a standard `HashMap`.
    #[must_use]
    pub fn to_hash(&self) -> HashMap<String, Value> {
        self.values
            .iter()
            .map(|(key, value)| (key.clone(), value.clone()))
            .collect()
    }
}

fn get_path<'a>(root: &'a IndexMap<String, Value>, key: &str) -> Option<&'a Value> {
    let mut segments = key.split('.').filter(|segment| !segment.is_empty());
    let first = segments.next()?;
    let mut current = root.get(first)?;

    for segment in segments {
        current = current.as_object()?.get(segment)?;
    }

    Some(current)
}

fn set_path(root: &mut IndexMap<String, Value>, key: &str, value: Value) {
    let parts: Vec<&str> = key
        .split('.')
        .filter(|segment| !segment.is_empty())
        .collect();
    if parts.is_empty() {
        return;
    }

    if parts.len() == 1 {
        root.insert(parts[0].to_owned(), value);
        return;
    }

    let mut current = root
        .entry(parts[0].to_owned())
        .or_insert_with(|| Value::Object(Map::new()));

    for part in &parts[1..parts.len() - 1] {
        match current {
            Value::Object(map) => {
                current = map
                    .entry((*part).to_owned())
                    .or_insert_with(|| Value::Object(Map::new()));
            }
            _ => {
                *current = Value::Object(Map::new());
                if let Value::Object(map) = current {
                    current = map
                        .entry((*part).to_owned())
                        .or_insert_with(|| Value::Object(Map::new()));
                }
            }
        }
    }

    if !current.is_object() {
        *current = Value::Object(Map::new());
    }

    if let Value::Object(map) = current {
        map.insert(parts[parts.len() - 1].to_owned(), value);
    }
}

fn merge_value(existing: &mut Value, incoming: &Value) {
    match (existing, incoming) {
        (Value::Object(existing), Value::Object(incoming)) => {
            for (key, value) in incoming {
                match existing.get_mut(key) {
                    Some(existing_value) => merge_value(existing_value, value),
                    None => {
                        existing.insert(key.clone(), value.clone());
                    }
                }
            }
        }
        (existing, incoming) => *existing = incoming.clone(),
    }
}

#[cfg(test)]
mod tests {
    use super::OrderedOptions;
    use serde_json::json;

    #[test]
    fn default_starts_empty() {
        let options = OrderedOptions::default();
        assert_eq!(options.get("missing"), None);
    }

    #[test]
    fn set_and_get_round_trip_top_level_values() {
        let mut options = OrderedOptions::new();
        options.set("host", json!("localhost"));

        assert_eq!(options.get("host"), Some(&json!("localhost")));
    }

    #[test]
    fn set_creates_nested_objects_for_dotted_paths() {
        let mut options = OrderedOptions::new();
        options.set("database.host", json!("localhost"));

        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
    }

    #[test]
    fn later_set_overrides_existing_values() {
        let mut options = OrderedOptions::new();
        options.set("database.host", json!("localhost"));
        options.set("database.host", json!("db.internal"));

        assert_eq!(options.get("database.host"), Some(&json!("db.internal")));
    }

    #[test]
    fn get_returns_none_for_missing_nested_keys() {
        let mut options = OrderedOptions::new();
        options.set("database.host", json!("localhost"));

        assert_eq!(options.get("database.port"), None);
    }

    #[test]
    fn get_treats_empty_segments_like_missing_separators() {
        let mut options = OrderedOptions::new();
        options.set("..database..host..", json!("localhost"));

        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
        assert_eq!(options.get("..database..host.."), Some(&json!("localhost")));
    }

    #[test]
    fn get_returns_none_for_empty_key() {
        let options = OrderedOptions::new();

        assert_eq!(options.get(""), None);
        assert_eq!(options.get("..."), None);
    }

    #[test]
    fn set_ignores_empty_path_segments_only_input() {
        let mut options = OrderedOptions::new();
        options.set("...", json!("ignored"));

        assert_eq!(options.get(""), None);
        assert_eq!(options.to_hash(), std::collections::HashMap::new());
    }

    #[test]
    fn set_replaces_scalar_intermediates_when_descending_into_nested_paths() {
        let mut options = OrderedOptions::new();
        options.set("database", json!("sqlite"));
        options.set("database.host", json!("localhost"));

        assert_eq!(
            options.get("database"),
            Some(&json!({ "host": "localhost" }))
        );
        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
    }

    #[test]
    fn merge_overrides_existing_scalar_values() {
        let mut first = OrderedOptions::new();
        first.set("host", json!("localhost"));
        let mut second = OrderedOptions::new();
        second.set("host", json!("db.internal"));

        let merged = first.merge(&second);

        assert_eq!(merged.get("host"), Some(&json!("db.internal")));
    }

    #[test]
    fn merge_recursively_combines_nested_objects() {
        let mut first = OrderedOptions::new();
        first.set("database.host", json!("localhost"));
        let mut second = OrderedOptions::new();
        second.set("database.port", json!(5432));

        let merged = first.merge(&second);

        assert_eq!(merged.get("database.host"), Some(&json!("localhost")));
        assert_eq!(merged.get("database.port"), Some(&json!(5432)));
    }

    #[test]
    fn merge_prefers_incoming_nested_values() {
        let mut first = OrderedOptions::new();
        first.set("database.host", json!("localhost"));
        let mut second = OrderedOptions::new();
        second.set("database.host", json!("db.internal"));

        let merged = first.merge(&second);

        assert_eq!(merged.get("database.host"), Some(&json!("db.internal")));
    }

    #[test]
    fn merge_preserves_existing_top_level_order_when_appending_new_keys() {
        let mut first = OrderedOptions::new();
        first.set("host", json!("localhost"));
        first.set("adapter", json!("sqlite"));

        let mut second = OrderedOptions::new();
        second.set("pool", json!(5));

        let merged = first.merge(&second);
        let keys = merged.values.keys().map(String::as_str).collect::<Vec<_>>();

        assert_eq!(keys, vec!["host", "adapter", "pool"]);
    }

    #[test]
    fn merge_does_not_mutate_nested_inputs() {
        let mut first = OrderedOptions::new();
        first.set("database.host", json!("localhost"));

        let mut second = OrderedOptions::new();
        second.set("database.port", json!(5432));

        let merged = first.merge(&second);

        assert_eq!(merged.get("database.host"), Some(&json!("localhost")));
        assert_eq!(merged.get("database.port"), Some(&json!(5432)));
        assert_eq!(first.get("database.port"), None);
        assert_eq!(second.get("database.host"), None);
    }

    #[test]
    fn merge_replaces_scalar_with_nested_object_from_incoming_options() {
        let mut first = OrderedOptions::new();
        first.set("database", json!("sqlite"));

        let mut second = OrderedOptions::new();
        second.set("database.host", json!("localhost"));

        let merged = first.merge(&second);

        assert_eq!(merged.get("database.host"), Some(&json!("localhost")));
    }

    #[test]
    fn merge_with_empty_options_returns_equal_copy() {
        let mut options = OrderedOptions::new();
        options.set("database.host", json!("localhost"));

        let merged = options.merge(&OrderedOptions::new());

        assert_eq!(merged, options);
    }

    #[test]
    fn to_hash_clones_the_top_level_values() {
        let mut options = OrderedOptions::new();
        options.set("database.host", json!("localhost"));

        let mut hash = options.to_hash();
        hash.insert(String::from("database"), json!("changed"));

        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
    }

    #[test]
    fn to_hash_clones_nested_values() {
        let mut options = OrderedOptions::new();
        options.set("database.host", json!("localhost"));

        let mut hash = options.to_hash();
        hash.entry(String::from("database")).and_modify(|value| {
            value
                .as_object_mut()
                .expect("database should be an object")
                .insert(String::from("host"), json!("db.internal"));
        });

        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
    }
}