1use crate::traits::{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) -> 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
165pub 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
182pub 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 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 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 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 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
335fn 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 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
367pub 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 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 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 #[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 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 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 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 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}