ccs2/
property_helper.rs

1use crate::{PersistentStr, PropertyValue, ast::Origin};
2
3/// Occurs when parsing a string into a specific type fails
4///
5/// Created when converting a [`PropertyValue`] with [`ToType::to_type`]
6#[derive(thiserror::Error, Debug)]
7#[error("Conversion to {type_name} failed for value {value} ({origin})")]
8pub struct ConversionFailed {
9    pub type_name: &'static str,
10    pub value: PersistentStr,
11    pub origin: Origin,
12}
13
14pub type ConversionResult<T> = Result<T, ConversionFailed>;
15
16/// Represents a type which can be created from a [`PropertyValue`]
17///
18/// `TypedProperty` can be implemented for user-defined types to make it more convenient to parse
19/// things. However, if you want to implement it for types that aren't defined in your crate, you'll
20/// have to use a newtype.
21///
22/// Example:
23///
24/// ```
25/// use ccs2::{CommaSeparatedList, ToType};
26/// let context = ccs2::Context::from_str_without_tracing("items = '1, 2, 3, 4'").unwrap();
27///
28/// let items = context.get_type::<CommaSeparatedList>("items")?;
29///
30/// assert_eq!(items.0, vec!["1", "2", "3", "4"]);
31/// # Ok::<(), ccs2::ContextError>(())
32/// ```
33///
34/// Note: The combined error type of the "get and convert" chain is [`ContextError`].
35///
36/// [`ContextError`]: crate::ContextError
37pub trait TypedProperty {
38    fn from_value(value: &PropertyValue) -> ConversionResult<Self>
39    where
40        Self: Sized;
41}
42
43/// A helper trait implemented by [`PropertyValue`] to make string parsing/conversion easier
44///
45/// See implementers of [`TypedProperty`] for more
46pub trait ToType {
47    fn to_type<T: TypedProperty>(&self) -> ConversionResult<T>
48    where
49        Self: Sized;
50}
51impl ToType for PropertyValue {
52    fn to_type<T: TypedProperty>(&self) -> ConversionResult<T> {
53        T::from_value(self)
54    }
55}
56
57impl TypedProperty for bool {
58    fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
59        prop.value.parse::<bool>().or_conversion_failed(prop)
60    }
61}
62
63impl TypedProperty for String {
64    fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
65        Ok(prop.value.to_string())
66    }
67}
68
69impl TypedProperty for std::path::PathBuf {
70    fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
71        Ok(std::path::PathBuf::from(prop.value.as_ref()))
72    }
73}
74
75/// Splits at commas, and trims whitespace from both ends of resulting items
76///
77/// TODO: Would be nice to make this trait lifetime-aware, so we could do `Vec<&str>`
78#[derive(Debug)]
79pub struct CommaSeparatedList(pub Vec<String>);
80impl TypedProperty for CommaSeparatedList {
81    fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
82        Ok(CommaSeparatedList(
83            prop.value
84                .as_ref()
85                .split(",")
86                .map(|s| s.trim().to_string())
87                .collect(),
88        ))
89    }
90}
91
92macro_rules! num_type {
93    ($num:ty) => {
94        impl TypedProperty for $num {
95            fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
96                prop.value.parse::<$num>().or_conversion_failed(prop)
97            }
98        }
99    };
100}
101
102num_type!(i8);
103num_type!(u8);
104
105num_type!(i16);
106num_type!(u16);
107
108num_type!(i32);
109num_type!(u32);
110num_type!(f32);
111
112num_type!(i64);
113num_type!(u64);
114num_type!(f64);
115
116#[cfg(feature = "extra_conversions")]
117pub mod extras {
118    use super::*;
119
120    /// Requires the `extra_conversions` feature, as it uses the [`humantime`] crate to do the parsing
121    impl TypedProperty for std::time::Duration {
122        fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
123            humantime::parse_duration(prop.value.as_ref()).or_conversion_failed(prop)
124        }
125    }
126
127    /// Requires the `extra_conversions` feature, as it uses the [`chrono`] crate to do the parsing
128    ///
129    /// See [`chrono::DateTime::parse_from_rfc3339`] for supported formatting
130    impl TypedProperty for std::time::SystemTime {
131        fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
132            Ok(chrono::DateTime::parse_from_rfc3339(prop.value.as_ref())
133                .or_conversion_failed(prop)?
134                .into())
135        }
136    }
137
138    impl TypedProperty for chrono::DateTime<chrono::FixedOffset> {
139        fn from_value(prop: &PropertyValue) -> ConversionResult<Self> {
140            let date_time = chrono::DateTime::parse_from_rfc3339(prop.value.as_ref())
141                .or_conversion_failed(prop)?;
142            let offset = date_time.offset();
143            Ok(chrono::DateTime::from_naive_utc_and_offset(
144                date_time.naive_utc(),
145                *offset,
146            ))
147        }
148    }
149}
150
151/// Helper to make the conversion errors easier to spell
152pub trait OrConversionFailed<T> {
153    fn or_conversion_failed(self, original: &PropertyValue) -> ConversionResult<T>;
154}
155impl<T, E> OrConversionFailed<T> for std::result::Result<T, E> {
156    fn or_conversion_failed(self, original: &PropertyValue) -> ConversionResult<T> {
157        match self {
158            Ok(value) => Ok(value),
159            Err(_) => Err(ConversionFailed {
160                type_name: simple_type_name::<T>().unwrap_or("UNKNOWN"),
161                value: original.value.clone(),
162                origin: original.origin.clone(),
163            }),
164        }
165    }
166}
167
168fn simple_type_name<T>() -> Option<&'static str> {
169    std::any::type_name::<T>().split("::").last()
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175    use crate::{CcsResult, Context};
176    use assert_approx_eq::assert_approx_eq;
177    use std::time::{Duration, SystemTime};
178
179    #[test]
180    fn get_duration() -> CcsResult<()> {
181        let contents = r#"
182        duration1 = '5ms'
183        duration2 = '3h'
184        constrained { duration2 = '1d5h' }
185        "#;
186        let context = Context::from_str_without_tracing(contents)?;
187
188        assert_eq!(
189            context.get("duration1")?.to_type::<Duration>()?,
190            Duration::from_millis(5)
191        );
192        assert_eq!(
193            context.get("duration2")?.to_type::<Duration>()?,
194            Duration::from_hours(3)
195        );
196
197        let context = context.constrain("constrained");
198        assert_eq!(
199            context.get("duration2")?.to_type::<Duration>()?,
200            Duration::from_hours(29)
201        );
202
203        Ok(())
204    }
205
206    #[test]
207    fn get_system_time() -> CcsResult<()> {
208        let contents = r#"
209        time1 = '2025-01-01T12:34:56Z'
210        time2 = "1999-12-31T12:23:01-04:00"
211        "#;
212        let context = Context::from_str_without_tracing(contents)?;
213
214        assert_eq!(
215            context.get("time1")?.to_type::<SystemTime>()?,
216            chrono::DateTime::parse_from_rfc3339("2025-01-01 12:34:56Z")
217                .unwrap()
218                .into()
219        );
220        assert_eq!(
221            context.get("time2")?.to_type::<SystemTime>()?,
222            chrono::DateTime::parse_from_rfc3339("1999-12-31T12:23:01-04:00")
223                .unwrap()
224                .into()
225        );
226
227        Ok(())
228    }
229
230    #[test]
231    fn get_date_time() -> CcsResult<()> {
232        let contents = r#"
233        time = "1999-12-31T12:23:01-04:00"
234        "#;
235        let context = Context::from_str_without_tracing(contents)?;
236
237        assert_eq!(
238            context
239                .get("time")?
240                .to_type::<chrono::DateTime<chrono::FixedOffset>>()?,
241            chrono::DateTime::parse_from_rfc3339("1999-12-31T12:23:01-04:00").unwrap()
242        );
243
244        Ok(())
245    }
246
247    #[test]
248    fn get_comma_separated_list() -> CcsResult<()> {
249        let contents = r#"
250        oneLineList = "first, second   ,third"
251        multiLineList = "
252            this,
253            that,
254            the other,
255        " // Extra comma will make an empty element
256        "#;
257        let context = Context::from_str_without_tracing(contents)?;
258
259        assert_eq!(
260            context
261                .get("oneLineList")?
262                .to_type::<CommaSeparatedList>()?
263                .0,
264            ["first", "second", "third"]
265        );
266
267        assert_eq!(
268            context
269                .get("multiLineList")?
270                .to_type::<CommaSeparatedList>()?
271                .0,
272            ["this", "that", "the other", ""]
273        );
274
275        Ok(())
276    }
277
278    #[test]
279    fn get_arithmetic_types() -> CcsResult<()> {
280        let contents = r#"
281        boolVal = false
282        intVal = 123
283        floatVal = 123.4
284        "#;
285        let context = Context::from_str_without_tracing(contents)?;
286
287        let bool_val = context.get("boolVal")?;
288        let int_val = context.get("intVal")?;
289        let float_val = context.get("floatVal")?;
290
291        assert!(!(bool_val.to_type::<bool>()?));
292
293        assert_eq!(int_val.to_type::<u32>()?, 123u32);
294        assert_eq!(int_val.to_type::<i64>()?, 123i64);
295
296        assert_approx_eq!(float_val.to_type::<f32>()?, 123.4f32);
297        assert_approx_eq!(float_val.to_type::<f64>()?, 123.4f64);
298
299        Ok(())
300    }
301
302    #[test]
303    fn realistic_constraints() -> CcsResult<()> {
304        let contents = r#"
305            env.prod module.logger {
306                level = INFO
307            }
308            env.dev module.logger {
309                level = DEBUG
310            }
311            env.dev module.debug {
312                format = "example format"
313            }
314        "#;
315        let context = Context::from_str_without_tracing(contents)?;
316
317        let prod_logger = context
318            .constrain(("env", "prod"))
319            .constrain(("module", "logger"));
320        let dev_logger = context
321            .constrain(("env", "dev"))
322            .constrain(("module", "logger"));
323
324        assert_eq!(&*prod_logger.get_value("level")?, "INFO");
325        assert_eq!(&*dev_logger.get_value("level")?, "DEBUG");
326
327        let dev_debug_module = context
328            .constrain(("env", "dev"))
329            .constrain(("module", "debug"));
330
331        assert_eq!(&*dev_debug_module.get_value("format")?, "example format");
332        assert!(dev_debug_module.get_value("level").is_err());
333
334        Ok(())
335    }
336}