Skip to main content

zeroclaw_config/
helpers.rs

1//! Property helpers used by the `Configurable` derive macro and the `zeroclaw config` CLI.
2
3use crate::traits::{PropFieldInfo, PropKind};
4
5/// For a `#[nested] HashMap<String, T>` field, parse a `get_prop`/`set_prop`
6/// path of the form `<my_prefix>.<field_name>.<hm_key>.<inner_suffix>` and
7/// return the HashMap key + the fully-qualified inner name that the value
8/// type's own `get_prop` / `set_prop` expects.
9///
10/// HashMap keys are user-controlled and may contain dots, URLs, or hostnames
11/// (for example `model_providers.custom:https://example.invalid/v1.api-key`).
12/// Inner values may themselves be deeply nested (`AliasedAgentConfig` has
13/// `agent.thinking.<...>` subpaths), so neither left-splitting nor
14/// right-splitting works in isolation. Match against the actual present
15/// keys and pick the longest prefix that is followed by `.` — this
16/// correctly handles dotted keys *and* deep inner paths in one parse.
17///
18/// `keys` is an iterator over the live HashMap's keys (typically
19/// `self.<field>.keys().map(String::as_str)` from the derive). Returns
20/// `None` when the path doesn't match, letting the derive's generated
21/// code fall through to the next nested field.
22pub fn route_hashmap_path<'a, 'k, I>(
23    name: &'a str,
24    my_prefix: &str,
25    field_name: &str,
26    inner_prefix: &str,
27    keys: I,
28) -> Option<(&'a str, String)>
29where
30    I: IntoIterator<Item = &'k str>,
31{
32    let key_prefix = if my_prefix.is_empty() {
33        field_name.to_string()
34    } else {
35        format!("{my_prefix}.{field_name}")
36    };
37    let rest = name.strip_prefix(&key_prefix)?.strip_prefix('.')?;
38    // Longest-match against present map keys. Dotted keys (URL-shaped
39    // custom provider entries) sort longer than their unprefixed siblings,
40    // so this also disambiguates `custom:https://x` vs. `custom`.
41    let mut best: Option<(usize, &'a str)> = None;
42    for k in keys {
43        if let Some(_suffix) = rest.strip_prefix(k).and_then(|s| s.strip_prefix('.'))
44            && best.is_none_or(|(len, _)| k.len() > len)
45        {
46            // Slice the original `rest` so we can keep the lifetime tied
47            // to `name` rather than to a transient `&str` from the keys
48            // iterator.
49            let hm_key = &rest[..k.len()];
50            best = Some((k.len(), hm_key));
51        }
52    }
53    let (key_len, hm_key) = best?;
54    let inner_suffix = &rest[key_len + 1..];
55    let inner_name = if inner_prefix.is_empty() {
56        inner_suffix.to_string()
57    } else {
58        format!("{inner_prefix}.{inner_suffix}")
59    };
60    Some((hm_key, inner_name))
61}
62
63/// For a `#[nested] HashMap<String, HashMap<String, T>>` field, parse a path
64/// `<my_prefix>.<field_name>.<outer_key>.<inner_key>.<inner_suffix>` and
65/// return (outer_key, inner_key, fully-qualified inner name for T::get_prop).
66///
67/// Returns `None` when the path doesn't match (wrong prefix or too few segments).
68pub fn route_double_hashmap_path<'a>(
69    name: &'a str,
70    my_prefix: &str,
71    field_name: &str,
72    inner_prefix: &str,
73) -> Option<(&'a str, &'a str, String)> {
74    let key_prefix = if my_prefix.is_empty() {
75        field_name.to_string()
76    } else {
77        format!("{my_prefix}.{field_name}")
78    };
79    let rest = name.strip_prefix(&key_prefix)?.strip_prefix('.')?;
80    let (outer_key, rest2) = rest.split_once('.')?;
81    let (inner_key, inner_suffix) = rest2.split_once('.')?;
82    let inner_name = if inner_prefix.is_empty() {
83        inner_suffix.to_string()
84    } else {
85        format!("{inner_prefix}.{inner_suffix}")
86    };
87    Some((outer_key, inner_key, inner_name))
88}
89
90/// Return a comma-separated string of valid enum variant names for display in error messages.
91#[cfg(feature = "schema-export")]
92pub fn enum_variants<T: schemars::JsonSchema>() -> String {
93    #[cfg(feature = "schema-export")]
94    let schema = schemars::schema_for!(T);
95    let json = match serde_json::to_value(&schema) {
96        Ok(v) => v,
97        Err(_) => return "(unknown variants)".to_string(),
98    };
99
100    if let Some(variants) = json.get("enum").and_then(|v| v.as_array()) {
101        let names: Vec<&str> = variants.iter().filter_map(|v| v.as_str()).collect();
102        if !names.is_empty() {
103            return names.join(", ");
104        }
105    }
106
107    if let Some(one_of) = json.get("oneOf").and_then(|v| v.as_array()) {
108        let names: Vec<&str> = one_of
109            .iter()
110            .filter_map(|s| {
111                s.get("const").and_then(|v| v.as_str()).or_else(|| {
112                    s.get("enum")
113                        .and_then(|v| v.as_array())
114                        .and_then(|arr| arr.first())
115                        .and_then(|v| v.as_str())
116                })
117            })
118            .collect();
119        if !names.is_empty() {
120            return names.join(", ");
121        }
122    }
123
124    "(unknown variants)".to_string()
125}
126
127/// Build a `PropFieldInfo` by reading the display value from a serialized TOML table.
128#[allow(clippy::too_many_arguments)]
129pub fn make_prop_field(
130    table: Option<&toml::Table>,
131    name: &str,
132    serde_name: &str,
133    category: &'static str,
134    type_hint: &'static str,
135    kind: PropKind,
136    is_secret: bool,
137    enum_variants: Option<fn() -> Vec<String>>,
138    description: &'static str,
139    derived_from_secret: bool,
140) -> PropFieldInfo {
141    let display_value = if is_secret || derived_from_secret {
142        match table.and_then(|t| t.get(serde_name)) {
143            Some(toml::Value::String(s)) if !s.is_empty() => "****".to_string(),
144            Some(toml::Value::Array(arr)) if !arr.is_empty() => {
145                format!("[{}]", vec!["****"; arr.len()].join(", "))
146            }
147            _ => "<unset>".to_string(),
148        }
149    } else {
150        toml_value_to_display(table.and_then(|t| t.get(serde_name)))
151    };
152    PropFieldInfo {
153        name: name.to_string(),
154        category,
155        display_value,
156        type_hint,
157        kind,
158        is_secret,
159        enum_variants,
160        description,
161        derived_from_secret,
162    }
163}
164
165/// Get a property value via serde serialization.
166pub fn serde_get_prop<T: serde::Serialize>(
167    target: &T,
168    prefix: &str,
169    name: &str,
170    is_secret: bool,
171) -> anyhow::Result<String> {
172    if is_secret {
173        return Ok("**** (encrypted)".to_string());
174    }
175    let serde_name = prop_name_to_serde_field(prefix, name)?;
176    let table = toml::Value::try_from(target)?;
177    Ok(toml_value_to_display(
178        table.as_table().and_then(|t| t.get(&serde_name)),
179    ))
180}
181
182/// Set a property value via serde roundtrip.
183pub fn serde_set_prop<T: serde::Serialize + serde::de::DeserializeOwned>(
184    target: &mut T,
185    prefix: &str,
186    name: &str,
187    value_str: &str,
188    kind: PropKind,
189    is_option: bool,
190) -> anyhow::Result<()> {
191    let serde_name = prop_name_to_serde_field(prefix, name)?;
192    let mut table: toml::Table = toml::from_str(&toml::to_string(target)?)?;
193    if value_str.is_empty() && is_option {
194        table.remove(&serde_name);
195    } else {
196        table.insert(serde_name, parse_prop_value(value_str, kind)?);
197    }
198    *target = toml::from_str(&toml::to_string(&table)?)?;
199    Ok(())
200}
201
202fn toml_value_to_display(value: Option<&toml::Value>) -> String {
203    match value {
204        None => "<unset>".to_string(),
205        Some(toml::Value::String(s)) => s.clone(),
206        Some(v) => v.to_string(),
207    }
208}
209
210fn prop_name_to_serde_field(prefix: &str, name: &str) -> anyhow::Result<String> {
211    let suffix = if prefix.is_empty() {
212        name
213    } else {
214        name.strip_prefix(prefix)
215            .and_then(|s| s.strip_prefix('.'))
216            .ok_or_else(|| {
217                ::zeroclaw_log::record!(
218                    WARN,
219                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
220                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
221                        .with_attrs(::serde_json::json!({"prefix": prefix, "name": name})),
222                    "prop_name_to_serde_field: property name does not share the configured prefix"
223                );
224                anyhow::Error::msg(format!("Unknown property '{name}'"))
225            })?
226    };
227    let field_part = suffix.split('.').next().unwrap_or(suffix);
228    Ok(field_part.replace('-', "_"))
229}
230
231fn parse_prop_value(value_str: &str, kind: PropKind) -> anyhow::Result<toml::Value> {
232    let reject = |reason: &'static str, attrs: serde_json::Value| {
233        ::zeroclaw_log::record!(
234            WARN,
235            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
236                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
237                .with_attrs(attrs),
238            "parse_prop_value rejected input"
239        );
240        let _ = reason;
241    };
242    match kind {
243        PropKind::Bool => Ok(toml::Value::Boolean(value_str.parse().map_err(|_| {
244            reject(
245                "bool",
246                ::serde_json::json!({"kind": "bool", "got_len": value_str.len()}),
247            );
248            anyhow::Error::msg(format!(
249                "Invalid bool value '{value_str}', expected 'true' or 'false'"
250            ))
251        })?)),
252        PropKind::Integer => Ok(toml::Value::Integer(value_str.parse().map_err(|_| {
253            reject(
254                "integer",
255                ::serde_json::json!({"kind": "integer", "got_len": value_str.len()}),
256            );
257            anyhow::Error::msg(format!("Invalid integer value '{value_str}'"))
258        })?)),
259        PropKind::Float => Ok(toml::Value::Float(value_str.parse().map_err(|_| {
260            reject(
261                "float",
262                ::serde_json::json!({"kind": "float", "got_len": value_str.len()}),
263            );
264            anyhow::Error::msg(format!("Invalid float value '{value_str}'"))
265        })?)),
266        PropKind::String | PropKind::Enum => Ok(toml::Value::String(value_str.to_string())),
267        PropKind::StringArray => {
268            let trimmed = value_str.trim();
269            // Accept JSON/TOML array syntax: ["a", "b", "c"]
270            if trimmed.starts_with('[')
271                && let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed)
272            {
273                return Ok(toml::Value::Array(
274                    arr.into_iter().map(toml::Value::String).collect(),
275                ));
276            }
277            // Fall back to comma-separated input.
278            let items = value_str
279                .split(',')
280                .map(|s| toml::Value::String(s.trim().to_string()))
281                .filter(|v| v.as_str().is_some_and(|s| !s.is_empty()))
282                .collect();
283            Ok(toml::Value::Array(items))
284        }
285        // `Vec<T>` of structs: round-trip a JSON array of objects to a
286        // TOML array. JSON `null` (used by serde for `Option::None`) is
287        // dropped because TOML has no null - the absent key conveys the
288        // same meaning when the field deserializes back into `Option<T>`.
289        PropKind::ObjectArray => {
290            let v: serde_json::Value = serde_json::from_str(value_str).map_err(|e| {
291                reject(
292                    "object_array",
293                    ::serde_json::json!({"kind": "object_array", "error": format!("{}", e)}),
294                );
295                anyhow::Error::msg(format!("invalid JSON array of objects: {e}"))
296            })?;
297            json_to_toml(v).ok_or_else(|| {
298                reject(
299                    "object_array_nulls",
300                    ::serde_json::json!({"kind": "object_array", "reason": "all-null"}),
301                );
302                anyhow::Error::msg("JSON value contained only nulls, nothing to write")
303            })
304        }
305        // Struct-shaped scalar: parse the JSON object into a TOML table so
306        // the parent serde round-trip deserializes into the typed struct
307        // (e.g. `Option<ModelPricing>`). Inserting a raw String here would
308        // fail serde because the field is typed, not free-form text.
309        PropKind::Object => {
310            let v: serde_json::Value = serde_json::from_str(value_str).map_err(|e| {
311                reject(
312                    "object",
313                    ::serde_json::json!({"kind": "object", "error": format!("{}", e)}),
314                );
315                anyhow::Error::msg(format!("invalid JSON object: {e}"))
316            })?;
317            if !matches!(v, serde_json::Value::Object(_)) {
318                reject(
319                    "object_shape",
320                    ::serde_json::json!({"kind": "object", "got_shape": "non-object"}),
321                );
322                anyhow::bail!("Object field requires a JSON object; got {v}");
323            }
324            json_to_toml(v).ok_or_else(|| {
325                reject(
326                    "object_nulls",
327                    ::serde_json::json!({"kind": "object", "reason": "all-null"}),
328                );
329                anyhow::Error::msg("JSON object contained only nulls, nothing to write")
330            })
331        }
332    }
333}
334
335/// Walk a `serde_json::Value` into a `toml::Value`, dropping any `null`s
336/// (TOML has no null; absence of a key conveys `Option::None`).
337fn json_to_toml(v: serde_json::Value) -> Option<toml::Value> {
338    match v {
339        serde_json::Value::Null => None,
340        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(b)),
341        serde_json::Value::String(s) => Some(toml::Value::String(s)),
342        serde_json::Value::Number(n) => {
343            if let Some(i) = n.as_i64() {
344                Some(toml::Value::Integer(i))
345            } else if let Some(u) = n.as_u64() {
346                // TOML integers are i64; clamp pathological u64 values.
347                Some(toml::Value::Integer(i64::try_from(u).unwrap_or(i64::MAX)))
348            } else {
349                n.as_f64().map(toml::Value::Float)
350            }
351        }
352        serde_json::Value::Array(items) => Some(toml::Value::Array(
353            items.into_iter().filter_map(json_to_toml).collect(),
354        )),
355        serde_json::Value::Object(map) => {
356            let mut table = toml::map::Map::new();
357            for (k, val) in map {
358                if let Some(tv) = json_to_toml(val) {
359                    table.insert(k, tv);
360                }
361            }
362            Some(toml::Value::Table(table))
363        }
364    }
365}
366
367/// Validate that an alias key is safe for use in TOML dotted paths, URLs,
368/// filesystem paths on Windows/macOS/Linux, and `ZEROCLAW_*` env-var grammar.
369///
370/// Allowed: lowercase ASCII alphanumeric plus single underscore, 1-63 chars.
371/// Must start AND end with alphanumeric. Adjacent underscores (`__`) are
372/// forbidden because they collide with the env-var grammar's path separator.
373///
374/// The env-var grammar uses `__` as path separator, which lets aliases keep
375/// single `_` literally (`prod_v2`, `staging_api`). Hyphens are forbidden
376/// because they are illegal in POSIX env-var identifiers; uppercase is
377/// forbidden so the bootstrap env-vars (`ZEROCLAW_WORKSPACE`,
378/// `ZEROCLAW_CONFIG_DIR`) stay disambiguated by case.
379pub fn validate_alias_key(key: &str) -> Result<(), String> {
380    if key.is_empty() {
381        return Err("alias must not be empty".to_string());
382    }
383    if key.len() > 63 {
384        return Err(format!(
385            "alias '{}' is too long ({} chars); maximum is 63",
386            key,
387            key.len()
388        ));
389    }
390    let first = key.chars().next().unwrap();
391    let last = key.chars().next_back().unwrap();
392    if !matches!(first, 'a'..='z' | '0'..='9') {
393        return Err(format!(
394            "alias '{key}' must start with a lowercase letter or digit"
395        ));
396    }
397    if !matches!(last, 'a'..='z' | '0'..='9') {
398        return Err(format!(
399            "alias '{key}' must end with a lowercase letter or digit"
400        ));
401    }
402    if key.contains("__") {
403        return Err(format!(
404            "alias '{key}' must not contain `__`; it is reserved as the env-var grammar's path separator"
405        ));
406    }
407    for ch in key.chars() {
408        if !matches!(ch, 'a'..='z' | '0'..='9' | '_') {
409            return Err(format!(
410                "alias '{}' contains invalid character {:?}; \
411                 only lowercase letters, digits, and single underscores are allowed (no hyphen, no uppercase)",
412                key, ch
413            ));
414        }
415    }
416    Ok(())
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    #[test]
424    fn route_hashmap_path_handles_deep_inner_paths() {
425        // Regression: AliasedAgentConfig has nested fields like
426        // `agent.thinking.<...>` (3+ segments under the alias key). The
427        // earlier rsplit-once parser would mis-route, yielding hm_key =
428        // "fake123.agent.thinking" instead of "fake123".
429        let keys = ["fake123"];
430        let got = route_hashmap_path(
431            "agents.fake123.agent.thinking.default-level",
432            "",
433            "agents",
434            "",
435            keys.iter().copied(),
436        );
437        assert_eq!(
438            got,
439            Some(("fake123", "agent.thinking.default-level".to_string()))
440        );
441    }
442
443    #[test]
444    fn route_hashmap_path_picks_longest_dotted_key() {
445        // Custom-URL keys may contain dots; the longest matching key
446        // wins so `custom:https://example/v1` is preferred over `custom`.
447        let keys = ["custom", "custom:https://example/v1"];
448        let got = route_hashmap_path(
449            "providers.models.custom:https://example/v1.api-key",
450            "",
451            "providers.models",
452            "",
453            keys.iter().copied(),
454        );
455        assert_eq!(
456            got,
457            Some(("custom:https://example/v1", "api-key".to_string()))
458        );
459    }
460
461    #[test]
462    fn parse_string_array_splits_on_comma() {
463        let result = parse_prop_value("alice, bob, charlie", PropKind::StringArray).unwrap();
464        let arr = result.as_array().unwrap();
465        assert_eq!(arr.len(), 3);
466        assert_eq!(arr[0].as_str(), Some("alice"));
467        assert_eq!(arr[1].as_str(), Some("bob"));
468        assert_eq!(arr[2].as_str(), Some("charlie"));
469    }
470
471    #[test]
472    fn parse_string_array_empty_input_gives_empty_array() {
473        let result = parse_prop_value("", PropKind::StringArray).unwrap();
474        assert_eq!(result.as_array().unwrap().len(), 0);
475    }
476
477    #[test]
478    fn parse_string_array_single_value() {
479        let result = parse_prop_value("alice", PropKind::StringArray).unwrap();
480        let arr = result.as_array().unwrap();
481        assert_eq!(arr.len(), 1);
482        assert_eq!(arr[0].as_str(), Some("alice"));
483    }
484
485    #[test]
486    fn parse_string_array_quote_in_value_is_literal() {
487        let result = parse_prop_value(r#"tok1, p@ss"word"#, PropKind::StringArray).unwrap();
488        let arr = result.as_array().unwrap();
489        assert_eq!(arr.len(), 2);
490        assert_eq!(arr[0].as_str(), Some("tok1"));
491        assert_eq!(arr[1].as_str(), Some(r#"p@ss"word"#));
492    }
493
494    // ── validate_alias_key ────────────────────────────────────────────────
495
496    #[test]
497    fn validate_alias_key_accepts_lowercase_alphanumeric_with_underscore() {
498        assert!(validate_alias_key("default").is_ok());
499        assert!(validate_alias_key("work").is_ok());
500        assert!(validate_alias_key("alias123").is_ok());
501        assert!(validate_alias_key("a").is_ok());
502        assert!(validate_alias_key("prod2024").is_ok());
503        // V0.8.0: env-var grammar uses `__` as separator, so single `_`
504        // inside an alias is unambiguous.
505        assert!(validate_alias_key("prod_v2").is_ok());
506        assert!(validate_alias_key("staging_api").is_ok());
507    }
508
509    #[test]
510    fn validate_alias_key_rejects_empty() {
511        assert!(validate_alias_key("").is_err());
512    }
513
514    #[test]
515    fn validate_alias_key_rejects_uppercase() {
516        // Leading uppercase trips the start-char rule.
517        let err = validate_alias_key("MyAlias").unwrap_err();
518        assert!(err.contains("must start with"), "{err}");
519        let err = validate_alias_key("A").unwrap_err();
520        assert!(err.contains("must start with"), "{err}");
521        // Embedded uppercase trips the per-char rule.
522        let err = validate_alias_key("myAlias").unwrap_err();
523        assert!(err.contains("invalid character"), "{err}");
524    }
525
526    #[test]
527    fn validate_alias_key_rejects_leading_underscore() {
528        let err = validate_alias_key("_bad").unwrap_err();
529        assert!(err.contains("must start with"), "{err}");
530    }
531
532    #[test]
533    fn validate_alias_key_rejects_trailing_underscore() {
534        let err = validate_alias_key("bad_").unwrap_err();
535        assert!(err.contains("must end with"), "{err}");
536    }
537
538    #[test]
539    fn validate_alias_key_rejects_double_underscore() {
540        let err = validate_alias_key("foo__bar").unwrap_err();
541        assert!(err.contains("must not contain `__`"), "{err}");
542    }
543
544    #[test]
545    fn validate_alias_key_rejects_hyphen() {
546        // V0.8.0: hyphens are illegal in env-var identifiers.
547        let err = validate_alias_key("my-alias").unwrap_err();
548        assert!(err.contains("invalid character"), "{err}");
549    }
550
551    #[test]
552    fn validate_alias_key_rejects_dot() {
553        let err = validate_alias_key("my.alias").unwrap_err();
554        assert!(err.contains("invalid character"), "{err}");
555    }
556
557    #[test]
558    fn validate_alias_key_rejects_slash() {
559        let err = validate_alias_key("my/alias").unwrap_err();
560        assert!(err.contains("invalid character"), "{err}");
561    }
562
563    #[test]
564    fn validate_alias_key_rejects_space() {
565        let err = validate_alias_key("my alias").unwrap_err();
566        assert!(err.contains("invalid character"), "{err}");
567    }
568
569    #[test]
570    fn validate_alias_key_rejects_over_63_chars() {
571        let long = "a".repeat(64);
572        let err = validate_alias_key(&long).unwrap_err();
573        assert!(err.contains("too long"), "{err}");
574    }
575
576    #[test]
577    fn validate_alias_key_accepts_exactly_63_chars() {
578        let at_limit = "a".repeat(63);
579        assert!(validate_alias_key(&at_limit).is_ok());
580    }
581
582    #[test]
583    fn validate_alias_key_rejects_windows_reserved_chars() {
584        for ch in [':', '*', '?', '"', '<', '>', '|', '\\'] {
585            let key = format!("alias{ch}name");
586            assert!(
587                validate_alias_key(&key).is_err(),
588                "expected rejection of char {ch:?} in alias key"
589            );
590        }
591    }
592}