use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ConfigValueType {
String,
Bool,
Integer,
StringList,
}
impl ConfigValueType {
pub fn label(self) -> &'static str {
match self {
ConfigValueType::String => "string",
ConfigValueType::Bool => "bool",
ConfigValueType::Integer => "int",
ConfigValueType::StringList => "list",
}
}
pub fn from_label(label: &str) -> Option<Self> {
match label {
"string" => Some(ConfigValueType::String),
"bool" => Some(ConfigValueType::Bool),
"int" => Some(ConfigValueType::Integer),
"list" => Some(ConfigValueType::StringList),
_ => None,
}
}
pub fn parse(self, raw: &str) -> Result<Value, String> {
match self {
ConfigValueType::String => Ok(Value::String(raw.to_string())),
ConfigValueType::Bool => match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Ok(Value::Bool(true)),
"false" | "0" | "no" | "off" => Ok(Value::Bool(false)),
_ => Err(format!("expected a boolean (true/false), got '{}'", raw)),
},
ConfigValueType::Integer => raw
.trim()
.parse::<i64>()
.map(|n| Value::Number(n.into()))
.map_err(|_| format!("expected an integer, got '{}'", raw)),
ConfigValueType::StringList => {
let trimmed = raw.trim();
if trimmed.starts_with('[') {
serde_json::from_str::<Vec<String>>(trimmed)
.map(|v| Value::Array(v.into_iter().map(Value::String).collect()))
.map_err(|_| format!("expected a JSON array of strings, got '{}'", raw))
} else if trimmed.is_empty() {
Ok(Value::Array(Vec::new()))
} else {
Ok(Value::Array(
trimmed
.split(',')
.map(|s| Value::String(s.trim().to_string()))
.collect(),
))
}
}
}
}
pub fn matches(self, value: &Value) -> bool {
match self {
ConfigValueType::String => value.is_string(),
ConfigValueType::Bool => value.is_boolean(),
ConfigValueType::Integer => value.is_i64() || value.is_u64(),
ConfigValueType::StringList => value
.as_array()
.map(|a| a.iter().all(Value::is_string))
.unwrap_or(false),
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ConfigSetting {
pub key: String,
pub description: String,
pub default: Option<String>,
pub value_type: ConfigValueType,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub env_var: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub choices: Option<Vec<String>>,
}
impl ConfigSetting {
pub fn new(
key: impl Into<String>,
description: impl Into<String>,
value_type: ConfigValueType,
) -> Self {
Self {
key: key.into(),
description: description.into(),
default: None,
value_type,
env_var: None,
choices: None,
}
}
pub fn with_default(mut self, default: impl Into<String>) -> Self {
self.default = Some(default.into());
self
}
pub fn with_env(mut self, env_var: impl Into<String>) -> Self {
self.env_var = Some(env_var.into());
self
}
pub fn with_choices<I, S>(mut self, choices: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.choices = Some(choices.into_iter().map(Into::into).collect());
self
}
pub fn coerce(&self, raw: &str) -> Result<Value, String> {
let value = self.value_type.parse(raw)?;
if let Some(choices) = &self.choices {
let s = match &value {
Value::String(s) => s.clone(),
other => other.to_string(),
};
if !choices.iter().any(|c| c == &s) {
return Err(format!(
"'{}' is not an allowed value for '{}'. Choices: {}",
raw,
self.key,
choices.join(", ")
));
}
}
Ok(value)
}
pub fn namespace(&self) -> &str {
self.key.split('.').next().unwrap_or(&self.key)
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
#[test]
fn bool_parses_common_spellings() {
assert_eq!(ConfigValueType::Bool.parse("true"), Ok(json!(true)));
assert_eq!(ConfigValueType::Bool.parse("Off"), Ok(json!(false)));
assert_eq!(ConfigValueType::Bool.parse("yes"), Ok(json!(true)));
assert!(ConfigValueType::Bool.parse("maybe").is_err());
}
#[test]
fn integer_parses_and_rejects() {
assert_eq!(ConfigValueType::Integer.parse(" 42 "), Ok(json!(42)));
assert!(ConfigValueType::Integer.parse("3.5").is_err());
}
#[test]
fn coerce_without_choices_matches_parse() {
let s = ConfigSetting::new("x", "d", ConfigValueType::Bool);
assert_eq!(s.coerce("true"), Ok(json!(true)));
assert!(s.coerce("maybe").is_err());
}
#[test]
fn coerce_enforces_choices() {
let s = ConfigSetting::new("mode", "d", ConfigValueType::String)
.with_choices(["off", "required"]);
assert_eq!(s.coerce("required"), Ok(json!("required")));
assert_eq!(s.coerce("off"), Ok(json!("off")));
let err = s.coerce("maybe").unwrap_err();
assert!(
err.contains("not an allowed value") && err.contains("off, required"),
"error should list choices, got: {err}"
);
}
#[test]
fn list_accepts_comma_and_json() {
assert_eq!(
ConfigValueType::StringList.parse("a, b ,c"),
Ok(json!(["a", "b", "c"]))
);
assert_eq!(
ConfigValueType::StringList.parse(r#"["x","y"]"#),
Ok(json!(["x", "y"]))
);
assert_eq!(ConfigValueType::StringList.parse(""), Ok(json!([])));
}
#[test]
fn matches_checks_existing_type() {
assert!(ConfigValueType::String.matches(&json!("s")));
assert!(!ConfigValueType::String.matches(&json!(1)));
assert!(ConfigValueType::StringList.matches(&json!(["a"])));
assert!(!ConfigValueType::StringList.matches(&json!([1])));
}
#[test]
fn namespace_is_first_segment() {
let s = ConfigSetting::new("skill.dest", "d", ConfigValueType::String);
assert_eq!(s.namespace(), "skill");
}
}