1use crate::traits::{ConfigTab, CredentialSurfaceClass, PropFieldInfo, PropKind};
4
5pub 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 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 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
63pub 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#[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#[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
174pub 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
195pub 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 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 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 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 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
472fn 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 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
504pub 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#[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
591pub 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 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 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 #[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 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 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 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 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 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 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}