use std::fmt;
use std::net::SocketAddr;
use std::str::FromStr;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Scope {
Device,
Workgroup,
Workspace,
}
impl fmt::Display for Scope {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Scope::Device => "device",
Scope::Workgroup => "workgroup",
Scope::Workspace => "workspace",
})
}
}
impl FromStr for Scope {
type Err = String;
fn from_str(s: &str) -> Result<Self, String> {
match s {
"device" => Ok(Scope::Device),
"workgroup" => Ok(Scope::Workgroup),
"workspace" => Ok(Scope::Workspace),
other => Err(format!(
"unknown scope `{other}` (expected device, workgroup or workspace)"
)),
}
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Kind {
Bool,
Int {
min: i64,
max: i64,
},
Enum {
choices: Vec<String>,
},
String {
non_empty: bool,
},
Url,
Path,
SocketAddr,
List {
item: Box<Kind>,
},
Secret,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Apply {
Live,
Restart,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(untagged)]
pub enum Value {
Bool(bool),
Int(i64),
String(String),
List(Vec<Value>),
}
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Bool(b) => write!(f, "{b}"),
Value::Int(i) => write!(f, "{i}"),
Value::String(s) => f.write_str(s),
Value::List(items) => {
for (i, item) in items.iter().enumerate() {
if i > 0 {
f.write_str(",")?;
}
write!(f, "{item}")?;
}
Ok(())
}
}
}
}
#[derive(
Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize,
)]
#[serde(rename_all = "snake_case")]
pub enum Process {
#[default]
Bridge,
App,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct SettingDef {
pub key: String,
pub kind: Kind,
pub scopes: Vec<Scope>,
pub overridable: bool,
pub apply: Apply,
pub default: Option<Value>,
pub section: String,
pub doc: String,
#[serde(default)]
pub process: Process,
}
impl SettingDef {
pub fn home(&self) -> Scope {
self.scopes[0]
}
pub fn allows(&self, scope: Scope) -> bool {
scope == self.home()
|| (scope == Scope::Device && self.overridable)
|| self.scopes.contains(&scope)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum SecretState {
Set,
Unset,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum EntryValue {
Value(Value),
Secret(SecretState),
Unset,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
Scope(Scope),
Default,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Entry {
pub key: String,
pub value: EntryValue,
pub origin: Origin,
pub pending_restart: bool,
pub note: Option<String>,
}
impl Kind {
pub fn parse(&self, text: &str) -> Result<Value, String> {
let value = match self {
Kind::Bool => match text {
"true" | "on" => Value::Bool(true),
"false" | "off" => Value::Bool(false),
_ => return Err("not a boolean (expected one of true, false, on, off)".into()),
},
Kind::Int { .. } => Value::Int(
text.trim()
.parse::<i64>()
.map_err(|_| "not an integer".to_string())?,
),
Kind::Enum { .. } | Kind::String { .. } | Kind::Url | Kind::Path | Kind::SocketAddr => {
Value::String(text.to_string())
}
Kind::List { item } => Value::List(
split_list(text)
.iter()
.map(|part| item.parse(part))
.collect::<Result<_, _>>()?,
),
Kind::Secret => {
return Err(
"a secret cannot be given as text; read it with --stdin or a prompt".into(),
);
}
};
self.check(&value)?;
Ok(value)
}
pub fn check(&self, value: &Value) -> Result<(), String> {
match (self, value) {
(Kind::Bool, Value::Bool(_)) => Ok(()),
(Kind::Int { min, max }, Value::Int(i)) => {
if (*min..=*max).contains(i) {
Ok(())
} else {
Err(format!("out of range (expected {min}..={max})"))
}
}
(Kind::Enum { choices }, Value::String(s)) => {
if choices.contains(s) {
Ok(())
} else {
Err(format!("not one of the choices ({})", choices.join(", ")))
}
}
(Kind::String { non_empty }, Value::String(s)) => {
if *non_empty && s.is_empty() {
Err("must not be empty".into())
} else {
Ok(())
}
}
(Kind::Url, Value::String(s)) => {
let rest = s
.strip_prefix("http://")
.or_else(|| s.strip_prefix("https://"));
match rest {
Some(rest) if !rest.is_empty() => Ok(()),
_ => Err("not an http or https URL".into()),
}
}
(Kind::Path, Value::String(s)) => {
if std::path::Path::new(s).as_os_str().is_empty() {
Err("path must not be empty".into())
} else {
Ok(())
}
}
(Kind::SocketAddr, Value::String(s)) => s
.parse::<SocketAddr>()
.map(|_| ())
.map_err(|_| "not a host:port socket address".to_string()),
(Kind::List { item }, Value::List(items)) => {
items.iter().try_for_each(|v| item.check(v))
}
(Kind::Secret, Value::String(_)) => Ok(()),
(Kind::Secret, _) => Err("a secret must be a string".into()),
(kind, _) => Err(format!("has the wrong type (expected {})", kind.name())),
}
}
fn name(&self) -> &'static str {
match self {
Kind::Bool => "bool",
Kind::Int { .. } => "integer",
Kind::Enum { .. } => "choice",
Kind::String { .. } => "string",
Kind::Url => "URL",
Kind::Path => "path",
Kind::SocketAddr => "socket address",
Kind::List { .. } => "list",
Kind::Secret => "secret",
}
}
}
fn split_list(text: &str) -> Vec<String> {
let text = text.trim();
let text = text
.strip_prefix('[')
.and_then(|t| t.strip_suffix(']'))
.unwrap_or(text);
if text.trim().is_empty() {
return Vec::new();
}
text.split(',')
.map(|part| {
let part = part.trim();
part.strip_prefix('"')
.and_then(|p| p.strip_suffix('"'))
.unwrap_or(part)
.to_string()
})
.collect()
}
pub mod gui {
pub const THEME_PREFERENCE: &str = "gui.theme.preference";
pub const THEME_CONTRAST: &str = "gui.theme.contrast";
pub const THEME_ACCENT: &str = "gui.theme.accent";
pub const THEME_ACCENT_HUE: &str = "gui.theme.accent_hue";
pub const THEME_ACCENT_CHROMA: &str = "gui.theme.accent_chroma";
pub const ACCENT_CHOICES: [&str; 10] = [
"default", "neutral", "red", "orange", "yellow", "green", "cyan", "blue", "purple",
"custom",
];
pub const LOCALE: &str = "gui.locale";
pub const THEME_KEYS: [&str; 5] = [
THEME_PREFERENCE,
THEME_CONTRAST,
THEME_ACCENT,
THEME_ACCENT_HUE,
THEME_ACCENT_CHROMA,
];
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_wrong_typed_secret_is_not_echoed() {
let value = Value::List(vec![Value::String("hunter2-sekrit".into())]);
let err = Kind::Secret.check(&value).unwrap_err();
assert!(!err.contains("hunter2"), "{err}");
let err = Kind::Secret.check(&Value::Int(424242)).unwrap_err();
assert!(!err.contains("424242"), "{err}");
}
#[test]
fn a_rejected_value_is_not_echoed() {
let list = Kind::List {
item: Box::new(Kind::Url),
};
let parsed = [
(Kind::Bool, "MARKER-42"),
(Kind::Int { min: 1, max: 10 }, "MARKER-42"),
(Kind::Int { min: 1, max: 10 }, "4242424"),
(
Kind::Enum {
choices: vec!["a".into()],
},
"MARKER-42",
),
(Kind::Url, "MARKER-42"),
(Kind::SocketAddr, "MARKER-42"),
(list, "MARKER-42"),
];
for (kind, text) in parsed {
let err = kind.parse(text).unwrap_err();
assert!(!err.contains(text), "{kind:?}: {err}");
}
let checked = [
(Kind::Int { min: 1, max: 10 }, Value::Int(4242424)),
(Kind::Bool, Value::String("MARKER-42".into())),
(
Kind::Int { min: 1, max: 10 },
Value::String("MARKER-42".into()),
),
(
Kind::Enum {
choices: vec!["a".into()],
},
Value::List(vec![Value::String("MARKER-42".into())]),
),
];
for (kind, value) in checked {
let err = kind.check(&value).unwrap_err();
assert!(!err.contains("MARKER-42"), "{kind:?}: {err}");
assert!(!err.contains("4242424"), "{kind:?}: {err}");
assert!(
!err.contains("choices"),
"names the kind, not its Debug: {err}"
);
}
}
fn def(key: &str, home: Scope) -> SettingDef {
SettingDef {
key: key.into(),
kind: Kind::Bool,
scopes: vec![home],
overridable: false,
apply: Apply::Live,
default: None,
section: "test".into(),
doc: "test".into(),
process: Process::Bridge,
}
}
#[test]
fn an_older_def_reads_as_the_bridges() {
let json = r#"{"key":"a.b","kind":"bool","scopes":["device"],"overridable":false,
"apply":"live","default":null,"section":"s","doc":"d"}"#;
let def: SettingDef = serde_json::from_str(json).unwrap();
assert_eq!(def.process, Process::Bridge);
assert_eq!(serde_json::to_value(Process::App).unwrap(), "app");
}
#[test]
fn bools_accept_on_off_true_false() {
for (t, v) in [
("true", true),
("on", true),
("false", false),
("off", false),
] {
assert_eq!(Kind::Bool.parse(t).unwrap(), Value::Bool(v));
}
assert!(
Kind::Bool
.parse("yes")
.unwrap_err()
.contains("true, false, on, off")
);
}
#[test]
fn ints_name_their_range() {
let k = Kind::Int { min: 64, max: 2048 };
assert_eq!(k.parse("1024").unwrap(), Value::Int(1024));
assert!(k.parse("32").unwrap_err().contains("64..=2048"));
}
#[test]
fn enums_list_their_choices() {
let k = Kind::Enum {
choices: vec!["q8_0".into(), "f16".into()],
};
assert!(k.parse("q4").unwrap_err().contains("q8_0, f16"));
}
#[test]
fn urls_are_http_only() {
assert!(Kind::Url.parse("https://a.example").is_ok());
assert!(Kind::Url.parse("ftp://a.example").is_err());
}
#[test]
fn lists_take_commas_or_a_toml_array() {
let k = Kind::List {
item: Box::new(Kind::Url),
};
let want = Value::List(vec![
Value::String("https://a".into()),
Value::String("https://b".into()),
]);
assert_eq!(k.parse("https://a,https://b").unwrap(), want);
assert_eq!(k.parse(r#"["https://a", "https://b"]"#).unwrap(), want);
assert_eq!(k.parse("").unwrap(), Value::List(vec![]));
}
#[test]
fn secrets_are_never_parsed_from_text() {
assert!(
Kind::Secret
.parse("sk-123")
.unwrap_err()
.contains("--stdin")
);
}
#[test]
fn device_is_allowed_only_when_overridable() {
let mut d = def("embed.remote.model", Scope::Workgroup);
assert!(!d.allows(Scope::Device));
d.overridable = true;
assert!(d.allows(Scope::Device));
}
#[test]
fn values_round_trip_through_json() {
let v = Value::List(vec![Value::Int(1), Value::Int(2)]);
let json = serde_json::to_string(&v).unwrap();
assert_eq!(json, "[1,2]");
assert_eq!(serde_json::from_str::<Value>(&json).unwrap(), v);
for v in [Value::Bool(true), Value::Int(-7), Value::String("x".into())] {
let back: Value = serde_json::from_str(&serde_json::to_string(&v).unwrap()).unwrap();
assert_eq!(back, v);
}
}
#[test]
fn scopes_display_and_parse() {
for s in [Scope::Device, Scope::Workgroup, Scope::Workspace] {
assert_eq!(s.to_string().parse::<Scope>().unwrap(), s);
}
assert_eq!(Scope::Device.to_string(), "device");
assert!("nope".parse::<Scope>().is_err());
}
#[test]
fn check_validates_typed_values() {
let k = Kind::Int { min: 1, max: 3 };
assert!(k.check(&Value::Int(2)).is_ok());
assert!(k.check(&Value::Int(9)).is_err());
assert!(k.check(&Value::Bool(true)).is_err());
assert!(
Kind::String { non_empty: true }
.check(&Value::String(String::new()))
.is_err()
);
assert!(Kind::SocketAddr.parse("127.0.0.1:80").is_ok());
assert!(Kind::SocketAddr.parse("nope").is_err());
assert!(Kind::Secret.check(&Value::String("x".into())).is_ok());
}
#[test]
fn the_crate_stays_free_of_framework_crates() {
let manifest = include_str!("../Cargo.toml");
let deps = manifest.split("[dependencies]").nth(1).unwrap();
assert!(!deps.contains("sapphire-framework-"));
}
}