use indexmap::IndexMap;
use serde_json::{Map, Value};
use std::collections::HashMap;
#[derive(Debug, Clone, Default, PartialEq)]
pub struct OrderedOptions {
values: IndexMap<String, Value>,
}
impl OrderedOptions {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn get(&self, key: &str) -> Option<&Value> {
get_path(&self.values, key)
}
pub fn set(&mut self, key: impl Into<String>, value: Value) {
set_path(&mut self.values, &key.into(), value);
}
#[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
}
#[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")));
}
}