Skip to main content

metarepo_core/
config_setting.rs

1//! Declarative configuration settings for plugins and modules.
2//!
3//! A plugin describes the settings it understands by returning a list of
4//! [`ConfigSetting`]s from [`crate::MetaPlugin::settings`]. The `meta config`
5//! command aggregates these into a catalog so settings can be listed, read, and
6//! written uniformly — instead of users hand-editing `.meta` and knowing each
7//! block by heart. Values are stored under their dotted key in the workspace
8//! config (e.g. `skill.dest`).
9
10use serde::{Deserialize, Serialize};
11use serde_json::Value;
12
13/// The value type of a configurable setting. Drives CLI parsing, validation,
14/// and display.
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
16pub enum ConfigValueType {
17    /// A single string.
18    String,
19    /// A boolean (`true`/`false`).
20    Bool,
21    /// A signed integer.
22    Integer,
23    /// A list of strings (CLI input accepts a JSON array or a comma-separated
24    /// list).
25    StringList,
26}
27
28impl ConfigValueType {
29    /// Short human label for help/listing output.
30    pub fn label(self) -> &'static str {
31        match self {
32            ConfigValueType::String => "string",
33            ConfigValueType::Bool => "bool",
34            ConfigValueType::Integer => "int",
35            ConfigValueType::StringList => "list",
36        }
37    }
38
39    /// Inverse of [`label`](Self::label): recover a type from its short label.
40    pub fn from_label(label: &str) -> Option<Self> {
41        match label {
42            "string" => Some(ConfigValueType::String),
43            "bool" => Some(ConfigValueType::Bool),
44            "int" => Some(ConfigValueType::Integer),
45            "list" => Some(ConfigValueType::StringList),
46            _ => None,
47        }
48    }
49
50    /// Parse a raw CLI string into a JSON value of this type. Returns a
51    /// human-readable error string on mismatch so `meta config set` can reject
52    /// bad input before writing.
53    pub fn parse(self, raw: &str) -> Result<Value, String> {
54        match self {
55            ConfigValueType::String => Ok(Value::String(raw.to_string())),
56            ConfigValueType::Bool => match raw.trim().to_ascii_lowercase().as_str() {
57                "true" | "1" | "yes" | "on" => Ok(Value::Bool(true)),
58                "false" | "0" | "no" | "off" => Ok(Value::Bool(false)),
59                _ => Err(format!("expected a boolean (true/false), got '{}'", raw)),
60            },
61            ConfigValueType::Integer => raw
62                .trim()
63                .parse::<i64>()
64                .map(|n| Value::Number(n.into()))
65                .map_err(|_| format!("expected an integer, got '{}'", raw)),
66            ConfigValueType::StringList => {
67                let trimmed = raw.trim();
68                if trimmed.starts_with('[') {
69                    serde_json::from_str::<Vec<String>>(trimmed)
70                        .map(|v| Value::Array(v.into_iter().map(Value::String).collect()))
71                        .map_err(|_| format!("expected a JSON array of strings, got '{}'", raw))
72                } else if trimmed.is_empty() {
73                    Ok(Value::Array(Vec::new()))
74                } else {
75                    Ok(Value::Array(
76                        trimmed
77                            .split(',')
78                            .map(|s| Value::String(s.trim().to_string()))
79                            .collect(),
80                    ))
81                }
82            }
83        }
84    }
85
86    /// True if an existing JSON value already matches this type.
87    pub fn matches(self, value: &Value) -> bool {
88        match self {
89            ConfigValueType::String => value.is_string(),
90            ConfigValueType::Bool => value.is_boolean(),
91            ConfigValueType::Integer => value.is_i64() || value.is_u64(),
92            ConfigValueType::StringList => value
93                .as_array()
94                .map(|a| a.iter().all(Value::is_string))
95                .unwrap_or(false),
96        }
97    }
98}
99
100/// A single setting a plugin or module declares as configurable.
101#[derive(Debug, Clone, Serialize, Deserialize)]
102pub struct ConfigSetting {
103    /// Dotted key in the workspace config, e.g. `skill.dest`. The segment
104    /// before the first `.` is the owning namespace (usually the plugin name).
105    pub key: String,
106    /// One-line description shown by `meta config list`.
107    pub description: String,
108    /// Default value (as a display string) used when the key is unset.
109    pub default: Option<String>,
110    /// Declared value type, used for validation and display.
111    pub value_type: ConfigValueType,
112}
113
114impl ConfigSetting {
115    /// Declare a setting with a dotted `key`, `description`, and value type.
116    pub fn new(
117        key: impl Into<String>,
118        description: impl Into<String>,
119        value_type: ConfigValueType,
120    ) -> Self {
121        Self {
122            key: key.into(),
123            description: description.into(),
124            default: None,
125            value_type,
126        }
127    }
128
129    /// Attach a default value shown when the setting is unset.
130    pub fn with_default(mut self, default: impl Into<String>) -> Self {
131        self.default = Some(default.into());
132        self
133    }
134
135    /// The namespace (segment before the first `.`), usually the plugin name.
136    pub fn namespace(&self) -> &str {
137        self.key.split('.').next().unwrap_or(&self.key)
138    }
139}
140
141#[cfg(test)]
142mod tests {
143    use super::*;
144    use serde_json::json;
145
146    #[test]
147    fn bool_parses_common_spellings() {
148        assert_eq!(ConfigValueType::Bool.parse("true"), Ok(json!(true)));
149        assert_eq!(ConfigValueType::Bool.parse("Off"), Ok(json!(false)));
150        assert_eq!(ConfigValueType::Bool.parse("yes"), Ok(json!(true)));
151        assert!(ConfigValueType::Bool.parse("maybe").is_err());
152    }
153
154    #[test]
155    fn integer_parses_and_rejects() {
156        assert_eq!(ConfigValueType::Integer.parse(" 42 "), Ok(json!(42)));
157        assert!(ConfigValueType::Integer.parse("3.5").is_err());
158    }
159
160    #[test]
161    fn list_accepts_comma_and_json() {
162        assert_eq!(
163            ConfigValueType::StringList.parse("a, b ,c"),
164            Ok(json!(["a", "b", "c"]))
165        );
166        assert_eq!(
167            ConfigValueType::StringList.parse(r#"["x","y"]"#),
168            Ok(json!(["x", "y"]))
169        );
170        assert_eq!(ConfigValueType::StringList.parse(""), Ok(json!([])));
171    }
172
173    #[test]
174    fn matches_checks_existing_type() {
175        assert!(ConfigValueType::String.matches(&json!("s")));
176        assert!(!ConfigValueType::String.matches(&json!(1)));
177        assert!(ConfigValueType::StringList.matches(&json!(["a"])));
178        assert!(!ConfigValueType::StringList.matches(&json!([1])));
179    }
180
181    #[test]
182    fn namespace_is_first_segment() {
183        let s = ConfigSetting::new("skill.dest", "d", ConfigValueType::String);
184        assert_eq!(s.namespace(), "skill");
185    }
186}