pamoja_profile/params.rs
1//! The parameters a manifest carries beside a control kind pamoja never shipped.
2
3use std::collections::BTreeMap;
4
5use serde::{Deserialize, Serialize};
6
7/// One parameter of a custom control kind: a number, a flag, or a piece of text.
8///
9/// A manifest writes it as the plain JSON value, so `"warn_below": 2.0`,
10/// `"latching": true`, and `"zone": "north"` are all parameters.
11#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
12#[serde(untagged)]
13pub enum Param {
14 /// A number; an integer in the manifest arrives as a whole number here.
15 Number(f64),
16 /// A flag.
17 Flag(bool),
18 /// A piece of text.
19 Text(String),
20}
21
22/// The parameters of a custom control kind, by name.
23///
24/// These are every field a manifest carried beside `kind` for a kind the library does
25/// not know, kept as data so the factory registered for the kind can read them. The
26/// accessors answer `None` when a parameter is absent or of another type, so a factory
27/// reports what it needed rather than guessing.
28///
29/// # Examples
30///
31/// ```
32/// use pamoja_profile::{Param, Params};
33///
34/// let params = Params::new()
35/// .with("warn_below", 2.0)
36/// .with("latching", true)
37/// .with("zone", "north");
38/// assert_eq!(params.number("warn_below"), Some(2.0));
39/// assert_eq!(params.flag("latching"), Some(true));
40/// assert_eq!(params.text("zone"), Some("north"));
41/// assert_eq!(params.number("zone"), None);
42/// assert_eq!(params.get("missing"), None);
43/// assert!(matches!(params.get("zone"), Some(Param::Text(_))));
44/// ```
45#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
46#[serde(transparent)]
47pub struct Params(BTreeMap<String, Param>);
48
49impl Params {
50 /// Starts with no parameters.
51 ///
52 /// # Returns
53 ///
54 /// An empty set.
55 pub fn new() -> Self {
56 Self::default()
57 }
58
59 /// Adds a parameter, replacing one of the same name.
60 ///
61 /// # Arguments
62 ///
63 /// * `name` - the parameter's name, as the manifest writes it.
64 /// * `value` - a number, a flag, or text.
65 ///
66 /// # Returns
67 ///
68 /// The parameters, for chaining.
69 pub fn with(mut self, name: impl Into<String>, value: impl Into<Param>) -> Self {
70 self.0.insert(name.into(), value.into());
71 self
72 }
73
74 /// Looks a parameter up by name.
75 ///
76 /// # Arguments
77 ///
78 /// * `name` - the parameter's name.
79 ///
80 /// # Returns
81 ///
82 /// The parameter, or `None` when the manifest carried none of that name.
83 pub fn get(&self, name: &str) -> Option<&Param> {
84 self.0.get(name)
85 }
86
87 /// Reads a numeric parameter.
88 ///
89 /// # Arguments
90 ///
91 /// * `name` - the parameter's name.
92 ///
93 /// # Returns
94 ///
95 /// The number, or `None` when the parameter is absent or not a number.
96 pub fn number(&self, name: &str) -> Option<f64> {
97 match self.0.get(name) {
98 Some(Param::Number(value)) => Some(*value),
99 _ => None,
100 }
101 }
102
103 /// Reads a flag parameter.
104 ///
105 /// # Arguments
106 ///
107 /// * `name` - the parameter's name.
108 ///
109 /// # Returns
110 ///
111 /// The flag, or `None` when the parameter is absent or not a flag.
112 pub fn flag(&self, name: &str) -> Option<bool> {
113 match self.0.get(name) {
114 Some(Param::Flag(value)) => Some(*value),
115 _ => None,
116 }
117 }
118
119 /// Reads a text parameter.
120 ///
121 /// # Arguments
122 ///
123 /// * `name` - the parameter's name.
124 ///
125 /// # Returns
126 ///
127 /// The text, or `None` when the parameter is absent or not text.
128 pub fn text(&self, name: &str) -> Option<&str> {
129 match self.0.get(name) {
130 Some(Param::Text(value)) => Some(value),
131 _ => None,
132 }
133 }
134
135 /// Loads parameters from the JSON object a manifest carries beside a custom kind.
136 ///
137 /// # Arguments
138 ///
139 /// * `text` - the JSON object.
140 ///
141 /// # Returns
142 ///
143 /// The parameters.
144 ///
145 /// # Errors
146 ///
147 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the text is not an object
148 /// of numbers, flags, and text.
149 #[cfg(feature = "json")]
150 pub fn from_json(text: &str) -> pamoja_core::Result<Self> {
151 serde_json::from_str(text).map_err(|error| pamoja_core::Error::Codec(error.to_string()))
152 }
153
154 /// Writes the parameters as the JSON object a manifest carries beside a custom kind.
155 ///
156 /// # Returns
157 ///
158 /// The JSON text, on one line.
159 ///
160 /// # Errors
161 ///
162 /// Returns [`Error::Codec`](pamoja_core::Error::Codec) if the parameters cannot be
163 /// serialized.
164 #[cfg(feature = "json")]
165 pub fn to_json(&self) -> pamoja_core::Result<String> {
166 serde_json::to_string(self).map_err(|error| pamoja_core::Error::Codec(error.to_string()))
167 }
168
169 /// Walks the parameters in name order.
170 ///
171 /// # Returns
172 ///
173 /// Each name with its parameter.
174 pub fn iter(&self) -> impl Iterator<Item = (&str, &Param)> {
175 self.0.iter().map(|(name, value)| (name.as_str(), value))
176 }
177
178 /// Counts the parameters.
179 ///
180 /// # Returns
181 ///
182 /// How many the manifest carried.
183 pub fn len(&self) -> usize {
184 self.0.len()
185 }
186
187 /// Whether there are no parameters.
188 ///
189 /// # Returns
190 ///
191 /// `true` when the manifest carried only the kind.
192 pub fn is_empty(&self) -> bool {
193 self.0.is_empty()
194 }
195}
196
197impl From<BTreeMap<String, Param>> for Params {
198 fn from(map: BTreeMap<String, Param>) -> Self {
199 Self(map)
200 }
201}
202
203impl From<Params> for BTreeMap<String, Param> {
204 fn from(params: Params) -> Self {
205 params.0
206 }
207}
208
209impl From<f64> for Param {
210 fn from(value: f64) -> Self {
211 Param::Number(value)
212 }
213}
214
215impl From<f32> for Param {
216 fn from(value: f32) -> Self {
217 Param::Number(f64::from(value))
218 }
219}
220
221impl From<i32> for Param {
222 fn from(value: i32) -> Self {
223 Param::Number(f64::from(value))
224 }
225}
226
227impl From<u32> for Param {
228 fn from(value: u32) -> Self {
229 Param::Number(f64::from(value))
230 }
231}
232
233impl From<bool> for Param {
234 fn from(value: bool) -> Self {
235 Param::Flag(value)
236 }
237}
238
239impl From<&str> for Param {
240 fn from(value: &str) -> Self {
241 Param::Text(value.to_owned())
242 }
243}
244
245impl From<String> for Param {
246 fn from(value: String) -> Self {
247 Param::Text(value)
248 }
249}
250
251#[cfg(test)]
252mod tests {
253 use super::*;
254
255 #[test]
256 fn parameters_keep_their_type_and_round_trip_as_json() {
257 let params = Params::new()
258 .with("warn_below", 2.5f32)
259 .with("samples", 4u32)
260 .with("latching", false)
261 .with("zone", "north");
262 let json = serde_json::to_string(¶ms).unwrap();
263 assert_eq!(
264 json,
265 r#"{"latching":false,"samples":4.0,"warn_below":2.5,"zone":"north"}"#
266 );
267 let restored: Params = serde_json::from_str(&json).unwrap();
268 assert_eq!(restored, params);
269 assert_eq!(restored.number("samples"), Some(4.0));
270 assert_eq!(restored.flag("latching"), Some(false));
271 assert_eq!(restored.text("zone"), Some("north"));
272 assert_eq!(restored.len(), 4);
273 assert!(!restored.is_empty());
274 assert_eq!(
275 restored.iter().map(|(name, _)| name).collect::<Vec<_>>(),
276 ["latching", "samples", "warn_below", "zone"]
277 );
278 }
279
280 #[test]
281 fn an_integer_in_a_manifest_is_a_number() {
282 let params: Params = serde_json::from_str(r#"{ "count": 3, "on": true }"#).unwrap();
283 assert_eq!(params.number("count"), Some(3.0));
284 assert_eq!(params.flag("on"), Some(true));
285 assert_eq!(params.flag("count"), None);
286 }
287}