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::{ConfigTab, CredentialSurfaceClass, 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    credential_class: Option<CredentialSurfaceClass>,
141    tab: ConfigTab,
142    display_secret_terminals: &[&str],
143) -> PropFieldInfo {
144    let display_value = if is_secret || derived_from_secret {
145        match table.and_then(|t| t.get(serde_name)) {
146            Some(toml::Value::String(s)) if !s.is_empty() => "****".to_string(),
147            Some(toml::Value::Array(arr)) if !arr.is_empty() => {
148                format!("[{}]", vec!["****"; arr.len()].join(", "))
149            }
150            _ => crate::traits::UNSET_DISPLAY.to_string(),
151        }
152    } else {
153        toml_value_to_display_for_kind(
154            table.and_then(|t| t.get(serde_name)),
155            kind,
156            display_secret_terminals,
157        )
158    };
159    PropFieldInfo {
160        name: name.to_string(),
161        category,
162        display_value,
163        type_hint,
164        kind,
165        is_secret,
166        enum_variants,
167        description,
168        derived_from_secret,
169        credential_class,
170        tab,
171    }
172}
173
174/// Get a property value via serde serialization.
175pub fn serde_get_prop<T: serde::Serialize>(
176    target: &T,
177    prefix: &str,
178    name: &str,
179    is_secret: bool,
180    kind: PropKind,
181    display_secret_terminals: &[&str],
182) -> anyhow::Result<String> {
183    if is_secret {
184        return Ok("**** (encrypted)".to_string());
185    }
186    let serde_name = prop_name_to_serde_field(prefix, name)?;
187    let table = toml::Value::try_from(target)?;
188    Ok(toml_value_to_display_for_kind(
189        table.as_table().and_then(|t| t.get(&serde_name)),
190        kind,
191        display_secret_terminals,
192    ))
193}
194
195/// Set a property value via serde roundtrip.
196pub fn serde_set_prop<T: serde::Serialize + serde::de::DeserializeOwned>(
197    target: &mut T,
198    prefix: &str,
199    name: &str,
200    value_str: &str,
201    kind: PropKind,
202    is_option: bool,
203) -> anyhow::Result<()> {
204    let serde_name = prop_name_to_serde_field(prefix, name)?;
205    let mut table: toml::Table = toml::from_str(&toml::to_string(target)?)?;
206    if (value_str.is_empty() || value_str == crate::traits::UNSET_DISPLAY || value_str == "****")
207        && is_option
208    {
209        table.remove(&serde_name);
210    } else {
211        table.insert(serde_name, parse_prop_value(value_str, kind)?);
212    }
213    *target = toml::from_str(&toml::to_string(&table)?)?;
214    Ok(())
215}
216
217fn toml_value_to_display(value: Option<&toml::Value>) -> String {
218    match value {
219        None => crate::traits::UNSET_DISPLAY.to_string(),
220        Some(toml::Value::String(s)) => s.clone(),
221        Some(v) => v.to_string(),
222    }
223}
224
225fn toml_value_to_display_for_kind(
226    value: Option<&toml::Value>,
227    kind: PropKind,
228    display_secret_terminals: &[&str],
229) -> String {
230    match kind {
231        PropKind::Object | PropKind::ObjectArray => match value {
232            None => crate::traits::UNSET_DISPLAY.to_string(),
233            Some(toml::Value::String(s)) => s.clone(),
234            Some(v) => {
235                let mut redacted = v.clone();
236                redact_toml_display_secrets(&mut redacted, display_secret_terminals);
237                redacted.to_string()
238            }
239        },
240        _ => toml_value_to_display(value),
241    }
242}
243
244pub fn object_array_json_display_value(
245    value: &impl serde::Serialize,
246    display_secret_terminals: &[&str],
247) -> String {
248    match serde_json::to_value(value) {
249        Ok(mut value) => {
250            redact_json_display_secrets(&mut value, display_secret_terminals);
251            serde_json::to_string(&value).unwrap_or_else(|_| "[]".to_string())
252        }
253        Err(_) => "[]".to_string(),
254    }
255}
256
257fn redact_json_display_secrets(value: &mut serde_json::Value, display_secret_terminals: &[&str]) {
258    match value {
259        serde_json::Value::Array(items) => {
260            for item in items {
261                redact_json_display_secrets(item, display_secret_terminals);
262            }
263        }
264        serde_json::Value::Object(map) => {
265            for (key, nested) in map.iter_mut() {
266                if display_key_is_secret_terminal(key, display_secret_terminals) {
267                    mask_json_value(nested);
268                } else {
269                    redact_json_display_secrets(nested, display_secret_terminals);
270                }
271            }
272        }
273        _ => {}
274    }
275}
276
277fn redact_toml_display_secrets(value: &mut toml::Value, display_secret_terminals: &[&str]) {
278    match value {
279        toml::Value::Array(items) => {
280            for item in items {
281                redact_toml_display_secrets(item, display_secret_terminals);
282            }
283        }
284        toml::Value::Table(table) => {
285            for (key, nested) in table.iter_mut() {
286                if display_key_is_secret_terminal(key, display_secret_terminals) {
287                    mask_toml_value(nested);
288                } else {
289                    redact_toml_display_secrets(nested, display_secret_terminals);
290                }
291            }
292        }
293        _ => {}
294    }
295}
296
297fn mask_json_value(value: &mut serde_json::Value) {
298    match value {
299        serde_json::Value::Array(items) => {
300            for item in items {
301                mask_json_value(item);
302            }
303        }
304        serde_json::Value::Object(map) => {
305            for nested in map.values_mut() {
306                mask_json_value(nested);
307            }
308        }
309        serde_json::Value::Null => {}
310        _ => *value = serde_json::Value::String("****".to_string()),
311    }
312}
313
314fn mask_toml_value(value: &mut toml::Value) {
315    match value {
316        toml::Value::Array(items) => {
317            for item in items {
318                mask_toml_value(item);
319            }
320        }
321        toml::Value::Table(table) => {
322            for (_, nested) in table.iter_mut() {
323                mask_toml_value(nested);
324            }
325        }
326        _ => *value = toml::Value::String("****".to_string()),
327    }
328}
329
330fn display_key_is_secret_terminal(key: &str, display_secret_terminals: &[&str]) -> bool {
331    let normalized = normalize_display_key(key);
332    display_secret_terminals
333        .iter()
334        .any(|terminal| normalize_display_key(terminal) == normalized)
335}
336
337fn normalize_display_key(key: &str) -> String {
338    key.replace('-', "_").to_ascii_lowercase()
339}
340
341fn prop_name_to_serde_field(prefix: &str, name: &str) -> anyhow::Result<String> {
342    let suffix = if prefix.is_empty() {
343        name
344    } else {
345        name.strip_prefix(prefix)
346            .and_then(|s| s.strip_prefix('.'))
347            .ok_or_else(|| {
348                ::zeroclaw_log::record!(
349                    WARN,
350                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
351                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
352                        .with_attrs(::serde_json::json!({"prefix": prefix, "name": name})),
353                    "prop_name_to_serde_field: property name does not share the configured prefix"
354                );
355                anyhow::Error::msg(format!("Unknown property '{name}'"))
356            })?
357    };
358    let field_part = suffix.split('.').next().unwrap_or(suffix);
359    Ok(field_part.replace('-', "_"))
360}
361
362fn parse_prop_value(value_str: &str, kind: PropKind) -> anyhow::Result<toml::Value> {
363    let reject = |reason: &'static str, attrs: serde_json::Value| {
364        ::zeroclaw_log::record!(
365            WARN,
366            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Reject)
367                .with_outcome(::zeroclaw_log::EventOutcome::Failure)
368                .with_attrs(attrs),
369            "parse_prop_value rejected input"
370        );
371        let _ = reason;
372    };
373    match kind {
374        PropKind::Bool => Ok(toml::Value::Boolean(value_str.parse().map_err(|_| {
375            reject(
376                "bool",
377                ::serde_json::json!({"kind": "bool", "got_len": value_str.len()}),
378            );
379            anyhow::Error::msg(format!(
380                "Invalid bool value '{value_str}', expected 'true' or 'false'"
381            ))
382        })?)),
383        PropKind::Integer => Ok(toml::Value::Integer(value_str.parse().map_err(|_| {
384            reject(
385                "integer",
386                ::serde_json::json!({"kind": "integer", "got_len": value_str.len()}),
387            );
388            anyhow::Error::msg(format!("Invalid integer value '{value_str}'"))
389        })?)),
390        PropKind::Float => Ok(toml::Value::Float(value_str.parse().map_err(|_| {
391            reject(
392                "float",
393                ::serde_json::json!({"kind": "float", "got_len": value_str.len()}),
394            );
395            anyhow::Error::msg(format!("Invalid float value '{value_str}'"))
396        })?)),
397        PropKind::String | PropKind::Enum => Ok(toml::Value::String(value_str.to_string())),
398        PropKind::StringArray => {
399            let trimmed = value_str.trim();
400            // Accept JSON/TOML array syntax: ["a", "b", "c"]
401            if trimmed.starts_with('[')
402                && let Ok(arr) = serde_json::from_str::<Vec<String>>(trimmed)
403            {
404                return Ok(toml::Value::Array(
405                    arr.into_iter()
406                        .filter(|s| !s.is_empty() && s != crate::traits::UNSET_DISPLAY)
407                        .map(toml::Value::String)
408                        .collect(),
409                ));
410            }
411            // Fall back to comma-separated input.
412            let items = value_str
413                .split(',')
414                .map(|s| toml::Value::String(s.trim().to_string()))
415                .filter(|v| {
416                    v.as_str()
417                        .is_some_and(|s| !s.is_empty() && s != crate::traits::UNSET_DISPLAY)
418                })
419                .collect();
420            Ok(toml::Value::Array(items))
421        }
422        // `Vec<T>` of structs: round-trip a JSON array of objects to a
423        // TOML array. JSON `null` (used by serde for `Option::None`) is
424        // dropped because TOML has no null - the absent key conveys the
425        // same meaning when the field deserializes back into `Option<T>`.
426        PropKind::ObjectArray => {
427            let v: serde_json::Value = serde_json::from_str(value_str).map_err(|e| {
428                reject(
429                    "object_array",
430                    ::serde_json::json!({"kind": "object_array", "error": format!("{}", e)}),
431                );
432                anyhow::Error::msg(format!("invalid JSON array of objects: {e}"))
433            })?;
434            json_to_toml(v).ok_or_else(|| {
435                reject(
436                    "object_array_nulls",
437                    ::serde_json::json!({"kind": "object_array", "reason": "all-null"}),
438                );
439                anyhow::Error::msg("JSON value contained only nulls, nothing to write")
440            })
441        }
442        // Struct-shaped scalar: parse the JSON object into a TOML table so
443        // the parent serde round-trip deserializes into the typed struct
444        // (e.g. `Option<ModelPricing>`). Inserting a raw String here would
445        // fail serde because the field is typed, not free-form text.
446        PropKind::Object => {
447            let v: serde_json::Value = serde_json::from_str(value_str).map_err(|e| {
448                reject(
449                    "object",
450                    ::serde_json::json!({"kind": "object", "error": format!("{}", e)}),
451                );
452                anyhow::Error::msg(format!("invalid JSON object: {e}"))
453            })?;
454            if !matches!(v, serde_json::Value::Object(_)) {
455                reject(
456                    "object_shape",
457                    ::serde_json::json!({"kind": "object", "got_shape": "non-object"}),
458                );
459                anyhow::bail!("Object field requires a JSON object; got {v}");
460            }
461            json_to_toml(v).ok_or_else(|| {
462                reject(
463                    "object_nulls",
464                    ::serde_json::json!({"kind": "object", "reason": "all-null"}),
465                );
466                anyhow::Error::msg("JSON object contained only nulls, nothing to write")
467            })
468        }
469    }
470}
471
472/// Walk a `serde_json::Value` into a `toml::Value`, dropping any `null`s
473/// (TOML has no null; absence of a key conveys `Option::None`).
474fn json_to_toml(v: serde_json::Value) -> Option<toml::Value> {
475    match v {
476        serde_json::Value::Null => None,
477        serde_json::Value::Bool(b) => Some(toml::Value::Boolean(b)),
478        serde_json::Value::String(s) => Some(toml::Value::String(s)),
479        serde_json::Value::Number(n) => {
480            if let Some(i) = n.as_i64() {
481                Some(toml::Value::Integer(i))
482            } else if let Some(u) = n.as_u64() {
483                // TOML integers are i64; clamp pathological u64 values.
484                Some(toml::Value::Integer(i64::try_from(u).unwrap_or(i64::MAX)))
485            } else {
486                n.as_f64().map(toml::Value::Float)
487            }
488        }
489        serde_json::Value::Array(items) => Some(toml::Value::Array(
490            items.into_iter().filter_map(json_to_toml).collect(),
491        )),
492        serde_json::Value::Object(map) => {
493            let mut table = toml::map::Map::new();
494            for (k, val) in map {
495                if let Some(tv) = json_to_toml(val) {
496                    table.insert(k, tv);
497                }
498            }
499            Some(toml::Value::Table(table))
500        }
501    }
502}
503
504/// Validate that an alias key is safe for use in TOML dotted paths, URLs,
505/// filesystem paths on Windows/macOS/Linux, and `ZEROCLAW_*` env-var grammar.
506///
507/// Allowed: lowercase ASCII alphanumeric plus single underscore, 1-63 chars.
508/// Must start AND end with alphanumeric. Adjacent underscores (`__`) are
509/// forbidden because they collide with the env-var grammar's path separator.
510///
511/// The env-var grammar uses `__` as path separator, which lets aliases keep
512/// single `_` literally (`prod_v2`, `staging_api`). Hyphens are forbidden
513/// because they are illegal in POSIX env-var identifiers; uppercase is
514/// forbidden so the bootstrap env-vars (`ZEROCLAW_WORKSPACE`,
515/// `ZEROCLAW_CONFIG_DIR`) stay disambiguated by case.
516pub fn validate_alias_key(key: &str) -> Result<(), String> {
517    if key.is_empty() {
518        return Err("alias must not be empty".to_string());
519    }
520    if key.len() > 63 {
521        return Err(format!(
522            "alias '{}' is too long ({} chars); maximum is 63",
523            key,
524            key.len()
525        ));
526    }
527    let first = key.chars().next().unwrap();
528    let last = key.chars().next_back().unwrap();
529    if !matches!(first, 'a'..='z' | '0'..='9') {
530        return Err(format!(
531            "alias '{key}' must start with a lowercase letter or digit"
532        ));
533    }
534    if !matches!(last, 'a'..='z' | '0'..='9') {
535        return Err(format!(
536            "alias '{key}' must end with a lowercase letter or digit"
537        ));
538    }
539    if key.contains("__") {
540        return Err(format!(
541            "alias '{key}' must not contain `__`; it is reserved as the env-var grammar's path separator"
542        ));
543    }
544    for ch in key.chars() {
545        if !matches!(ch, 'a'..='z' | '0'..='9' | '_') {
546            return Err(format!(
547                "alias '{}' contains invalid character {:?}; \
548                 only lowercase letters, digits, and single underscores are allowed (no hyphen, no uppercase)",
549                key, ch
550            ));
551        }
552    }
553    Ok(())
554}
555
556/// Resolve a CLI-typed config path to its canonical form.
557///
558/// Field segments derived from the schema are kebab-case; aliases are
559/// snake-only per [`validate_alias_key`]. For each known canonical
560/// path, segments are compared pairwise: equal verbatim, equal after
561/// swapping `-` → `_` when the canonical segment contains `-`, or
562/// equal after swapping `_` → `-` for the final field segment. The
563/// final-segment rule lets older CLI spelling like `api-key` resolve
564/// to schema-canonical `api_key` without rewriting map aliases such as
565/// `my_bot`. Returns `raw` unchanged when no canonical path matches.
566#[must_use]
567pub fn resolve_field_path(known_paths: &[String], raw: &str) -> String {
568    let raw_segs: Vec<&str> = raw.split('.').collect();
569    for known in known_paths {
570        let known_segs: Vec<&str> = known.split('.').collect();
571        if known_segs.len() != raw_segs.len() {
572            continue;
573        }
574        let final_index = known_segs.len().saturating_sub(1);
575        let all_match = known_segs
576            .iter()
577            .zip(raw_segs.iter())
578            .enumerate()
579            .all(|(idx, (k, r))| {
580                k == r
581                    || (k.contains('-') && k.replace('-', "_") == **r)
582                    || (idx == final_index && k.contains('_') && k.replace('_', "-") == **r)
583            });
584        if all_match {
585            return known.clone();
586        }
587    }
588    raw.to_string()
589}
590
591/// Inverse of the `Configurable` macro's internal `snake_to_kebab`.
592///
593/// Field paths emitted by `prop_fields()` are kebab-case (per the macro's
594/// snake→kebab transform of the underlying Rust idents). Surfaces that want
595/// to display the field under its serde-canonical snake_case spelling — for
596/// example `api_key` rather than `api-key` — use this to convert.
597///
598/// No-op for keys without `-`.
599pub fn kebab_to_snake(key: &str) -> String {
600    key.replace('-', "_")
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn route_hashmap_path_handles_deep_inner_paths() {
609        // Regression: AliasedAgentConfig has nested fields like
610        // `agent.thinking.<...>` (3+ segments under the alias key). The
611        // earlier rsplit-once parser would mis-route, yielding hm_key =
612        // "fake123.agent.thinking" instead of "fake123".
613        let keys = ["fake123"];
614        let got = route_hashmap_path(
615            "agents.fake123.agent.thinking.default-level",
616            "",
617            "agents",
618            "",
619            keys.iter().copied(),
620        );
621        assert_eq!(
622            got,
623            Some(("fake123", "agent.thinking.default-level".to_string()))
624        );
625    }
626
627    #[test]
628    fn route_hashmap_path_picks_longest_dotted_key() {
629        // Custom-URL keys may contain dots; the longest matching key
630        // wins so `custom:https://example/v1` is preferred over `custom`.
631        let keys = ["custom", "custom:https://example/v1"];
632        let got = route_hashmap_path(
633            "providers.models.custom:https://example/v1.api-key",
634            "",
635            "providers.models",
636            "",
637            keys.iter().copied(),
638        );
639        assert_eq!(
640            got,
641            Some(("custom:https://example/v1", "api-key".to_string()))
642        );
643    }
644
645    #[test]
646    fn parse_string_array_splits_on_comma() {
647        let result = parse_prop_value("alice, bob, charlie", PropKind::StringArray).unwrap();
648        let arr = result.as_array().unwrap();
649        assert_eq!(arr.len(), 3);
650        assert_eq!(arr[0].as_str(), Some("alice"));
651        assert_eq!(arr[1].as_str(), Some("bob"));
652        assert_eq!(arr[2].as_str(), Some("charlie"));
653    }
654
655    #[test]
656    fn parse_string_array_empty_input_gives_empty_array() {
657        let result = parse_prop_value("", PropKind::StringArray).unwrap();
658        assert_eq!(result.as_array().unwrap().len(), 0);
659    }
660
661    #[test]
662    fn parse_string_array_single_value() {
663        let result = parse_prop_value("alice", PropKind::StringArray).unwrap();
664        let arr = result.as_array().unwrap();
665        assert_eq!(arr.len(), 1);
666        assert_eq!(arr[0].as_str(), Some("alice"));
667    }
668
669    #[test]
670    fn parse_string_array_drops_unset_sentinel() {
671        let bare = parse_prop_value(crate::traits::UNSET_DISPLAY, PropKind::StringArray).unwrap();
672        assert_eq!(bare.as_array().unwrap().len(), 0);
673        let json = parse_prop_value(r#"["<unset>", "/real"]"#, PropKind::StringArray).unwrap();
674        let arr = json.as_array().unwrap();
675        assert_eq!(arr.len(), 1);
676        assert_eq!(arr[0].as_str(), Some("/real"));
677    }
678
679    #[test]
680    fn parse_string_array_quote_in_value_is_literal() {
681        let result = parse_prop_value(r#"tok1, p@ss"word"#, PropKind::StringArray).unwrap();
682        let arr = result.as_array().unwrap();
683        assert_eq!(arr.len(), 2);
684        assert_eq!(arr[0].as_str(), Some("tok1"));
685        assert_eq!(arr[1].as_str(), Some(r#"p@ss"word"#));
686    }
687
688    // ── validate_alias_key ────────────────────────────────────────────────
689
690    #[test]
691    fn validate_alias_key_accepts_lowercase_alphanumeric_with_underscore() {
692        assert!(validate_alias_key("default").is_ok());
693        assert!(validate_alias_key("work").is_ok());
694        assert!(validate_alias_key("alias123").is_ok());
695        assert!(validate_alias_key("a").is_ok());
696        assert!(validate_alias_key("prod2024").is_ok());
697        // V0.8.0: env-var grammar uses `__` as separator, so single `_`
698        // inside an alias is unambiguous.
699        assert!(validate_alias_key("prod_v2").is_ok());
700        assert!(validate_alias_key("staging_api").is_ok());
701    }
702
703    #[test]
704    fn validate_alias_key_rejects_empty() {
705        assert!(validate_alias_key("").is_err());
706    }
707
708    #[test]
709    fn validate_alias_key_rejects_uppercase() {
710        // Leading uppercase trips the start-char rule.
711        let err = validate_alias_key("MyAlias").unwrap_err();
712        assert!(err.contains("must start with"), "{err}");
713        let err = validate_alias_key("A").unwrap_err();
714        assert!(err.contains("must start with"), "{err}");
715        // Embedded uppercase trips the per-char rule.
716        let err = validate_alias_key("myAlias").unwrap_err();
717        assert!(err.contains("invalid character"), "{err}");
718    }
719
720    #[test]
721    fn validate_alias_key_rejects_leading_underscore() {
722        let err = validate_alias_key("_bad").unwrap_err();
723        assert!(err.contains("must start with"), "{err}");
724    }
725
726    #[test]
727    fn validate_alias_key_rejects_trailing_underscore() {
728        let err = validate_alias_key("bad_").unwrap_err();
729        assert!(err.contains("must end with"), "{err}");
730    }
731
732    #[test]
733    fn validate_alias_key_rejects_double_underscore() {
734        let err = validate_alias_key("foo__bar").unwrap_err();
735        assert!(err.contains("must not contain `__`"), "{err}");
736    }
737
738    #[test]
739    fn validate_alias_key_rejects_hyphen() {
740        // V0.8.0: hyphens are illegal in env-var identifiers.
741        let err = validate_alias_key("my-alias").unwrap_err();
742        assert!(err.contains("invalid character"), "{err}");
743    }
744
745    #[test]
746    fn validate_alias_key_rejects_dot() {
747        let err = validate_alias_key("my.alias").unwrap_err();
748        assert!(err.contains("invalid character"), "{err}");
749    }
750
751    #[test]
752    fn validate_alias_key_rejects_slash() {
753        let err = validate_alias_key("my/alias").unwrap_err();
754        assert!(err.contains("invalid character"), "{err}");
755    }
756
757    #[test]
758    fn validate_alias_key_rejects_space() {
759        let err = validate_alias_key("my alias").unwrap_err();
760        assert!(err.contains("invalid character"), "{err}");
761    }
762
763    #[test]
764    fn validate_alias_key_rejects_over_63_chars() {
765        let long = "a".repeat(64);
766        let err = validate_alias_key(&long).unwrap_err();
767        assert!(err.contains("too long"), "{err}");
768    }
769
770    #[test]
771    fn validate_alias_key_accepts_exactly_63_chars() {
772        let at_limit = "a".repeat(63);
773        assert!(validate_alias_key(&at_limit).is_ok());
774    }
775
776    #[test]
777    fn validate_alias_key_rejects_windows_reserved_chars() {
778        for ch in [':', '*', '?', '"', '<', '>', '|', '\\'] {
779            let key = format!("alias{ch}name");
780            assert!(
781                validate_alias_key(&key).is_err(),
782                "expected rejection of char {ch:?} in alias key"
783            );
784        }
785    }
786
787    #[test]
788    fn resolve_field_path_canonicalizes_snake_field_segments() {
789        let known = vec![
790            "providers.models.anthropic.my_bot.api-key".to_string(),
791            "providers.models.anthropic.my_bot.model".to_string(),
792        ];
793        // User typed snake `api_key`; alias `my_bot` stays untouched
794        // because the canonical segment has no `-`.
795        assert_eq!(
796            resolve_field_path(&known, "providers.models.anthropic.my_bot.api_key"),
797            "providers.models.anthropic.my_bot.api-key",
798        );
799    }
800
801    #[test]
802    fn resolve_field_path_passes_through_canonical_input() {
803        let known = vec!["providers.models.anthropic.my_bot.api-key".to_string()];
804        assert_eq!(
805            resolve_field_path(&known, "providers.models.anthropic.my_bot.api-key"),
806            "providers.models.anthropic.my_bot.api-key",
807        );
808    }
809
810    #[test]
811    fn resolve_field_path_canonicalizes_kebab_final_field_segments() {
812        let known = vec!["providers.models.deepseek.default.api_key".to_string()];
813        assert_eq!(
814            resolve_field_path(&known, "providers.models.deepseek.default.api-key"),
815            "providers.models.deepseek.default.api_key",
816        );
817    }
818
819    #[test]
820    fn resolve_field_path_returns_raw_when_no_match() {
821        let known: Vec<String> = vec![];
822        assert_eq!(resolve_field_path(&known, "no.such.path"), "no.such.path");
823    }
824
825    #[test]
826    fn resolve_field_path_does_not_corrupt_snake_alias() {
827        // `my_bot` is an alias; user typed it correctly; we must not
828        // turn it into `my-bot` while resolving an api_key snake input.
829        let known = vec!["providers.models.anthropic.my_bot.api-key".to_string()];
830        let resolved = resolve_field_path(&known, "providers.models.anthropic.my_bot.api_key");
831        assert!(resolved.contains("my_bot"));
832        assert!(!resolved.contains("my-bot"));
833    }
834
835    #[test]
836    fn kebab_to_snake_converts_hyphens() {
837        assert_eq!(kebab_to_snake("api-key"), "api_key");
838        assert_eq!(kebab_to_snake("bot-token"), "bot_token");
839        assert_eq!(kebab_to_snake("allowed-users"), "allowed_users");
840        assert_eq!(kebab_to_snake("external-peers"), "external_peers");
841    }
842
843    #[test]
844    fn kebab_to_snake_noop_for_plain_keys() {
845        assert_eq!(kebab_to_snake("uri"), "uri");
846        assert_eq!(kebab_to_snake("model"), "model");
847        assert_eq!(kebab_to_snake(""), "");
848    }
849}