onetaskgraph_plugin_api/
projection.rs1use std::collections::BTreeMap;
11
12use serde_json::Value;
13
14use crate::MetadataKey;
15
16#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ProjectedValue {
20 Text(String),
22 List(Vec<String>),
24 Map(BTreeMap<String, String>),
27}
28
29impl ProjectedValue {
30 pub fn read(
40 metadata: &BTreeMap<String, Value>,
41 key: &str,
42 path: &[String],
43 ) -> Result<Option<Self>, String> {
44 let mut value = metadata.get(key);
45 for step in path {
46 value = value.and_then(|held| held.get(step.as_str()));
47 }
48 match value {
49 None | Some(Value::Null) => Ok(None),
50 Some(Value::String(text)) => Ok(Some(Self::Text(text.clone()))),
51 Some(Value::Array(elements)) => elements
52 .iter()
53 .enumerate()
54 .map(|(index, element)| match element {
55 Value::String(text) => Ok(text.clone()),
56 other => Err(format!(
57 "an array whose element {index} is {}",
58 type_name(other)
59 )),
60 })
61 .collect::<Result<_, _>>()
62 .map(|list| Some(Self::List(list))),
63 Some(Value::Object(entries)) => entries
65 .iter()
66 .collect::<BTreeMap<_, _>>()
67 .into_iter()
68 .map(|(name, held)| match held {
69 Value::String(text) => Ok((name.clone(), text.clone())),
70 other => Err(format!(
71 "an object whose value at key {name:?} is {}",
72 type_name(other)
73 )),
74 })
75 .collect::<Result<_, _>>()
76 .map(|map| Some(Self::Map(map))),
77 Some(other) => Err(type_name(other).to_owned()),
78 }
79 }
80
81 #[must_use]
83 pub fn is_empty(&self) -> bool {
84 match self {
85 Self::Text(text) => text.is_empty(),
86 Self::List(list) => list.is_empty(),
87 Self::Map(map) => map.is_empty(),
88 }
89 }
90
91 #[must_use]
97 pub fn entry_refusal<'a>(
98 field: &str,
99 key: &str,
100 earlier: impl IntoIterator<Item = &'a str>,
101 noun: &str,
102 ) -> Option<String> {
103 if field.trim().is_empty() {
104 return Some("names a blank field".to_owned());
105 }
106 if key.trim().is_empty() {
107 return Some("names a blank key".to_owned());
108 }
109 if key.split('.').next() == Some(MetadataKey::RESERVED_NAMESPACE) {
110 return Some(format!(
111 "names a key under the reserved \"{}.\" prefix, which this product keeps for \
112 its own keys; next: project a caller key instead",
113 MetadataKey::RESERVED_NAMESPACE
114 ));
115 }
116 earlier
117 .into_iter()
118 .position(|other| other.eq_ignore_ascii_case(field))
119 .map(|at| {
120 format!(
121 "names the same {noun} as metadata_fields[{at}]; one field cannot hold two \
122 values"
123 )
124 })
125 }
126}
127
128fn type_name(value: &Value) -> &'static str {
130 match value {
131 Value::Null => "null",
132 Value::Bool(_) => "a boolean",
133 Value::Number(_) => "a number",
134 Value::String(_) => "a string",
135 Value::Array(_) => "an array",
136 Value::Object(_) => "an object",
137 }
138}
139
140#[cfg(test)]
141mod tests {
142 use super::ProjectedValue;
143 use serde_json::json;
144 use std::collections::BTreeMap;
145
146 fn at(value: serde_json::Value) -> Result<Option<ProjectedValue>, String> {
147 let metadata = BTreeMap::from([("acme.record".to_owned(), json!({"inner": value}))]);
148 ProjectedValue::read(&metadata, "acme.record", &["inner".to_owned()])
149 }
150
151 #[test]
152 fn every_accepted_shape_reads_and_everything_else_is_named() {
153 assert_eq!(at(json!(null)), Ok(None));
154 assert_eq!(at(json!("")), Ok(Some(ProjectedValue::Text(String::new()))));
155 assert_eq!(
156 at(json!(["b", "a"])),
157 Ok(Some(ProjectedValue::List(vec!["b".into(), "a".into()])))
158 );
159 let Ok(Some(ProjectedValue::Map(map))) = at(json!({"b": "2", "a": "1"})) else {
160 panic!("an object of strings reads as a map");
161 };
162 assert_eq!(map.keys().collect::<Vec<_>>(), ["a", "b"]);
163 assert_eq!(at(json!(true)), Err("a boolean".into()));
164 assert_eq!(at(json!(1)), Err("a number".into()));
165 assert_eq!(
166 at(json!(["a", ["b"]])),
167 Err("an array whose element 1 is an array".into())
168 );
169 assert_eq!(
170 at(json!({"z": "1", "b": 2, "c": {}})),
171 Err("an object whose value at key \"b\" is a number".into())
172 );
173 }
174
175 #[test]
176 fn an_entry_is_refused_for_each_reason_and_otherwise_kept() {
177 assert!(ProjectedValue::entry_refusal(" ", "a.b", [], "field").is_some());
178 assert!(ProjectedValue::entry_refusal("F", " ", [], "field").is_some());
179 assert!(ProjectedValue::entry_refusal("F", "onetaskgraph.x", [], "field").is_some());
180 assert_eq!(
181 ProjectedValue::entry_refusal("host", "a.b", ["Other", "HOST"], "field").as_deref(),
182 Some("names the same field as metadata_fields[1]; one field cannot hold two values")
183 );
184 assert_eq!(
185 ProjectedValue::entry_refusal("F", "a.b", ["G"], "field"),
186 None
187 );
188 }
189}