Skip to main content

trunk_recorder_plugin/
schema.rs

1//! Settings schemas: a config struct's JSON Schema (from `schemars`), reduced
2//! to the subset the recorder's settings form draws.
3//!
4//! The form understands an object whose properties are:
5//! - `"type": "string"` — a text box; `"x-secret": true` hides what's typed,
6//!   `"format": "uri"` checks for a URL, `"x-multiline": true` for a text area
7//! - `"type": "integer"` or `"number"` — `minimum` / `maximum` apply
8//! - `"type": "boolean"` — a switch
9//! - `"enum": [...]` — a menu (`"x-enum-labels"`, when given, are what it shows)
10//! - `"type": "array"` of strings or numbers — a list
11//! - `"type": "array"` of objects — a list of groups, added and removed one by one
12//! - `"type": "object"` — a group of the above
13//!
14//! Two more, for a field of either:
15//! - `"x-system": true` on a string — a menu of the recorder's systems (by
16//!   short name); the recorder updates it when a system is renamed
17//! - `"x-required": true` — the field has to be filled in: until it is, the
18//!   recorder shows the plugin, or that system, as not set up. ([`normalize`]
19//!   moves these into the object's `required` list; `#[schemars(required)]`
20//!   doesn't work on a `#[serde(default)]` struct, so mark them this way:
21//!   `#[schemars(extend("x-required" = true))]`.)
22//!
23//! Fields are shown in `x-order` (the struct's order, added by [`normalize`]).
24//! Each may have a `title` (else the key, spelled out), a `description`
25//! (help under the field) and a `default` — which `schemars` only writes
26//! when the config type also derives `Serialize`.
27//!
28//! [`normalize`] turns what `schemars` makes of ordinary Rust into that:
29//! `Option<T>` becomes `T` (left empty = not set), unit-variant enums become
30//! `enum`, nested structs are inlined, and a doc comment's first paragraph
31//! becomes the `title` with the rest the `description`.
32
33use schemars::generate::SchemaSettings;
34use schemars::JsonSchema;
35use serde_json::{Map, Value};
36
37/// The normalized schema of `T`.
38pub fn schema_for<T: JsonSchema>() -> Value {
39    let generator = SchemaSettings::draft2020_12()
40        .with(|s| {
41            s.inline_subschemas = true;
42            s.meta_schema = None;
43        })
44        .into_generator();
45    let mut v = generator.into_root_schema_for::<T>().to_value();
46    normalize(&mut v);
47    if let Some(o) = v.as_object_mut() {
48        // The root's title is the Rust type's name, and its doc comment is
49        // for programmers: neither is for users.
50        o.remove("title");
51        o.remove("description");
52    }
53    v
54}
55
56/// See the module docs.
57pub fn normalize(v: &mut Value) {
58    let Some(o) = v.as_object_mut() else { return };
59    // Option<T>: "type": ["T", "null"] → "T"
60    if let Some(Value::Array(types)) = o.get("type") {
61        let rest: Vec<Value> = types.iter().filter(|t| t.as_str() != Some("null")).cloned().collect();
62        if rest.len() == 1 {
63            o.insert("type".into(), rest[0].clone());
64        }
65    }
66    // Option<Struct> / Option<Enum>: "anyOf": [{…}, {"type": "null"}] → {…}
67    if let Some(Value::Array(any)) = o.get("anyOf") {
68        let rest: Vec<Value> = any.iter().filter(|s| s.get("type").and_then(Value::as_str) != Some("null")).cloned().collect();
69        if rest.len() == 1 {
70            o.remove("anyOf");
71            if let Value::Object(inner) = &rest[0] {
72                for (k, x) in inner {
73                    o.entry(k.clone()).or_insert_with(|| x.clone());
74                }
75            }
76        }
77    }
78    // Unit-variant enums: "oneOf": [{"enum": ["A"]}, {"const": "B", "description": …}] → "enum"
79    if let Some(Value::Array(one)) = o.get("oneOf") {
80        let mut values = Vec::new();
81        let mut labels = Vec::new();
82        let mut plain = true;
83        for s in one {
84            let vs: Vec<Value> = match (s.get("const"), s.get("enum")) {
85                (Some(c), _) => vec![c.clone()],
86                (None, Some(Value::Array(e))) => e.clone(),
87                _ => {
88                    plain = false;
89                    break;
90                }
91            };
92            for x in vs {
93                let label = s.get("title").or(s.get("description")).and_then(Value::as_str).map(str::to_string);
94                labels.push(label.map(Value::String).unwrap_or_else(|| x.clone()));
95                values.push(x);
96            }
97        }
98        if plain {
99            o.remove("oneOf");
100            o.insert("type".into(), "string".into());
101            if labels != values {
102                o.insert("x-enum-labels".into(), Value::Array(labels));
103            }
104            o.insert("enum".into(), Value::Array(values));
105        }
106    }
107    // Doc comments are wrapped to fit the source: unwrap them (paragraphs stay).
108    if let Some(d) = o.get("description").and_then(Value::as_str) {
109        let unwrapped = d.split("\n\n").map(|p| p.split_whitespace().collect::<Vec<_>>().join(" ")).collect::<Vec<_>>().join("\n\n");
110        o.insert("description".into(), unwrapped.into());
111    }
112    // Doc comments: the first paragraph is the title.
113    if !o.contains_key("title") {
114        if let Some(d) = o.get("description").and_then(Value::as_str).map(str::to_string) {
115            match d.split_once("\n\n") {
116                Some((title, rest)) => {
117                    o.insert("title".into(), title.trim().into());
118                    o.insert("description".into(), rest.trim().into());
119                }
120                None if d.len() <= 60 && !d.trim_end().ends_with('.') => {
121                    o.insert("title".into(), d.trim().into());
122                    o.remove("description");
123                }
124                None => {}
125            }
126        }
127    }
128    // `Option`s default to null: that's "not set", which the form shows anyway.
129    if o.get("default").is_some_and(Value::is_null) {
130        o.remove("default");
131    }
132    // Rust integer formats mean nothing to the form.
133    if let Some(f) = o.get("format").and_then(Value::as_str) {
134        if f.starts_with("int") || f.starts_with("uint") || f == "float" || f == "double" {
135            o.remove("format");
136        }
137    }
138    // JSON objects have no order: say which the fields come in (as declared).
139    if let Some(Value::Object(props)) = o.get("properties") {
140        let order: Vec<Value> = props.keys().map(|k| Value::String(k.clone())).collect();
141        o.insert("x-order".into(), Value::Array(order));
142    }
143    for key in ["properties", "items"] {
144        match o.get_mut(key) {
145            Some(Value::Object(props)) if key == "properties" => props.values_mut().for_each(normalize),
146            Some(items @ Value::Object(_)) => normalize(items),
147            _ => {}
148        }
149    }
150    // `"x-required": true` on a field → the object's `required` list.
151    if let Some(Value::Object(props)) = o.get_mut("properties") {
152        let marked: Vec<String> = props
153            .iter_mut()
154            .filter_map(|(k, f)| f.as_object_mut()?.remove("x-required").filter(|v| v == &Value::Bool(true)).map(|_| k.clone()))
155            .collect();
156        if !marked.is_empty() {
157            let req = o.entry("required").or_insert_with(|| Value::Array(vec![]));
158            if let Value::Array(r) = req {
159                for k in marked {
160                    if !r.contains(&Value::String(k.clone())) {
161                        r.push(Value::String(k));
162                    }
163                }
164            }
165        }
166    }
167    // A config struct with `#[serde(default)]` needs nothing: drop an empty list.
168    if o.get("required").and_then(Value::as_array).is_some_and(|r| r.is_empty()) {
169        o.remove("required");
170    }
171}
172
173/// A config with no settings.
174#[derive(Clone, Copy, Debug, Default, serde::Deserialize)]
175pub struct NoConfig {}
176
177impl JsonSchema for NoConfig {
178    fn schema_name() -> std::borrow::Cow<'static, str> {
179        "NoConfig".into()
180    }
181    fn json_schema(_: &mut schemars::SchemaGenerator) -> schemars::Schema {
182        let mut m = Map::new();
183        m.insert("type".into(), "object".into());
184        m.insert("properties".into(), Value::Object(Map::new()));
185        schemars::Schema::from(m)
186    }
187}
188
189pub(crate) fn is_empty(schema: &Value) -> bool {
190    schema.get("properties").and_then(Value::as_object).is_none_or(|p| p.is_empty())
191}
192
193#[cfg(test)]
194mod tests {
195    use super::*;
196    use serde::{Deserialize, Serialize};
197    use serde_json::json;
198
199    #[derive(Serialize, Deserialize, JsonSchema, Default)]
200    #[serde(rename_all = "camelCase", default)]
201    #[allow(dead_code)]
202    struct Settings {
203        /// Upload server
204        ///
205        /// Where calls
206        /// go.
207        server: String,
208        /// API key
209        #[schemars(extend("x-secret" = true))]
210        api_key: Option<String>,
211        mode: Mode,
212        #[schemars(range(min = 1, max = 10))]
213        retries: u32,
214    }
215
216    #[derive(Serialize, Deserialize, JsonSchema, Default)]
217    #[serde(rename_all = "lowercase")]
218    #[allow(dead_code)]
219    enum Mode {
220        #[default]
221        Fast,
222        /// Slow and steady
223        Slow,
224    }
225
226    #[derive(Serialize, Deserialize, JsonSchema, Default)]
227    #[serde(rename_all = "camelCase", default)]
228    #[allow(dead_code)]
229    struct Marked {
230        /// Server
231        #[schemars(extend("x-required" = true))]
232        server: String,
233        streams: Vec<Stream>,
234    }
235
236    #[derive(Serialize, Deserialize, JsonSchema, Default)]
237    #[serde(rename_all = "camelCase", default)]
238    #[allow(dead_code)]
239    struct Stream {
240        /// System
241        #[schemars(extend("x-system" = true, "x-required" = true))]
242        short_name: String,
243        port: u16,
244    }
245
246    #[test]
247    fn required_and_system_fields_come_through() {
248        let s = schema_for::<Marked>();
249        assert_eq!(s["required"], json!(["server"]));
250        let item = &s["properties"]["streams"]["items"];
251        assert_eq!(item["properties"]["shortName"]["x-system"], true);
252        assert_eq!(item["required"], json!(["shortName"]));
253        assert!(item["properties"]["shortName"].get("x-required").is_none());
254        assert!(item.get("required").is_some() && s["properties"]["streams"].get("required").is_none());
255    }
256
257    #[test]
258    fn normalizes_what_schemars_makes() {
259        let s = schema_for::<Settings>();
260        let p = &s["properties"];
261        assert_eq!(p["server"]["title"], "Upload server");
262        assert_eq!(p["server"]["description"], "Where calls go.");
263        assert_eq!(p["apiKey"]["type"], "string");
264        assert_eq!(p["apiKey"]["title"], "API key");
265        assert_eq!(p["apiKey"]["x-secret"], true);
266        assert!(p["apiKey"].get("default").is_none());
267        assert_eq!(p["mode"]["enum"], json!(["fast", "slow"]));
268        assert_eq!(p["mode"]["x-enum-labels"], json!(["fast", "Slow and steady"]));
269        assert_eq!(p["mode"]["default"], "fast");
270        assert_eq!(p["retries"]["maximum"], 10);
271        assert!(p["retries"].get("format").is_none());
272        assert!(s.get("title").is_none());
273        assert_eq!(s["x-order"], json!(["server", "apiKey", "mode", "retries"]));
274    }
275}