Skip to main content

onetaskgraph_plugin_api/
projection.rs

1//! The one rule deciding what a source's `metadata_fields` entry shows of an item's metadata.
2//!
3//! A `github-projects` source writes the value onto a board text field and a `linear` source
4//! into a collapsed `Fields` section of the item's description; both read the value at an
5//! entry's `key` and `path` through [`ProjectedValue::read`], and refuse a configuration entry
6//! through [`ProjectedValue::entry_refusal`], so the two sources cannot accept different
7//! shapes. How each one renders a value is its own. `docs/metadata.md` states the rule for a
8//! caller.
9
10use std::collections::BTreeMap;
11
12use serde_json::Value;
13
14use crate::MetadataKey;
15
16/// What an item's metadata holds at one projected key and path, when it holds a value a
17/// projection can show.
18#[derive(Debug, Clone, PartialEq, Eq)]
19pub enum ProjectedValue {
20    /// A string, the empty string included.
21    Text(String),
22    /// An array whose every element is a string, in array order.
23    List(Vec<String>),
24    /// An object whose every value is a string, in ascending byte order of its keys whatever
25    /// order the JSON was written in.
26    Map(BTreeMap<String, String>),
27}
28
29impl ProjectedValue {
30    /// The value `metadata` holds at `key` and then each object key of `path` in turn:
31    /// `Ok(None)` for nothing there or `null`, and otherwise the value, or — for any other
32    /// shape — `Err` with what was found, as a refusal spells it: `a boolean`, `a number`, or
33    /// for a mixed array or object `an array whose element 2 is a number` or
34    /// `an object whose value at key "b" is an array`.
35    ///
36    /// # Errors
37    ///
38    /// The shape found, when it is none a projection can show.
39    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            // `Map` iterates in key order, so the first offending key is the first in byte order.
64            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    /// Whether the value shows nothing: the empty string, the empty array or the empty object.
82    #[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    /// Why one `metadata_fields` entry naming `field` and `key` cannot stand beside the
92    /// `earlier` fields of the same source, compared ignoring ASCII case — or `None` when it
93    /// can: a blank field, a blank key, a key under [`MetadataKey::RESERVED_NAMESPACE`], or a
94    /// field an earlier entry names, which the refusal calls a `noun` (`board field`, say). A
95    /// source refusing field names of its own checks them after this.
96    #[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
128/// A JSON value's type, with its article, as a refusal names it.
129fn 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}