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