use serde_json::{Map, Number, Value};
use crate::compression::engine::Tool;
use crate::Error;
pub fn parse_argv(argv: &[String], tool: &Tool) -> Result<serde_json::Value, Error> {
if argv.first().is_some_and(|arg| arg == "--json") {
let json = argv
.get(1)
.ok_or_else(|| Error::Parse("--json requires a value".to_string()))?;
if argv.len() > 2 {
return Err(Error::Parse(
"--json cannot be combined with other arguments".to_string(),
));
}
return Ok(serde_json::from_str(json)?);
}
let properties = schema_properties(tool);
let required = required_properties(tool);
let mut output = Map::new();
let mut index = 0;
while index < argv.len() {
let arg = &argv[index];
if !arg.starts_with("--") || arg == "--" {
return Err(Error::Parse(format!(
"unexpected positional argument: {arg}"
)));
}
let (property_name, forced_bool) = parse_flag_name(arg);
let schema = properties
.get(&property_name)
.ok_or_else(|| Error::Parse(format!("unknown flag: {arg}")))?;
let schema_type = schema_type(schema);
let (raw_value, consumed) = if forced_bool == Some(false) {
if schema_type != Some("boolean") {
return Err(Error::Parse(format!(
"{arg} can only be used with boolean properties"
)));
}
(None, 1)
} else if schema_type == Some("boolean") {
match argv.get(index + 1) {
Some(next) if !next.starts_with("--") => (Some(next.as_str()), 2),
_ => (None, 1),
}
} else {
let value = argv
.get(index + 1)
.filter(|next| !next.starts_with("--"))
.ok_or_else(|| Error::Parse(format!("{arg} requires a value")))?;
(Some(value.as_str()), 2)
};
let value = coerce_value(&property_name, schema, raw_value, forced_bool)?;
insert_value(&mut output, &property_name, schema, value);
index += consumed;
}
for property in required {
if !output.contains_key(&property) {
return Err(Error::Validation(format!(
"missing required argument: {property}"
)));
}
}
Ok(Value::Object(output))
}
fn schema_properties(tool: &Tool) -> Map<String, Value> {
tool.input_schema
.get("properties")
.and_then(Value::as_object)
.cloned()
.unwrap_or_default()
}
fn required_properties(tool: &Tool) -> Vec<String> {
tool.input_schema
.get("required")
.and_then(Value::as_array)
.map(|required| {
required
.iter()
.filter_map(Value::as_str)
.map(ToString::to_string)
.collect()
})
.unwrap_or_default()
}
fn parse_flag_name(flag: &str) -> (String, Option<bool>) {
let name = flag.trim_start_matches("--");
if let Some(name) = name.strip_prefix("no-") {
(flag_to_property_name(name), Some(false))
} else {
(flag_to_property_name(name), None)
}
}
fn flag_to_property_name(flag: &str) -> String {
flag.replace('-', "_")
}
fn schema_type(schema: &Value) -> Option<&str> {
schema.get("type").and_then(Value::as_str)
}
fn array_item_schema(schema: &Value) -> Option<&Value> {
schema.get("items")
}
fn coerce_value(
property_name: &str,
schema: &Value,
raw_value: Option<&str>,
forced_bool: Option<bool>,
) -> Result<Value, Error> {
if let Some(value) = forced_bool {
return Ok(Value::Bool(value));
}
match schema_type(schema) {
Some("boolean") => coerce_bool(property_name, raw_value),
Some("integer") => coerce_integer(property_name, raw_value),
Some("number") => coerce_number(property_name, raw_value),
Some("array") => {
let raw = raw_value.unwrap_or_default();
if let Ok(Value::Array(values)) = serde_json::from_str::<Value>(raw) {
return Ok(Value::Array(values));
}
let item_schema = array_item_schema(schema).unwrap_or(&Value::Null);
coerce_value(property_name, item_schema, raw_value, None)
}
Some("object") => coerce_json_or_string(raw_value),
_ => coerce_json_or_string(raw_value),
}
}
fn coerce_json_or_string(raw_value: Option<&str>) -> Result<Value, Error> {
let raw = raw_value.unwrap_or_default();
Ok(serde_json::from_str::<Value>(raw).unwrap_or_else(|_| Value::String(raw.to_string())))
}
fn coerce_bool(property_name: &str, raw_value: Option<&str>) -> Result<Value, Error> {
match raw_value {
None => Ok(Value::Bool(true)),
Some("true") => Ok(Value::Bool(true)),
Some("false") => Ok(Value::Bool(false)),
Some(value) => Err(Error::Parse(format!(
"invalid boolean value for {property_name}: {value}"
))),
}
}
fn coerce_integer(property_name: &str, raw_value: Option<&str>) -> Result<Value, Error> {
let value =
raw_value.ok_or_else(|| Error::Parse(format!("{property_name} requires a value")))?;
let parsed = value.parse::<i64>().map_err(|_| {
Error::Parse(format!(
"invalid integer value for {property_name}: {value}"
))
})?;
Ok(Value::Number(Number::from(parsed)))
}
fn coerce_number(property_name: &str, raw_value: Option<&str>) -> Result<Value, Error> {
let value =
raw_value.ok_or_else(|| Error::Parse(format!("{property_name} requires a value")))?;
let parsed = value
.parse::<f64>()
.map_err(|_| Error::Parse(format!("invalid number value for {property_name}: {value}")))?;
let number = Number::from_f64(parsed).ok_or_else(|| {
Error::Parse(format!("invalid number value for {property_name}: {value}"))
})?;
Ok(Value::Number(number))
}
fn insert_value(
output: &mut Map<String, Value>,
property_name: &str,
schema: &Value,
value: Value,
) {
if schema_type(schema) == Some("array") {
let array = output
.entry(property_name.to_string())
.or_insert_with(|| Value::Array(Vec::new()))
.as_array_mut()
.expect("array property should be stored as array");
match value {
Value::Array(values) => array.extend(values),
value => array.push(value),
}
} else {
output.insert(property_name.to_string(), value);
}
}
#[cfg(test)]
mod tests {
use super::*;
use serde_json::json;
fn tool_with_schema(schema: serde_json::Value) -> Tool {
Tool::new("test_tool", None::<String>, schema)
}
fn args(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn string_arg() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"]
}));
let result = parse_argv(&args(&["--url", "https://example.com"]), &tool).unwrap();
assert_eq!(result, json!({ "url": "https://example.com" }));
}
#[test]
fn multiple_string_args() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": {
"url": { "type": "string" },
"method": { "type": "string" }
}
}));
let result = parse_argv(
&args(&["--url", "https://example.com", "--method", "GET"]),
&tool,
)
.unwrap();
assert_eq!(
result,
json!({ "url": "https://example.com", "method": "GET" })
);
}
#[test]
fn boolean_flag_bare() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "verbose": { "type": "boolean" } }
}));
let result = parse_argv(&args(&["--verbose"]), &tool).unwrap();
assert_eq!(result, json!({ "verbose": true }));
}
#[test]
fn boolean_flag_explicit_true() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "verbose": { "type": "boolean" } }
}));
let result = parse_argv(&args(&["--verbose", "true"]), &tool).unwrap();
assert_eq!(result, json!({ "verbose": true }));
}
#[test]
fn boolean_flag_explicit_false() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "verbose": { "type": "boolean" } }
}));
let result = parse_argv(&args(&["--verbose", "false"]), &tool).unwrap();
assert_eq!(result, json!({ "verbose": false }));
}
#[test]
fn no_prefix_produces_false() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "verbose": { "type": "boolean" } }
}));
let result = parse_argv(&args(&["--no-verbose"]), &tool).unwrap();
assert_eq!(result, json!({ "verbose": false }));
}
#[test]
fn integer_arg() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "count": { "type": "integer" } }
}));
let result = parse_argv(&args(&["--count", "5"]), &tool).unwrap();
assert_eq!(result, json!({ "count": 5 }));
}
#[test]
fn number_arg_float() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "ratio": { "type": "number" } }
}));
let result = parse_argv(&args(&["--ratio", "0.5"]), &tool).unwrap();
assert_eq!(result, json!({ "ratio": 0.5 }));
}
#[test]
fn integer_arg_invalid_value() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "count": { "type": "integer" } }
}));
assert!(parse_argv(&args(&["--count", "notanumber"]), &tool).is_err());
}
#[test]
fn array_arg_repeated_flag() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": {
"tags": { "type": "array", "items": { "type": "string" } }
}
}));
let result = parse_argv(&args(&["--tags", "a", "--tags", "b"]), &tool).unwrap();
assert_eq!(result, json!({ "tags": ["a", "b"] }));
}
#[test]
fn array_arg_json_array_value() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": {
"tags": { "type": "array", "items": { "type": "string" } }
}
}));
let result = parse_argv(&args(&["--tags", "[\"a\",\"b\"]"]), &tool).unwrap();
assert_eq!(result, json!({ "tags": ["a", "b"] }));
}
#[test]
fn object_arg_json_value() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "metadata": { "type": "object" } }
}));
let result = parse_argv(&args(&["--metadata", "{\"ok\":true}"]), &tool).unwrap();
assert_eq!(result, json!({ "metadata": { "ok": true } }));
}
#[test]
fn object_arg_invalid_json_falls_back_to_string() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "metadata": { "type": "object" } }
}));
let result = parse_argv(&args(&["--metadata", "not-json"]), &tool).unwrap();
assert_eq!(result, json!({ "metadata": "not-json" }));
}
#[test]
fn array_arg_single_element() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": {
"tags": { "type": "array", "items": { "type": "string" } }
}
}));
let result = parse_argv(&args(&["--tags", "only"]), &tool).unwrap();
assert_eq!(result, json!({ "tags": ["only"] }));
}
#[test]
fn kebab_flag_maps_to_snake_prop() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "page_id": { "type": "string" } },
"required": ["page_id"]
}));
let result = parse_argv(&args(&["--page-id", "ABC123"]), &tool).unwrap();
assert_eq!(result, json!({ "page_id": "ABC123" }));
}
#[test]
fn snake_flag_also_accepted() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "page_id": { "type": "string" } },
"required": ["page_id"]
}));
let result = parse_argv(&args(&["--page_id", "ABC123"]), &tool).unwrap();
assert_eq!(result, json!({ "page_id": "ABC123" }));
}
#[test]
fn missing_required_arg_is_error() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "url": { "type": "string" } },
"required": ["url"]
}));
assert!(parse_argv(&[], &tool).is_err());
}
#[test]
fn optional_arg_may_be_omitted() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": {
"url": { "type": "string" },
"timeout": { "type": "number" }
},
"required": ["url"]
}));
let result = parse_argv(&args(&["--url", "https://example.com"]), &tool).unwrap();
assert_eq!(result, json!({ "url": "https://example.com" }));
}
#[test]
fn unknown_flag_is_error() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "url": { "type": "string" } }
}));
assert!(parse_argv(&args(&["--unknown", "value"]), &tool).is_err());
}
#[test]
fn positional_arg_is_error() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "url": { "type": "string" } }
}));
assert!(parse_argv(&args(&["positional"]), &tool).is_err());
}
#[test]
fn flag_missing_value_is_error() {
let tool = tool_with_schema(json!({
"type": "object",
"properties": { "url": { "type": "string" } }
}));
assert!(parse_argv(&args(&["--url"]), &tool).is_err());
}
#[test]
fn json_escape_hatch() {
let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
let result = parse_argv(&args(&["--json", r#"{"key": "val"}"#]), &tool).unwrap();
assert_eq!(result, json!({ "key": "val" }));
}
#[test]
fn json_escape_hatch_requires_value() {
let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
assert!(parse_argv(&args(&["--json"]), &tool).is_err());
}
#[test]
fn json_escape_hatch_array() {
let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
let result = parse_argv(&args(&["--json", "[1,2,3]"]), &tool).unwrap();
assert_eq!(result, json!([1, 2, 3]));
}
#[test]
fn empty_argv_no_required() {
let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
let result = parse_argv(&[], &tool).unwrap();
assert_eq!(result, json!({}));
}
}