use alog::{MessageLevel, alog_channel, use_channel};
use serde_json::{Value, json};
use crate::utils::ui::Ui;
use_channel!("PRMPT");
pub fn prompt_from_schema(
ui: &dyn Ui,
schema: &schemars::Schema,
defaults: &Value,
) -> anyhow::Result<Value> {
let root = serde_json::to_value(schema)?;
prompt_value(ui, &root, &root, defaults, "", "")
}
fn prompt_value(
ui: &dyn Ui,
root: &Value,
node: &Value,
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
let node = resolve_ref(root, node);
if let Some(choices) = enum_choices(root, node) {
return prompt_enum_scalar(ui, root, &choices, default, indent, label);
}
alog_channel!(MessageLevel::Debug3, "Prompting for {:#?}", node);
match get_promptable_type(node).as_deref() {
Some("object") => prompt_object(ui, root, node, default, indent, label),
Some("array") => prompt_array(ui, root, node, default, indent, label),
Some("string") => Ok(prompt_string(ui, node, default, indent, label)?),
Some("integer") | Some("number") => Ok(prompt_number(ui, node, default, indent, label)?),
Some("boolean") => Ok(prompt_bool(ui, default, indent, label)?),
_ => Ok(default.clone()),
}
}
fn prompt_object(
ui: &dyn Ui,
root: &Value,
node: &Value,
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
if !indent.is_empty() && !label.is_empty() {
ui.info(&format!("{indent}{label}:"));
}
let mut result = serde_json::Map::new();
let properties = node.get("properties").and_then(Value::as_object);
if let Some(properties) = properties {
let child_indent = format!("{indent} ");
for (name, prop_schema) in properties {
let prop_schema = resolve_ref(root, prop_schema);
if get_promptable_type(prop_schema).is_none()
&& enum_choices(root, prop_schema).is_none()
{
continue;
}
let prop_default = default.get(name).cloned().unwrap_or(Value::Null);
let value = prompt_value(ui, root, prop_schema, &prop_default, &child_indent, name)?;
result.insert(name.clone(), value);
}
}
if let Some(additional_props_schema_val) = node.get("additionalProperties") {
let additional_props_schema = resolve_ref(root, additional_props_schema_val);
if get_promptable_type(additional_props_schema).is_some()
|| enum_choices(root, additional_props_schema).is_some()
{
let child_indent = format!("{indent} ");
let default_obj = default.as_object().cloned().unwrap_or_default();
let remaining_defaults: Vec<(String, Value)> = default_obj
.iter()
.filter(|(k, _)| !result.contains_key(k.as_str()))
.map(|(k, v)| (k.to_string(), v.clone()))
.collect();
let mut defaults_iter = remaining_defaults.into_iter();
loop {
let next_default = defaults_iter.next();
let add = ui.confirm(&format!("{indent}Add {label}?"), next_default.is_some())?;
if !add {
break;
}
let (key_default_str, item_default) = next_default
.unwrap_or((String::new(), zero_value_for(additional_props_schema)));
let key_schema = json!({"type": "string"});
let key_value = prompt_string(
ui,
&key_schema,
&json!(key_default_str),
&child_indent,
"key",
)?;
let key_str = key_value.as_str().unwrap().to_string();
let value = prompt_value(
ui,
root,
additional_props_schema,
&item_default,
&child_indent,
&key_str,
)?;
result.insert(key_str, value);
}
}
}
Ok(Value::Object(result))
}
fn prompt_array(
ui: &dyn Ui,
root: &Value,
node: &Value,
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
let Some(items_schema) = node.get("items").map(|v| resolve_ref(root, v)) else {
return Ok(Value::Array(vec![]));
};
if let Some(choices) = enum_choices(root, items_schema)
&& choices.iter().all(|c| matches!(c, EnumChoice::Literal(_)))
{
return prompt_enum_array(ui, &choices, default, indent, label);
}
let default_items: Vec<Value> = default.as_array().cloned().unwrap_or_default();
let mut defaults_iter = default_items.into_iter();
let child_indent = format!("{indent} ");
let mut items = Vec::new();
loop {
let next_default = defaults_iter.next();
let add = ui.confirm(&format!("{indent}Add {label}?"), next_default.is_some())?;
if !add {
break;
}
let item_default = next_default.unwrap_or_else(|| zero_value_for(items_schema));
let item = if let Some(choices) = enum_choices(root, items_schema) {
prompt_enum_scalar(ui, root, &choices, &item_default, &child_indent, label)?
} else {
prompt_value(ui, root, items_schema, &item_default, &child_indent, label)?
};
items.push(item);
}
Ok(Value::Array(items))
}
enum EnumChoice {
Literal(String),
Tagged { key: String, schema: Value },
}
impl EnumChoice {
fn label(&self) -> &str {
match self {
EnumChoice::Literal(s) => s,
EnumChoice::Tagged { key, .. } => key,
}
}
}
fn enum_choices(root: &Value, node: &Value) -> Option<Vec<EnumChoice>> {
if let Some(values) = node.get("enum").and_then(Value::as_array) {
let literals: Vec<EnumChoice> = values
.iter()
.filter_map(Value::as_str)
.map(|s| EnumChoice::Literal(s.to_string()))
.collect();
return (!literals.is_empty() && literals.len() == values.len()).then_some(literals);
}
if let Some(s) = node.get("const").and_then(Value::as_str) {
return Some(vec![EnumChoice::Literal(s.to_string())]);
}
let alternatives = node.get("oneOf").and_then(Value::as_array)?;
let mut choices = Vec::new();
for alt in alternatives {
let alt = resolve_ref(root, alt);
if let Some(values) = alt.get("enum").and_then(Value::as_array) {
let strs: Vec<&str> = values.iter().filter_map(Value::as_str).collect();
if strs.len() != values.len() {
return None;
}
choices.extend(strs.into_iter().map(|s| EnumChoice::Literal(s.to_string())));
continue;
}
if let Some(s) = alt.get("const").and_then(Value::as_str) {
choices.push(EnumChoice::Literal(s.to_string()));
continue;
}
let props = alt.get("properties").and_then(Value::as_object)?;
let required = alt.get("required").and_then(Value::as_array)?;
if props.len() != 1 || required.len() != 1 {
return None;
}
let (key, sub_schema) = props.iter().next()?;
if required.first().and_then(Value::as_str) != Some(key.as_str()) {
return None;
}
choices.push(EnumChoice::Tagged {
key: key.clone(),
schema: sub_schema.clone(),
});
}
(!choices.is_empty()).then_some(choices)
}
fn prompt_enum_scalar(
ui: &dyn Ui,
root: &Value,
choices: &[EnumChoice],
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
let labels: Vec<String> = choices.iter().map(|c| c.label().to_string()).collect();
let default_idx = default_choice_index(choices, default).unwrap_or(0);
let chosen = ui.select(&format!("{indent}{label}"), &labels, default_idx)?;
match &choices[chosen] {
EnumChoice::Literal(s) => Ok(Value::String(s.clone())),
EnumChoice::Tagged { key, schema } => {
let child_indent = format!("{indent} ");
let sub_default = default.get(key).cloned().unwrap_or(Value::Null);
let value = prompt_value(ui, root, schema, &sub_default, &child_indent, key)?;
Ok(serde_json::json!({ key: value }))
}
}
}
fn prompt_enum_array(
ui: &dyn Ui,
choices: &[EnumChoice],
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
let labels: Vec<String> = choices.iter().map(|c| c.label().to_string()).collect();
let default_items: Vec<&str> = default
.as_array()
.map(|a| a.iter().filter_map(Value::as_str).collect())
.unwrap_or_default();
let defaults: Vec<bool> = labels
.iter()
.map(|l| default_items.contains(&l.as_str()))
.collect();
let chosen = ui.multi_select(&format!("{indent}{label}"), &labels, &defaults)?;
Ok(Value::Array(
chosen
.into_iter()
.map(|i| Value::String(labels[i].clone()))
.collect(),
))
}
fn default_choice_index(choices: &[EnumChoice], default: &Value) -> Option<usize> {
if let Some(s) = default.as_str() {
return choices
.iter()
.position(|c| matches!(c, EnumChoice::Literal(l) if l == s));
}
if let Some(obj) = default.as_object() {
let key = obj.keys().next()?;
return choices
.iter()
.position(|c| matches!(c, EnumChoice::Tagged { key: k, .. } if k == key));
}
None
}
fn prompt_string(
ui: &dyn Ui,
node: &Value,
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
let default_str = default.as_str().unwrap_or("").to_string();
let prompt = format!("{indent}{label}");
if is_secret_schema(node) {
let entered = ui.password(&format!("{prompt} (leave blank to keep current)"))?;
let value = if entered.is_empty() {
default_str
} else {
entered
};
Ok(Value::String(value))
} else {
let is_optional = is_optional_field(node);
let entered = if is_optional {
ui.text_optional(&prompt, &default_str)?
} else {
ui.text(&prompt, &default_str)?
};
if is_optional && entered.is_empty() && default_str.is_empty() {
Ok(Value::Null)
} else {
Ok(Value::String(entered))
}
}
}
fn prompt_number(
ui: &dyn Ui,
node: &Value,
default: &Value,
indent: &str,
label: &str,
) -> anyhow::Result<Value> {
let is_integer = get_promptable_type(node).as_deref() == Some("integer");
let default_str = default
.as_number()
.map(|n| n.to_string())
.unwrap_or_else(|| "0".to_string());
let entered = ui.text(&format!("{indent}{label}"), &default_str)?;
let number = if is_integer {
entered
.trim()
.parse::<i64>()
.map(serde_json::Number::from)
.map_err(|_| anyhow::anyhow!("'{entered}' is not a valid integer for {label}"))?
} else {
entered
.trim()
.parse::<f64>()
.ok()
.and_then(serde_json::Number::from_f64)
.ok_or_else(|| anyhow::anyhow!("'{entered}' is not a valid number for {label}"))?
};
Ok(Value::Number(number))
}
fn prompt_bool(ui: &dyn Ui, default: &Value, indent: &str, label: &str) -> anyhow::Result<Value> {
let default_bool = default.as_bool().unwrap_or(false);
let value = ui.confirm(&format!("{indent}{label}"), default_bool)?;
Ok(Value::Bool(value))
}
fn is_secret_schema(node: &Value) -> bool {
node.get("format").and_then(Value::as_str) == Some("password")
}
fn is_optional_field(node: &Value) -> bool {
node.get("type")
.and_then(Value::as_array)
.is_some_and(|types| types.iter().any(|t| t.as_str() == Some("null")))
}
fn get_promptable_type(node: &Value) -> Option<String> {
let promptable_types = ["object", "array", "string", "integer", "number", "boolean"];
if let Some(t) = node.get("type").and_then(Value::as_str)
&& promptable_types.contains(&t)
{
return Some(t.to_string());
}
if let Some(types) = node.get("type").and_then(Value::as_array) {
let non_null: Vec<&str> = types
.iter()
.filter_map(|v| v.as_str())
.filter(|t| *t != "null")
.collect();
if non_null.len() == 1 && promptable_types.contains(&non_null[0]) {
return Some(non_null[0].to_string());
}
}
if let Some(variants) = node.get("anyOf").and_then(Value::as_array) {
let mut non_null = variants.iter().filter(|v| {
v.get("type").and_then(Value::as_str) != Some("null")
&& v.get("type").and_then(Value::as_str).is_some()
});
if let (Some(only), None) = (non_null.next(), non_null.next()) {
return get_promptable_type(only);
}
}
None
}
fn resolve_ref<'a>(root: &'a Value, node: &'a Value) -> &'a Value {
if let Some(reference) = node.get("$ref").and_then(Value::as_str) {
let name = reference.rsplit('/').next().unwrap_or(reference);
let target = root
.get("$defs")
.or_else(|| root.get("definitions"))
.and_then(|defs| defs.get(name));
return match target {
Some(target) => resolve_ref(root, target),
None => node,
};
}
if let Some(variants) = node.get("anyOf").and_then(Value::as_array) {
let mut non_null = variants
.iter()
.filter(|v| v.get("type").and_then(Value::as_str) != Some("null"));
if let (Some(only), None) = (non_null.next(), non_null.next()) {
return resolve_ref(root, only);
}
}
node
}
fn zero_value_for(schema: &Value) -> Value {
match get_promptable_type(schema).as_deref() {
Some("object") => Value::Object(serde_json::Map::new()),
Some("array") => Value::Array(vec![]),
Some("string") => Value::String(String::new()),
Some("integer") | Some("number") => Value::Number(0.into()),
Some("boolean") => Value::Bool(false),
_ => Value::Null,
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde::{Deserialize, Serialize};
use serde_json::json;
#[test]
fn secret_format_is_detected_from_schema_not_field_name() {
assert!(is_secret_schema(
&json!({"type": "string", "format": "password"})
));
assert!(!is_secret_schema(&json!({"type": "string"})));
assert!(!is_secret_schema(
&json!({"type": "string", "title": "api_key"})
));
}
#[test]
fn is_optional_field_detects_type_array_with_null() {
assert!(is_optional_field(&json!({"type": ["string", "null"]})));
assert!(is_optional_field(&json!({"type": ["integer", "null"]})));
assert!(!is_optional_field(&json!({"type": "string"})));
assert!(!is_optional_field(&json!({"type": ["string", "integer"]})));
}
#[test]
fn prompt_from_schema_returns_null_for_empty_optional_string() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
command_path: Option<String>,
}
let ui = CaptureUi::default();
ui.text_answers.borrow_mut().push_back("".to_string());
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(result["command_path"], json!(null));
let config: TestConfig = serde_json::from_value(result).unwrap();
assert!(config.command_path.is_none());
}
#[test]
fn prompt_from_schema_returns_string_for_non_empty_optional_string() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
command_path: Option<String>,
}
let ui = CaptureUi::default();
ui.text_answers
.borrow_mut()
.push_back("/usr/bin/bob".to_string());
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(result["command_path"], json!("/usr/bin/bob"));
let config: TestConfig = serde_json::from_value(result).unwrap();
assert_eq!(config.command_path, Some("/usr/bin/bob".to_string()));
}
#[test]
fn prompt_from_schema_keeps_default_for_empty_input_on_optional_string() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
command_path: Option<String>,
}
let ui = CaptureUi::default();
ui.text_answers.borrow_mut().push_back("".to_string());
let schema = schemars::schema_for!(TestConfig);
let defaults = json!({"command_path": "/existing/path"});
let result = prompt_from_schema(&ui, &schema, &defaults).unwrap();
assert_eq!(result["command_path"], json!(""));
let config: TestConfig = serde_json::from_value(result).unwrap();
assert_eq!(config.command_path, Some("".to_string()));
}
#[test]
fn prompt_from_schema_returns_empty_string_for_required_string() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
name: String,
}
let ui = CaptureUi::default();
ui.text_answers.borrow_mut().push_back("".to_string());
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(result["name"], json!(""));
let config: TestConfig = serde_json::from_value(result).unwrap();
assert_eq!(config.name, "");
}
#[test]
fn promptable_types_are_object_array_and_scalars() {
assert_eq!(
get_promptable_type(&json!({"type": "object"})),
Some("object".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": "array"})),
Some("array".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": "string"})),
Some("string".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": "integer"})),
Some("integer".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": "number"})),
Some("number".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": "boolean"})),
Some("boolean".to_string())
);
assert_eq!(get_promptable_type(&json!({})), None);
assert_eq!(
get_promptable_type(&json!({"$ref": "#/$defs/Unresolved"})),
None
);
}
#[test]
fn promptable_type_falls_back_to_any_of_for_option_types() {
assert_eq!(
get_promptable_type(&json!({"anyOf": [{"type": "string"}, {"type": "null"}]})),
Some("string".to_string())
);
assert_eq!(
get_promptable_type(&json!({"anyOf": [{"type": "integer"}, {"type": "null"}]})),
Some("integer".to_string())
);
assert_eq!(
get_promptable_type(&json!({"anyOf": [{"type": "boolean"}, {"type": "null"}]})),
Some("boolean".to_string())
);
assert_eq!(
get_promptable_type(&json!({"anyOf": [{"type": "object"}, {"type": "null"}]})),
Some("object".to_string())
);
assert_eq!(
get_promptable_type(&json!({"anyOf": [{"type": "array"}, {"type": "null"}]})),
Some("array".to_string())
);
assert_eq!(
get_promptable_type(&json!({"anyOf": [{"type": "number"}, {"type": "null"}]})),
Some("number".to_string())
);
assert_eq!(
get_promptable_type(
&json!({"anyOf": [{"type": "string"}, {"type": "integer"}, {"type": "null"}]})
),
None
);
assert_eq!(
get_promptable_type(
&json!({"anyOf": [{"$ref": "#/$defs/Unresolved"}, {"type": "null"}]})
),
None
);
}
#[test]
fn promptable_type_handles_type_as_array_for_option() {
assert_eq!(
get_promptable_type(&json!({"type": ["string", "null"]})),
Some("string".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": ["integer", "null"]})),
Some("integer".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": ["boolean", "null"]})),
Some("boolean".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": ["number", "null"]})),
Some("number".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": ["array", "null"]})),
Some("array".to_string())
);
assert_eq!(
get_promptable_type(&json!({"type": ["object", "null"]})),
Some("object".to_string())
);
assert_eq!(get_promptable_type(&json!({"type": ["null"]})), None);
assert_eq!(
get_promptable_type(&json!({"type": ["string", "integer", "null"]})),
None
);
}
#[test]
fn ref_resolves_against_defs() {
let root = json!({
"$defs": {
"Inner": {"type": "object", "properties": {"x": {"type": "integer"}}}
}
});
let node = json!({"$ref": "#/$defs/Inner"});
let resolved = resolve_ref(&root, &node);
assert_eq!(resolved.get("type").and_then(Value::as_str), Some("object"));
}
#[test]
fn ref_resolves_against_legacy_definitions() {
let root = json!({
"definitions": {
"Inner": {"type": "string"}
}
});
let node = json!({"$ref": "#/definitions/Inner"});
let resolved = resolve_ref(&root, &node);
assert_eq!(resolved.get("type").and_then(Value::as_str), Some("string"));
}
#[test]
fn unresolvable_ref_falls_back_to_node_itself() {
let root = json!({});
let node = json!({"$ref": "#/$defs/Missing"});
let resolved = resolve_ref(&root, &node);
assert_eq!(resolved, &node);
}
#[test]
fn any_of_option_wrapper_resolves_to_the_non_null_variant() {
let root = json!({});
let node = json!({"anyOf": [{"type": "string"}, {"type": "null"}]});
let resolved = resolve_ref(&root, &node);
assert_eq!(resolved.get("type").and_then(Value::as_str), Some("string"));
}
#[test]
fn any_of_option_wrapper_around_a_ref_resolves_through_both() {
let root = json!({
"$defs": {
"Secret": {"type": "string", "format": "password"}
}
});
let node = json!({"anyOf": [{"$ref": "#/$defs/Secret"}, {"type": "null"}]});
let resolved = resolve_ref(&root, &node);
assert_eq!(resolved.get("type").and_then(Value::as_str), Some("string"));
assert!(is_secret_schema(resolved));
assert_eq!(get_promptable_type(resolved), Some("string".to_string()));
}
#[test]
fn is_optional_field_cannot_detect_ref_wrapped_options() {
let root = json!({
"$defs": {
"Secret": {"type": "string", "format": "password"}
}
});
let raw = json!({"anyOf": [{"$ref": "#/$defs/Secret"}, {"type": "null"}]});
assert!(!is_optional_field(&raw));
assert!(!is_optional_field(resolve_ref(&root, &raw)));
}
#[test]
fn is_optional_field_detects_plain_scalar_options_before_and_after_resolve() {
let root = json!({});
let raw = json!({"type": ["string", "null"]});
assert!(is_optional_field(&raw));
assert!(is_optional_field(resolve_ref(&root, &raw)));
}
#[test]
fn zero_value_matches_schema_type() {
assert_eq!(zero_value_for(&json!({"type": "string"})), json!(""));
assert_eq!(zero_value_for(&json!({"type": "integer"})), json!(0));
assert_eq!(zero_value_for(&json!({"type": "boolean"})), json!(false));
assert_eq!(zero_value_for(&json!({"type": "array"})), json!([]));
assert_eq!(zero_value_for(&json!({"type": "object"})), json!({}));
assert_eq!(
zero_value_for(&json!({"type": ["string", "null"]})),
json!("")
);
assert_eq!(
zero_value_for(&json!({"type": ["integer", "null"]})),
json!(0)
);
}
#[test]
fn enum_choices_detects_pure_unit_enum() {
let node = json!({"type": "string", "enum": ["FileRead", "FileWrite"]});
let choices = enum_choices(&json!({}), &node).unwrap();
assert_eq!(choices.len(), 2);
assert!(choices.iter().all(|c| matches!(c, EnumChoice::Literal(_))));
assert_eq!(choices[0].label(), "FileRead");
assert_eq!(choices[1].label(), "FileWrite");
}
#[test]
fn enum_choices_detects_mixed_enum_with_tagged_variants() {
let node = json!({
"oneOf": [
{"type": "string", "enum": ["FileRead", "FileWrite"]},
{"type": "object", "properties": {"Mcp": {"type": "object"}}, "required": ["Mcp"]},
{"type": "object", "properties": {"Other": {"type": "string"}}, "required": ["Other"]},
]
});
let choices = enum_choices(&json!({}), &node).unwrap();
let labels: Vec<&str> = choices.iter().map(EnumChoice::label).collect();
assert_eq!(labels, vec!["FileRead", "FileWrite", "Mcp", "Other"]);
assert!(matches!(choices[2], EnumChoice::Tagged { .. }));
assert!(matches!(choices[3], EnumChoice::Tagged { .. }));
}
#[test]
fn enum_choices_returns_none_for_non_enum_schema() {
assert!(enum_choices(&json!({}), &json!({"type": "object", "properties": {}})).is_none());
assert!(enum_choices(&json!({}), &json!({"type": "string"})).is_none());
assert!(enum_choices(&json!({}), &json!({})).is_none());
}
#[test]
fn enum_choices_returns_none_for_one_of_alternative_that_is_not_single_tagged_property() {
let node = json!({
"oneOf": [
{"type": "string", "enum": ["FileRead"]},
{"type": "object", "properties": {"a": {}, "b": {}}, "required": ["a", "b"]},
]
});
assert!(enum_choices(&json!({}), &node).is_none());
}
#[test]
fn prompt_from_schema_drives_mixed_enum_array_through_select_and_tagged_recursion() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
enum TestTool {
FileRead,
FileWrite,
Mcp {
server: String,
tool: Option<String>,
},
Other(String),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
tools: Vec<TestTool>,
}
let ui = CaptureUi::default();
ui.confirm_answers.borrow_mut().push_back(true); ui.select_answers.borrow_mut().push_back(2); ui.text_answers.borrow_mut().push_back("vision".to_string()); ui.text_answers
.borrow_mut()
.push_back("vlm_compare_images".to_string()); ui.confirm_answers.borrow_mut().push_back(false);
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(
result,
json!({"tools": [{"Mcp": {"server": "vision", "tool": "vlm_compare_images"}}]})
);
}
#[test]
fn enum_choices_detects_a_one_of_alternative_shaped_as_const_not_enum() {
let node = json!({
"oneOf": [
{"type": "string", "enum": ["FileRead", "FileWrite"]},
{"type": "string", "const": "Search", "description": "Content search."},
{"type": "object", "properties": {"Other": {"type": "string"}}, "required": ["Other"]},
]
});
let choices = enum_choices(&json!({}), &node).unwrap();
let labels: Vec<&str> = choices.iter().map(EnumChoice::label).collect();
assert_eq!(labels, vec!["FileRead", "FileWrite", "Search", "Other"]);
assert!(matches!(choices[2], EnumChoice::Literal(_)));
}
#[test]
fn enum_choices_detects_the_real_tool_name_schema_end_to_end() {
let schema = schemars::schema_for!(crate::capabilities::SubAgentCapabilityConfig);
let root = serde_json::to_value(&schema).unwrap();
let items_schema = root
.get("properties")
.and_then(|p| p.get("tools"))
.map(|v| resolve_ref(&root, v))
.and_then(|tools| tools.get("items"))
.map(|v| resolve_ref(&root, v))
.expect("tools.items present");
assert!(enum_choices(&root, items_schema).is_some());
}
#[test]
fn prompt_from_schema_drives_the_real_sub_agent_capability_config_end_to_end() {
use crate::capabilities::SubAgentCapabilityConfig;
use crate::utils::ui::base::tests::CaptureUi;
let ui = CaptureUi::default();
ui.text_answers
.borrow_mut()
.push_back("Reviews code".to_string());
ui.text_answers
.borrow_mut()
.push_back("granite-3.1-8b-instruct".to_string());
ui.text_answers
.borrow_mut()
.push_back("You are a meticulous code reviewer.".to_string());
ui.confirm_answers.borrow_mut().push_back(true); ui.select_answers.borrow_mut().push_back(5);
ui.confirm_answers.borrow_mut().push_back(false);
let schema = schemars::schema_for!(SubAgentCapabilityConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(result["description"], "Reviews code");
assert_eq!(result["model_id"], "granite-3.1-8b-instruct");
assert_eq!(result["prompt"], "You are a meticulous code reviewer.");
assert_eq!(result["tools"], json!(["Search"]));
let config: SubAgentCapabilityConfig = serde_json::from_value(result).unwrap();
assert_eq!(config.tools, vec![crate::capabilities::ToolName::Search]);
}
#[test]
fn prompt_from_schema_drives_mixed_enum_array_through_select_of_a_plain_literal() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
enum TestTool {
FileRead,
FileWrite,
Other(String),
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
tools: Vec<TestTool>,
}
let ui = CaptureUi::default();
ui.confirm_answers.borrow_mut().push_back(true); ui.select_answers.borrow_mut().push_back(0); ui.confirm_answers.borrow_mut().push_back(false);
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(result, json!({"tools": ["FileRead"]}));
}
#[test]
fn prompt_from_schema_multi_selects_a_pure_unit_enum_array_in_one_shot() {
use crate::utils::ui::base::tests::CaptureUi;
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
#[allow(dead_code)]
enum TestTool {
FileRead,
FileWrite,
Shell,
}
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize, schemars::JsonSchema)]
struct TestConfig {
tools: Vec<TestTool>,
}
let ui = CaptureUi::default();
ui.multi_select_answers.borrow_mut().push_back(vec![0, 2]);
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &json!({})).unwrap();
assert_eq!(result, json!({"tools": ["FileRead", "Shell"]}));
assert_eq!(ui.multi_select_prompts.borrow().len(), 1);
assert!(ui.confirm_prompts.borrow().is_empty());
}
#[test]
fn prompt_from_schema_handles_hashmap_string_string() {
use crate::utils::ui::base::tests::CaptureUi;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct TestConfig {
headers: HashMap<String, String>,
}
let ui = CaptureUi::default();
ui.confirm_answers.borrow_mut().push_back(true); ui.text_answers
.borrow_mut()
.push_back("X-API-Key".to_string()); ui.text_answers.borrow_mut().push_back("my-key".to_string()); ui.confirm_answers.borrow_mut().push_back(false);
let defaults = json!({"headers": {"X-API-Key": "default-key"}});
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &defaults).unwrap();
assert_eq!(result["headers"], json!({"X-API-Key": "my-key"}));
}
#[test]
fn prompt_from_schema_handles_hashmap_string_secret() {
use crate::registry::Secret;
use crate::utils::ui::base::tests::CaptureUi;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct TestConfig {
custom_headers: HashMap<String, Secret>,
}
let ui = CaptureUi::default();
ui.confirm_answers.borrow_mut().push_back(true); ui.text_answers
.borrow_mut()
.push_back("Authorization".to_string()); ui.password_answers
.borrow_mut()
.push_back("Bearer my-token".to_string()); ui.confirm_answers.borrow_mut().push_back(false);
let defaults = json!({"custom_headers": {"Authorization": "Bearer default-token"}});
let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &defaults).unwrap();
assert_eq!(
result["custom_headers"],
json!({"Authorization": "Bearer my-token"})
);
}
#[test]
fn prompt_from_schema_handles_hashmap_without_defaults() {
use crate::utils::ui::base::tests::CaptureUi;
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, schemars::JsonSchema)]
struct TestConfig {
headers: HashMap<String, String>,
}
let ui = CaptureUi::default();
ui.confirm_answers.borrow_mut().push_back(true); ui.text_answers
.borrow_mut()
.push_back("X-API-Key".to_string()); ui.text_answers.borrow_mut().push_back("my-key".to_string()); ui.confirm_answers.borrow_mut().push_back(false);
let defaults = json!({"headers": {}}); let schema = schemars::schema_for!(TestConfig);
let result = prompt_from_schema(&ui, &schema, &defaults).unwrap();
assert_eq!(result["headers"], json!({"X-API-Key": "my-key"}));
}
}