#![allow(dead_code)]
use std::fmt;
use std::path::{Path, PathBuf};
use garde::Validate;
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
pub const CONFIG_FILE_NAME: &str = "config.json";
pub const SCHEMA_PATH: &str = "docs/mobux.schema.json";
const DEFAULT_PORT: u16 = 8080;
const DEFAULT_ACME_DIRECTORY: &str = "https://acme-v02.api.letsencrypt.org/directory";
const DEFAULT_ACME_HTTP_PORT: u16 = 80;
const DEFAULT_SERVICE_NAME: &str = "mobux";
const DEFAULT_VAPID_CONTACT: &str = "mailto:admin@example.com";
const DEFAULT_UPDATE_CHECK_URL: &str = "https://index.crates.io/mo/bu/mobux";
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default)]
#[garde(dive)]
pub server: ServerConfig,
#[serde(default)]
#[garde(dive)]
pub auth: AuthConfig,
#[serde(default)]
#[garde(dive)]
pub tls: TlsConfig,
#[serde(default)]
#[garde(dive)]
pub paths: PathsConfig,
#[serde(default)]
#[garde(dive)]
pub session: SessionConfig,
#[serde(default)]
#[garde(dive)]
pub app: AppConfig,
#[serde(default)]
#[garde(dive)]
pub push: PushConfig,
#[serde(default)]
#[garde(dive)]
pub update: UpdateConfig,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct ServerConfig {
#[serde(default = "default_port")]
#[garde(range(min = 1))]
pub port: u16,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct AuthConfig {
#[serde(default)]
#[garde(custom(plain_value))]
pub user: String,
#[serde(default)]
#[garde(custom(plain_value))]
pub pass: String,
#[serde(default)]
#[garde(custom(pin_value))]
pub pin: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct TlsConfig {
#[serde(default = "default_tls_enabled")]
#[garde(skip)]
pub enabled: bool,
#[serde(default)]
#[garde(inner(custom(host_value)))]
pub hosts: Vec<String>,
#[serde(default)]
#[garde(skip)]
pub cert_file: String,
#[serde(default)]
#[garde(skip)]
pub key_file: String,
#[serde(default)]
#[garde(inner(custom(host_value)))]
pub acme_domains: Vec<String>,
#[serde(default)]
#[garde(custom(email_value))]
pub acme_email: String,
#[serde(default = "default_acme_directory")]
#[garde(custom(https_url_value))]
pub acme_directory: String,
#[serde(default = "default_acme_http_port")]
#[garde(range(min = 1))]
pub acme_http_port: u16,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct PathsConfig {
#[serde(default)]
#[garde(skip)]
pub data_dir: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct SessionConfig {
#[serde(default)]
#[garde(skip)]
pub shell: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct AppConfig {
#[serde(default)]
#[garde(custom(authority_value))]
pub domain: String,
#[serde(default)]
#[garde(skip)]
pub dev: bool,
#[serde(default = "default_service_name")]
#[garde(custom(service_name_value))]
pub service_name: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct PushConfig {
#[serde(default = "default_vapid_contact")]
#[garde(custom(vapid_contact_value))]
pub vapid_contact: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, Validate)]
#[serde(deny_unknown_fields)]
pub struct UpdateConfig {
#[serde(default = "default_update_check_url")]
#[garde(custom(https_url_value))]
pub check_url: String,
}
fn default_port() -> u16 {
DEFAULT_PORT
}
fn default_tls_enabled() -> bool {
true
}
fn default_acme_directory() -> String {
DEFAULT_ACME_DIRECTORY.to_string()
}
fn default_acme_http_port() -> u16 {
DEFAULT_ACME_HTTP_PORT
}
fn default_service_name() -> String {
DEFAULT_SERVICE_NAME.to_string()
}
fn default_vapid_contact() -> String {
DEFAULT_VAPID_CONTACT.to_string()
}
fn default_update_check_url() -> String {
DEFAULT_UPDATE_CHECK_URL.to_string()
}
impl Default for ServerConfig {
fn default() -> Self {
ServerConfig {
port: default_port(),
}
}
}
impl Default for TlsConfig {
fn default() -> Self {
TlsConfig {
enabled: default_tls_enabled(),
hosts: Vec::new(),
cert_file: String::new(),
key_file: String::new(),
acme_domains: Vec::new(),
acme_email: String::new(),
acme_directory: default_acme_directory(),
acme_http_port: default_acme_http_port(),
}
}
}
impl Default for AppConfig {
fn default() -> Self {
AppConfig {
domain: String::new(),
dev: false,
service_name: default_service_name(),
}
}
}
impl Default for PushConfig {
fn default() -> Self {
PushConfig {
vapid_contact: default_vapid_contact(),
}
}
}
impl Default for UpdateConfig {
fn default() -> Self {
UpdateConfig {
check_url: default_update_check_url(),
}
}
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialConfig {
#[serde(default)]
pub server: Option<PartialServerConfig>,
#[serde(default)]
pub auth: Option<PartialAuthConfig>,
#[serde(default)]
pub tls: Option<PartialTlsConfig>,
#[serde(default)]
pub paths: Option<PartialPathsConfig>,
#[serde(default)]
pub session: Option<PartialSessionConfig>,
#[serde(default)]
pub app: Option<PartialAppConfig>,
#[serde(default)]
pub push: Option<PartialPushConfig>,
#[serde(default)]
pub update: Option<PartialUpdateConfig>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialServerConfig {
#[serde(default)]
pub port: Option<u16>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialAuthConfig {
#[serde(default)]
pub user: Option<String>,
#[serde(default)]
pub pass: Option<String>,
#[serde(default)]
pub pin: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialTlsConfig {
#[serde(default)]
pub enabled: Option<bool>,
#[serde(default)]
pub hosts: Option<Vec<String>>,
#[serde(default)]
pub cert_file: Option<String>,
#[serde(default)]
pub key_file: Option<String>,
#[serde(default)]
pub acme_domains: Option<Vec<String>>,
#[serde(default)]
pub acme_email: Option<String>,
#[serde(default)]
pub acme_directory: Option<String>,
#[serde(default)]
pub acme_http_port: Option<u16>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialPathsConfig {
#[serde(default)]
pub data_dir: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialSessionConfig {
#[serde(default)]
pub shell: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialAppConfig {
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub dev: Option<bool>,
#[serde(default)]
pub service_name: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialPushConfig {
#[serde(default)]
pub vapid_contact: Option<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct PartialUpdateConfig {
#[serde(default)]
pub check_url: Option<String>,
}
impl Config {
pub fn merged(mut self, partial: PartialConfig) -> Config {
if let Some(server) = partial.server {
overlay(&mut self.server.port, server.port);
}
if let Some(auth) = partial.auth {
overlay(&mut self.auth.user, auth.user);
overlay(&mut self.auth.pass, auth.pass);
overlay(&mut self.auth.pin, auth.pin);
}
if let Some(tls) = partial.tls {
overlay(&mut self.tls.enabled, tls.enabled);
overlay(&mut self.tls.hosts, tls.hosts);
overlay(&mut self.tls.cert_file, tls.cert_file);
overlay(&mut self.tls.key_file, tls.key_file);
overlay(&mut self.tls.acme_domains, tls.acme_domains);
overlay(&mut self.tls.acme_email, tls.acme_email);
overlay(&mut self.tls.acme_directory, tls.acme_directory);
overlay(&mut self.tls.acme_http_port, tls.acme_http_port);
}
if let Some(paths) = partial.paths {
overlay(&mut self.paths.data_dir, paths.data_dir);
}
if let Some(session) = partial.session {
overlay(&mut self.session.shell, session.shell);
}
if let Some(app) = partial.app {
overlay(&mut self.app.domain, app.domain);
overlay(&mut self.app.dev, app.dev);
overlay(&mut self.app.service_name, app.service_name);
}
if let Some(push) = partial.push {
overlay(&mut self.push.vapid_contact, push.vapid_contact);
}
if let Some(update) = partial.update {
overlay(&mut self.update.check_url, update.check_url);
}
self
}
}
fn overlay<T>(target: &mut T, value: Option<T>) {
if let Some(value) = value {
*target = value;
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct FieldSpec {
pub key: &'static str,
pub env: &'static str,
pub flag: Option<&'static str>,
}
pub const FIELDS: &[FieldSpec] = &[
FieldSpec {
key: "server.port",
env: "MOBUX_PORT",
flag: Some("--port"),
},
FieldSpec {
key: "auth.user",
env: "MOBUX_AUTH_USER",
flag: Some("--user"),
},
FieldSpec {
key: "auth.pass",
env: "MOBUX_AUTH_PASS",
flag: None,
},
FieldSpec {
key: "auth.pin",
env: "MOBUX_PIN",
flag: Some("--pin"),
},
FieldSpec {
key: "tls.enabled",
env: "MOBUX_TLS",
flag: None,
},
FieldSpec {
key: "tls.hosts",
env: "MOBUX_TLS_HOSTS",
flag: None,
},
FieldSpec {
key: "tls.cert_file",
env: "MOBUX_CERT_FILE",
flag: None,
},
FieldSpec {
key: "tls.key_file",
env: "MOBUX_KEY_FILE",
flag: None,
},
FieldSpec {
key: "tls.acme_domains",
env: "MOBUX_ACME_DOMAINS",
flag: None,
},
FieldSpec {
key: "tls.acme_email",
env: "MOBUX_ACME_EMAIL",
flag: None,
},
FieldSpec {
key: "tls.acme_directory",
env: "MOBUX_ACME_DIRECTORY",
flag: None,
},
FieldSpec {
key: "tls.acme_http_port",
env: "MOBUX_ACME_HTTP_PORT",
flag: None,
},
FieldSpec {
key: "paths.data_dir",
env: "MOBUX_DATA_DIR",
flag: None,
},
FieldSpec {
key: "session.shell",
env: "MOBUX_SESSION_SHELL",
flag: None,
},
FieldSpec {
key: "app.domain",
env: "MOBUX_DOMAIN",
flag: None,
},
FieldSpec {
key: "app.dev",
env: "MOBUX_DEV",
flag: None,
},
FieldSpec {
key: "app.service_name",
env: "MOBUX_SERVICE_NAME",
flag: None,
},
FieldSpec {
key: "push.vapid_contact",
env: "MOBUX_VAPID_CONTACT",
flag: None,
},
FieldSpec {
key: "update.check_url",
env: "MOBUX_UPDATE_CHECK_URL",
flag: None,
},
];
pub const ENV_ONLY: &[&str] = &[
"PORT",
"MOBUX_CONFIG_DIR",
"MOBUX_TMUX_SOCKET",
"MOBUX_UPDATE_TEST_INDEX",
"MOBUX_UPDATE_DISABLE_RUN",
"MOBUX_DEV_CERT",
"MOBUX_DEV_KEY",
];
#[derive(Debug, Default, Clone, PartialEq, Eq)]
pub struct DirEnv {
pub mobux_config_dir: Option<String>,
pub xdg_config_home: Option<String>,
pub home: Option<String>,
}
impl DirEnv {
pub fn from_env() -> Self {
let read = |key: &str| std::env::var(key).ok();
DirEnv {
mobux_config_dir: read("MOBUX_CONFIG_DIR"),
xdg_config_home: read("XDG_CONFIG_HOME"),
home: read("HOME"),
}
}
}
pub fn resolve_config_dir(env: &DirEnv) -> PathBuf {
if let Some(dir) = non_empty(&env.mobux_config_dir) {
return PathBuf::from(dir);
}
if let Some(dir) = non_empty(&env.xdg_config_home) {
return PathBuf::from(dir).join("mobux");
}
let home = non_empty(&env.home).unwrap_or(".");
PathBuf::from(home).join(".config").join("mobux")
}
pub fn config_dir() -> PathBuf {
resolve_config_dir(&DirEnv::from_env())
}
pub fn config_file_path() -> PathBuf {
config_dir().join(CONFIG_FILE_NAME)
}
fn non_empty(value: &Option<String>) -> Option<&str> {
value.as_deref().map(str::trim).filter(|v| !v.is_empty())
}
#[derive(Debug)]
pub enum LoadError {
Read { path: PathBuf, message: String },
Syntax { path: PathBuf, message: String },
Shape {
path: PathBuf,
key: String,
message: String,
suggestion: Option<String>,
},
Invalid { path: PathBuf, message: String },
}
impl fmt::Display for LoadError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
LoadError::Read { path, message } => {
write!(f, "{}: {message}", path.display())
}
LoadError::Syntax { path, message } => {
write!(f, "{}: not valid JSON: {message}", path.display())
}
LoadError::Shape {
path,
key,
message,
suggestion,
} => {
write!(f, "{}: at `{key}`: {message}", path.display())?;
match suggestion {
Some(word) => write!(f, " — did you mean `{word}`?"),
None => Ok(()),
}
}
LoadError::Invalid { path, message } => {
write!(f, "{}: {message}", path.display())
}
}
}
}
impl std::error::Error for LoadError {}
pub fn load_from(path: &Path) -> Result<Config, LoadError> {
let raw = match std::fs::read_to_string(path) {
Ok(raw) => raw,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(Config::default()),
Err(err) => {
return Err(LoadError::Read {
path: path.to_path_buf(),
message: err.to_string(),
})
}
};
parse(path, &raw)
}
pub fn load() -> Result<Config, LoadError> {
load_from(&config_file_path())
}
pub fn parse(path: &Path, raw: &str) -> Result<Config, LoadError> {
let partial = parse_partial(path, raw)?;
let config = Config::default().merged(partial);
validate(path, &config)?;
Ok(config)
}
fn parse_partial(path: &Path, raw: &str) -> Result<PartialConfig, LoadError> {
let mut deserializer = serde_json::Deserializer::from_str(raw);
serde_path_to_error::deserialize(&mut deserializer).map_err(|err| shape_error(path, err))
}
fn shape_error(path: &Path, err: serde_path_to_error::Error<serde_json::Error>) -> LoadError {
let key = pointer_of(&err);
let inner = err.into_inner();
if matches!(
inner.classify(),
serde_json::error::Category::Syntax | serde_json::error::Category::Eof
) {
return LoadError::Syntax {
path: path.to_path_buf(),
message: inner.to_string(),
};
}
let message = inner.to_string();
let suggestion = unknown_field_suggestion(&message);
LoadError::Shape {
path: path.to_path_buf(),
key,
message,
suggestion,
}
}
fn pointer_of(err: &serde_path_to_error::Error<serde_json::Error>) -> String {
let path = err.path().to_string();
if path.is_empty() || path == "." {
return "(root)".to_string();
}
path
}
fn unknown_field_suggestion(message: &str) -> Option<String> {
let rest = message.strip_prefix("unknown field `")?;
let (unknown, rest) = rest.split_once('`')?;
let candidates = backticked(rest);
let best = candidates
.into_iter()
.map(|candidate| (edit_distance(unknown, candidate), candidate))
.min_by_key(|(distance, _)| *distance)?;
let (distance, candidate) = best;
if distance == 0 || distance > 3 || distance >= candidate.chars().count() {
return None;
}
Some(candidate.to_string())
}
fn backticked(text: &str) -> Vec<&str> {
text.split('`').skip(1).step_by(2).collect()
}
fn edit_distance(left: &str, right: &str) -> usize {
let right_chars: Vec<char> = right.chars().collect();
let mut previous: Vec<usize> = (0..=right_chars.len()).collect();
let mut current = vec![0usize; right_chars.len() + 1];
for (row, left_char) in left.chars().enumerate() {
current[0] = row + 1;
for (column, right_char) in right_chars.iter().enumerate() {
let substitution = previous[column] + usize::from(left_char != *right_char);
current[column + 1] = substitution
.min(previous[column + 1] + 1)
.min(current[column] + 1);
}
std::mem::swap(&mut previous, &mut current);
}
previous[right_chars.len()]
}
fn validate(path: &Path, config: &Config) -> Result<(), LoadError> {
if let Err(report) = config.validate() {
return Err(LoadError::Invalid {
path: path.to_path_buf(),
message: report.to_string().trim().to_string(),
});
}
if !config.tls.acme_domains.is_empty() && config.tls.acme_email.trim().is_empty() {
return Err(LoadError::Invalid {
path: path.to_path_buf(),
message: "tls.acme_email: required when tls.acme_domains is set".to_string(),
});
}
Ok(())
}
fn plain_value(value: &str, _: &()) -> garde::Result {
let offender = value
.chars()
.find(|c| c.is_whitespace() || c.is_control() || matches!(c, '"' | '\'' | '\\' | '$'));
match offender {
None => Ok(()),
Some(c) => Err(garde::Error::new(format!(
"contains {c:?}; whitespace, quotes, backslashes and $ cannot survive a systemd unit"
))),
}
}
fn pin_value(value: &str, ctx: &()) -> garde::Result {
plain_value(value, ctx)?;
if value.is_empty() {
return Ok(());
}
let length = value.chars().count();
if !(4..=64).contains(&length) {
return Err(garde::Error::new(
"must be between 4 and 64 characters, or empty to leave the PIN unset",
));
}
Ok(())
}
fn host_value(value: &str, _: &()) -> garde::Result {
if value.trim().is_empty() {
return Err(garde::Error::new("must not be blank"));
}
let ok = value
.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '*' | '_'));
if !ok {
return Err(garde::Error::new(
"must be a hostname: letters, digits, dots, dashes",
));
}
Ok(())
}
fn authority_value(value: &str, ctx: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
let (host, port) = match value.split_once(':') {
Some((host, port)) => (host, Some(port)),
None => (value, None),
};
host_value(host, ctx)?;
match port {
None => Ok(()),
Some(port) if port.parse::<u16>().is_ok_and(|p| p > 0) => Ok(()),
Some(_) => Err(garde::Error::new(
"port must be a number between 1 and 65535",
)),
}
}
fn email_value(value: &str, _: &()) -> garde::Result {
if value.is_empty() {
return Ok(());
}
let Some((local, domain)) = value.split_once('@') else {
return Err(garde::Error::new("must be an email address"));
};
if local.is_empty() || !domain.contains('.') || domain.starts_with('.') {
return Err(garde::Error::new("must be an email address"));
}
Ok(())
}
fn https_url_value(value: &str, _: &()) -> garde::Result {
if value.starts_with("https://") && value.len() > "https://".len() {
return Ok(());
}
Err(garde::Error::new("must be an https:// URL"))
}
fn service_name_value(value: &str, ctx: &()) -> garde::Result {
if value.is_empty() {
return Err(garde::Error::new("must name a systemd unit"));
}
plain_value(value, ctx)
}
fn vapid_contact_value(value: &str, _: &()) -> garde::Result {
let mailto = value
.strip_prefix("mailto:")
.is_some_and(|rest| rest.contains('@'));
if mailto || value.starts_with("https://") {
return Ok(());
}
Err(garde::Error::new(
"must be a mailto: address or an https:// URL",
))
}
pub fn schema() -> serde_json::Value {
serde_json::to_value(schemars::schema_for!(Config)).expect("schema serializes")
}
#[cfg(test)]
mod tests {
use super::*;
const SOURCE: &str = "config.json";
fn parse_str(raw: &str) -> Result<Config, LoadError> {
parse(Path::new(SOURCE), raw)
}
fn message(raw: &str) -> String {
parse_str(raw)
.expect_err("expected a load error")
.to_string()
}
#[test]
fn schema_file_matches_the_types() {
let path = Path::new(env!("CARGO_MANIFEST_DIR")).join(SCHEMA_PATH);
let generated = format!("{}\n", serde_json::to_string_pretty(&schema()).unwrap());
if std::env::var_os("MOBUX_WRITE_SCHEMA").is_some() {
std::fs::write(&path, &generated).expect("writing the schema");
return;
}
let committed = std::fs::read_to_string(&path).expect("docs/mobux.schema.json exists");
assert_eq!(
serde_json::from_str::<serde_json::Value>(&committed).expect("valid JSON"),
schema(),
"docs/mobux.schema.json is stale — rerun this test with MOBUX_WRITE_SCHEMA=1"
);
}
fn schema_node(schema: &serde_json::Value, key: &str) -> serde_json::Value {
let (section, leaf) = key.split_once('.').expect("dotted key");
let reference = schema["properties"][section]["$ref"]
.as_str()
.unwrap_or_else(|| panic!("no section `{section}` in the schema"))
.rsplit('/')
.next()
.expect("a $defs pointer")
.to_string();
schema["$defs"][reference]["properties"][leaf].clone()
}
#[test]
fn schema_marks_every_field_optional_and_rejects_unknown_keys() {
let schema = schema();
assert_eq!(schema["additionalProperties"], serde_json::json!(false));
assert!(schema.get("required").is_none());
assert_eq!(
schema["$defs"]["ServerConfig"]["additionalProperties"],
serde_json::json!(false)
);
assert_eq!(
schema_node(&schema, "server.port")["default"],
serde_json::json!(8080)
);
}
#[test]
fn an_empty_document_is_the_default_config() {
assert_eq!(parse_str("{}").unwrap(), Config::default());
}
#[test]
fn defaults_mirror_the_values_the_server_uses_today() {
let config = Config::default();
assert_eq!(config.server.port, 8080);
assert!(config.tls.enabled);
assert_eq!(config.tls.acme_http_port, 80);
assert_eq!(config.app.service_name, "mobux");
assert_eq!(config.push.vapid_contact, "mailto:admin@example.com");
assert!(!config.app.dev);
}
#[test]
fn a_stated_leaf_wins_and_the_rest_keeps_its_default() {
let config = parse_str(r#"{"server": {"port": 5151}}"#).unwrap();
assert_eq!(config.server.port, 5151);
assert!(config.tls.enabled);
assert_eq!(config.update.check_url, Config::default().update.check_url);
}
#[test]
fn every_section_round_trips_through_the_partial_mirror() {
let raw = serde_json::to_string(&Config::default()).unwrap();
assert_eq!(parse_str(&raw).unwrap(), Config::default());
}
#[test]
fn malformed_json_names_the_position() {
let error = message(r#"{"server": }"#);
assert!(error.starts_with("config.json: not valid JSON:"), "{error}");
assert!(error.contains("line 1"), "{error}");
}
#[test]
fn an_unknown_key_suggests_the_nearest_spelling() {
let error = message(r#"{"server": {"prot": 8080}}"#);
assert!(error.contains("at `server.prot`"), "{error}");
assert!(error.contains("did you mean `port`?"), "{error}");
}
#[test]
fn an_unknown_section_suggests_the_nearest_section() {
let error = message(r#"{"serve": {}}"#);
assert!(error.contains("did you mean `server`?"), "{error}");
}
#[test]
fn an_unrecognisable_key_is_reported_without_a_guess() {
let error = message(r#"{"telemetry": {}}"#);
assert!(error.contains("unknown field `telemetry`"), "{error}");
assert!(!error.contains("did you mean"), "{error}");
}
#[test]
fn a_wrong_type_names_the_key_and_the_expected_type() {
let error = message(r#"{"server": {"port": "8080"}}"#);
assert!(error.contains("at `server.port`"), "{error}");
assert!(error.contains("invalid type: string"), "{error}");
}
#[test]
fn a_number_outside_the_port_range_is_rejected_by_serde() {
let error = message(r#"{"server": {"port": 70000}}"#);
assert!(error.contains("at `server.port`"), "{error}");
}
#[test]
fn port_zero_is_rejected_by_the_range_rule() {
let error = message(r#"{"server": {"port": 0}}"#);
assert!(error.contains("server.port"), "{error}");
}
#[test]
fn a_short_pin_is_rejected() {
let error = message(r#"{"auth": {"pin": "12"}}"#);
assert!(error.contains("auth.pin"), "{error}");
assert!(error.contains("between 4 and 64"), "{error}");
}
#[test]
fn an_empty_pin_leaves_the_pin_unset() {
assert_eq!(parse_str(r#"{"auth": {"pin": ""}}"#).unwrap().auth.pin, "");
}
#[test]
fn a_credential_systemd_would_mangle_is_rejected() {
let error = message(r#"{"auth": {"pass": "hunter 2"}}"#);
assert!(error.contains("auth.pass"), "{error}");
}
#[test]
fn a_blank_tls_host_is_rejected() {
let error = message(r#"{"tls": {"hosts": ["ok.example", " "]}}"#);
assert!(error.contains("tls.hosts[1]"), "{error}");
}
#[test]
fn acme_domains_without_an_email_are_rejected() {
let error = message(r#"{"tls": {"acme_domains": ["mobux.example"]}}"#);
assert!(error.contains("tls.acme_email"), "{error}");
assert!(error.contains("required"), "{error}");
}
#[test]
fn acme_domains_with_an_email_are_accepted() {
let config = parse_str(
r#"{"tls": {"acme_domains": ["a.example"], "acme_email": "me@example.com"}}"#,
)
.unwrap();
assert_eq!(config.tls.acme_domains, vec!["a.example".to_string()]);
}
#[test]
fn a_plain_http_acme_directory_is_rejected() {
let error = message(r#"{"tls": {"acme_directory": "http://acme.example/dir"}}"#);
assert!(error.contains("tls.acme_directory"), "{error}");
assert!(error.contains("https://"), "{error}");
}
#[test]
fn a_domain_may_carry_a_port() {
let config = parse_str(r#"{"app": {"domain": "mobux.example:5151"}}"#).unwrap();
assert_eq!(config.app.domain, "mobux.example:5151");
}
#[test]
fn a_domain_with_a_junk_port_is_rejected() {
let error = message(r#"{"app": {"domain": "mobux.example:https"}}"#);
assert!(error.contains("app.domain"), "{error}");
}
#[test]
fn an_empty_service_name_is_rejected() {
let error = message(r#"{"app": {"service_name": ""}}"#);
assert!(error.contains("app.service_name"), "{error}");
}
#[test]
fn a_vapid_contact_must_be_mailto_or_https() {
let error = message(r#"{"push": {"vapid_contact": "admin@example.com"}}"#);
assert!(error.contains("push.vapid_contact"), "{error}");
}
#[test]
fn a_missing_file_is_the_default_config() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
assert_eq!(load_from(&path).unwrap(), Config::default());
}
#[test]
fn a_file_on_disk_is_read_and_merged() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join(CONFIG_FILE_NAME);
std::fs::write(&path, r#"{"session": {"shell": "/bin/zsh"}}"#).unwrap();
assert_eq!(load_from(&path).unwrap().session.shell, "/bin/zsh");
}
#[test]
fn config_dir_prefers_the_explicit_override() {
let env = DirEnv {
mobux_config_dir: Some("/srv/mobux".to_string()),
xdg_config_home: Some("/home/me/.xdg".to_string()),
home: Some("/home/me".to_string()),
};
assert_eq!(resolve_config_dir(&env), PathBuf::from("/srv/mobux"));
}
#[test]
fn config_dir_falls_back_to_xdg_config_home() {
let env = DirEnv {
mobux_config_dir: None,
xdg_config_home: Some("/home/me/.xdg".to_string()),
home: Some("/home/me".to_string()),
};
assert_eq!(
resolve_config_dir(&env),
PathBuf::from("/home/me/.xdg/mobux")
);
}
#[test]
fn config_dir_falls_back_to_home() {
let env = DirEnv {
mobux_config_dir: None,
xdg_config_home: None,
home: Some("/home/me".to_string()),
};
assert_eq!(
resolve_config_dir(&env),
PathBuf::from("/home/me/.config/mobux")
);
}
#[test]
fn an_empty_override_is_ignored() {
let env = DirEnv {
mobux_config_dir: Some(" ".to_string()),
xdg_config_home: Some(String::new()),
home: Some("/home/me".to_string()),
};
assert_eq!(
resolve_config_dir(&env),
PathBuf::from("/home/me/.config/mobux")
);
}
#[test]
fn config_dir_without_a_home_stays_relative() {
assert_eq!(
resolve_config_dir(&DirEnv::default()),
PathBuf::from("./.config/mobux")
);
}
#[test]
fn every_field_spec_names_a_key_in_the_schema() {
let schema = schema();
for field in FIELDS {
let node = schema_node(&schema, field.key);
assert!(!node.is_null(), "{} is missing from the schema", field.key);
let description = node["description"].as_str().unwrap_or_default();
assert!(
description.contains(field.env),
"{} does not document {}",
field.key,
field.env
);
}
}
#[test]
fn the_env_namespaces_do_not_overlap() {
for field in FIELDS {
assert!(
!ENV_ONLY.contains(&field.env),
"{} is listed both in the schema and as env-only",
field.env
);
}
}
}