use std::collections::HashSet;
use clap::{Arg, ArgAction, Command};
use serde_json::{Map, Value, json};
use crate::config::Config;
use crate::schema::types::{SchemaType, known_type_classifications};
use crate::selector::FlagMatcher;
pub fn build_flags_schema(
cmd: &Command,
cfg: &Config,
cmd_path: &str,
local_flag: Option<&FlagMatcher>,
inherited_flag: Option<&FlagMatcher>,
) -> (Map<String, Value>, Vec<String>) {
let mut properties: Map<String, Value> = Map::new();
let mut required: Vec<String> = Vec::new();
let local_ids: HashSet<&str> = cmd
.get_arguments()
.filter(|a| !a.is_global_set())
.map(|a| a.get_id().as_str())
.collect();
for arg in cmd.get_arguments().filter(|a| !a.is_global_set()) {
if local_flag.is_some_and(|m| !m(arg)) {
continue;
}
process_arg(arg, cfg, cmd_path, &mut properties, &mut required);
}
for arg in cmd.get_arguments().filter(|a| a.is_global_set()) {
if local_ids.contains(arg.get_id().as_str()) {
continue; }
if inherited_flag.is_some_and(|m| !m(arg)) {
continue;
}
process_arg(arg, cfg, cmd_path, &mut properties, &mut required);
}
(properties, required)
}
fn process_arg(
arg: &Arg,
cfg: &Config,
cmd_path: &str,
properties: &mut Map<String, Value>,
required: &mut Vec<String>,
) {
if arg.is_hide_set() {
return;
}
let id = arg.get_id().as_str();
if id == "help" || id == "version" {
return;
}
if arg.is_positional() {
return;
}
let name = arg.get_id().as_str().to_owned();
let key = (cmd_path.to_owned(), name.clone());
if let Some(override_schema) = cfg.flag_schemas.get(&key) {
properties.insert(name.clone(), override_schema.clone());
if arg.is_required_set() {
required.push(name);
}
return;
}
let prop = build_arg_schema(arg, cfg, cmd_path);
if arg.is_required_set() {
required.push(name.clone());
}
properties.insert(name, Value::Object(prop));
}
fn build_arg_schema(arg: &Arg, cfg: &Config, cmd_path: &str) -> Map<String, Value> {
let mut prop: Map<String, Value> = Map::new();
if let Some(help) = arg.get_help() {
prop.insert("description".into(), Value::String(help.to_string()));
}
let coarse_type = classify(arg, cfg, cmd_path);
prop.insert(
"type".into(),
Value::String(coarse_type.as_json_type().to_owned()),
);
match coarse_type {
SchemaType::StringPath => {
prop.insert("format".into(), Value::String("path".into()));
}
SchemaType::Array => {
let item_type = classify_array_item(arg, cfg, cmd_path);
let mut items_obj = Map::new();
items_obj.insert(
"type".into(),
Value::String(item_type.as_json_type().to_owned()),
);
if item_type == SchemaType::StringPath {
items_obj.insert("format".into(), Value::String("path".into()));
}
prop.insert("items".into(), Value::Object(items_obj));
}
_ => {}
}
let possible_values: Vec<String> = arg
.get_possible_values()
.iter()
.map(|pv| pv.get_name().to_string())
.collect();
if !possible_values.is_empty() {
prop.insert(
"enum".into(),
Value::Array(possible_values.into_iter().map(Value::String).collect()),
);
prop.insert("type".into(), Value::String("string".into()));
}
let defaults: Vec<String> = arg
.get_default_values()
.iter()
.map(|os| os.to_string_lossy().to_string())
.collect();
if !defaults.is_empty() {
let encoded = encode_defaults(arg, &defaults);
prop.insert("default".into(), encoded);
}
prop
}
fn classify(arg: &Arg, cfg: &Config, cmd_path: &str) -> SchemaType {
let name = arg.get_id().as_str();
if let Some(&ty) = cfg
.flag_type_overrides
.get(&(cmd_path.to_owned(), name.to_owned()))
{
return ty;
}
match arg.get_action() {
ArgAction::SetTrue | ArgAction::SetFalse => return SchemaType::Boolean,
ArgAction::Count => return SchemaType::Integer,
ArgAction::Append => return SchemaType::Array,
_ => {}
}
classify_by_type_id(arg, cmd_path)
}
fn classify_array_item(arg: &Arg, cfg: &Config, cmd_path: &str) -> SchemaType {
let name = arg.get_id().as_str();
if let Some(&ty) = cfg
.flag_type_overrides
.get(&(cmd_path.to_owned(), name.to_owned()))
&& ty != SchemaType::Array
{
return ty;
}
classify_by_type_id(arg, cmd_path)
}
fn classify_by_type_id(arg: &Arg, cmd_path: &str) -> SchemaType {
let parser_id = arg.get_value_parser().type_id();
for &(known_ty, schema) in known_type_classifications() {
if parser_id == known_ty {
return schema;
}
}
tracing::debug!(
target: "brontes::schema::flag",
flag = arg.get_id().as_str(),
cmd_path,
"unrecognized value parser; falling back to string"
);
SchemaType::String
}
fn encode_defaults(arg: &Arg, defaults: &[String]) -> Value {
match arg.get_action() {
ArgAction::SetTrue | ArgAction::SetFalse => {
Value::Bool(defaults.first().is_some_and(|s| s == "true"))
}
ArgAction::Count => defaults
.first()
.and_then(|s| s.parse::<u64>().ok())
.map_or_else(|| json!(0u64), |n| Value::Number(n.into())),
_ => {
if defaults.len() == 1 {
Value::String(defaults[0].clone())
} else {
Value::Array(defaults.iter().map(|s| Value::String(s.clone())).collect())
}
}
}
}
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use clap::{Arg, ArgAction, Command, value_parser};
use serde_json::{Value, json};
use super::*;
use crate::config::Config;
use crate::schema::SchemaType;
fn schema_for(cmd: &Command) -> (Map<String, Value>, Vec<String>) {
let cfg = Config::default();
build_flags_schema(cmd, &cfg, "my-cli", None, None)
}
fn cmd_with_arg(arg: Arg) -> Command {
Command::new("my-cli").arg(arg)
}
#[test]
fn bool_flag_set_true_action_becomes_boolean() {
let cmd = cmd_with_arg(
Arg::new("verbose")
.long("verbose")
.action(ArgAction::SetTrue),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("verbose").expect("verbose in props");
assert_eq!(prop["type"], json!("boolean"));
}
#[test]
fn count_action_becomes_integer() {
let cmd = cmd_with_arg(
Arg::new("verbosity")
.long("verbosity")
.action(ArgAction::Count),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("verbosity").expect("verbosity in props");
assert_eq!(prop["type"], json!("integer"));
}
#[test]
fn append_action_becomes_array() {
let cmd = cmd_with_arg(
Arg::new("tags")
.long("tags")
.action(ArgAction::Append)
.value_parser(value_parser!(String)),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("tags").expect("tags in props");
assert_eq!(prop["type"], json!("array"));
assert_eq!(prop["items"], json!({"type": "string"}));
}
#[test]
fn value_parser_i64_becomes_integer() {
let cmd = cmd_with_arg(
Arg::new("count")
.long("count")
.value_parser(value_parser!(i64)),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("count").expect("count in props");
assert_eq!(prop["type"], json!("integer"));
}
#[test]
fn value_parser_pathbuf_becomes_string_path() {
let cmd = cmd_with_arg(
Arg::new("output")
.long("output")
.value_parser(value_parser!(PathBuf)),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("output").expect("output in props");
assert_eq!(prop["type"], json!("string"));
assert_eq!(prop["format"], json!("path"));
}
#[test]
fn possible_values_become_enum_string() {
let cmd = cmd_with_arg(
Arg::new("level")
.long("level")
.value_parser(["debug", "info", "warn"]),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("level").expect("level in props");
assert_eq!(prop["type"], json!("string"));
let enum_vals = prop["enum"].as_array().expect("enum array");
let names: Vec<&str> = enum_vals
.iter()
.map(|v| v.as_str().expect("string enum value"))
.collect();
assert_eq!(names, vec!["debug", "info", "warn"]);
}
#[test]
fn required_flag_lands_in_required_list() {
let cmd = cmd_with_arg(
Arg::new("input")
.long("input")
.required(true)
.value_parser(value_parser!(String)),
);
let (props, required) = schema_for(&cmd);
assert!(props.contains_key("input"), "input in props");
assert!(required.contains(&"input".to_owned()), "input in required");
}
#[test]
fn encode_defaults_set_true_with_false_string_returns_false() {
let arg = Arg::new("dry-run")
.long("dry-run")
.action(ArgAction::SetTrue);
let encoded = encode_defaults(&arg, &["false".to_string()]);
assert_eq!(encoded, Value::Bool(false));
}
#[test]
fn encode_defaults_set_false_with_true_string_returns_true() {
let arg = Arg::new("noisy").long("noisy").action(ArgAction::SetFalse);
let encoded = encode_defaults(&arg, &["true".to_string()]);
assert_eq!(encoded, Value::Bool(true));
}
#[test]
fn encode_defaults_set_true_with_explicit_true_default_returns_true() {
let arg = Arg::new("opt-in")
.long("opt-in")
.action(ArgAction::SetTrue)
.default_value("true");
let encoded = encode_defaults(&arg, &["true".to_string()]);
assert_eq!(encoded, Value::Bool(true));
}
#[test]
fn set_true_flag_with_explicit_default_value_true_overrides() {
let cmd = cmd_with_arg(
Arg::new("opt-in")
.long("opt-in")
.action(ArgAction::SetTrue)
.default_value("true"),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("opt-in").expect("opt-in in props");
assert_eq!(prop["type"], json!("boolean"));
assert_eq!(
prop["default"],
json!(true),
"explicit .default_value(\"true\") on SetTrue must surface as default:true"
);
}
#[test]
fn default_value_populates_default() {
let cmd = cmd_with_arg(
Arg::new("level")
.long("level")
.default_value("info")
.value_parser(value_parser!(String)),
);
let (props, _) = schema_for(&cmd);
let prop = props.get("level").expect("level in props");
assert_eq!(prop["type"], json!("string"));
assert_eq!(prop["default"], json!("info"));
}
#[test]
fn hidden_flag_is_skipped() {
let cmd = cmd_with_arg(
Arg::new("secret")
.long("secret")
.hide(true)
.value_parser(value_parser!(String)),
);
let (props, required) = schema_for(&cmd);
assert!(
!props.contains_key("secret"),
"hidden flag must not appear in props"
);
assert!(
!required.contains(&"secret".to_owned()),
"hidden flag must not appear in required"
);
}
#[test]
fn local_then_inherited_dedup() {
let parent = Command::new("my-cli")
.arg(
Arg::new("log-level")
.long("log-level")
.global(true)
.help("parent")
.value_parser(value_parser!(String)),
)
.subcommand(
Command::new("sub").arg(
Arg::new("log-level")
.long("log-level")
.help("child")
.value_parser(value_parser!(String)),
),
);
let mut parent = parent;
parent.build();
let sub = parent
.get_subcommands()
.find(|s: &&Command| s.get_name() == "sub")
.expect("sub command");
let cfg = Config::default();
let (props, _) = build_flags_schema(sub, &cfg, "my-cli sub", None, None);
let prop = props.get("log-level").expect("log-level in props");
assert_eq!(
prop["description"],
json!("child"),
"local flag should win over inherited"
);
}
#[test]
fn flag_schemas_wholesale_override_applies() {
let mut cfg = Config::default();
cfg.flag_schemas.insert(
("my-cli".to_owned(), "limit".to_owned()),
json!({"type": "integer", "minimum": 1, "maximum": 100}),
);
let cmd = Command::new("my-cli").arg(
Arg::new("limit")
.long("limit")
.value_parser(value_parser!(String)), );
let (props, _) = build_flags_schema(&cmd, &cfg, "my-cli", None, None);
let prop = props.get("limit").expect("limit in props");
assert_eq!(
*prop,
json!({"type": "integer", "minimum": 1, "maximum": 100}),
"wholesale override must be used verbatim"
);
}
#[test]
fn flag_type_overrides_nudges_classify() {
let mut cfg = Config::default();
cfg.flag_type_overrides
.insert(("my-cli".to_owned(), "tags".to_owned()), SchemaType::Array);
let cmd = Command::new("my-cli").arg(
Arg::new("tags")
.long("tags")
.value_parser(value_parser!(String)),
);
let (props, _) = build_flags_schema(&cmd, &cfg, "my-cli", None, None);
let prop = props.get("tags").expect("tags in props");
assert_eq!(
prop["type"],
json!("array"),
"type override must produce array"
);
assert_eq!(
prop["items"],
json!({"type": "string"}),
"items must reflect the value_parser scalar type"
);
}
}