1use std::fmt;
9use std::net::SocketAddr;
10use std::str::FromStr;
11
12use serde::{Deserialize, Serialize};
13
14#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
16#[serde(rename_all = "snake_case")]
17pub enum Scope {
18 Device,
20 Workgroup,
22 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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
53#[serde(rename_all = "snake_case")]
54pub enum Kind {
55 Bool,
57 Int {
59 min: i64,
61 max: i64,
63 },
64 Enum {
66 choices: Vec<String>,
68 },
69 String {
71 non_empty: bool,
73 },
74 Url,
76 Path,
78 SocketAddr,
80 List {
82 item: Box<Kind>,
84 },
85 Secret,
87}
88
89#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
91#[serde(rename_all = "snake_case")]
92pub enum Apply {
93 Live,
95 Restart,
97}
98
99#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
103#[serde(untagged)]
104pub enum Value {
105 Bool(bool),
107 Int(i64),
109 String(String),
111 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#[derive(
136 Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
137)]
138#[serde(rename_all = "snake_case")]
139pub enum Process {
140 #[default]
142 Bridge,
143 App,
145}
146
147#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
149pub struct SettingDef {
150 pub key: String,
152 pub kind: Kind,
154 pub scopes: Vec<Scope>,
156 pub overridable: bool,
158 pub apply: Apply,
160 pub default: Option<Value>,
162 pub section: String,
164 pub doc: String,
166 #[serde(default)]
168 pub process: Process,
169}
170
171impl SettingDef {
172 pub fn home(&self) -> Scope {
174 self.scopes[0]
175 }
176
177 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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
188#[serde(rename_all = "snake_case")]
189pub enum SecretState {
190 Set,
192 Unset,
194}
195
196#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
198#[serde(rename_all = "snake_case")]
199pub enum EntryValue {
200 Value(Value),
202 Secret(SecretState),
204 Unset,
206}
207
208#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
210#[serde(rename_all = "snake_case")]
211pub enum Origin {
212 Scope(Scope),
214 Default,
216}
217
218#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
220pub struct Entry {
221 pub key: String,
223 pub value: EntryValue,
225 pub origin: Origin,
227 pub pending_restart: bool,
229 pub note: Option<String>,
231}
232
233impl Kind {
234 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 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 (Kind::Secret, _) => Err("a secret must be a string".into()),
317 (kind, _) => Err(format!("has the wrong type (expected {})", kind.name())),
319 }
320 }
321
322 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
338fn 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
359pub mod gui {
361 pub const THEME_PREFERENCE: &str = "gui.theme.preference";
363 pub const THEME_CONTRAST: &str = "gui.theme.contrast";
365 pub const THEME_ACCENT: &str = "gui.theme.accent";
367 pub const THEME_ACCENT_HUE: &str = "gui.theme.accent_hue";
369 pub const THEME_ACCENT_CHROMA: &str = "gui.theme.accent_chroma";
371 pub const ACCENT_CHOICES: [&str; 10] = [
374 "default", "neutral", "red", "orange", "yellow", "green", "cyan", "blue", "purple",
375 "custom",
376 ];
377 pub const LOCALE: &str = "gui.locale";
380 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}