//! Map MCP tool catalogs to clap commands, and argv back to JSON. //! //! An MCP server advertises its tools as `(name, description, JSON //! Schema)` triples; this crate turns each CLI-safe tool into a clap //! subcommand whose flags come from the schema's top-level properties, //! then turns parsed argv back into the `{property: value}` object the //! server expects. It is pure mapping: no I/O, no processes, no MCP //! protocol, no rhema dependencies — `clap`, `serde_json`, and //! `thiserror` are the whole list. //! //! The mapping is conservative, with escape hatches at every level of //! schema complexity: enums become clap `possible_values`, scalar //! arrays become repeatable flags, and every shape the flag form //! cannot express collapses to a `--_json` flag or to the //! per-tool `--json` payload override. Schema defaults are never //! pre-filled — an omitted flag omits the key — and the server stays //! the validator: constraint keywords are ignored and JSON-flag //! properties are never clap-required. #![deny(missing_docs)] #![forbid(unsafe_code)] use std::collections::BTreeSet; use clap::builder::PossibleValuesParser; use clap::{Arg, ArgAction, ArgMatches, Command}; use serde_json::{Map, Number, Value}; /// One advertised MCP tool: its name, description, and input schema. #[derive(Clone, Debug, Eq, PartialEq)] pub struct ToolInfo { /// The tool's name as the server advertises it, e.g. `create_issue`. pub name: String, /// The tool's description, flowed into the subcommand's help verbatim. pub description: String, /// The tool's JSON Schema input: the wire format's `inputSchema` /// object, read as-is. pub input_schema: Value, } /// The arguments for one tool call, recovered from parsed argv. #[derive(Clone, Debug, Eq, PartialEq)] #[non_exhaustive] pub enum Payload { /// The mapped `{property: value}` object: one entry per flag the /// caller passed, absent flags leaving absent keys. Mapped(Value), /// The raw `--json` string, verbatim. `-` means stdin and is the /// caller's to read; this crate never touches I/O. Overridden(String), /// A discovery request: the caller should print this tool's /// `input_schema` and exit. The value is the subcommand's tool /// name; [`schema_of`] is the lookup that matches it. Schema { /// The tool whose schema the caller should print. tool: String, }, } /// Why parsed argv could not be mapped to tool arguments. #[derive(Clone, Debug, Eq, PartialEq, thiserror::Error)] #[non_exhaustive] pub enum MappingError { /// No subcommand matched: the invocation was bare, and the tool /// list ([`list_json`]) is the answer. #[error("no subcommand was matched; a bare invocation lists the tools")] NoSubcommand, /// The matches carry a subcommand the command does not define. #[error("unknown subcommand `{name}`")] UnknownSubcommand { /// The subcommand name found in the matches but missing from /// the command. name: String, }, /// The `--json` payload override was combined with the tool's own /// flags. #[error("the --json override cannot be combined with the tool's own flags")] JsonConflict, } /// Build the root command for one server's catalog: one subcommand per /// CLI-safe tool, flags from the tool's input schema. /// /// The returned [`Command`] requires no subcommand — a bare invocation /// is the caller's cue to print [`list_json`] — and carries no /// decoration of its own, so the caller is free to add `.about()`, /// `.version()`, and friends. A tool becomes a subcommand when /// [`cli_safe`] accepts its name, its name is not `help` (clap owns /// that subcommand), and its schema's root is an object; an explicit /// non-object `"type"` omits the tool, a missing `"type"` is read as /// the object shape every tool call has. Tools left out are reachable /// only through the caller's raw-call path; duplicate names keep the /// first occurrence. /// /// Each tool renders per its schema, property names passing through /// verbatim (`--per_page` — no case translation, no aliases): /// /// - `string` is a value flag; a string `enum` adds clap /// `possible_values`, so validation and completion come free. An /// enum with any non-string value falls back to the JSON flag. /// - `number` parses as `f64`, `integer` as `i64`. /// - A required boolean is a value flag accepting exactly `true` or /// `false`. An optional boolean with no `default` (or `default: /// false`) is a presence flag — passing it maps `true`, omitting it /// omits the key. With `default: true` it stays a value flag, never /// a guess. /// - An array of scalars is a repeatable flag (`--tag a --tag b`, /// one value per occurrence); an array of anything else takes the /// JSON flag. /// - Objects, `anyOf`/`oneOf`/`allOf`, and unknown or missing types /// map to `--_json`, taking one raw JSON value verbatim. /// A two-variant `anyOf`/`oneOf` of `{"type": "null"}` and one other /// shape — the `Optional[T]` form FastMCP emits for every optional /// parameter — unwraps to that other shape first, recursively and /// through array `items`; so does a `type` array of `"null"` plus /// one other type. Nullable properties stay optional: JSON `null` is /// a value the flag form cannot say, so they are never /// clap-required. /// - A property in the schema's `required` array is clap-required — /// but only when its flag can express the value, and never beneath /// the `--json` override, which replaces the whole payload. /// JSON-flag properties are never clap-required and stay the /// server's to validate. /// - Schema `default`s are never pre-filled. Constraint keywords /// (`pattern`, `minimum`, `maximum`, `format`, ...) and unknown /// extension keys (`x-mcp-header`) are ignored. /// /// Two collisions demote a tool to a subcommand with no property /// flags, reachable only through `--json`: a property literally named /// `json` would collide with the payload override, and any flag /// derived from the tool that collides with another (`help` or `json` /// as a property name, or a `_json` flag against a sibling) /// does the same. Every subcommand also carries the `--json ` /// payload override; see [`args`]. #[must_use] pub fn command(server_name: &str, catalog: &[ToolInfo]) -> Command { let mut root = Command::new(server_name.to_owned()).subcommand_required(false); let mut seen = BTreeSet::new(); for tool in catalog { if !renderable(tool) || !seen.insert(tool.name.as_str()) { continue; } root = root.subcommand(tool_subcommand(server_name, tool)); } root } /// Recover one tool call's arguments from matches parsed against /// [`command`]'s tree. /// /// The matched subcommand's flags become the [`Payload::Mapped`] /// object, each passed flag contributing its key and absent flags /// leaving absent keys — schema defaults included, never pre-filled. /// A lone `--json` wins as [`Payload::Overridden`], carrying the raw /// string untouched; `-` means stdin and is never read here. `--json` /// next to any of the tool's own flags is [`MappingError::JsonConflict`]. /// `--schema` short-circuits everything as [`Payload::Schema`]: a /// question about the shape, not a call. /// /// # Errors /// /// - [`MappingError::NoSubcommand`] when `matches` has no subcommand: /// a bare invocation, answered with [`list_json`]. /// - [`MappingError::UnknownSubcommand`] when the matched subcommand /// is not part of `cmd`. /// - [`MappingError::JsonConflict`] when `--json` rides alongside the /// tool's own flags. pub fn args(cmd: &Command, matches: &ArgMatches) -> Result { let (name, sub) = matches.subcommand().ok_or(MappingError::NoSubcommand)?; let sc = cmd .find_subcommand(name) .ok_or_else(|| MappingError::UnknownSubcommand { name: name.to_owned(), })?; // Discovery first: `--schema` is a question about the tool, not a // call, so it wins over everything else the command line carries. if sub.get_flag("schema") { return Ok(Payload::Schema { tool: name.to_owned(), }); } let mut object = Map::new(); for arg in sc.get_arguments() { let id = arg.get_id().as_str(); if id == "json" || id == "help" || id == "version" { continue; } let Some(long) = arg.get_long() else { continue; }; let Some(slot) = slot_of_id(id) else { continue; }; insert_flag(&mut object, sub, id, long, slot, arg.get_action()); } if let Some(raw) = sub.get_one::("json") { if !object.is_empty() { return Err(MappingError::JsonConflict); } return Ok(Payload::Overridden(raw.clone())); } Ok(Payload::Mapped(Value::Object(object))) } /// Whether a tool name can ride argv: non-empty `[a-zA-Z0-9_-]` only. /// /// The charset test only — a name passing it can still be skipped by /// [`command`] when clap itself owns it (`help`). #[must_use] pub fn cli_safe(name: &str) -> bool { !name.is_empty() && name .chars() .all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-') } /// One tool's `input_schema`, by the command tree's own rules: /// renderable tools only, first name wins. The caller behind /// [`Payload::Schema`] looks the schema up here so the printed shape /// is exactly the one the subcommand's flags were built from. #[must_use] pub fn schema_of<'a>(catalog: &'a [ToolInfo], tool: &str) -> Option<&'a Value> { catalog .iter() .find(|info| info.name == tool && renderable(info)) .map(|info| &info.input_schema) } /// Render the bare-invocation tool list: `[{name, description}]`. /// /// The list mirrors [`command`]'s tree exactly — the same tools, in /// catalog order, duplicates collapsed first-wins — so what it /// advertises is exactly what the subcommands can run. #[must_use] pub fn list_json(catalog: &[ToolInfo]) -> Value { let mut seen = BTreeSet::new(); let tools = catalog .iter() .filter(|tool| renderable(tool) && seen.insert(tool.name.as_str())) .map(|tool| { [ ("name".to_owned(), Value::String(tool.name.clone())), ( "description".to_owned(), Value::String(tool.description.clone()), ), ] .into_iter() .collect::>() }) .collect::>>(); Value::Array(tools.into_iter().map(Value::Object).collect()) } /// The clap-side value kind of one property. /// /// The kind is tagged into the arg's id (a private encoding: `t`, `e`, /// `n`, `i`, `b`, `j`, or `f` for the presence flag) so [`args`] can /// extract typed values from a rebuilt [`Command`], which carries no /// schema. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Kind { /// Free-form string. Text, /// String restricted by the schema's `enum`. Enum, /// JSON number, parsed as `f64`. Number, /// JSON integer, parsed as `i64`. Integer, /// Boolean value, exactly `true` or `false`. Bool, /// One raw JSON value, verbatim. Json, } impl Kind { /// The id tag for one value of this kind. const fn tag(self) -> char { match self { Kind::Text => 't', Kind::Enum => 'e', Kind::Number => 'n', Kind::Integer => 'i', Kind::Bool => 'b', Kind::Json => 'j', } } /// The help placeholder for one value; text and enums show the /// property's own name, the rest name their shape. fn value_name(self, prop: &str) -> String { match self { Kind::Text | Kind::Enum => prop.to_uppercase(), Kind::Number => "NUMBER".to_owned(), Kind::Integer => "INTEGER".to_owned(), Kind::Bool => "BOOL".to_owned(), Kind::Json => "JSON".to_owned(), } } } /// The flag shape one property maps to. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Shape { /// One value per flag: `--field value`. Value(Kind), /// Repeatable, one value per occurrence: an array of scalars. Repeat(Kind), /// Presence flag: an optional boolean, `true` when passed. SetTrue, /// The JSON escape: `--_json`, one raw JSON value. Json, } /// The id slot one built arg decodes back to in [`args`]. #[derive(Clone, Copy, Debug, Eq, PartialEq)] enum Slot { /// A presence flag; only `true` is ever stored. Presence, /// A value or repeatable flag of the given kind. Valued(Kind), } fn slot_of_id(id: &str) -> Option { match id.chars().next()? { 'f' => Some(Slot::Presence), 't' => Some(Slot::Valued(Kind::Text)), 'e' => Some(Slot::Valued(Kind::Enum)), 'n' => Some(Slot::Valued(Kind::Number)), 'i' => Some(Slot::Valued(Kind::Integer)), 'b' => Some(Slot::Valued(Kind::Bool)), 'j' => Some(Slot::Valued(Kind::Json)), _ => None, } } /// Whether a tool joins the clap tree. fn renderable(tool: &ToolInfo) -> bool { tool.name != "help" && cli_safe(&tool.name) && root_is_object(&tool.input_schema) } /// Whether a schema's root is object-shaped: a JSON object carrying an /// explicit `"object"` type, or none at all — `{}` included, since the /// missing `"type"` is the common tool-schema shape. fn root_is_object(schema: &Value) -> bool { if !schema.is_object() { return false; } match schema.get("type") { None => true, Some(t) => t.as_str() == Some("object"), } } fn tool_subcommand(server_name: &str, tool: &ToolInfo) -> Command { let mut cmd = Command::new(tool.name.clone()).about(&tool.description); let mut used = vec!["help".to_owned(), "json".to_owned(), "schema".to_owned()]; let mut demoted = false; let mut mandatory = Vec::new(); if let Some(props) = properties(&tool.input_schema) { let required = required_names(&tool.input_schema); for (index, (prop, schema)) in props.iter().enumerate() { let Some((arg, enforced)) = property_arg( prop, schema, index, required.contains(&prop.as_str()), &mut used, ) else { demoted = true; break; }; if enforced { mandatory.push(arg.clone()); } cmd = cmd.arg(arg); } } if demoted { // Collision between derived flags: no property flags at all, // the tool stays reachable only through `--json`. cmd = Command::new(tool.name.clone()).about(&tool.description); } else if !mandatory.is_empty() { // clap's own usage line renders [OPTIONS] alone — our required // flags are `required_unless_present` so `--json` can carry the // payload — so the usage is synthesized: the mandatory flags, // in schema order, where a usage line traditionally puts them. let prefix = mandatory .iter() .map(|arg| { let long = arg.get_long().expect("property args have longs"); let value = arg .get_value_names() .and_then(|names| names.first()) .map(|v| v.to_string()) .unwrap_or_else(|| long.to_uppercase()); format!("--{long} <{value}>") }) .collect::>() .join(" "); cmd = cmd.override_usage(format!("{server_name} {} {prefix} [OPTIONS]", tool.name)); } cmd.arg(override_arg()).arg(schema_arg()) } /// The per-tool `--schema` discovery flag: the caller prints the /// tool's `input_schema` and exits. The one answer to "what shape /// does this payload take" that needs no guessing. fn schema_arg() -> Arg { Arg::new("schema") .long("schema") .action(ArgAction::SetTrue) .display_order(3) .help("Print this tool's input schema as JSON and exit") } /// The per-tool `--json` whole-payload override, present on every /// subcommand. fn override_arg() -> Arg { Arg::new("json") .long("json") .value_name("JSON") .display_order(2) .help( "Replace the whole payload with this JSON; '-' reads it from stdin. \ For anything beyond a one-liner, pipe or heredoc it: \ --json - <<'EOF' ... EOF", ) } /// The flag for one property — with whether it is clap-required — or /// `None` when the property would make derived flags collide, which /// demotes the whole tool. fn property_arg( prop: &str, schema: &Value, index: usize, prop_required: bool, used: &mut Vec, ) -> Option<(Arg, bool)> { let mut shape = classify(schema); if shape == Shape::Value(Kind::Bool) && !prop_required && !is_nullable(schema) && !default_true(schema) { // Optional boolean, no default to honor: presence means true, // absence means the key stays absent. Required booleans and // nullable ones keep the explicit `true|false` value flag. shape = Shape::SetTrue; } let long = match shape { Shape::Json => format!("{prop}_json"), Shape::Value(_) | Shape::Repeat(_) | Shape::SetTrue => prop.to_owned(), }; if !flag_name_ok(&long) || used.contains(&long) { return None; } used.push(long.clone()); let help = schema.get("description").and_then(Value::as_str); // clap-required only when the flags are the whole answer: JSON-flag // properties and nullable ones stay optional. clap cannot spell // `required_unless_present` args out in its usage line, so the // required mark rides the help text instead. let enforced = prop_required && !is_nullable(schema) && matches!(shape, Shape::Value(_) | Shape::Repeat(_)); // Help groups mandatory flags first: clap's usage line cannot // spell out `required_unless_present` args, so the display order // is where requiredness reads at a glance — the `(required)` mark // is the second signal. let mut arg = Arg::new(arg_id(shape, index)) .long(long) .display_order(if enforced { 0 } else { 1 }); match shape { Shape::SetTrue => arg = arg.action(ArgAction::SetTrue), Shape::Json => { // The shape rides the help: the model reading // `--help` learns the payload's structure here, without a // second round trip to `--schema`. let shaped = format!( "{} JSON shape: {}", flag_help(help, false, false), shape_of(schema, 4) ); arg = arg .help(shaped) .value_name("JSON") .value_parser(clap::builder::ValueParser::new(json_value)); } Shape::Value(kind) => { arg = typed_arg(arg, kind, schema, prop); // A JSON-flag property can never be clap-required; a // nullable one neither, since null is a value the flag // cannot say. if prop_required && !is_nullable(schema) { // Required unless the override carries the payload — // the one case the flags are not the answer. arg = arg.required_unless_present("json"); } } Shape::Repeat(kind) => { // The enum values, where any, live on the items schema. let items = schema.get("items").unwrap_or(schema); arg = typed_arg(arg.action(ArgAction::Append), kind, items, prop); if enforced { arg = arg.required_unless_present("json"); } arg = arg.help(flag_help(help, enforced, true)); } } if !matches!(shape, Shape::Repeat(_)) { arg = with_help(arg, help, enforced); } Some((arg, enforced)) } /// One raw JSON value; clap's parse step owns the rejection of bad /// JSON, the usage-error path. fn json_value(raw: &str) -> Result { serde_json::from_str(raw) } /// The `number`-typed flag's value as a JSON number. Schemas say /// `number` for fields that are semantically integers (`issue_number` /// in github-mcp-server), and a bare `137.0` is JSON-valid but /// rejected by strict parsers — Go's unmarshaller, for one — so a /// whole-number float becomes a JSON integer and keeps its shortest /// representation. Non-finite values have no JSON form: `None`. fn number_of(v: f64) -> Option { if v.fract() == 0.0 && v >= i64::MIN as f64 && v <= i64::MAX as f64 { Some(Number::from(v as i64)) } else { Number::from_f64(v) } } /// The value name and parser for one scalar value flag. fn typed_arg(arg: Arg, kind: Kind, schema: &Value, prop: &str) -> Arg { let arg = arg.value_name(kind.value_name(prop)); match kind { Kind::Text => arg, Kind::Enum => match string_enum_values(schema) { Some(values) => arg.value_parser(PossibleValuesParser::new(values)), None => arg, }, Kind::Number => arg.value_parser(clap::value_parser!(f64)), Kind::Integer => arg.value_parser(clap::value_parser!(i64)), Kind::Bool => arg.value_parser(clap::value_parser!(bool)), Kind::Json => arg, } } /// The arg id carrying the shape tag and the property's index. fn arg_id(shape: Shape, index: usize) -> String { let tag = match shape { Shape::SetTrue => 'f', Shape::Json => Kind::Json.tag(), Shape::Value(kind) | Shape::Repeat(kind) => kind.tag(), }; format!("{tag}{index}") } /// clap rejects longs holding whitespace or `=`, and anything leading /// with `-`; the empty long is unusable too. fn flag_name_ok(long: &str) -> bool { !long.is_empty() && !long.starts_with('-') && !long.chars().any(|c| c.is_whitespace() || c == '=') } /// Help text for a repeatable flag, stating so. /// The flag's help text: the schema description, honest about what the /// flag demands (`required`, `repeatable`). clap renders neither mark /// in its usage line — our required flags are /// `required_unless_present`, so `--json` can carry the payload — and /// the help text is where the model reads. fn flag_help(help: Option<&str>, required: bool, repeatable: bool) -> String { let mut marks = Vec::new(); if required { marks.push("required"); } if repeatable { marks.push("repeatable"); } match help { Some(h) if !h.is_empty() => format!("{h} ({})", marks.join(", ")), _ => format!("({})", marks.join(", ")), } } fn with_help(arg: Arg, help: Option<&str>, enforced: bool) -> Arg { if enforced { return arg.help(flag_help(help, true, false)); } match help { Some(h) if !h.is_empty() => arg.help(h.to_owned()), _ => arg, } } /// A compact structural summary of one schema: the shape a caller /// needs to build a matching payload, depth-limited so pathological /// schemas cannot flood the help. `!` marks required properties. #[must_use] pub fn shape_of(schema: &Value, depth: usize) -> String { if depth == 0 { return String::from("..."); } for key in ["anyOf", "oneOf"] { if let Some(alts) = schema.get(key).and_then(Value::as_array) { let parts = alts .iter() .map(|alt| shape_of(alt, depth.saturating_sub(1))) .collect::>() .join(" | "); return format!("{key} {parts}"); } } match schema.get("type").and_then(Value::as_str) { Some("object") => match properties(schema) { Some(props) if !props.is_empty() => { let required = required_names(schema); let parts = props .iter() .map(|(name, sub)| { let mark = if required.contains(&name.as_str()) { "!" } else { "" }; format!("{name}{mark}: {}", shape_of(sub, depth - 1)) }) .collect::>() .join(", "); format!("{{{parts}}}") } _ => String::from("object"), }, Some("array") => schema .get("items") .map(|items| format!("array of {}", shape_of(items, depth - 1))) .unwrap_or_else(|| String::from("array")), Some("string") => match schema.get("enum").and_then(Value::as_array) { Some(values) if !values.is_empty() => { let names = values .iter() .filter_map(Value::as_str) .collect::>() .join("|"); format!("string, one of {names}") } _ => String::from("string"), }, Some(other) => other.to_owned(), None => String::from("any"), } } /// The schema's top-level properties, in schema order. fn properties(schema: &Value) -> Option<&Map> { schema.get("properties").and_then(Value::as_object) } /// The names in the schema's `required` array; non-strings ignored. fn required_names(schema: &Value) -> Vec<&str> { schema .get("required") .and_then(Value::as_array) .map(|names| names.iter().filter_map(Value::as_str).collect()) .unwrap_or_default() } /// Whether the schema pins `default: true` — such a boolean keeps the /// explicit `true|false` value flag, never a presence guess. fn default_true(schema: &Value) -> bool { schema.get("default") == Some(&Value::Bool(true)) } /// The flag shape one property maps to, per the schema. fn classify(schema: &Value) -> Shape { // FastMCP's Optional[T]: a two-variant anyOf/oneOf of null and one // other shape, unwrapped recursively before classifying. if let Some(inner) = nullable_variant(schema) { return classify(inner); } // JSON Schema's ["null", "T"] type array, the same unwrapping. if let Some(inner) = nullable_type(schema) { return classify(&inner); } if schema.get("enum").is_some() { // A string enum earns possible_values; any other enum falls // back to the JSON flag. return match string_enum_values(schema) { Some(_) => Shape::Value(Kind::Enum), None => Shape::Json, }; } match schema.get("type").and_then(Value::as_str) { Some("string") => Shape::Value(Kind::Text), Some("number") => Shape::Value(Kind::Number), Some("integer") => Shape::Value(Kind::Integer), Some("boolean") => Shape::Value(Kind::Bool), Some("array") => array_shape(schema), // Objects, unions that survived the nullable unwrap, unknown // and missing types all take the JSON escape. _ => Shape::Json, } } /// An array of scalars repeats; anything else falls back to JSON. fn array_shape(schema: &Value) -> Shape { match schema.get("items") { Some(items) => match classify(items) { Shape::Value(kind) => Shape::Repeat(kind), _ => Shape::Json, }, None => Shape::Json, } } /// The single non-null variant of an `anyOf`/`oneOf` `[T, null]` — the /// `Optional[T]` shape FastMCP emits for every optional parameter. fn nullable_variant(schema: &Value) -> Option<&Value> { let unions = ["anyOf", "oneOf"] .iter() .filter_map(|key| schema.get(key).and_then(Value::as_array)); for variants in unions { if variants.len() == 2 { if is_null_schema(&variants[0]) { return Some(&variants[1]); } if is_null_schema(&variants[1]) { return Some(&variants[0]); } } } None } /// The schema with a `["null", "T"]` type array rewritten to `"T"`. fn nullable_type(schema: &Value) -> Option { let types = schema.get("type")?.as_array()?; if types.len() != 2 { return None; } let mut others = types.iter().filter(|t| !is_null_schema(t)); let other = others.next()?; if others.next().is_some() { return None; } let mut rewritten = schema.clone(); rewritten .as_object_mut()? .insert("type".to_owned(), other.clone()); Some(rewritten) } /// Whether the property itself is nullable, top level: such a flag /// stays optional even when `required` lists it. fn is_nullable(schema: &Value) -> bool { nullable_variant(schema).is_some() || nullable_type(schema).is_some() } /// Whether a value is the JSON `null` schema, in either spelling. fn is_null_schema(value: &Value) -> bool { value.as_str() == Some("null") || value.get("type").and_then(Value::as_str) == Some("null") } /// The enum values when the schema is a plain string enum; any /// non-string value — or a non-string `type` — disqualifies it. fn string_enum_values(schema: &Value) -> Option> { let string_typed = match schema.get("type") { None => true, Some(Value::String(t)) => t == "string", Some(_) => false, }; if !string_typed { return None; } let values = schema.get("enum")?.as_array()?; if values.is_empty() { return None; } values .iter() .map(|v| v.as_str().map(str::to_owned)) .collect() } /// One flag's contribution to the mapped object, when it was passed. fn insert_flag( object: &mut Map, sub: &ArgMatches, id: &str, long: &str, slot: Slot, action: &ArgAction, ) { let key = match slot { Slot::Presence => long.to_owned(), Slot::Valued(Kind::Json) => long.strip_suffix("_json").unwrap_or(long).to_owned(), Slot::Valued(_) => long.to_owned(), }; match slot { Slot::Presence => { if matches!(action, ArgAction::SetTrue) && let Some(&true) = sub.get_one::(id) { object.insert(key, Value::Bool(true)); } } Slot::Valued(kind) => { let value = match action { ArgAction::Append => append_value(sub, id, kind), _ => single_value(sub, id, kind), }; if let Some(value) = value { object.insert(key, value); } } } } /// The mapped value of one passed flag, typed per its kind. fn single_value(sub: &ArgMatches, id: &str, kind: Kind) -> Option { match kind { Kind::Text | Kind::Enum => sub.get_one::(id).map(|v| Value::String(v.clone())), Kind::Number => sub .get_one::(id) .and_then(|v| number_of(*v)) .map(Value::Number), Kind::Integer => sub.get_one::(id).map(|v| Value::Number((*v).into())), Kind::Bool => sub.get_one::(id).map(|v| Value::Bool(*v)), Kind::Json => sub.get_one::(id).cloned(), } } /// The mapped array of one repeatable flag's occurrences. fn append_value(sub: &ArgMatches, id: &str, kind: Kind) -> Option { let values = match kind { Kind::Text | Kind::Enum => sub .get_many::(id)? .map(|v| Value::String(v.clone())) .collect(), Kind::Number => sub .get_many::(id)? .filter_map(|v| number_of(*v)) .map(Value::Number) .collect(), Kind::Integer => sub .get_many::(id)? .map(|v| Value::Number((*v).into())) .collect(), Kind::Bool => sub.get_many::(id)?.map(|v| Value::Bool(*v)).collect(), // Arrays of JSON take the JSON flag and never repeat. Kind::Json => return None, }; Some(Value::Array(values)) } #[cfg(test)] mod tests { use super::*; use serde_json::json; fn classified(schema: Value) -> Shape { classify(&schema) } #[test] fn nullable_anyof_unwraps_recursively() { let optional_string = json!({"anyOf": [{"type": "string"}, {"type": "null"}]}); assert_eq!(classified(optional_string), Shape::Value(Kind::Text)); let optional_array = json!({ "anyOf": [ {"type": "array", "items": {"anyOf": [{"type": "integer"}, {"type": "null"}]}}, {"type": "null"}, ] }); assert_eq!(classified(optional_array), Shape::Repeat(Kind::Integer)); } #[test] fn nullable_oneof_and_type_arrays_unwrap() { let oneof = json!({"oneOf": [{"type": "null"}, {"type": "boolean"}]}); assert_eq!(classified(oneof), Shape::Value(Kind::Bool)); let type_array = json!({"type": ["integer", "null"], "default": 7}); assert_eq!(classified(type_array), Shape::Value(Kind::Integer)); } #[test] fn three_variant_unions_and_multi_type_arrays_stay_json() { let three = json!({"anyOf": [{"type": "object"}, {"type": "string"}, {"type": "null"}]}); assert_eq!(classified(three), Shape::Json); let multi = json!({"type": ["string", "number"]}); assert_eq!(classified(multi), Shape::Json); } #[test] fn non_string_enums_and_nested_arrays_stay_json() { let numeric_enum = json!({"type": "integer", "enum": [1, 2, 3]}); assert_eq!(classified(numeric_enum), Shape::Json); let nested = json!({"type": "array", "items": {"type": "array", "items": {"type": "string"}}}); assert_eq!(classified(nested), Shape::Json); let items_missing = json!({"type": "array"}); assert_eq!(classified(items_missing), Shape::Json); } #[test] fn shape_summary_describes_structure() { // The batch shape the shiba session fumbled: an array of // objects under a required key. let steps = json!({ "type": "array", "items": { "type": "object", "required": ["command"], "properties": { "command": {"type": "string"}, "params": {"type": "object"} } } }); assert_eq!( shape_of(&steps, 4), "array of {command!: string, params: object}" ); // Properties inserted in sorted order: with serde_json's // default BTreeMap the map sorts anyway, and when feature // unification brings `preserve_order` in (an insertion-ordered // map), the summary follows the insertion — so the fixture // must not rely on sorting the input itself. let flat = json!({"type": "object", "properties": {"height": {"type": "integer"}, "width": {"type": "integer"}}}); assert_eq!(shape_of(&flat, 4), "{height: integer, width: integer}"); let enum_ = json!({"type": "string", "enum": ["circle", "rect"]}); assert_eq!(shape_of(&enum_, 4), "string, one of circle|rect"); let union = json!({"anyOf": [{"type": "string"}, {"type": "integer"}]}); assert_eq!(shape_of(&union, 4), "anyOf string | integer"); // Depth-limited: deep schemas end in an ellipsis, not a flood. let mut deep = json!({"type": "string"}); for _ in 0..8 { deep = json!({"type": "object", "properties": {"inner": deep}}); } let summary = shape_of(&deep, 4); assert!(summary.contains("..."), "{summary}"); assert!(summary.ends_with('}'), "{summary}"); assert_eq!(shape_of(&json!({}), 4), "any"); } #[test] fn the_schema_flag_short_circuits_to_discovery() { let tool = ToolInfo { name: "doc_execute".to_owned(), description: "run batch steps".to_owned(), input_schema: json!({ "type": "object", "properties": {"steps": {"type": "array", "items": {"type": "object"}}} }), }; let tree = command("vectorcraft-cli", &[tool]); // `--schema` wins even beside other flags and the override: // it is a question, not a call. for argv in [ vec!["vectorcraft-cli", "doc_execute", "--schema"], vec!["vectorcraft-cli", "doc_execute", "--schema", "--json", "-"], vec!["vectorcraft-cli", "doc_execute", "--json", "-", "--schema"], ] { let matches = tree.clone().try_get_matches_from(argv).unwrap(); let payload = args(&tree, &matches).unwrap(); assert_eq!( payload, Payload::Schema { tool: "doc_execute".to_owned() } ); } // And the lookup returns the same schema the flags were built // from. let catalog = [ToolInfo { name: "doc_execute".to_owned(), description: String::new(), input_schema: json!({"type": "object"}), }]; assert_eq!( schema_of(&catalog, "doc_execute"), Some(&json!({"type": "object"})) ); assert_eq!(schema_of(&[], "doc_execute"), None); } #[test] fn a_property_named_schema_demotes_the_tool() { let tool = ToolInfo { name: "colliding".to_owned(), description: String::new(), input_schema: json!({ "type": "object", "properties": {"schema": {"type": "string"}} }), }; let tree = command("srv", &[tool]); let matches = tree .clone() .try_get_matches_from(vec!["srv", "colliding", "--json", "{}"]) .unwrap(); // The tool survives, reachable through the override only. assert_eq!( args(&tree, &matches).unwrap(), Payload::Overridden("{}".to_owned()) ); let matches = tree .clone() .try_get_matches_from(vec!["srv", "colliding", "--schema"]) .unwrap(); assert_eq!( args(&tree, &matches).unwrap(), Payload::Schema { tool: "colliding".to_owned() } ); assert_eq!( args(&tree, &matches).unwrap(), Payload::Schema { tool: "colliding".to_owned() } ); } #[test] fn cli_safe_is_the_charset_only() { assert!(cli_safe("help")); assert!(cli_safe("create_pull_request")); assert!(!cli_safe("")); assert!(!cli_safe("has space")); assert!(!cli_safe("dot.name")); } }