pub mod args;
pub mod cache;
pub mod flag;
pub mod types;
pub use types::SchemaType;
use std::sync::Arc;
use clap::Command;
use rmcp::model::JsonObject;
use serde_json::Value;
use crate::config::Config;
use crate::selector::FlagMatcher;
pub fn build_input_schema_with_matchers(
cmd: &Command,
cfg: &Config,
cmd_path: &str,
local_flag: Option<&FlagMatcher>,
inherited_flag: Option<&FlagMatcher>,
) -> Arc<JsonObject> {
let mut schema = cache::fresh_tool_input_schema();
if let Some(Value::Object(properties_root)) = schema.get_mut("properties") {
if let Some(Value::Object(flags_obj)) = properties_root.get_mut("flags") {
let (properties, required) =
flag::build_flags_schema(cmd, cfg, cmd_path, local_flag, inherited_flag);
flags_obj.clear();
flags_obj.insert("type".into(), Value::String("object".into()));
flags_obj.insert("properties".into(), Value::Object(properties));
if !required.is_empty() {
flags_obj.insert(
"required".into(),
Value::Array(required.into_iter().map(Value::String).collect()),
);
}
flags_obj.insert("additionalProperties".into(), Value::Bool(false));
}
if let Some(Value::Object(args_obj)) = properties_root.get_mut("args") {
args_obj.insert(
"description".into(),
Value::String(args::args_description(cmd)),
);
}
}
Arc::new(schema)
}
pub fn build_output_schema() -> Arc<JsonObject> {
Arc::clone(&cache::TOOL_OUTPUT_BASE_SCHEMA)
}
pub fn build_description(cmd: &Command) -> String {
let name = cmd.get_name();
let main = cmd
.get_long_about()
.or_else(|| cmd.get_about())
.map_or_else(
|| format!("Execute the {name} command"),
ToString::to_string,
);
let mut out = main;
if let Some(after) = cmd.get_after_help() {
let after_str = after.to_string();
if !after_str.trim().is_empty() {
out.push_str("\n\nExamples:\n");
out.push_str(after_str.trim_end());
}
}
out
}
#[cfg(test)]
mod tests {
use clap::{Arg, Command};
use super::*;
use crate::config::Config;
#[test]
fn input_schema_has_flags_and_args_properties() {
let cmd = Command::new("test").arg(Arg::new("foo").long("foo"));
let cfg = Config::default();
let schema = build_input_schema_with_matchers(&cmd, &cfg, "test", None, None);
let props = schema
.get("properties")
.and_then(|v| v.as_object())
.expect("schema must have properties");
assert!(props.contains_key("flags"), "must have 'flags' property");
assert!(props.contains_key("args"), "must have 'args' property");
}
#[test]
fn input_schema_flags_object_has_per_flag_property() {
let cmd = Command::new("test").arg(Arg::new("foo").long("foo"));
let cfg = Config::default();
let schema = build_input_schema_with_matchers(&cmd, &cfg, "test", None, None);
let flags_props = schema
.get("properties")
.and_then(|v| v.as_object())
.and_then(|p| p.get("flags"))
.and_then(|v| v.as_object())
.and_then(|f| f.get("properties"))
.and_then(|v| v.as_object())
.expect("flags.properties must be an object");
assert!(
flags_props.contains_key("foo"),
"flags.properties must contain 'foo'"
);
}
#[test]
fn input_schema_flags_object_is_additional_properties_false() {
let cmd = Command::new("test").arg(Arg::new("foo").long("foo"));
let cfg = Config::default();
let schema = build_input_schema_with_matchers(&cmd, &cfg, "test", None, None);
let flags_obj = schema
.get("properties")
.and_then(|v| v.as_object())
.and_then(|p| p.get("flags"))
.and_then(|v| v.as_object())
.expect("flags must be an object");
assert_eq!(
flags_obj.get("additionalProperties"),
Some(&Value::Bool(false)),
"flags.additionalProperties must be false"
);
}
#[test]
fn output_schema_matches_cache() {
let s1 = build_output_schema();
let props = s1
.get("properties")
.and_then(|v| v.as_object())
.expect("output schema must have properties");
assert!(props.contains_key("stdout"), "must have 'stdout'");
assert!(props.contains_key("stderr"), "must have 'stderr'");
assert!(props.contains_key("exit_code"), "must have 'exit_code'");
let s2 = build_output_schema();
assert!(
Arc::ptr_eq(&s1, &s2),
"build_output_schema must Arc::clone the cached static, not allocate fresh"
);
}
#[test]
fn args_description_is_spliced_into_args_property() {
let cmd = Command::new("test").arg(Arg::new("file").required(true));
let cfg = Config::default();
let schema = build_input_schema_with_matchers(&cmd, &cfg, "test", None, None);
let args_desc = schema
.get("properties")
.and_then(|v| v.as_object())
.and_then(|p| p.get("args"))
.and_then(|v| v.as_object())
.and_then(|a| a.get("description"))
.and_then(|v| v.as_str())
.expect("args.description must be a string");
assert!(
args_desc.contains("Positional command line arguments"),
"args.description must contain the canonical phrase"
);
}
#[test]
fn description_uses_long_about_when_present() {
let cmd = Command::new("test").long_about("Long form description");
let desc = build_description(&cmd);
assert!(
desc.starts_with("Long form description"),
"must use long_about: {desc:?}"
);
}
#[test]
fn description_falls_back_to_about_then_default() {
let cmd_long = Command::new("test")
.about("Short description")
.long_about("Long description");
assert!(
build_description(&cmd_long).starts_with("Long description"),
"long_about takes precedence"
);
let cmd_short = Command::new("test").about("Short only");
assert_eq!(build_description(&cmd_short), "Short only");
let cmd_neither = Command::new("test");
assert_eq!(build_description(&cmd_neither), "Execute the test command");
}
#[test]
fn description_appends_examples_block() {
let cmd = Command::new("test")
.about("Does a thing")
.after_help("example one\nexample two");
let desc = build_description(&cmd);
assert!(
desc.contains("\n\nExamples:\nexample one\nexample two"),
"expected examples block, got: {desc:?}"
);
}
#[test]
fn description_no_examples_block_when_after_help_empty() {
let cmd_empty = Command::new("test").about("Does a thing").after_help("");
assert!(
!build_description(&cmd_empty).contains("Examples:"),
"empty after_help must not produce an Examples block"
);
let cmd_none = Command::new("test").about("Does a thing");
assert!(
!build_description(&cmd_none).contains("Examples:"),
"absent after_help must not produce an Examples block"
);
}
#[test]
fn description_examples_block_no_trailing_blank_line() {
let cmd = Command::new("test")
.about("Does a thing")
.after_help("my example\n\n");
let desc = build_description(&cmd);
assert!(
!desc.ends_with('\n'),
"description must not end with a trailing newline: {desc:?}"
);
}
}