1use serde_json::{Map, Number, Value};
25
26use crate::compression::engine::Tool;
27use crate::Error;
28
29pub 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#[cfg(test)]
206mod tests {
207 use super::*;
208 use serde_json::json;
209
210 fn tool_with_schema(schema: serde_json::Value) -> Tool {
212 Tool::new("test_tool", None::<String>, schema)
213 }
214
215 fn args(parts: &[&str]) -> Vec<String> {
217 parts.iter().map(|s| s.to_string()).collect()
218 }
219
220 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[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}