use serde_json::{json, Value as JsonValue};
use crate::models::{ScannedArg, ScannedCommand, ScannedFlag, ValueType};
fn value_type_to_json_schema(vt: ValueType) -> &'static str {
match vt {
ValueType::String => "string",
ValueType::Integer => "integer",
ValueType::Float => "number",
ValueType::Boolean => "boolean",
ValueType::Path => "string",
ValueType::Enum => "string",
ValueType::Url => "string",
ValueType::Unknown => "string",
}
}
fn apply_default(schema: &mut JsonValue, flag: &ScannedFlag) {
if let Some(ref default) = flag.default {
match flag.value_type {
ValueType::Integer => {
if let Ok(n) = default.parse::<i64>() {
schema["default"] = json!(n);
} else {
schema["default"] = json!(default);
}
}
ValueType::Float => {
if let Ok(n) = default.parse::<f64>() {
schema["default"] = json!(n);
} else {
schema["default"] = json!(default);
}
}
ValueType::Boolean => {
schema["default"] = json!(default.parse::<bool>().unwrap_or(false));
}
_ => {
schema["default"] = json!(default);
}
}
} else if flag.value_type == ValueType::Boolean {
schema["default"] = json!(false);
}
}
fn apply_long_running(schema: &mut JsonValue, flag: &ScannedFlag) {
if flag.long_running {
schema["x-apexe-long-running"] = json!(true);
}
}
const CREDENTIAL_NAME_WORDS: &[&str] = &[
"auth",
"cert",
"certificate",
"cred",
"creds",
"header",
"identity",
"jwt",
"key",
"keyfile",
"login",
"pass",
];
const CREDENTIAL_NAME_FRAGMENTS: &[&str] = &[
"apikey",
"bearer",
"cookie",
"credential",
"netrc",
"oauth",
"passphrase",
"passwd",
"password",
"secret",
"sigv4",
"token",
"user",
];
const CREDENTIAL_HELP_PHRASES: &[&str] = &[
"access key",
"access token",
"api key",
"api token",
"auth token",
"authentication token",
"bearer token",
"credential",
"identity file",
"netrc",
"pass phrase",
"passphrase",
"passwd",
"password",
"private key",
"secret",
"session token",
"ssh key",
"user name",
"username",
];
fn name_identifies_credential(name: &str) -> bool {
let name = name.trim_start_matches('-').to_lowercase();
name.split(['-', '_', '.'])
.any(|word| CREDENTIAL_NAME_WORDS.contains(&word))
|| CREDENTIAL_NAME_FRAGMENTS
.iter()
.any(|fragment| name.contains(fragment))
}
fn help_identifies_credential(description: &str) -> bool {
let description = description.to_lowercase();
CREDENTIAL_HELP_PHRASES
.iter()
.any(|phrase| description.contains(phrase))
}
fn is_credential_bearing(names: &[&str], description: &str) -> bool {
names.iter().any(|name| name_identifies_credential(name))
|| help_identifies_credential(description)
}
fn apply_sensitive_flag(schema: &mut JsonValue, flag: &ScannedFlag) {
if flag.value_type == ValueType::Boolean {
return;
}
let names: Vec<&str> = [flag.long_name.as_deref(), flag.short_name.as_deref()]
.into_iter()
.flatten()
.collect();
if is_credential_bearing(&names, &flag.description) {
schema["x-sensitive"] = json!(true);
}
}
fn apply_sensitive_arg(schema: &mut JsonValue, arg: &ScannedArg) {
if is_credential_bearing(&[&arg.name], &arg.description) {
schema["x-sensitive"] = json!(true);
}
}
fn apply_flag_literal(schema: &mut JsonValue, flag: &ScannedFlag) {
if let Some(literal) = flag.long_name.as_deref().or(flag.short_name.as_deref()) {
schema["x-apexe-flag"] = json!(literal);
}
}
fn format_hint(value_type: ValueType) -> Option<&'static str> {
match value_type {
ValueType::Url => Some("uri"),
_ => None,
}
}
fn is_path_valued(value_type: ValueType) -> bool {
value_type == ValueType::Path
}
fn apply_path_marker(schema: &mut JsonValue, value_type: ValueType) {
if is_path_valued(value_type) {
schema["x-apexe-path"] = json!(true);
}
}
fn apply_items_type_hints(schema: &mut JsonValue, value_type: ValueType) {
if let Some(format) = format_hint(value_type) {
schema["items"]["format"] = json!(format);
}
apply_path_marker(&mut schema["items"], value_type);
}
fn apply_scalar_type_hints(schema: &mut JsonValue, value_type: ValueType) {
if let Some(format) = format_hint(value_type) {
schema["format"] = json!(format);
}
apply_path_marker(schema, value_type);
}
fn repeatable_flag_schema(flag: &ScannedFlag) -> JsonValue {
let mut schema = json!({
"type": "array",
"items": { "type": value_type_to_json_schema(flag.value_type) },
});
apply_items_type_hints(&mut schema, flag.value_type);
if !flag.description.is_empty() {
schema["description"] = json!(flag.description);
}
apply_long_running(&mut schema, flag);
apply_flag_literal(&mut schema, flag);
apply_sensitive_flag(&mut schema, flag);
apply_flag_placement(&mut schema, flag);
schema
}
fn scalar_flag_schema(flag: &ScannedFlag) -> JsonValue {
let base_type = value_type_to_json_schema(flag.value_type);
let mut schema = if flag.value_optional {
json!({ "type": [base_type, "boolean"], "x-apexe-value-optional": true })
} else {
json!({ "type": base_type })
};
apply_scalar_type_hints(&mut schema, flag.value_type);
if !flag.description.is_empty() {
schema["description"] = json!(flag.description);
}
apply_default(&mut schema, flag);
if let Some(ref enum_values) = flag.enum_values {
schema["enum"] = json!(enum_values);
}
apply_long_running(&mut schema, flag);
apply_flag_literal(&mut schema, flag);
apply_flag_placement(&mut schema, flag);
apply_sensitive_flag(&mut schema, flag);
schema
}
fn flag_to_schema(flag: &ScannedFlag) -> JsonValue {
if flag.repeatable {
repeatable_flag_schema(flag)
} else {
scalar_flag_schema(flag)
}
}
fn apply_flag_placement(schema: &mut JsonValue, flag: &ScannedFlag) {
if flag.before_operands {
schema["x-apexe-flag-position"] = json!("before-operands");
}
}
fn arg_to_schema(arg: &ScannedArg, index: usize) -> JsonValue {
let base_type = value_type_to_json_schema(arg.value_type);
let mut schema = if arg.variadic {
let mut schema = json!({
"type": "array",
"items": { "type": base_type },
});
apply_items_type_hints(&mut schema, arg.value_type);
schema
} else {
let mut schema = json!({ "type": base_type });
apply_scalar_type_hints(&mut schema, arg.value_type);
schema
};
if !arg.description.is_empty() {
schema["description"] = json!(arg.description);
}
schema["x-apexe-positional"] = json!(index);
if arg.before_flags {
schema["x-apexe-operand-position"] = json!("before-flags");
}
apply_sensitive_arg(&mut schema, arg);
schema
}
fn property_names_by_literal(
command: &ScannedCommand,
global_flags: &[ScannedFlag],
) -> std::collections::HashMap<String, String> {
let mut by_literal = std::collections::HashMap::new();
for flag in command.flags.iter().chain(global_flags) {
let prop_name = flag.canonical_name();
for literal in [flag.short_name.as_deref(), flag.long_name.as_deref()]
.into_iter()
.flatten()
{
by_literal
.entry(literal.to_string())
.or_insert_with(|| prop_name.clone());
}
}
by_literal
}
fn apply_conflicts(
schema: &mut JsonValue,
flag: &ScannedFlag,
by_literal: &std::collections::HashMap<String, String>,
) {
let conflicts: Vec<String> = flag
.conflicts_with
.iter()
.filter_map(|literal| by_literal.get(literal).cloned())
.collect();
if !conflicts.is_empty() {
schema["x-apexe-conflicts-with"] = json!(conflicts);
}
}
fn free_rescue_key(
prop_name: &str,
properties: &serde_json::Map<String, JsonValue>,
) -> Option<String> {
let preferred = format!("{prop_name}_option");
if !properties.contains_key(&preferred) {
return Some(preferred);
}
(2..=9)
.map(|n| format!("{prop_name}_option_{n}"))
.find(|candidate| !properties.contains_key(candidate))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CommandPosition {
Root,
Subcommand,
}
fn apply_global_flag_placement(schema: &mut JsonValue, position: CommandPosition) {
if position == CommandPosition::Subcommand && schema.get("x-apexe-flag-position").is_none() {
schema["x-apexe-flag-position"] = json!("before-subcommand");
}
}
#[derive(Default)]
struct InputProperties {
properties: serde_json::Map<String, JsonValue>,
required: Vec<String>,
}
impl InputProperties {
fn insert(&mut self, name: String, schema: JsonValue, required: bool) {
if required && !self.required.contains(&name) {
self.required.push(name.clone());
}
self.properties.insert(name, schema);
}
fn rekey_displaced_flag(&mut self, prop_name: &str, displaced: JsonValue) {
let Some(rescued) = free_rescue_key(prop_name, &self.properties) else {
return;
};
self.properties.insert(rescued.clone(), displaced);
if let Some(slot) = self.required.iter_mut().find(|name| *name == prop_name) {
slot.clone_from(&rescued);
}
}
fn into_schema(self, end_of_options: bool) -> JsonValue {
let mut schema = json!({
"type": "object",
"properties": self.properties,
"additionalProperties": false,
});
if !self.required.is_empty() {
schema["required"] = json!(self.required);
}
if end_of_options {
schema["x-apexe-end-of-options"] = json!(true);
}
schema
}
}
fn add_command_flags(
acc: &mut InputProperties,
command: &ScannedCommand,
by_literal: &std::collections::HashMap<String, String>,
) {
for flag in &command.flags {
let mut prop_schema = flag_to_schema(flag);
apply_conflicts(&mut prop_schema, flag, by_literal);
acc.insert(flag.canonical_name(), prop_schema, flag.required);
}
}
fn add_global_flags(
acc: &mut InputProperties,
global_flags: &[ScannedFlag],
by_literal: &std::collections::HashMap<String, String>,
position: CommandPosition,
) {
for flag in global_flags {
let prop_name = flag.canonical_name();
if acc.properties.contains_key(&prop_name) {
continue;
}
let mut prop_schema = flag_to_schema(flag);
apply_conflicts(&mut prop_schema, flag, by_literal);
apply_global_flag_placement(&mut prop_schema, position);
acc.insert(prop_name, prop_schema, flag.required);
}
}
fn inherit_from_displaced(prop_schema: &mut JsonValue, displaced: &JsonValue) {
if prop_schema.get("description").is_none() {
if let Some(description) = displaced.get("description") {
prop_schema["description"] = description.clone();
}
}
if displaced.get("x-sensitive").is_some() {
prop_schema["x-sensitive"] = json!(true);
}
}
fn add_positional_args(acc: &mut InputProperties, command: &ScannedCommand) {
for (index, arg) in command.positional_args.iter().enumerate() {
let prop_name = arg.name.to_lowercase().replace('-', "_");
let mut prop_schema = arg_to_schema(arg, index);
if let Some(displaced) = acc.properties.get(&prop_name).cloned() {
inherit_from_displaced(&mut prop_schema, &displaced);
if displaced.get("x-apexe-flag").is_some() {
acc.rekey_displaced_flag(&prop_name, displaced);
}
}
acc.insert(prop_name, prop_schema, arg.required);
}
}
pub fn build_input_schema(
command: &ScannedCommand,
global_flags: &[ScannedFlag],
position: CommandPosition,
) -> JsonValue {
let by_literal = property_names_by_literal(command, global_flags);
let mut acc = InputProperties::default();
add_command_flags(&mut acc, command, &by_literal);
add_global_flags(&mut acc, global_flags, &by_literal, position);
add_positional_args(&mut acc, command);
acc.into_schema(command.end_of_options)
}
pub fn build_output_schema(command: &ScannedCommand) -> JsonValue {
let mut schema = json!({
"type": "object",
"properties": {
"stdout": {
"type": "string",
"description": "Standard output from the command",
},
"stderr": {
"type": "string",
"description": "Standard error output from the command",
},
"exit_code": {
"type": "integer",
"description": "Process exit code (0 = success)",
},
},
"required": ["stdout", "stderr", "exit_code"],
});
let is_json = command.structured_output.supported
&& command
.structured_output
.format
.as_deref()
.is_some_and(|f| f.eq_ignore_ascii_case("json"));
if is_json {
schema["properties"]["json_output"] = json!({
"type": "object",
"description": "Parsed JSON output (when structured output is available)",
});
}
schema
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{HelpFormat, StructuredOutputInfo};
fn make_flag(
long_name: Option<&str>,
description: &str,
value_type: ValueType,
required: bool,
default: Option<&str>,
enum_values: Option<Vec<String>>,
repeatable: bool,
) -> ScannedFlag {
ScannedFlag {
long_name: long_name.map(|s| s.to_string()),
short_name: None,
description: description.to_string(),
value_type,
required,
default: default.map(|s| s.to_string()),
enum_values,
repeatable,
value_name: None,
..Default::default()
}
}
fn make_command(flags: Vec<ScannedFlag>, args: Vec<ScannedArg>) -> ScannedCommand {
ScannedCommand {
name: "test".to_string(),
full_command: "tool test".to_string(),
description: "A test command".to_string(),
flags,
positional_args: args,
subcommands: vec![],
examples: vec![],
help_format: HelpFormat::Gnu,
structured_output: StructuredOutputInfo::default(),
end_of_options: false,
raw_help: String::new(),
}
}
#[test]
fn test_build_input_schema_emits_the_end_of_options_marker() {
let mut command = make_command(vec![], vec![]);
command.end_of_options = true;
let schema = build_input_schema(&command, &[], CommandPosition::Root);
assert_eq!(schema["x-apexe-end-of-options"], true);
}
#[test]
fn test_build_input_schema_omits_the_end_of_options_marker_by_default() {
let schema = build_input_schema(&make_command(vec![], vec![]), &[], CommandPosition::Root);
assert!(
schema.get("x-apexe-end-of-options").is_none(),
"the marker must be absent unless an overlay asserted it"
);
}
#[test]
fn test_schema_string_flag() {
let flag = make_flag(
Some("--output"),
"Output file",
ValueType::String,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["output"]["type"], "string");
assert_eq!(schema["properties"]["output"]["description"], "Output file");
}
#[test]
fn test_schema_boolean_flag() {
let flag = make_flag(
Some("--verbose"),
"Enable verbose",
ValueType::Boolean,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["verbose"]["type"], "boolean");
assert_eq!(schema["properties"]["verbose"]["default"], false);
}
#[test]
fn test_schema_enum_flag() {
let flag = make_flag(
Some("--format"),
"Output format",
ValueType::Enum,
false,
None,
Some(vec!["json".to_string(), "text".to_string()]),
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["format"]["type"], "string");
let enum_vals = schema["properties"]["format"]["enum"].as_array().unwrap();
assert_eq!(enum_vals, &[json!("json"), json!("text")]);
}
#[test]
fn test_schema_required_flag() {
let flag = make_flag(
Some("--name"),
"The name",
ValueType::String,
true,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("name")));
}
#[test]
fn test_schema_repeatable_flag() {
let flag = make_flag(
Some("--include"),
"Include pattern",
ValueType::String,
false,
None,
None,
true,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["include"]["type"], "array");
assert_eq!(schema["properties"]["include"]["items"]["type"], "string");
}
#[test]
fn test_schema_long_running_flag_is_annotated() {
let mut flag = make_flag(
Some("--follow"),
"Output appended data as the file grows.",
ValueType::Boolean,
false,
None,
None,
false,
);
flag.long_running = true;
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["follow"]["x-apexe-long-running"], true);
}
#[test]
fn test_schema_long_running_survives_repeatable_branch() {
let mut flag = make_flag(
Some("--watch"),
"Keep watching.",
ValueType::String,
false,
None,
None,
true,
);
flag.long_running = true;
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["watch"]["type"], "array");
assert_eq!(schema["properties"]["watch"]["x-apexe-long-running"], true);
}
#[test]
fn test_schema_omits_long_running_when_unset() {
let flag = make_flag(
Some("--lines"),
"Number of lines.",
ValueType::Integer,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert!(schema["properties"]["lines"]["x-apexe-long-running"].is_null());
}
#[test]
fn test_is_credential_bearing_matches_name_words() {
for name in [
"--key",
"--key-type",
"--cert",
"--header",
"--proxy-header",
"--login-path",
"--client-cert",
"--identity-file",
"--pass",
] {
assert!(
is_credential_bearing(&[name], ""),
"{name} names a credential"
);
}
}
#[test]
fn test_is_credential_bearing_matches_name_fragments() {
for name in [
"--user",
"--proxy-user",
"--oauth2-bearer",
"--tlspassword",
"--tlsuser",
"--netrc-file",
"--aws-sigv4",
"--cookiejar",
"--apikey",
"--service-account-token",
] {
assert!(
is_credential_bearing(&[name], ""),
"{name} names a credential"
);
}
}
#[test]
fn test_is_credential_bearing_matches_help_phrases() {
assert!(is_credential_bearing(
&["-i"],
"Selects a file from which the identity (private key) for public key authentication is read."
));
assert!(is_credential_bearing(
&["-p"],
"The password to use when connecting to the server."
));
assert!(is_credential_bearing(
&["-t"],
"Send this bearer token with every request."
));
}
#[test]
fn test_is_credential_bearing_rejects_ordinary_options() {
let ordinary: &[(&str, &str)] = &[
("--max-time", "Maximum time allowed for the transfer."),
("--output", "Write to file instead of stdout."),
("--silent", "Silent mode."),
("--author", "With -l, print the author of each file."),
("--keyword", "Search the manual page names for the keyword."),
("--bypass", "Skip the intermediate stage."),
("--verbose", "Make the operation more talkative."),
("--retry", "Retry request if transient problems occur."),
(
"-exec",
"The expression is composed of tokens separated by whitespace.",
),
];
for (name, description) in ordinary {
assert!(
!is_credential_bearing(&[name], description),
"{name} is not a credential"
);
}
}
#[test]
fn test_schema_credential_flag_is_marked_sensitive() {
let flag = make_flag(
Some("--user"),
"<user:password> Server user and password",
ValueType::String,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["user"]["x-sensitive"], true);
}
#[test]
fn test_schema_repeatable_header_flag_is_marked_sensitive() {
let flag = make_flag(
Some("--header"),
"<header/@file> Pass custom header(s) to server",
ValueType::String,
false,
None,
None,
true,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let property = &schema["properties"]["header"];
assert_eq!(property["type"], "array");
assert_eq!(property["x-sensitive"], true);
}
#[test]
fn test_schema_short_only_flag_is_marked_from_its_help_text() {
let flag = ScannedFlag {
short_name: Some("-i".to_string()),
description: "Selects a file from which the identity (private key) is read."
.to_string(),
value_type: ValueType::Path,
..Default::default()
};
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["i"]["x-sensitive"], true);
}
#[test]
fn test_schema_omits_sensitive_for_ordinary_flags() {
for (name, description) in [
("--max-time", "Maximum time allowed for the transfer."),
("--output", "Write to file instead of stdout."),
("--silent", "Silent mode."),
] {
let flag = make_flag(
Some(name),
description,
ValueType::String,
false,
None,
None,
false,
);
let key = flag.canonical_name();
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert!(
schema["properties"][&key]["x-sensitive"].is_null(),
"{name} must not be marked sensitive"
);
}
}
#[test]
fn test_schema_omits_sensitive_for_boolean_auth_selectors() {
let flag = make_flag(
Some("--netrc"),
"Must read .netrc for user name and password",
ValueType::Boolean,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["netrc"]["type"], "boolean");
assert!(schema["properties"]["netrc"]["x-sensitive"].is_null());
}
#[test]
fn test_schema_credential_operand_is_marked_sensitive() {
let arg = ScannedArg {
name: "password".to_string(),
description: String::new(),
value_type: ValueType::String,
required: true,
variadic: false,
before_flags: false,
};
let cmd = make_command(vec![], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["password"]["x-sensitive"], true);
}
#[test]
fn test_schema_sensitive_survives_an_operand_displacing_the_flag() {
let flag = make_flag(
Some("--secret"),
"The shared secret to authenticate with.",
ValueType::String,
false,
None,
None,
false,
);
let arg = ScannedArg {
name: "secret".to_string(),
description: String::new(),
value_type: ValueType::String,
required: true,
variadic: false,
before_flags: false,
};
let cmd = make_command(vec![flag], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["secret"]["x-apexe-positional"], 0);
assert_eq!(schema["properties"]["secret"]["x-sensitive"], true);
assert_eq!(schema["properties"]["secret_option"]["x-sensitive"], true);
}
#[test]
fn test_schema_sensitive_is_inherited_when_only_the_flags_help_says_so() {
let flag = make_flag(
Some("--url"),
"URL to work with. May embed a password as user:pass@host.",
ValueType::String,
false,
None,
None,
false,
);
let arg = ScannedArg {
name: "url".to_string(),
description: String::new(),
value_type: ValueType::String,
required: true,
variadic: false,
before_flags: false,
};
let cmd = make_command(vec![flag], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(
schema["properties"]["url"]["x-sensitive"], true,
"the operand that took the flag's key must keep its credential \
judgement: {schema}"
);
}
#[test]
fn test_a_displaced_flag_falls_back_to_a_numbered_rescue_key() {
let taken = make_flag(
Some("--path-option"),
"Strategy option.",
ValueType::String,
false,
None,
None,
false,
);
let displaced = make_flag(
Some("--path"),
"Glob predicate, unrelated to the walk roots.",
ValueType::String,
true,
None,
None,
false,
);
let arg = ScannedArg {
name: "path".to_string(),
description: String::new(),
value_type: ValueType::String,
required: true,
variadic: false,
before_flags: false,
};
let cmd = make_command(vec![taken, displaced], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(
schema["properties"]["path"]["x-apexe-positional"], 0,
"the operand takes the plain key"
);
assert_eq!(
schema["properties"]["path_option_2"]["x-apexe-flag"], "--path",
"`path_option` was already taken, so the flag lands on the numbered \
fallback: {schema}"
);
let required = schema["required"].as_array().expect("required list");
assert!(
required.iter().any(|v| v == "path_option_2"),
"`required` must follow the flag to its new home: {schema}"
);
}
#[test]
fn test_schema_positional_arg() {
let arg = ScannedArg {
name: "file".to_string(),
description: "Input file".to_string(),
value_type: ValueType::Path,
required: true,
variadic: false,
before_flags: false,
};
let cmd = make_command(vec![], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["file"]["type"], "string");
let required = schema["required"].as_array().unwrap();
assert!(required.contains(&json!("file")));
}
#[test]
fn test_schema_variadic_operand_carries_the_path_marker_on_items() {
let arg = ScannedArg {
name: "file".to_string(),
description: "Files to read.".to_string(),
value_type: ValueType::Path,
required: false,
variadic: true,
before_flags: false,
};
let cmd = make_command(vec![], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let property = &schema["properties"]["file"];
assert_eq!(property["type"], "array");
assert_eq!(property["items"]["x-apexe-path"], true);
assert!(
property["x-apexe-path"].is_null(),
"the array itself is not a path: {property}"
);
}
#[test]
fn test_schema_repeatable_path_flag_carries_the_path_marker_on_items() {
let flag = ScannedFlag {
long_name: Some("--include".to_string()),
description: "Include a path.".to_string(),
value_type: ValueType::Path,
repeatable: true,
..Default::default()
};
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let property = &schema["properties"]["include"];
assert_eq!(property["type"], "array");
assert_eq!(property["items"]["x-apexe-path"], true);
}
#[test]
fn test_schema_never_emits_the_unregistered_path_format() {
let flag = ScannedFlag {
long_name: Some("--config".to_string()),
value_type: ValueType::Path,
..Default::default()
};
let repeatable = ScannedFlag {
long_name: Some("--include".to_string()),
value_type: ValueType::Path,
repeatable: true,
..Default::default()
};
let scalar_operand = ScannedArg {
name: "target".to_string(),
description: String::new(),
value_type: ValueType::Path,
required: true,
variadic: false,
before_flags: false,
};
let variadic_operand = ScannedArg {
name: "source".to_string(),
description: String::new(),
value_type: ValueType::Path,
required: true,
variadic: true,
before_flags: false,
};
let cmd = make_command(
vec![flag, repeatable],
vec![scalar_operand, variadic_operand],
);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let rendered = serde_json::to_string(&schema).expect("the schema serializes");
assert!(
!rendered.contains(r#""format":"path""#),
"no `format: \"path\"` may survive anywhere in the contract: {rendered}"
);
let properties = &schema["properties"];
assert_eq!(properties["config"]["x-apexe-path"], true);
assert_eq!(properties["target"]["x-apexe-path"], true);
assert_eq!(properties["include"]["items"]["x-apexe-path"], true);
assert_eq!(properties["source"]["items"]["x-apexe-path"], true);
}
#[test]
fn test_schema_url_operand_uses_the_uri_format() {
let scalar = ScannedArg {
name: "url".to_string(),
description: String::new(),
value_type: ValueType::Url,
required: true,
variadic: false,
before_flags: false,
};
let variadic = ScannedArg {
name: "mirror".to_string(),
description: String::new(),
value_type: ValueType::Url,
required: false,
variadic: true,
before_flags: false,
};
let cmd = make_command(vec![], vec![scalar, variadic]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["url"]["format"], "uri");
assert_eq!(schema["properties"]["mirror"]["items"]["format"], "uri");
}
#[test]
fn test_schema_non_path_types_carry_no_format_hint() {
let arg = ScannedArg {
name: "pattern".to_string(),
description: String::new(),
value_type: ValueType::String,
required: true,
variadic: true,
before_flags: false,
};
let cmd = make_command(vec![], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let property = &schema["properties"]["pattern"];
assert!(property["items"]["format"].is_null(), "{property}");
assert!(property["format"].is_null(), "{property}");
assert!(property["items"]["x-apexe-path"].is_null(), "{property}");
assert!(property["x-apexe-path"].is_null(), "{property}");
}
#[test]
fn test_schema_rescues_a_flag_displaced_by_a_same_named_operand() {
let flag = ScannedFlag {
short_name: Some("-path".to_string()),
description: "True if the pathname matches pattern.".to_string(),
value_type: ValueType::String,
required: true,
..Default::default()
};
let arg = ScannedArg {
name: "path".to_string(),
description: "Roots of the walk.".to_string(),
value_type: ValueType::Path,
required: true,
variadic: true,
before_flags: true,
};
let cmd = make_command(vec![flag], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
let properties = &schema["properties"];
assert_eq!(properties["path"]["x-apexe-positional"], 0);
assert_eq!(properties["path_option"]["x-apexe-flag"], "-path");
assert_eq!(
properties["path_option"]["description"],
"True if the pathname matches pattern."
);
let required: Vec<&str> = schema["required"]
.as_array()
.unwrap()
.iter()
.filter_map(|v| v.as_str())
.collect();
assert!(required.contains(&"path_option"), "{required:?}");
assert_eq!(
required.iter().filter(|n| **n == "path").count(),
1,
"the operand's own requirement is recorded once: {required:?}"
);
}
#[test]
fn test_schema_variadic_arg() {
let arg = ScannedArg {
name: "files".to_string(),
description: "Input files".to_string(),
value_type: ValueType::String,
required: false,
variadic: true,
before_flags: false,
};
let cmd = make_command(vec![], vec![arg]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["files"]["type"], "array");
assert_eq!(schema["properties"]["files"]["items"]["type"], "string");
}
#[test]
fn test_schema_global_flags_included() {
let cmd_flag = make_flag(
Some("--local"),
"Local flag",
ValueType::Boolean,
false,
None,
None,
false,
);
let global_flag = make_flag(
Some("--verbose"),
"Global verbose",
ValueType::Boolean,
false,
None,
None,
false,
);
let global_collision = make_flag(
Some("--local"),
"Global local",
ValueType::String,
false,
None,
None,
false,
);
let cmd = make_command(vec![cmd_flag], vec![]);
let schema = build_input_schema(
&cmd,
&[global_flag, global_collision],
CommandPosition::Root,
);
assert_eq!(schema["properties"]["verbose"]["type"], "boolean");
assert_eq!(schema["properties"]["local"]["type"], "boolean");
}
fn git_global_flags() -> Vec<ScannedFlag> {
vec![
ScannedFlag {
short_name: Some("-C".to_string()),
description: "Run as if git was started in <path>.".to_string(),
value_type: ValueType::Path,
..Default::default()
},
ScannedFlag {
long_name: Some("--paginate".to_string()),
description: "Pipe all output into less.".to_string(),
value_type: ValueType::Boolean,
..Default::default()
},
]
}
#[test]
fn test_schema_marks_a_subcommands_global_flags_before_the_subcommand() {
let own = make_flag(
Some("--oneline"),
"Compact output.",
ValueType::Boolean,
false,
None,
None,
false,
);
let cmd = make_command(vec![own], vec![]);
let schema = build_input_schema(&cmd, &git_global_flags(), CommandPosition::Subcommand);
let properties = &schema["properties"];
assert_eq!(
properties["C"]["x-apexe-flag-position"],
"before-subcommand"
);
assert_eq!(
properties["paginate"]["x-apexe-flag-position"],
"before-subcommand"
);
assert!(
properties["oneline"]["x-apexe-flag-position"].is_null(),
"a subcommand's own flag belongs where it always did: {properties}"
);
}
#[test]
fn test_schema_leaves_a_root_commands_global_flags_in_place() {
let cmd = make_command(vec![], vec![]);
let schema = build_input_schema(&cmd, &git_global_flags(), CommandPosition::Root);
let properties = &schema["properties"];
assert!(properties["C"]["x-apexe-flag-position"].is_null());
assert!(properties["paginate"]["x-apexe-flag-position"].is_null());
}
#[test]
fn test_schema_keeps_a_curated_flag_placement_over_the_derived_one() {
let mut global = ScannedFlag {
short_name: Some("-f".to_string()),
description: "Name a path that would otherwise parse as an option.".to_string(),
value_type: ValueType::Path,
..Default::default()
};
global.before_operands = true;
let cmd = make_command(vec![], vec![]);
let schema = build_input_schema(&cmd, &[global], CommandPosition::Subcommand);
assert_eq!(
schema["properties"]["f"]["x-apexe-flag-position"],
"before-operands"
);
}
#[test]
fn test_schema_omits_the_placement_for_a_colliding_global_flag() {
let own = make_flag(
Some("--verbose"),
"Subcommand verbosity.",
ValueType::Boolean,
false,
None,
None,
false,
);
let global = make_flag(
Some("--verbose"),
"Tool verbosity.",
ValueType::Boolean,
false,
None,
None,
false,
);
let cmd = make_command(vec![own], vec![]);
let schema = build_input_schema(&cmd, &[global], CommandPosition::Subcommand);
assert_eq!(
schema["properties"]["verbose"]["description"],
"Subcommand verbosity."
);
assert!(schema["properties"]["verbose"]["x-apexe-flag-position"].is_null());
}
#[test]
fn test_schema_output_json() {
let mut cmd = make_command(vec![], vec![]);
cmd.structured_output = StructuredOutputInfo {
supported: true,
flag: Some("--json".to_string()),
format: Some("json".to_string()),
};
let schema = build_output_schema(&cmd);
assert_eq!(schema["properties"]["json_output"]["type"], "object");
assert_eq!(schema["properties"]["stdout"]["type"], "string");
}
#[test]
fn test_schema_output_structured_non_json_has_no_json_output() {
let mut cmd = make_command(vec![], vec![]);
cmd.structured_output = StructuredOutputInfo {
supported: true,
flag: Some("--format".to_string()),
format: Some("csv".to_string()),
};
let schema = build_output_schema(&cmd);
assert!(schema["properties"]["json_output"].is_null());
assert_eq!(schema["properties"]["stdout"]["type"], "string");
}
#[test]
fn test_schema_output_raw() {
let cmd = make_command(vec![], vec![]);
let schema = build_output_schema(&cmd);
assert_eq!(schema["properties"]["stdout"]["type"], "string");
assert_eq!(schema["properties"]["stderr"]["type"], "string");
assert_eq!(schema["properties"]["exit_code"]["type"], "integer");
assert!(schema["properties"]["json_output"].is_null());
}
#[test]
fn test_schema_path_flag_carries_the_path_marker() {
let flag = make_flag(
Some("--config"),
"Config file",
ValueType::Path,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["config"]["type"], "string");
assert_eq!(schema["properties"]["config"]["x-apexe-path"], true);
assert!(schema["properties"]["config"]["format"].is_null());
}
#[test]
fn test_schema_url_flag_has_format() {
let flag = make_flag(
Some("--url"),
"Remote URL",
ValueType::Url,
false,
None,
None,
false,
);
let cmd = make_command(vec![flag], vec![]);
let schema = build_input_schema(&cmd, &[], CommandPosition::Root);
assert_eq!(schema["properties"]["url"]["type"], "string");
assert_eq!(schema["properties"]["url"]["format"], "uri");
}
}