Skip to main content

dynamic_config/
value.rs

1//! An owned mirror of the resolved configuration tree.
2//!
3//! A boundary that is not `serde` — a language binding, an exporter, a
4//! templating engine — needs the resolved values as *data*, not as a type
5//! to deserialize into. The underlying loader has such a tree, but its
6//! types are figment's, and this crate's public surface keeps figment
7//! behind [one deliberate door](crate::Source::provider). So the export is
8//! a small owned mirror: seven shapes, no lifetimes, no third-party types
9//! in the signature — and built by walking the resolved tree directly,
10//! never by a JSON round trip.
11
12use std::collections::BTreeMap;
13
14/// One resolved configuration value, owned.
15///
16/// What [`Snapshot::to_value`](crate::Snapshot::to_value) returns. This is
17/// configuration *handover*, not a diagnostic: real values, secrets
18/// included, exactly like deserializing into a struct — the paths-only
19/// rule governs what this crate prints, not what it hands the program.
20///
21/// Which is why `Debug` is hand-written and shape-only: the same data
22/// sits inside [`Snapshot`](crate::Snapshot), whose `Debug` prints keys
23/// and never values, and `{:?}` in a log line is exactly how resolved
24/// secrets leak. Read values through the enum; print them on purpose or
25/// not at all.
26#[derive(Clone, PartialEq)]
27pub enum Value {
28    /// An explicit null (or unit) in a source.
29    Null,
30    /// A boolean.
31    Bool(bool),
32    /// Any integer a source can express.
33    ///
34    /// `i128`, so every `i64` and `u64` fits without a sign decision at
35    /// this boundary. The one unrepresentable case — a `u128` above
36    /// `i128::MAX` — arrives as [`Value::Float`], lossily; a configuration
37    /// value up there is measuring something no unit this crate knows
38    /// about.
39    Integer(i128),
40    /// A floating-point number.
41    Float(f64),
42    /// A string; a single character in a source arrives as one too.
43    String(String),
44    /// A sequence.
45    Array(Vec<Value>),
46    /// A table, keyed by field name.
47    Table(BTreeMap<String, Value>),
48}
49
50impl Value {
51    /// The value at a dotted `path` below this one, if every step exists.
52    ///
53    /// Steps are table keys; anything else — an array, a leaf — ends the
54    /// walk with `None`. The empty path is this value itself.
55    #[must_use]
56    pub fn get(&self, path: &str) -> Option<&Value> {
57        if path.is_empty() {
58            return Some(self);
59        }
60
61        path.split('.').try_fold(self, |value, step| match value {
62            Value::Table(table) => table.get(step),
63            _ => None,
64        })
65    }
66}
67
68impl std::fmt::Debug for Value {
69    /// Shape and keys, never values — the line every diagnostic in this
70    /// crate holds, held here too because `to_value` hands over the same
71    /// secret-bearing data `Snapshot` guards.
72    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
73        match self {
74            Self::Null => f.write_str("Null"),
75            Self::Bool(_) => f.write_str("Bool(***)"),
76            Self::Integer(_) => f.write_str("Integer(***)"),
77            Self::Float(_) => f.write_str("Float(***)"),
78            Self::String(_) => f.write_str("String(***)"),
79            Self::Array(values) => f.debug_list().entries(values.iter()).finish(),
80            Self::Table(table) => f.debug_map().entries(table.iter()).finish(),
81        }
82    }
83}
84
85/// The walk from figment's tree, tags dropped, no serialization involved.
86pub(crate) fn from_figment(value: &figment::value::Value) -> Value {
87    use figment::value::{Empty, Num};
88
89    match value {
90        figment::value::Value::String(_, string) => Value::String(string.clone()),
91        figment::value::Value::Char(_, character) => Value::String(character.to_string()),
92        figment::value::Value::Bool(_, boolean) => Value::Bool(*boolean),
93        figment::value::Value::Num(_, number) => match number {
94            Num::U8(n) => Value::Integer(i128::from(*n)),
95            Num::U16(n) => Value::Integer(i128::from(*n)),
96            Num::U32(n) => Value::Integer(i128::from(*n)),
97            Num::U64(n) => Value::Integer(i128::from(*n)),
98            Num::USize(n) => Value::Integer(*n as i128),
99            Num::U128(n) => i128::try_from(*n)
100                .map(Value::Integer)
101                .unwrap_or(Value::Float(*n as f64)),
102            Num::I8(n) => Value::Integer(i128::from(*n)),
103            Num::I16(n) => Value::Integer(i128::from(*n)),
104            Num::I32(n) => Value::Integer(i128::from(*n)),
105            Num::I64(n) => Value::Integer(i128::from(*n)),
106            Num::ISize(n) => Value::Integer(*n as i128),
107            Num::I128(n) => Value::Integer(*n),
108            Num::F32(n) => Value::Float(f64::from(*n)),
109            Num::F64(n) => Value::Float(*n),
110        },
111        figment::value::Value::Empty(_, Empty::None | Empty::Unit) => Value::Null,
112        figment::value::Value::Dict(_, dict) => Value::Table(
113            dict.iter()
114                .map(|(key, value)| (key.clone(), from_figment(value)))
115                .collect(),
116        ),
117        figment::value::Value::Array(_, values) => {
118            Value::Array(values.iter().map(from_figment).collect())
119        }
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126
127    #[test]
128    fn the_walk_preserves_shape_and_numbers() {
129        let source: figment::value::Value = figment::value::Value::serialize(serde_json::json!({
130            "port": 5432,
131            "ratio": 0.5,
132            "tls": true,
133            "host": "db",
134            "tags": ["a", "b"],
135            "pool": { "max": 8 },
136        }))
137        .expect("a literal serializes");
138
139        let value = from_figment(&source);
140
141        assert_eq!(value.get("port"), Some(&Value::Integer(5432)));
142        assert_eq!(value.get("ratio"), Some(&Value::Float(0.5)));
143        assert_eq!(value.get("tls"), Some(&Value::Bool(true)));
144        assert_eq!(value.get("host"), Some(&Value::String("db".into())));
145        assert_eq!(value.get("pool.max"), Some(&Value::Integer(8)));
146        assert_eq!(
147            value.get("tags"),
148            Some(&Value::Array(vec![
149                Value::String("a".into()),
150                Value::String("b".into())
151            ]))
152        );
153    }
154
155    #[test]
156    fn a_step_through_a_leaf_is_none_and_the_empty_path_is_identity() {
157        let value = Value::Table(BTreeMap::from([("port".to_owned(), Value::Integer(1))]));
158
159        assert_eq!(value.get("port.deeper"), None);
160        assert_eq!(value.get("missing"), None);
161        assert_eq!(value.get(""), Some(&value));
162    }
163}