Skip to main content

sapphire_framework_bridge_api/
settings.rs

1//! Schema and value types of the settings registry.
2//!
3//! A setting is addressed by its key (its TOML path in a scope's `config.toml`) and
4//! described by a [`SettingDef`]: its [`Kind`], the [`Scope`]s it may live in, and
5//! whether a change applies live or needs a restart. This module is types only, so the
6//! bridge, the app servers and the CLIs can share it without sharing a store.
7
8use std::fmt;
9use std::net::SocketAddr;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14/// Where a setting is stored. Ordered as declared: device, workgroup, workspace.
15#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Scope {
18    /// This machine only.
19    Device,
20    /// Shared by every member of a workgroup.
21    Workgroup,
22    /// One workspace.
23    Workspace,
24}
25
26impl fmt::Display for Scope {
27    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
28        f.write_str(match self {
29            Scope::Device => "device",
30            Scope::Workgroup => "workgroup",
31            Scope::Workspace => "workspace",
32        })
33    }
34}
35
36impl FromStr for Scope {
37    type Err = String;
38
39    fn from_str(s: &str) -> Result<Self, String> {
40        match s {
41            "device" => Ok(Scope::Device),
42            "workgroup" => Ok(Scope::Workgroup),
43            "workspace" => Ok(Scope::Workspace),
44            other => Err(format!(
45                "unknown scope `{other}` (expected device, workgroup or workspace)"
46            )),
47        }
48    }
49}
50
51/// The type of a setting's value, and so what it accepts.
52#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum Kind {
55    /// `true`/`false`, also written `on`/`off`.
56    Bool,
57    /// An integer within `min..=max`.
58    Int {
59        /// Smallest accepted value.
60        min: i64,
61        /// Largest accepted value.
62        max: i64,
63    },
64    /// One of a fixed set of strings.
65    Enum {
66        /// The accepted strings.
67        choices: Vec<String>,
68    },
69    /// Free text.
70    String {
71        /// Reject the empty string.
72        non_empty: bool,
73    },
74    /// An `http` or `https` URL.
75    Url,
76    /// A filesystem path.
77    Path,
78    /// A `host:port` socket address.
79    SocketAddr,
80    /// A list of items of one kind.
81    List {
82        /// The kind of every item.
83        item: Box<Kind>,
84    },
85    /// A credential: device-scope, never read back, never taken from text on a command line.
86    Secret,
87}
88
89/// When a changed setting takes effect.
90#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Apply {
93    /// Picked up by the running process.
94    Live,
95    /// Needs a restart.
96    Restart,
97}
98
99/// A typed setting value.
100///
101/// Untagged on the wire, so a JSON `5` is `Int` and `[1,2]` is a list of `Int`.
102#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum Value {
105    /// A boolean.
106    Bool(bool),
107    /// An integer.
108    Int(i64),
109    /// A string (also URLs, paths, addresses, enum choices).
110    String(String),
111    /// A list.
112    List(Vec<Value>),
113}
114
115impl fmt::Display for Value {
116    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
117        match self {
118            Value::Bool(b) => write!(f, "{b}"),
119            Value::Int(i) => write!(f, "{i}"),
120            Value::String(s) => f.write_str(s),
121            Value::List(items) => {
122                for (i, item) in items.iter().enumerate() {
123                    if i > 0 {
124                        f.write_str(",")?;
125                    }
126                    write!(f, "{item}")?;
127                }
128                Ok(())
129            }
130        }
131    }
132}
133
134/// Which process owns a setting: the one that stores, validates and applies it.
135#[derive(
136    Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
137)]
138#[serde(rename_all = "snake_case")]
139pub enum Process {
140    /// The bridge.
141    #[default]
142    Bridge,
143    /// The app server.
144    App,
145}
146
147/// The registry's description of one setting.
148#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149pub struct SettingDef {
150    /// The TOML path, e.g. `embed.remote.endpoint`.
151    pub key: String,
152    /// What the value must look like.
153    pub kind: Kind,
154    /// The scopes it may be set in; the first is its home.
155    pub scopes: Vec<Scope>,
156    /// Whether a device may override a value set in a wider scope.
157    pub overridable: bool,
158    /// When a change takes effect.
159    pub apply: Apply,
160    /// The value in effect when no scope sets it.
161    pub default: Option<Value>,
162    /// The heading it is listed under.
163    pub section: String,
164    /// One-line help text.
165    pub doc: String,
166    /// The process that owns it; an older peer's definitions read as the bridge's.
167    #[serde(default)]
168    pub process: Process,
169}
170
171impl SettingDef {
172    /// The scope the setting normally lives in.
173    pub fn home(&self) -> Scope {
174        self.scopes[0]
175    }
176
177    /// Whether the setting may be set in `scope`: its home, any other listed scope, or
178    /// `Device` when it is overridable.
179    pub fn allows(&self, scope: Scope) -> bool {
180        scope == self.home()
181            || (scope == Scope::Device && self.overridable)
182            || self.scopes.contains(&scope)
183    }
184}
185
186/// Whether a secret has a value; the value itself is never exposed.
187#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum SecretState {
190    /// A value is stored.
191    Set,
192    /// Nothing is stored.
193    Unset,
194}
195
196/// What `get`/`list` report for a key.
197#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum EntryValue {
200    /// The effective value.
201    Value(Value),
202    /// A secret: only whether it is set.
203    Secret(SecretState),
204    /// No value and no default.
205    Unset,
206}
207
208/// Where the effective value came from.
209#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum Origin {
212    /// Set in this scope.
213    Scope(Scope),
214    /// The registry default.
215    Default,
216}
217
218/// One setting as reported to a client.
219#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
220pub struct Entry {
221    /// The setting's key.
222    pub key: String,
223    /// Its effective value.
224    pub value: EntryValue,
225    /// Where that value came from.
226    pub origin: Origin,
227    /// A restart-only change has been written but is not yet in effect.
228    pub pending_restart: bool,
229    /// Extra remark for the user (e.g. an unknown key kept in the file).
230    pub note: Option<String>,
231}
232
233impl Kind {
234    /// Parse user text into a value of this kind, then [`check`](Kind::check) it.
235    pub fn parse(&self, text: &str) -> Result<Value, String> {
236        let value = match self {
237            Kind::Bool => match text {
238                "true" | "on" => Value::Bool(true),
239                "false" | "off" => Value::Bool(false),
240                _ => return Err("not a boolean (expected one of true, false, on, off)".into()),
241            },
242            Kind::Int { .. } => Value::Int(
243                text.trim()
244                    .parse::<i64>()
245                    .map_err(|_| "not an integer".to_string())?,
246            ),
247            Kind::Enum { .. } | Kind::String { .. } | Kind::Url | Kind::Path | Kind::SocketAddr => {
248                Value::String(text.to_string())
249            }
250            Kind::List { item } => Value::List(
251                split_list(text)
252                    .iter()
253                    .map(|part| item.parse(part))
254                    .collect::<Result<_, _>>()?,
255            ),
256            Kind::Secret => {
257                return Err(
258                    "a secret cannot be given as text; read it with --stdin or a prompt".into(),
259                );
260            }
261        };
262        self.check(&value)?;
263        Ok(value)
264    }
265
266    /// Check an already-typed value against this kind.
267    pub fn check(&self, value: &Value) -> Result<(), String> {
268        match (self, value) {
269            (Kind::Bool, Value::Bool(_)) => Ok(()),
270            (Kind::Int { min, max }, Value::Int(i)) => {
271                if (*min..=*max).contains(i) {
272                    Ok(())
273                } else {
274                    Err(format!("out of range (expected {min}..={max})"))
275                }
276            }
277            (Kind::Enum { choices }, Value::String(s)) => {
278                if choices.contains(s) {
279                    Ok(())
280                } else {
281                    Err(format!("not one of the choices ({})", choices.join(", ")))
282                }
283            }
284            (Kind::String { non_empty }, Value::String(s)) => {
285                if *non_empty && s.is_empty() {
286                    Err("must not be empty".into())
287                } else {
288                    Ok(())
289                }
290            }
291            (Kind::Url, Value::String(s)) => {
292                let rest = s
293                    .strip_prefix("http://")
294                    .or_else(|| s.strip_prefix("https://"));
295                match rest {
296                    Some(rest) if !rest.is_empty() => Ok(()),
297                    _ => Err("not an http or https URL".into()),
298                }
299            }
300            (Kind::Path, Value::String(s)) => {
301                if std::path::Path::new(s).as_os_str().is_empty() {
302                    Err("path must not be empty".into())
303                } else {
304                    Ok(())
305                }
306            }
307            (Kind::SocketAddr, Value::String(s)) => s
308                .parse::<SocketAddr>()
309                .map(|_| ())
310                .map_err(|_| "not a host:port socket address".to_string()),
311            (Kind::List { item }, Value::List(items)) => {
312                items.iter().try_for_each(|v| item.check(v))
313            }
314            (Kind::Secret, Value::String(_)) => Ok(()),
315            // A secret's value is never shown, not even a wrong one.
316            (Kind::Secret, _) => Err("a secret must be a string".into()),
317            // Error text never carries the value: it reaches logs, the GUI and CLI output.
318            (kind, _) => Err(format!("has the wrong type (expected {})", kind.name())),
319        }
320    }
321
322    /// A short name for this kind, for error text.
323    fn name(&self) -> &'static str {
324        match self {
325            Kind::Bool => "bool",
326            Kind::Int { .. } => "integer",
327            Kind::Enum { .. } => "choice",
328            Kind::String { .. } => "string",
329            Kind::Url => "URL",
330            Kind::Path => "path",
331            Kind::SocketAddr => "socket address",
332            Kind::List { .. } => "list",
333            Kind::Secret => "secret",
334        }
335    }
336}
337
338/// Split list text: a comma-separated string or a TOML-style `["a", "b"]` array.
339fn split_list(text: &str) -> Vec<String> {
340    let text = text.trim();
341    let text = text
342        .strip_prefix('[')
343        .and_then(|t| t.strip_suffix(']'))
344        .unwrap_or(text);
345    if text.trim().is_empty() {
346        return Vec::new();
347    }
348    text.split(',')
349        .map(|part| {
350            let part = part.trim();
351            part.strip_prefix('"')
352                .and_then(|p| p.strip_suffix('"'))
353                .unwrap_or(part)
354                .to_string()
355        })
356        .collect()
357}
358
359/// The keys of the GUI's settings: stored by the bridge, read by the apps' windows.
360pub mod gui {
361    /// Light, dark, or the system's.
362    pub const THEME_PREFERENCE: &str = "gui.theme.preference";
363    /// How far apart text and background are.
364    pub const THEME_CONTRAST: &str = "gui.theme.contrast";
365    /// The accent: one of [`ACCENT_CHOICES`].
366    pub const THEME_ACCENT: &str = "gui.theme.accent";
367    /// A custom accent's hue, in whole degrees.
368    pub const THEME_ACCENT_HUE: &str = "gui.theme.accent_hue";
369    /// A custom accent's chroma, in thousandths.
370    pub const THEME_ACCENT_CHROMA: &str = "gui.theme.accent_chroma";
371    /// The accent's values: `default`, one of the theme's colours by name, or `custom`, whose
372    /// hue and chroma are [`THEME_ACCENT_HUE`] and [`THEME_ACCENT_CHROMA`].
373    pub const ACCENT_CHOICES: [&str; 10] = [
374        "default", "neutral", "red", "orange", "yellow", "green", "cyan", "blue", "purple",
375        "custom",
376    ];
377    /// The language of the apps' windows: `system`, or a language tag such as `ja` or
378    /// `en-US`.
379    pub const LOCALE: &str = "gui.locale";
380    /// Every theme key.
381    pub const THEME_KEYS: [&str; 5] = [
382        THEME_PREFERENCE,
383        THEME_CONTRAST,
384        THEME_ACCENT,
385        THEME_ACCENT_HUE,
386        THEME_ACCENT_CHROMA,
387    ];
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    #[test]
395    fn a_wrong_typed_secret_is_not_echoed() {
396        let value = Value::List(vec![Value::String("hunter2-sekrit".into())]);
397        let err = Kind::Secret.check(&value).unwrap_err();
398        assert!(!err.contains("hunter2"), "{err}");
399        let err = Kind::Secret.check(&Value::Int(424242)).unwrap_err();
400        assert!(!err.contains("424242"), "{err}");
401    }
402
403    #[test]
404    fn a_rejected_value_is_not_echoed() {
405        let list = Kind::List {
406            item: Box::new(Kind::Url),
407        };
408        let parsed = [
409            (Kind::Bool, "MARKER-42"),
410            (Kind::Int { min: 1, max: 10 }, "MARKER-42"),
411            (Kind::Int { min: 1, max: 10 }, "4242424"),
412            (
413                Kind::Enum {
414                    choices: vec!["a".into()],
415                },
416                "MARKER-42",
417            ),
418            (Kind::Url, "MARKER-42"),
419            (Kind::SocketAddr, "MARKER-42"),
420            (list, "MARKER-42"),
421        ];
422        for (kind, text) in parsed {
423            let err = kind.parse(text).unwrap_err();
424            assert!(!err.contains(text), "{kind:?}: {err}");
425        }
426        let checked = [
427            (Kind::Int { min: 1, max: 10 }, Value::Int(4242424)),
428            (Kind::Bool, Value::String("MARKER-42".into())),
429            (
430                Kind::Int { min: 1, max: 10 },
431                Value::String("MARKER-42".into()),
432            ),
433            (
434                Kind::Enum {
435                    choices: vec!["a".into()],
436                },
437                Value::List(vec![Value::String("MARKER-42".into())]),
438            ),
439        ];
440        for (kind, value) in checked {
441            let err = kind.check(&value).unwrap_err();
442            assert!(!err.contains("MARKER-42"), "{kind:?}: {err}");
443            assert!(!err.contains("4242424"), "{kind:?}: {err}");
444            assert!(
445                !err.contains("choices"),
446                "names the kind, not its Debug: {err}"
447            );
448        }
449    }
450
451    fn def(key: &str, home: Scope) -> SettingDef {
452        SettingDef {
453            key: key.into(),
454            kind: Kind::Bool,
455            scopes: vec![home],
456            overridable: false,
457            apply: Apply::Live,
458            default: None,
459            section: "test".into(),
460            doc: "test".into(),
461            process: Process::Bridge,
462        }
463    }
464
465    #[test]
466    fn an_older_def_reads_as_the_bridges() {
467        let json = r#"{"key":"a.b","kind":"bool","scopes":["device"],"overridable":false,
468            "apply":"live","default":null,"section":"s","doc":"d"}"#;
469        let def: SettingDef = serde_json::from_str(json).unwrap();
470        assert_eq!(def.process, Process::Bridge);
471        assert_eq!(serde_json::to_value(Process::App).unwrap(), "app");
472    }
473
474    #[test]
475    fn bools_accept_on_off_true_false() {
476        for (t, v) in [
477            ("true", true),
478            ("on", true),
479            ("false", false),
480            ("off", false),
481        ] {
482            assert_eq!(Kind::Bool.parse(t).unwrap(), Value::Bool(v));
483        }
484        assert!(
485            Kind::Bool
486                .parse("yes")
487                .unwrap_err()
488                .contains("true, false, on, off")
489        );
490    }
491    #[test]
492    fn ints_name_their_range() {
493        let k = Kind::Int { min: 64, max: 2048 };
494        assert_eq!(k.parse("1024").unwrap(), Value::Int(1024));
495        assert!(k.parse("32").unwrap_err().contains("64..=2048"));
496    }
497    #[test]
498    fn enums_list_their_choices() {
499        let k = Kind::Enum {
500            choices: vec!["q8_0".into(), "f16".into()],
501        };
502        assert!(k.parse("q4").unwrap_err().contains("q8_0, f16"));
503    }
504    #[test]
505    fn urls_are_http_only() {
506        assert!(Kind::Url.parse("https://a.example").is_ok());
507        assert!(Kind::Url.parse("ftp://a.example").is_err());
508    }
509    #[test]
510    fn lists_take_commas_or_a_toml_array() {
511        let k = Kind::List {
512            item: Box::new(Kind::Url),
513        };
514        let want = Value::List(vec![
515            Value::String("https://a".into()),
516            Value::String("https://b".into()),
517        ]);
518        assert_eq!(k.parse("https://a,https://b").unwrap(), want);
519        assert_eq!(k.parse(r#"["https://a", "https://b"]"#).unwrap(), want);
520        assert_eq!(k.parse("").unwrap(), Value::List(vec![]));
521    }
522    #[test]
523    fn secrets_are_never_parsed_from_text() {
524        assert!(
525            Kind::Secret
526                .parse("sk-123")
527                .unwrap_err()
528                .contains("--stdin")
529        );
530    }
531    #[test]
532    fn device_is_allowed_only_when_overridable() {
533        let mut d = def("embed.remote.model", Scope::Workgroup);
534        assert!(!d.allows(Scope::Device));
535        d.overridable = true;
536        assert!(d.allows(Scope::Device));
537    }
538    #[test]
539    fn values_round_trip_through_json() {
540        let v = Value::List(vec![Value::Int(1), Value::Int(2)]);
541        let json = serde_json::to_string(&v).unwrap();
542        assert_eq!(json, "[1,2]");
543        assert_eq!(serde_json::from_str::<Value>(&json).unwrap(), v);
544        for v in [Value::Bool(true), Value::Int(-7), Value::String("x".into())] {
545            let back: Value = serde_json::from_str(&serde_json::to_string(&v).unwrap()).unwrap();
546            assert_eq!(back, v);
547        }
548    }
549    #[test]
550    fn scopes_display_and_parse() {
551        for s in [Scope::Device, Scope::Workgroup, Scope::Workspace] {
552            assert_eq!(s.to_string().parse::<Scope>().unwrap(), s);
553        }
554        assert_eq!(Scope::Device.to_string(), "device");
555        assert!("nope".parse::<Scope>().is_err());
556    }
557    #[test]
558    fn check_validates_typed_values() {
559        let k = Kind::Int { min: 1, max: 3 };
560        assert!(k.check(&Value::Int(2)).is_ok());
561        assert!(k.check(&Value::Int(9)).is_err());
562        assert!(k.check(&Value::Bool(true)).is_err());
563        assert!(
564            Kind::String { non_empty: true }
565                .check(&Value::String(String::new()))
566                .is_err()
567        );
568        assert!(Kind::SocketAddr.parse("127.0.0.1:80").is_ok());
569        assert!(Kind::SocketAddr.parse("nope").is_err());
570        assert!(Kind::Secret.check(&Value::String("x".into())).is_ok());
571    }
572    #[test]
573    fn the_crate_stays_free_of_framework_crates() {
574        let manifest = include_str!("../Cargo.toml");
575        let deps = manifest.split("[dependencies]").nth(1).unwrap();
576        assert!(!deps.contains("sapphire-framework-"));
577    }
578}