Skip to main content

rustrails_support/
ordered_options.rs

1use indexmap::IndexMap;
2use serde_json::{Map, Value};
3use std::collections::HashMap;
4
5/// An ordered options hash with dotted-path access.
6#[derive(Debug, Clone, Default, PartialEq)]
7pub struct OrderedOptions {
8    values: IndexMap<String, Value>,
9}
10
11impl OrderedOptions {
12    /// Creates an empty ordered options hash.
13    #[must_use]
14    pub fn new() -> Self {
15        Self::default()
16    }
17
18    /// Returns the value stored at `key`.
19    #[must_use]
20    pub fn get(&self, key: &str) -> Option<&Value> {
21        get_path(&self.values, key)
22    }
23
24    /// Sets `value` at `key`, creating nested objects as needed.
25    pub fn set(&mut self, key: impl Into<String>, value: Value) {
26        set_path(&mut self.values, &key.into(), value);
27    }
28
29    /// Returns a merged copy with values from `other` overriding values in `self`.
30    #[must_use]
31    pub fn merge(&self, other: &Self) -> Self {
32        let mut merged = self.clone();
33        for (key, value) in &other.values {
34            match merged.values.get_mut(key) {
35                Some(existing) => merge_value(existing, value),
36                None => {
37                    merged.values.insert(key.clone(), value.clone());
38                }
39            }
40        }
41        merged
42    }
43
44    /// Converts the ordered options hash into a standard `HashMap`.
45    #[must_use]
46    pub fn to_hash(&self) -> HashMap<String, Value> {
47        self.values
48            .iter()
49            .map(|(key, value)| (key.clone(), value.clone()))
50            .collect()
51    }
52}
53
54fn get_path<'a>(root: &'a IndexMap<String, Value>, key: &str) -> Option<&'a Value> {
55    let mut segments = key.split('.').filter(|segment| !segment.is_empty());
56    let first = segments.next()?;
57    let mut current = root.get(first)?;
58
59    for segment in segments {
60        current = current.as_object()?.get(segment)?;
61    }
62
63    Some(current)
64}
65
66fn set_path(root: &mut IndexMap<String, Value>, key: &str, value: Value) {
67    let parts: Vec<&str> = key
68        .split('.')
69        .filter(|segment| !segment.is_empty())
70        .collect();
71    if parts.is_empty() {
72        return;
73    }
74
75    if parts.len() == 1 {
76        root.insert(parts[0].to_owned(), value);
77        return;
78    }
79
80    let mut current = root
81        .entry(parts[0].to_owned())
82        .or_insert_with(|| Value::Object(Map::new()));
83
84    for part in &parts[1..parts.len() - 1] {
85        match current {
86            Value::Object(map) => {
87                current = map
88                    .entry((*part).to_owned())
89                    .or_insert_with(|| Value::Object(Map::new()));
90            }
91            _ => {
92                *current = Value::Object(Map::new());
93                if let Value::Object(map) = current {
94                    current = map
95                        .entry((*part).to_owned())
96                        .or_insert_with(|| Value::Object(Map::new()));
97                }
98            }
99        }
100    }
101
102    if !current.is_object() {
103        *current = Value::Object(Map::new());
104    }
105
106    if let Value::Object(map) = current {
107        map.insert(parts[parts.len() - 1].to_owned(), value);
108    }
109}
110
111fn merge_value(existing: &mut Value, incoming: &Value) {
112    match (existing, incoming) {
113        (Value::Object(existing), Value::Object(incoming)) => {
114            for (key, value) in incoming {
115                match existing.get_mut(key) {
116                    Some(existing_value) => merge_value(existing_value, value),
117                    None => {
118                        existing.insert(key.clone(), value.clone());
119                    }
120                }
121            }
122        }
123        (existing, incoming) => *existing = incoming.clone(),
124    }
125}
126
127#[cfg(test)]
128mod tests {
129    use super::OrderedOptions;
130    use serde_json::json;
131
132    #[test]
133    fn default_starts_empty() {
134        let options = OrderedOptions::default();
135        assert_eq!(options.get("missing"), None);
136    }
137
138    #[test]
139    fn set_and_get_round_trip_top_level_values() {
140        let mut options = OrderedOptions::new();
141        options.set("host", json!("localhost"));
142
143        assert_eq!(options.get("host"), Some(&json!("localhost")));
144    }
145
146    #[test]
147    fn set_creates_nested_objects_for_dotted_paths() {
148        let mut options = OrderedOptions::new();
149        options.set("database.host", json!("localhost"));
150
151        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
152    }
153
154    #[test]
155    fn later_set_overrides_existing_values() {
156        let mut options = OrderedOptions::new();
157        options.set("database.host", json!("localhost"));
158        options.set("database.host", json!("db.internal"));
159
160        assert_eq!(options.get("database.host"), Some(&json!("db.internal")));
161    }
162
163    #[test]
164    fn get_returns_none_for_missing_nested_keys() {
165        let mut options = OrderedOptions::new();
166        options.set("database.host", json!("localhost"));
167
168        assert_eq!(options.get("database.port"), None);
169    }
170
171    #[test]
172    fn get_treats_empty_segments_like_missing_separators() {
173        let mut options = OrderedOptions::new();
174        options.set("..database..host..", json!("localhost"));
175
176        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
177        assert_eq!(options.get("..database..host.."), Some(&json!("localhost")));
178    }
179
180    #[test]
181    fn get_returns_none_for_empty_key() {
182        let options = OrderedOptions::new();
183
184        assert_eq!(options.get(""), None);
185        assert_eq!(options.get("..."), None);
186    }
187
188    #[test]
189    fn set_ignores_empty_path_segments_only_input() {
190        let mut options = OrderedOptions::new();
191        options.set("...", json!("ignored"));
192
193        assert_eq!(options.get(""), None);
194        assert_eq!(options.to_hash(), std::collections::HashMap::new());
195    }
196
197    #[test]
198    fn set_replaces_scalar_intermediates_when_descending_into_nested_paths() {
199        let mut options = OrderedOptions::new();
200        options.set("database", json!("sqlite"));
201        options.set("database.host", json!("localhost"));
202
203        assert_eq!(
204            options.get("database"),
205            Some(&json!({ "host": "localhost" }))
206        );
207        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
208    }
209
210    #[test]
211    fn merge_overrides_existing_scalar_values() {
212        let mut first = OrderedOptions::new();
213        first.set("host", json!("localhost"));
214        let mut second = OrderedOptions::new();
215        second.set("host", json!("db.internal"));
216
217        let merged = first.merge(&second);
218
219        assert_eq!(merged.get("host"), Some(&json!("db.internal")));
220    }
221
222    #[test]
223    fn merge_recursively_combines_nested_objects() {
224        let mut first = OrderedOptions::new();
225        first.set("database.host", json!("localhost"));
226        let mut second = OrderedOptions::new();
227        second.set("database.port", json!(5432));
228
229        let merged = first.merge(&second);
230
231        assert_eq!(merged.get("database.host"), Some(&json!("localhost")));
232        assert_eq!(merged.get("database.port"), Some(&json!(5432)));
233    }
234
235    #[test]
236    fn merge_prefers_incoming_nested_values() {
237        let mut first = OrderedOptions::new();
238        first.set("database.host", json!("localhost"));
239        let mut second = OrderedOptions::new();
240        second.set("database.host", json!("db.internal"));
241
242        let merged = first.merge(&second);
243
244        assert_eq!(merged.get("database.host"), Some(&json!("db.internal")));
245    }
246
247    #[test]
248    fn merge_preserves_existing_top_level_order_when_appending_new_keys() {
249        let mut first = OrderedOptions::new();
250        first.set("host", json!("localhost"));
251        first.set("adapter", json!("sqlite"));
252
253        let mut second = OrderedOptions::new();
254        second.set("pool", json!(5));
255
256        let merged = first.merge(&second);
257        let keys = merged.values.keys().map(String::as_str).collect::<Vec<_>>();
258
259        assert_eq!(keys, vec!["host", "adapter", "pool"]);
260    }
261
262    #[test]
263    fn merge_does_not_mutate_nested_inputs() {
264        let mut first = OrderedOptions::new();
265        first.set("database.host", json!("localhost"));
266
267        let mut second = OrderedOptions::new();
268        second.set("database.port", json!(5432));
269
270        let merged = first.merge(&second);
271
272        assert_eq!(merged.get("database.host"), Some(&json!("localhost")));
273        assert_eq!(merged.get("database.port"), Some(&json!(5432)));
274        assert_eq!(first.get("database.port"), None);
275        assert_eq!(second.get("database.host"), None);
276    }
277
278    #[test]
279    fn merge_replaces_scalar_with_nested_object_from_incoming_options() {
280        let mut first = OrderedOptions::new();
281        first.set("database", json!("sqlite"));
282
283        let mut second = OrderedOptions::new();
284        second.set("database.host", json!("localhost"));
285
286        let merged = first.merge(&second);
287
288        assert_eq!(merged.get("database.host"), Some(&json!("localhost")));
289    }
290
291    #[test]
292    fn merge_with_empty_options_returns_equal_copy() {
293        let mut options = OrderedOptions::new();
294        options.set("database.host", json!("localhost"));
295
296        let merged = options.merge(&OrderedOptions::new());
297
298        assert_eq!(merged, options);
299    }
300
301    #[test]
302    fn to_hash_clones_the_top_level_values() {
303        let mut options = OrderedOptions::new();
304        options.set("database.host", json!("localhost"));
305
306        let mut hash = options.to_hash();
307        hash.insert(String::from("database"), json!("changed"));
308
309        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
310    }
311
312    #[test]
313    fn to_hash_clones_nested_values() {
314        let mut options = OrderedOptions::new();
315        options.set("database.host", json!("localhost"));
316
317        let mut hash = options.to_hash();
318        hash.entry(String::from("database")).and_modify(|value| {
319            value
320                .as_object_mut()
321                .expect("database should be an object")
322                .insert(String::from("host"), json!("db.internal"));
323        });
324
325        assert_eq!(options.get("database.host"), Some(&json!("localhost")));
326    }
327}