Skip to main content

mcp_compressor_core/cli/
parser.rs

1//! CLI argument parser: `argv → tool_input`.
2//!
3//! Parses a list of CLI arguments (everything after the subcommand) into a
4//! `serde_json::Value` dict that can be passed directly as `tool_input` to the
5//! backend MCP server.
6//!
7//! # Argument conventions (mirrors Python `parse_argv_to_tool_input`)
8//!
9//! | Syntax | Produces |
10//! |---|---|
11//! | `--flag value` | `{"flag": "value"}` (string) |
12//! | `--flag` | `{"flag": true}` (boolean) |
13//! | `--no-flag` | `{"flag": false}` (boolean) |
14//! | `--flag true` / `--flag false` | explicit bool |
15//! | `--flag 5` (integer prop) | `{"flag": 5}` |
16//! | `--flag 0.5` (number prop) | `{"flag": 0.5}` |
17//! | `--tag a --tag b` (array prop) | `{"tag": ["a","b"]}` |
18//! | `--json '{"k":"v"}'` | `{"k": "v"}` (raw JSON escape-hatch) |
19//! | `--page-id 123` (kebab flag) | `{"page_id": "123"}` (snake prop) |
20//!
21//! Unknown flags and positional arguments are errors.
22//! Missing required arguments are errors.
23
24use serde_json::{Map, Number, Value};
25
26use crate::compression::engine::Tool;
27use crate::Error;
28
29/// Parse CLI `argv` (everything after the subcommand itself) into a JSON
30/// object suitable for use as `tool_input`.
31///
32/// The `tool`'s `input_schema` drives type coercion and required-argument
33/// checking.
34pub fn parse_argv(argv: &[String], tool: &Tool) -> Result<serde_json::Value, Error> {
35    if argv.first().is_some_and(|arg| arg == "--json") {
36        let json = argv
37            .get(1)
38            .ok_or_else(|| Error::Parse("--json requires a value".to_string()))?;
39        if argv.len() > 2 {
40            return Err(Error::Parse("--json cannot be combined with other arguments".to_string()));
41        }
42        return Ok(serde_json::from_str(json)?);
43    }
44
45    let properties = schema_properties(tool);
46    let required = required_properties(tool);
47    let mut output = Map::new();
48    let mut index = 0;
49
50    while index < argv.len() {
51        let arg = &argv[index];
52        if !arg.starts_with("--") || arg == "--" {
53            return Err(Error::Parse(format!("unexpected positional argument: {arg}")));
54        }
55
56        let (property_name, forced_bool) = parse_flag_name(arg);
57        let schema = properties
58            .get(&property_name)
59            .ok_or_else(|| Error::Parse(format!("unknown flag: {arg}")))?;
60        let schema_type = schema_type(schema);
61
62        let (raw_value, consumed) = if forced_bool == Some(false) {
63            if schema_type != Some("boolean") {
64                return Err(Error::Parse(format!("{arg} can only be used with boolean properties")));
65            }
66            (None, 1)
67        } else if schema_type == Some("boolean") {
68            match argv.get(index + 1) {
69                Some(next) if !next.starts_with("--") => (Some(next.as_str()), 2),
70                _ => (None, 1),
71            }
72        } else {
73            let value = argv
74                .get(index + 1)
75                .filter(|next| !next.starts_with("--"))
76                .ok_or_else(|| Error::Parse(format!("{arg} requires a value")))?;
77            (Some(value.as_str()), 2)
78        };
79
80        let value = coerce_value(&property_name, schema, raw_value, forced_bool)?;
81        insert_value(&mut output, &property_name, schema, value);
82        index += consumed;
83    }
84
85    for property in required {
86        if !output.contains_key(&property) {
87            return Err(Error::Validation(format!("missing required argument: {property}")));
88        }
89    }
90
91    Ok(Value::Object(output))
92}
93
94fn schema_properties(tool: &Tool) -> Map<String, Value> {
95    tool.input_schema
96        .get("properties")
97        .and_then(Value::as_object)
98        .cloned()
99        .unwrap_or_default()
100}
101
102fn required_properties(tool: &Tool) -> Vec<String> {
103    tool.input_schema
104        .get("required")
105        .and_then(Value::as_array)
106        .map(|required| {
107            required
108                .iter()
109                .filter_map(Value::as_str)
110                .map(ToString::to_string)
111                .collect()
112        })
113        .unwrap_or_default()
114}
115
116fn parse_flag_name(flag: &str) -> (String, Option<bool>) {
117    let name = flag.trim_start_matches("--");
118    if let Some(name) = name.strip_prefix("no-") {
119        (flag_to_property_name(name), Some(false))
120    } else {
121        (flag_to_property_name(name), None)
122    }
123}
124
125fn flag_to_property_name(flag: &str) -> String {
126    flag.replace('-', "_")
127}
128
129fn schema_type(schema: &Value) -> Option<&str> {
130    schema.get("type").and_then(Value::as_str)
131}
132
133fn array_item_schema(schema: &Value) -> Option<&Value> {
134    schema.get("items")
135}
136
137fn coerce_value(
138    property_name: &str,
139    schema: &Value,
140    raw_value: Option<&str>,
141    forced_bool: Option<bool>,
142) -> Result<Value, Error> {
143    if let Some(value) = forced_bool {
144        return Ok(Value::Bool(value));
145    }
146
147    match schema_type(schema) {
148        Some("boolean") => coerce_bool(property_name, raw_value),
149        Some("integer") => coerce_integer(property_name, raw_value),
150        Some("number") => coerce_number(property_name, raw_value),
151        Some("array") => {
152            let item_schema = array_item_schema(schema).unwrap_or(&Value::Null);
153            coerce_value(property_name, item_schema, raw_value, None)
154        }
155        _ => Ok(Value::String(raw_value.unwrap_or_default().to_string())),
156    }
157}
158
159fn coerce_bool(property_name: &str, raw_value: Option<&str>) -> Result<Value, Error> {
160    match raw_value {
161        None => Ok(Value::Bool(true)),
162        Some("true") => Ok(Value::Bool(true)),
163        Some("false") => Ok(Value::Bool(false)),
164        Some(value) => Err(Error::Parse(format!(
165            "invalid boolean value for {property_name}: {value}"
166        ))),
167    }
168}
169
170fn coerce_integer(property_name: &str, raw_value: Option<&str>) -> Result<Value, Error> {
171    let value = raw_value.ok_or_else(|| Error::Parse(format!("{property_name} requires a value")))?;
172    let parsed = value
173        .parse::<i64>()
174        .map_err(|_| Error::Parse(format!("invalid integer value for {property_name}: {value}")))?;
175    Ok(Value::Number(Number::from(parsed)))
176}
177
178fn coerce_number(property_name: &str, raw_value: Option<&str>) -> Result<Value, Error> {
179    let value = raw_value.ok_or_else(|| Error::Parse(format!("{property_name} requires a value")))?;
180    let parsed = value
181        .parse::<f64>()
182        .map_err(|_| Error::Parse(format!("invalid number value for {property_name}: {value}")))?;
183    let number = Number::from_f64(parsed)
184        .ok_or_else(|| Error::Parse(format!("invalid number value for {property_name}: {value}")))?;
185    Ok(Value::Number(number))
186}
187
188fn insert_value(output: &mut Map<String, Value>, property_name: &str, schema: &Value, value: Value) {
189    if schema_type(schema) == Some("array") {
190        output
191            .entry(property_name.to_string())
192            .or_insert_with(|| Value::Array(Vec::new()))
193            .as_array_mut()
194            .expect("array property should be stored as array")
195            .push(value);
196    } else {
197        output.insert(property_name.to_string(), value);
198    }
199}
200
201// ---------------------------------------------------------------------------
202// Tests
203// ---------------------------------------------------------------------------
204
205#[cfg(test)]
206mod tests {
207    use super::*;
208    use serde_json::json;
209
210    // Helper: build a Tool with just a name and a given JSON Schema.
211    fn tool_with_schema(schema: serde_json::Value) -> Tool {
212        Tool::new("test_tool", None::<String>, schema)
213    }
214
215    // Helper: args vec from string literals.
216    fn args(parts: &[&str]) -> Vec<String> {
217        parts.iter().map(|s| s.to_string()).collect()
218    }
219
220    // ------------------------------------------------------------------
221    // String arguments
222    // ------------------------------------------------------------------
223
224    /// A simple `--flag value` pair produces a string in the output dict.
225    #[test]
226    fn string_arg() {
227        let tool = tool_with_schema(json!({
228            "type": "object",
229            "properties": { "url": { "type": "string" } },
230            "required": ["url"]
231        }));
232        let result = parse_argv(&args(&["--url", "https://example.com"]), &tool).unwrap();
233        assert_eq!(result, json!({ "url": "https://example.com" }));
234    }
235
236    /// Multiple string flags are captured independently.
237    #[test]
238    fn multiple_string_args() {
239        let tool = tool_with_schema(json!({
240            "type": "object",
241            "properties": {
242                "url":    { "type": "string" },
243                "method": { "type": "string" }
244            }
245        }));
246        let result =
247            parse_argv(&args(&["--url", "https://example.com", "--method", "GET"]), &tool).unwrap();
248        assert_eq!(result, json!({ "url": "https://example.com", "method": "GET" }));
249    }
250
251    // ------------------------------------------------------------------
252    // Boolean arguments
253    // ------------------------------------------------------------------
254
255    /// A bare `--flag` (no value following) produces `true`.
256    #[test]
257    fn boolean_flag_bare() {
258        let tool = tool_with_schema(json!({
259            "type": "object",
260            "properties": { "verbose": { "type": "boolean" } }
261        }));
262        let result = parse_argv(&args(&["--verbose"]), &tool).unwrap();
263        assert_eq!(result, json!({ "verbose": true }));
264    }
265
266    /// `--flag true` produces `true`.
267    #[test]
268    fn boolean_flag_explicit_true() {
269        let tool = tool_with_schema(json!({
270            "type": "object",
271            "properties": { "verbose": { "type": "boolean" } }
272        }));
273        let result = parse_argv(&args(&["--verbose", "true"]), &tool).unwrap();
274        assert_eq!(result, json!({ "verbose": true }));
275    }
276
277    /// `--flag false` produces `false`.
278    #[test]
279    fn boolean_flag_explicit_false() {
280        let tool = tool_with_schema(json!({
281            "type": "object",
282            "properties": { "verbose": { "type": "boolean" } }
283        }));
284        let result = parse_argv(&args(&["--verbose", "false"]), &tool).unwrap();
285        assert_eq!(result, json!({ "verbose": false }));
286    }
287
288    /// `--no-flag` produces `false` for a boolean property.
289    #[test]
290    fn no_prefix_produces_false() {
291        let tool = tool_with_schema(json!({
292            "type": "object",
293            "properties": { "verbose": { "type": "boolean" } }
294        }));
295        let result = parse_argv(&args(&["--no-verbose"]), &tool).unwrap();
296        assert_eq!(result, json!({ "verbose": false }));
297    }
298
299    // ------------------------------------------------------------------
300    // Integer and number arguments
301    // ------------------------------------------------------------------
302
303    /// An `integer` property is coerced from the string value.
304    #[test]
305    fn integer_arg() {
306        let tool = tool_with_schema(json!({
307            "type": "object",
308            "properties": { "count": { "type": "integer" } }
309        }));
310        let result = parse_argv(&args(&["--count", "5"]), &tool).unwrap();
311        assert_eq!(result, json!({ "count": 5 }));
312    }
313
314    /// A `number` property is coerced to a float.
315    #[test]
316    fn number_arg_float() {
317        let tool = tool_with_schema(json!({
318            "type": "object",
319            "properties": { "ratio": { "type": "number" } }
320        }));
321        let result = parse_argv(&args(&["--ratio", "0.5"]), &tool).unwrap();
322        assert_eq!(result, json!({ "ratio": 0.5 }));
323    }
324
325    /// Passing a non-numeric string to an integer property is an error.
326    #[test]
327    fn integer_arg_invalid_value() {
328        let tool = tool_with_schema(json!({
329            "type": "object",
330            "properties": { "count": { "type": "integer" } }
331        }));
332        assert!(parse_argv(&args(&["--count", "notanumber"]), &tool).is_err());
333    }
334
335    // ------------------------------------------------------------------
336    // Array arguments (repeated flag)
337    // ------------------------------------------------------------------
338
339    /// Repeating a flag for an array property accumulates values.
340    #[test]
341    fn array_arg_repeated_flag() {
342        let tool = tool_with_schema(json!({
343            "type": "object",
344            "properties": {
345                "tags": { "type": "array", "items": { "type": "string" } }
346            }
347        }));
348        let result = parse_argv(&args(&["--tags", "a", "--tags", "b"]), &tool).unwrap();
349        assert_eq!(result, json!({ "tags": ["a", "b"] }));
350    }
351
352    /// A single-element array works correctly.
353    #[test]
354    fn array_arg_single_element() {
355        let tool = tool_with_schema(json!({
356            "type": "object",
357            "properties": {
358                "tags": { "type": "array", "items": { "type": "string" } }
359            }
360        }));
361        let result = parse_argv(&args(&["--tags", "only"]), &tool).unwrap();
362        assert_eq!(result, json!({ "tags": ["only"] }));
363    }
364
365    // ------------------------------------------------------------------
366    // kebab-case → snake_case flag mapping
367    // ------------------------------------------------------------------
368
369    /// A kebab-case CLI flag maps to the corresponding snake_case property.
370    #[test]
371    fn kebab_flag_maps_to_snake_prop() {
372        let tool = tool_with_schema(json!({
373            "type": "object",
374            "properties": { "page_id": { "type": "string" } },
375            "required": ["page_id"]
376        }));
377        let result = parse_argv(&args(&["--page-id", "ABC123"]), &tool).unwrap();
378        assert_eq!(result, json!({ "page_id": "ABC123" }));
379    }
380
381    /// The snake_case version of a flag name is also accepted directly.
382    #[test]
383    fn snake_flag_also_accepted() {
384        let tool = tool_with_schema(json!({
385            "type": "object",
386            "properties": { "page_id": { "type": "string" } },
387            "required": ["page_id"]
388        }));
389        let result = parse_argv(&args(&["--page_id", "ABC123"]), &tool).unwrap();
390        assert_eq!(result, json!({ "page_id": "ABC123" }));
391    }
392
393    // ------------------------------------------------------------------
394    // Required argument validation
395    // ------------------------------------------------------------------
396
397    /// A missing required argument is an error.
398    #[test]
399    fn missing_required_arg_is_error() {
400        let tool = tool_with_schema(json!({
401            "type": "object",
402            "properties": { "url": { "type": "string" } },
403            "required": ["url"]
404        }));
405        assert!(parse_argv(&[], &tool).is_err());
406    }
407
408    /// Optional arguments may be omitted without error.
409    #[test]
410    fn optional_arg_may_be_omitted() {
411        let tool = tool_with_schema(json!({
412            "type": "object",
413            "properties": {
414                "url":     { "type": "string" },
415                "timeout": { "type": "number" }
416            },
417            "required": ["url"]
418        }));
419        let result = parse_argv(&args(&["--url", "https://example.com"]), &tool).unwrap();
420        assert_eq!(result, json!({ "url": "https://example.com" }));
421    }
422
423    // ------------------------------------------------------------------
424    // Error cases
425    // ------------------------------------------------------------------
426
427    /// An unknown flag is an error.
428    #[test]
429    fn unknown_flag_is_error() {
430        let tool = tool_with_schema(json!({
431            "type": "object",
432            "properties": { "url": { "type": "string" } }
433        }));
434        assert!(parse_argv(&args(&["--unknown", "value"]), &tool).is_err());
435    }
436
437    /// A positional argument (no `--` prefix) is an error.
438    #[test]
439    fn positional_arg_is_error() {
440        let tool = tool_with_schema(json!({
441            "type": "object",
442            "properties": { "url": { "type": "string" } }
443        }));
444        assert!(parse_argv(&args(&["positional"]), &tool).is_err());
445    }
446
447    /// A flag missing its value (end of argv) is an error.
448    #[test]
449    fn flag_missing_value_is_error() {
450        let tool = tool_with_schema(json!({
451            "type": "object",
452            "properties": { "url": { "type": "string" } }
453        }));
454        assert!(parse_argv(&args(&["--url"]), &tool).is_err());
455    }
456
457    // ------------------------------------------------------------------
458    // --json escape hatch
459    // ------------------------------------------------------------------
460
461    /// `--json '{"k":"v"}'` passes the raw JSON object through unchanged.
462    #[test]
463    fn json_escape_hatch() {
464        let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
465        let result =
466            parse_argv(&args(&["--json", r#"{"key": "val"}"#]), &tool).unwrap();
467        assert_eq!(result, json!({ "key": "val" }));
468    }
469
470    /// `--json` with no following value is an error.
471    #[test]
472    fn json_escape_hatch_requires_value() {
473        let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
474        assert!(parse_argv(&args(&["--json"]), &tool).is_err());
475    }
476
477    /// `--json` accepts a JSON array (not just objects).
478    #[test]
479    fn json_escape_hatch_array() {
480        let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
481        let result = parse_argv(&args(&["--json", "[1,2,3]"]), &tool).unwrap();
482        assert_eq!(result, json!([1, 2, 3]));
483    }
484
485    // ------------------------------------------------------------------
486    // Empty arguments
487    // ------------------------------------------------------------------
488
489    /// An empty argv with no required args succeeds with an empty dict.
490    #[test]
491    fn empty_argv_no_required() {
492        let tool = tool_with_schema(json!({ "type": "object", "properties": {} }));
493        let result = parse_argv(&[], &tool).unwrap();
494        assert_eq!(result, json!({}));
495    }
496}