1use std::collections::HashMap;
5use std::path::PathBuf;
6
7use clap::Arg;
8use serde_json::Value;
9use thiserror::Error;
10use tracing::warn;
11
12pub const RESERVED_PROPERTY_NAMES: &[&str] = &[
19 "input",
20 "yes",
21 "large_input",
22 "format",
23 "fields",
24 "sandbox",
25 "verbose",
26 "dry_run",
27 "trace",
28 "stream",
29 "strategy",
30 "approval_timeout",
31 "approval_token",
32];
33
34#[derive(Debug, Error)]
36pub enum SchemaParserError {
37 #[error("Flag name collision: properties '{prop_a}' and '{prop_b}' both map to '{flag_name}'")]
40 FlagCollision {
41 prop_a: String,
42 prop_b: String,
43 flag_name: String,
44 },
45 #[error("Schema property '{name}' conflicts with built-in CLI flag")]
48 ReservedPropertyName { name: String },
49}
50
51#[derive(Debug)]
57pub struct BoolFlagPair {
58 pub prop_name: String,
60 pub flag_long: String,
62 pub default_val: bool,
64}
65
66#[derive(Debug)]
68pub struct SchemaArgs {
69 pub args: Vec<Arg>,
71 pub bool_pairs: Vec<BoolFlagPair>,
73 pub enum_maps: HashMap<String, Vec<Value>>,
76}
77
78pub const HELP_TEXT_MAX_LEN: usize = 1000;
83
84pub fn prop_name_to_flag_name(s: &str) -> String {
90 s.replace('_', "-")
91}
92
93fn is_file_property(prop_name: &str, prop_schema: &Value) -> bool {
95 prop_name.ends_with("_file")
96 || prop_schema
97 .get("x-cli-file")
98 .and_then(|v| v.as_bool())
99 .unwrap_or(false)
100}
101
102pub fn extract_help(prop_schema: &Value) -> Option<String> {
106 extract_help_with_limit(prop_schema, HELP_TEXT_MAX_LEN)
107}
108
109pub fn extract_help_with_limit(prop_schema: &Value, max_len: usize) -> Option<String> {
111 let text = prop_schema
112 .get("x-llm-description")
113 .and_then(|v| v.as_str())
114 .filter(|s| !s.is_empty())
115 .or_else(|| {
116 prop_schema
117 .get("description")
118 .and_then(|v| v.as_str())
119 .filter(|s| !s.is_empty())
120 })?;
121
122 if max_len > 0 && text.len() > max_len {
123 Some(format!("{}...", &text[..max_len - 3]))
124 } else {
125 Some(text.to_string())
126 }
127}
128
129pub fn map_type(prop_name: &str, prop_schema: &Value) -> Result<Arg, SchemaParserError> {
138 let flag_long = prop_name_to_flag_name(prop_name);
139 let schema_type = prop_schema.get("type").and_then(|v| v.as_str());
140
141 let arg = Arg::new(prop_name.to_string()).long(flag_long);
142
143 let arg = match schema_type {
147 Some("integer") | Some("number") => arg,
148 Some("string") if is_file_property(prop_name, prop_schema) => {
149 arg.value_parser(clap::value_parser!(PathBuf))
150 }
151 Some("string") | Some("object") | Some("array") => arg,
152 Some(unknown) => {
153 warn!(
154 "Unknown schema type '{}' for property '{}', defaulting to string.",
155 unknown, prop_name
156 );
157 arg
158 }
159 None => {
160 warn!(
161 "No type specified for property '{}', defaulting to string.",
162 prop_name
163 );
164 arg
165 }
166 };
167
168 Ok(arg)
169}
170
171pub fn schema_to_clap_args(
192 schema: &Value,
193 max_help_length: Option<usize>,
194) -> Result<SchemaArgs, SchemaParserError> {
195 schema_to_clap_args_with_limit(schema, max_help_length.unwrap_or(HELP_TEXT_MAX_LEN))
196}
197
198pub fn schema_to_clap_args_with_limit(
200 schema: &Value,
201 help_max_len: usize,
202) -> Result<SchemaArgs, SchemaParserError> {
203 let properties = match schema.get("properties").and_then(|v| v.as_object()) {
204 Some(p) => p,
205 None => {
206 return Ok(SchemaArgs {
207 args: Vec::new(),
208 bool_pairs: Vec::new(),
209 enum_maps: HashMap::new(),
210 });
211 }
212 };
213
214 let required_list: Vec<&str> = schema
215 .get("required")
216 .and_then(|v| v.as_array())
217 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
218 .unwrap_or_default();
219
220 for req_name in &required_list {
222 if !properties.contains_key(*req_name) {
223 warn!(
224 "Required property '{}' not found in properties, skipping.",
225 req_name
226 );
227 }
228 }
229
230 let mut args: Vec<Arg> = Vec::new();
231 let mut bool_pairs: Vec<BoolFlagPair> = Vec::new();
232 let mut enum_maps: HashMap<String, Vec<Value>> = HashMap::new();
233 let mut seen_flags: HashMap<String, String> = HashMap::new(); for (prop_name, prop_schema) in properties {
236 if RESERVED_PROPERTY_NAMES.contains(&prop_name.as_str()) {
238 return Err(SchemaParserError::ReservedPropertyName {
239 name: prop_name.clone(),
240 });
241 }
242
243 let flag_long = prop_name_to_flag_name(prop_name);
244
245 if let Some(existing) = seen_flags.get(&flag_long) {
247 return Err(SchemaParserError::FlagCollision {
248 prop_a: prop_name.clone(),
249 prop_b: existing.clone(),
250 flag_name: flag_long,
251 });
252 }
253 seen_flags.insert(flag_long.clone(), prop_name.clone());
254
255 let schema_type = prop_schema.get("type").and_then(|v| v.as_str());
256 let is_required = required_list.contains(&prop_name.as_str());
257 let help_text = extract_help_with_limit(prop_schema, help_max_len);
258 let default_val = prop_schema.get("default");
259
260 if schema_type == Some("boolean") {
262 let bool_default = prop_schema
263 .get("default")
264 .and_then(|v| v.as_bool())
265 .unwrap_or(false);
266
267 let mut pos_arg = Arg::new(prop_name.clone())
268 .long(flag_long.clone())
269 .action(clap::ArgAction::SetTrue);
270 let mut neg_arg = Arg::new(format!("no-{}", prop_name))
271 .long(format!("no-{}", flag_long))
272 .action(clap::ArgAction::SetFalse);
273
274 if let Some(ref help) = help_text {
275 pos_arg = pos_arg.help(help.clone());
276 neg_arg = neg_arg.help(format!("Disable --{flag_long}"));
277 }
278
279 let no_flag_long = format!("no-{}", flag_long);
281 seen_flags.insert(no_flag_long, format!("no-{}", prop_name));
282
283 args.push(pos_arg);
284 args.push(neg_arg);
285
286 bool_pairs.push(BoolFlagPair {
287 prop_name: prop_name.clone(),
288 flag_long,
289 default_val: bool_default,
290 });
291
292 let _ = is_required;
295
296 continue;
297 }
298
299 if let Some(enum_values) = prop_schema.get("enum").and_then(|v| v.as_array()) {
301 if enum_values.is_empty() {
302 warn!(
303 "Empty enum for property '{}', falling through to plain string arg.",
304 prop_name
305 );
306 } else {
308 let string_values: Vec<String> = enum_values
310 .iter()
311 .map(|v| match v {
312 Value::String(s) => s.clone(),
313 other => other.to_string(),
314 })
315 .collect();
316
317 enum_maps.insert(prop_name.clone(), enum_values.to_vec());
319
320 let mut arg = Arg::new(prop_name.clone())
321 .long(flag_long)
322 .value_parser(clap::builder::PossibleValuesParser::new(string_values))
323 .required(false); if let Some(help) = help_text {
327 let annotated = if is_required {
328 format!("{} [required]", help)
329 } else {
330 help
331 };
332 arg = arg.help(annotated);
333 } else if is_required {
334 arg = arg.help("[required]");
335 }
336
337 if let Some(dv) = default_val {
338 let dv_str = match dv {
339 Value::String(s) => s.clone(),
340 other => other.to_string(),
341 };
342 arg = arg.default_value(dv_str);
343 }
344
345 args.push(arg);
346 continue;
347 }
348 }
349
350 let mut arg = map_type(prop_name, prop_schema)?.required(is_required);
352
353 if let Some(help) = help_text {
354 arg = arg.help(help);
355 }
356
357 if let Some(dv) = default_val {
359 let dv_str = match dv {
360 Value::String(s) => s.clone(),
361 other => other.to_string(),
362 };
363 arg = arg.default_value(dv_str);
364 }
365
366 args.push(arg);
367 }
368
369 Ok(SchemaArgs {
370 args,
371 bool_pairs,
372 enum_maps,
373 })
374}
375
376pub fn reconvert_enum_values(
393 kwargs: HashMap<String, Value>,
394 schema_args: &SchemaArgs,
395) -> HashMap<String, Value> {
396 let mut result = kwargs;
397
398 for (key, original_variants) in &schema_args.enum_maps {
399 let val = match result.get(key) {
400 Some(v) => v.clone(),
401 None => continue,
402 };
403
404 let str_val = match &val {
406 Value::String(s) => s.clone(),
407 _ => continue,
408 };
409
410 let original = original_variants.iter().find(|v| {
412 let as_str = match v {
413 Value::String(s) => s.clone(),
414 other => other.to_string(),
415 };
416 as_str == str_val
417 });
418
419 if let Some(orig) = original {
420 let converted = match orig {
421 Value::Number(n) => {
422 if n.as_i64().is_some() {
423 str_val
424 .parse::<i64>()
425 .ok()
426 .map(|i| Value::Number(i.into()))
427 .unwrap_or(val.clone())
428 } else {
429 str_val
430 .parse::<f64>()
431 .ok()
432 .and_then(serde_json::Number::from_f64)
433 .map(Value::Number)
434 .unwrap_or(val.clone())
435 }
436 }
437 Value::Bool(_) => Value::Bool(str_val.to_lowercase() == "true"),
438 _ => val.clone(), };
440 result.insert(key.clone(), converted);
441 }
442 }
443
444 result
445}
446
447#[cfg(test)]
452mod tests {
453 use super::*;
454 use serde_json::json;
455
456 fn find_arg<'a>(args: &'a [clap::Arg], long: &str) -> Option<&'a clap::Arg> {
458 args.iter().find(|a| a.get_long() == Some(long))
459 }
460
461 #[test]
462 fn test_schema_to_clap_args_empty_schema() {
463 let schema = json!({});
464 let result = schema_to_clap_args(&schema, None).unwrap();
465 assert!(result.args.is_empty());
466 assert!(result.bool_pairs.is_empty());
467 assert!(result.enum_maps.is_empty());
468 }
469
470 #[test]
471 fn test_schema_to_clap_args_string_property() {
472 let schema = json!({
473 "properties": {"text": {"type": "string", "description": "Some text"}},
474 "required": []
475 });
476 let result = schema_to_clap_args(&schema, None).unwrap();
477 assert_eq!(result.args.len(), 1);
478 let arg = find_arg(&result.args, "text").expect("--text must exist");
479 assert_eq!(arg.get_id(), "text");
480 assert!(!arg.is_required_set());
481 }
482
483 #[test]
484 fn test_schema_to_clap_args_integer_property() {
485 let schema = json!({
486 "properties": {"count": {"type": "integer"}},
487 "required": ["count"]
488 });
489 let result = schema_to_clap_args(&schema, None).unwrap();
490 let arg = find_arg(&result.args, "count").expect("--count must exist");
491 assert!(arg.is_required_set());
492 }
493
494 #[test]
495 fn test_schema_to_clap_args_number_property() {
496 let schema = json!({
497 "properties": {"rate": {"type": "number"}}
498 });
499 let result = schema_to_clap_args(&schema, None).unwrap();
500 assert!(find_arg(&result.args, "rate").is_some());
501 }
502
503 #[test]
504 fn test_schema_to_clap_args_object_and_array_as_string() {
505 let schema = json!({
506 "properties": {
507 "data": {"type": "object"},
508 "items": {"type": "array"}
509 }
510 });
511 let result = schema_to_clap_args(&schema, None).unwrap();
512 assert!(find_arg(&result.args, "data").is_some());
513 assert!(find_arg(&result.args, "items").is_some());
514 }
515
516 #[test]
517 fn test_schema_to_clap_args_underscore_to_hyphen() {
518 let schema = json!({
519 "properties": {"input_file": {"type": "string"}}
520 });
521 let result = schema_to_clap_args(&schema, None).unwrap();
522 assert!(find_arg(&result.args, "input-file").is_some());
524 let arg = find_arg(&result.args, "input-file").unwrap();
526 assert_eq!(arg.get_id(), "input_file");
527 }
528
529 #[test]
530 fn test_schema_to_clap_args_file_convention_suffix() {
531 let schema = json!({
532 "properties": {"config_file": {"type": "string"}}
533 });
534 let result = schema_to_clap_args(&schema, None).unwrap();
535 let arg = find_arg(&result.args, "config-file").expect("must exist");
536 let _ = arg; }
538
539 #[test]
540 fn test_schema_to_clap_args_x_cli_file_flag() {
541 let schema = json!({
542 "properties": {"report": {"type": "string", "x-cli-file": true}}
543 });
544 let result = schema_to_clap_args(&schema, None).unwrap();
545 assert!(find_arg(&result.args, "report").is_some());
546 }
547
548 #[test]
549 fn test_schema_to_clap_args_unknown_type_defaults_to_string() {
550 let schema = json!({
551 "properties": {"x": {"type": "foobar"}}
552 });
553 let result = schema_to_clap_args(&schema, None).unwrap();
554 assert!(find_arg(&result.args, "x").is_some());
555 }
556
557 #[test]
558 fn test_schema_to_clap_args_missing_type_defaults_to_string() {
559 let schema = json!({
560 "properties": {"x": {"description": "no type field"}}
561 });
562 let result = schema_to_clap_args(&schema, None).unwrap();
563 assert!(find_arg(&result.args, "x").is_some());
564 }
565
566 #[test]
567 fn test_schema_to_clap_args_default_value_set() {
568 let schema = json!({
569 "properties": {"timeout": {"type": "integer", "default": 30}}
570 });
571 let result = schema_to_clap_args(&schema, None).unwrap();
572 let arg = find_arg(&result.args, "timeout").unwrap();
573 assert_eq!(
574 arg.get_default_values().first().and_then(|v| v.to_str()),
575 Some("30")
576 );
577 }
578
579 #[test]
582 fn test_extract_help_uses_description() {
583 let prop = json!({"description": "A plain description"});
584 assert_eq!(extract_help(&prop), Some("A plain description".to_string()));
585 }
586
587 #[test]
588 fn test_extract_help_prefers_x_llm_description() {
589 let prop = json!({
590 "description": "Plain description",
591 "x-llm-description": "LLM description"
592 });
593 assert_eq!(extract_help(&prop), Some("LLM description".to_string()));
594 }
595
596 #[test]
597 fn test_extract_help_truncates_at_1000() {
598 let long_text = "a".repeat(1100);
599 let prop = json!({"description": long_text});
600 let result = extract_help(&prop).unwrap();
601 assert_eq!(result.len(), 1000);
602 assert!(result.ends_with("..."));
603 }
604
605 #[test]
606 fn test_extract_help_no_truncation_within_limit() {
607 let text = "b".repeat(999);
608 let prop = json!({"description": text.clone()});
609 let result = extract_help(&prop).unwrap();
610 assert_eq!(result, text);
611 assert!(!result.ends_with("..."));
612 }
613
614 #[test]
615 fn test_extract_help_custom_max_length() {
616 let long_text = "c".repeat(300);
617 let prop = json!({"description": long_text});
618 let result = extract_help_with_limit(&prop, 200).unwrap();
619 assert_eq!(result.len(), 200);
620 assert!(result.ends_with("..."));
621 }
622
623 #[test]
624 fn test_extract_help_returns_none_when_absent() {
625 let prop = json!({"type": "string"});
626 assert_eq!(extract_help(&prop), None);
627 }
628
629 #[test]
632 fn test_prop_name_to_flag_name() {
633 assert_eq!(prop_name_to_flag_name("my_val"), "my-val");
634 assert_eq!(prop_name_to_flag_name("simple"), "simple");
635 assert_eq!(prop_name_to_flag_name("a_b_c"), "a-b-c");
636 }
637
638 #[test]
641 fn test_map_type_string() {
642 let prop = json!({"type": "string"});
643 let arg = map_type("name", &prop).unwrap();
644 assert_eq!(arg.get_long(), Some("name"));
645 assert_eq!(arg.get_id(), "name");
646 }
647
648 #[test]
649 fn test_map_type_integer() {
650 let prop = json!({"type": "integer"});
651 let arg = map_type("count", &prop).unwrap();
652 assert_eq!(arg.get_long(), Some("count"));
653 }
654
655 #[test]
656 fn test_map_type_number() {
657 let prop = json!({"type": "number"});
658 let arg = map_type("rate", &prop).unwrap();
659 assert_eq!(arg.get_long(), Some("rate"));
660 }
661
662 #[test]
663 fn test_map_type_file_suffix() {
664 let prop = json!({"type": "string"});
665 let arg = map_type("config_file", &prop).unwrap();
666 assert_eq!(arg.get_long(), Some("config-file"));
668 }
669
670 #[test]
671 fn test_map_type_x_cli_file() {
672 let prop = json!({"type": "string", "x-cli-file": true});
673 let arg = map_type("report", &prop).unwrap();
674 assert_eq!(arg.get_long(), Some("report"));
675 }
676
677 #[test]
678 fn test_map_type_object_as_string() {
679 let prop = json!({"type": "object"});
680 let arg = map_type("data", &prop).unwrap();
681 assert_eq!(arg.get_long(), Some("data"));
682 }
683
684 #[test]
685 fn test_map_type_array_as_string() {
686 let prop = json!({"type": "array"});
687 let arg = map_type("items", &prop).unwrap();
688 assert_eq!(arg.get_long(), Some("items"));
689 }
690
691 #[test]
692 fn test_map_type_unknown_defaults_to_string() {
693 let prop = json!({"type": "foobar"});
694 let arg = map_type("x", &prop).unwrap();
695 assert_eq!(arg.get_long(), Some("x"));
696 }
697
698 #[test]
701 fn test_boolean_flag_pair_produced() {
702 let schema = json!({
703 "properties": {"log_output": {"type": "boolean"}}
704 });
705 let result = schema_to_clap_args(&schema, None).unwrap();
706 assert!(
707 find_arg(&result.args, "log-output").is_some(),
708 "--log-output must be present"
709 );
710 assert!(
711 find_arg(&result.args, "no-log-output").is_some(),
712 "--no-log-output must be present"
713 );
714 }
715
716 #[test]
717 fn test_boolean_pair_actions() {
718 let schema = json!({
719 "properties": {"log_output": {"type": "boolean"}}
720 });
721 let result = schema_to_clap_args(&schema, None).unwrap();
722 let pos_arg = find_arg(&result.args, "log-output").unwrap();
723 let neg_arg = find_arg(&result.args, "no-log-output").unwrap();
724 assert!(matches!(pos_arg.get_action(), clap::ArgAction::SetTrue));
725 assert!(matches!(neg_arg.get_action(), clap::ArgAction::SetFalse));
726 }
727
728 #[test]
729 fn test_boolean_default_false() {
730 let schema = json!({
731 "properties": {"debug": {"type": "boolean"}}
732 });
733 let result = schema_to_clap_args(&schema, None).unwrap();
734 let pair = result.bool_pairs.iter().find(|p| p.prop_name == "debug");
735 assert!(pair.is_some());
736 assert!(
737 !pair.unwrap().default_val,
738 "default must be false when not specified"
739 );
740 }
741
742 #[test]
743 fn test_boolean_default_true() {
744 let schema = json!({
745 "properties": {"enabled": {"type": "boolean", "default": true}}
746 });
747 let result = schema_to_clap_args(&schema, None).unwrap();
748 let pair = result
749 .bool_pairs
750 .iter()
751 .find(|p| p.prop_name == "enabled")
752 .expect("BoolFlagPair must be recorded");
753 assert!(
754 pair.default_val,
755 "default must be true when schema says true"
756 );
757 }
758
759 #[test]
760 fn test_boolean_pair_recorded_in_bool_pairs() {
761 let schema = json!({
762 "properties": {"skip_writes": {"type": "boolean"}}
763 });
764 let result = schema_to_clap_args(&schema, None).unwrap();
765 let pair = result
766 .bool_pairs
767 .iter()
768 .find(|p| p.prop_name == "skip_writes");
769 assert!(
770 pair.is_some(),
771 "BoolFlagPair must be recorded for skip_writes"
772 );
773 assert_eq!(
774 pair.unwrap().flag_long,
775 "skip-writes",
776 "flag_long must use hyphen form"
777 );
778 }
779
780 #[test]
781 fn test_boolean_underscore_to_hyphen() {
782 let schema = json!({
783 "properties": {"skip_writes": {"type": "boolean"}}
784 });
785 let result = schema_to_clap_args(&schema, None).unwrap();
786 assert!(
787 find_arg(&result.args, "skip-writes").is_some(),
788 "--skip-writes"
789 );
790 assert!(
791 find_arg(&result.args, "no-skip-writes").is_some(),
792 "--no-skip-writes"
793 );
794 }
795
796 #[test]
797 fn test_boolean_with_enum_true_treated_as_flag() {
798 let schema = json!({
799 "properties": {"strict": {"type": "boolean", "enum": [true]}}
800 });
801 let result = schema_to_clap_args(&schema, None).unwrap();
802 assert!(find_arg(&result.args, "strict").is_some());
803 assert!(find_arg(&result.args, "no-strict").is_some());
804 assert!(!result.enum_maps.contains_key("strict"));
805 }
806
807 #[test]
808 fn test_boolean_not_counted_as_required_arg() {
809 let schema = json!({
810 "properties": {"active": {"type": "boolean"}},
811 "required": ["active"]
812 });
813 let result = schema_to_clap_args(&schema, None).unwrap();
814 let pos = find_arg(&result.args, "active").unwrap();
815 let neg = find_arg(&result.args, "no-active").unwrap();
816 assert!(!pos.is_required_set());
817 assert!(!neg.is_required_set());
818 }
819
820 #[test]
823 fn test_enum_string_choices() {
824 let schema = json!({
825 "properties": {
826 "output_type": {"type": "string", "enum": ["json", "csv", "xml"]}
827 }
828 });
829 let result = schema_to_clap_args(&schema, None).unwrap();
830 let arg = find_arg(&result.args, "output-type").expect("--output-type must exist");
831 let pvs = arg.get_possible_values();
832 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
833 assert_eq!(possible, vec!["json", "csv", "xml"]);
834 }
835
836 #[test]
837 fn test_enum_integer_choices_as_strings() {
838 let schema = json!({
839 "properties": {
840 "level": {"type": "integer", "enum": [1, 2, 3]}
841 }
842 });
843 let result = schema_to_clap_args(&schema, None).unwrap();
844 let arg = find_arg(&result.args, "level").expect("--level must exist");
845 let pvs = arg.get_possible_values();
846 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
847 assert_eq!(possible, vec!["1", "2", "3"]);
848 let map = result
849 .enum_maps
850 .get("level")
851 .expect("enum_maps must have 'level'");
852 assert_eq!(map[0], serde_json::Value::Number(1.into()));
853 }
854
855 #[test]
856 fn test_enum_float_choices_as_strings() {
857 let schema = json!({
858 "properties": {
859 "ratio": {"type": "number", "enum": [0.5, 1.0, 1.5]}
860 }
861 });
862 let result = schema_to_clap_args(&schema, None).unwrap();
863 let arg = find_arg(&result.args, "ratio").unwrap();
864 let pvs = arg.get_possible_values();
865 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
866 assert!(possible.contains(&"0.5"));
867 }
868
869 #[test]
870 fn test_enum_bool_choices_as_strings() {
871 let schema = json!({
872 "properties": {
873 "flag": {"type": "string", "enum": [true, false]}
874 }
875 });
876 let result = schema_to_clap_args(&schema, None).unwrap();
877 let arg = find_arg(&result.args, "flag").expect("--flag must exist");
878 let pvs = arg.get_possible_values();
879 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
880 assert!(possible.contains(&"true"));
881 assert!(possible.contains(&"false"));
882 }
883
884 #[test]
885 fn test_enum_empty_array_falls_through_to_string() {
886 let schema = json!({
887 "properties": {
888 "x": {"type": "string", "enum": []}
889 }
890 });
891 let result = schema_to_clap_args(&schema, None).unwrap();
892 let arg = find_arg(&result.args, "x").expect("--x must exist");
893 assert!(arg.get_possible_values().is_empty());
894 assert!(!result.enum_maps.contains_key("x"));
895 }
896
897 #[test]
898 fn test_enum_with_default() {
899 let schema = json!({
900 "properties": {
901 "output_type": {"type": "string", "enum": ["json", "table"], "default": "json"}
902 }
903 });
904 let result = schema_to_clap_args(&schema, None).unwrap();
905 let arg = find_arg(&result.args, "output-type").unwrap();
906 assert_eq!(
907 arg.get_default_values().first().and_then(|v| v.to_str()),
908 Some("json")
909 );
910 }
911
912 #[test]
913 fn test_enum_required_property() {
914 let schema = json!({
915 "properties": {
916 "mode": {"type": "string", "enum": ["a", "b"]}
917 },
918 "required": ["mode"]
919 });
920 let result = schema_to_clap_args(&schema, None).unwrap();
921 let arg = find_arg(&result.args, "mode").unwrap();
922 assert!(
923 !arg.is_required_set(),
924 "required enforced post-parse, not at clap level"
925 );
926 }
927
928 #[test]
929 fn test_enum_stored_in_enum_maps() {
930 let schema = json!({
931 "properties": {
932 "priority": {"type": "integer", "enum": [1, 2, 3]}
933 }
934 });
935 let result = schema_to_clap_args(&schema, None).unwrap();
936 assert!(result.enum_maps.contains_key("priority"));
937 let map = &result.enum_maps["priority"];
938 assert_eq!(map.len(), 3);
939 }
940
941 #[test]
944 fn test_help_prefers_x_llm_description() {
945 let schema = json!({
946 "properties": {
947 "q": {
948 "type": "string",
949 "description": "plain description",
950 "x-llm-description": "LLM-optimised description"
951 }
952 }
953 });
954 let result = schema_to_clap_args(&schema, None).unwrap();
955 let arg = find_arg(&result.args, "q").unwrap();
956 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
957 assert!(
958 help.contains("LLM-optimised"),
959 "help must come from x-llm-description, got: {help}"
960 );
961 assert!(
962 !help.contains("plain description"),
963 "help must NOT come from description when x-llm-description is present"
964 );
965 }
966
967 #[test]
968 fn test_help_falls_back_to_description() {
969 let schema = json!({
970 "properties": {
971 "q": {"type": "string", "description": "fallback text"}
972 }
973 });
974 let result = schema_to_clap_args(&schema, None).unwrap();
975 let arg = find_arg(&result.args, "q").unwrap();
976 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
977 assert!(help.contains("fallback text"));
978 }
979
980 #[test]
981 fn test_help_truncated_at_1000_chars() {
982 let long_desc = "A".repeat(1100);
983 let schema = json!({
984 "properties": {
985 "q": {"type": "string", "description": long_desc}
986 }
987 });
988 let result = schema_to_clap_args(&schema, None).unwrap();
989 let arg = find_arg(&result.args, "q").unwrap();
990 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
991 assert_eq!(
992 help.len(),
993 1000,
994 "truncated help must be exactly 1000 chars"
995 );
996 assert!(help.ends_with("..."), "truncated help must end with '...'");
997 }
998
999 #[test]
1000 fn test_help_within_limit_not_truncated() {
1001 let desc = "B".repeat(999);
1002 let schema = json!({
1003 "properties": {
1004 "q": {"type": "string", "description": desc}
1005 }
1006 });
1007 let result = schema_to_clap_args(&schema, None).unwrap();
1008 let arg = find_arg(&result.args, "q").unwrap();
1009 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
1010 assert_eq!(help.len(), 999);
1011 assert!(!help.ends_with("..."));
1012 }
1013
1014 #[test]
1015 fn test_help_none_when_no_description_fields() {
1016 let schema = json!({
1017 "properties": {"q": {"type": "string"}}
1018 });
1019 let result = schema_to_clap_args(&schema, None).unwrap();
1020 let arg = find_arg(&result.args, "q").unwrap();
1021 assert!(arg.get_help().is_none());
1022 }
1023
1024 #[test]
1025 fn test_flag_collision_detection() {
1026 let schema = json!({
1027 "properties": {
1028 "foo_bar": {"type": "string"},
1029 "foo-bar": {"type": "string"}
1030 }
1031 });
1032 let result = schema_to_clap_args(&schema, None);
1033 assert!(
1034 matches!(result, Err(SchemaParserError::FlagCollision { .. })),
1035 "expected FlagCollision, got: {result:?}"
1036 );
1037 }
1038
1039 #[test]
1040 fn test_flag_collision_error_message_contains_both_names() {
1041 let schema = json!({
1042 "properties": {
1043 "my_flag": {"type": "string"},
1044 "my-flag": {"type": "string"}
1045 }
1046 });
1047 let err = schema_to_clap_args(&schema, None).unwrap_err();
1048 let msg = err.to_string();
1049 assert!(msg.contains("my_flag") || msg.contains("my-flag"));
1050 assert!(msg.contains("my-flag") || msg.contains("--my-flag"));
1051 }
1052
1053 #[test]
1054 fn test_no_collision_for_distinct_flags() {
1055 let schema = json!({
1056 "properties": {
1057 "alpha": {"type": "string"},
1058 "beta": {"type": "string"}
1059 }
1060 });
1061 let result = schema_to_clap_args(&schema, None);
1062 assert!(result.is_ok());
1063 }
1064
1065 fn make_kwargs(pairs: &[(&str, &str)]) -> HashMap<String, Value> {
1068 pairs
1069 .iter()
1070 .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
1071 .collect()
1072 }
1073
1074 #[test]
1075 fn test_reconvert_string_enum_passthrough() {
1076 let schema = json!({
1077 "properties": {"output_type": {"type": "string", "enum": ["json", "csv"]}}
1078 });
1079 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1080 let kwargs = make_kwargs(&[("output_type", "json")]);
1081 let result = reconvert_enum_values(kwargs, &schema_args);
1082 assert_eq!(result["output_type"], Value::String("json".to_string()));
1083 }
1084
1085 #[test]
1086 fn test_reconvert_integer_enum() {
1087 let schema = json!({
1088 "properties": {"level": {"type": "integer", "enum": [1, 2, 3]}}
1089 });
1090 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1091 let kwargs = make_kwargs(&[("level", "2")]);
1092 let result = reconvert_enum_values(kwargs, &schema_args);
1093 assert_eq!(result["level"], json!(2));
1094 assert!(result["level"].is_number());
1095 }
1096
1097 #[test]
1098 fn test_reconvert_float_enum() {
1099 let schema = json!({
1100 "properties": {"ratio": {"type": "number", "enum": [0.5, 1.0, 1.5]}}
1101 });
1102 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1103 let kwargs = make_kwargs(&[("ratio", "1.5")]);
1104 let result = reconvert_enum_values(kwargs, &schema_args);
1105 assert!(result["ratio"].is_number());
1106 assert_eq!(result["ratio"].as_f64(), Some(1.5));
1107 }
1108
1109 #[test]
1110 fn test_reconvert_bool_enum() {
1111 let schema = json!({
1112 "properties": {"strict": {"type": "string", "enum": [true, false]}}
1113 });
1114 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1115 let kwargs = make_kwargs(&[("strict", "true")]);
1116 let result = reconvert_enum_values(kwargs, &schema_args);
1117 assert_eq!(result["strict"], Value::Bool(true));
1118 }
1119
1120 #[test]
1121 fn test_reconvert_non_enum_field_unchanged() {
1122 let schema = json!({
1123 "properties": {"name": {"type": "string"}}
1124 });
1125 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1126 let kwargs = make_kwargs(&[("name", "alice")]);
1127 let result = reconvert_enum_values(kwargs, &schema_args);
1128 assert_eq!(result["name"], Value::String("alice".to_string()));
1129 }
1130
1131 #[test]
1132 fn test_reconvert_null_value_unchanged() {
1133 let schema = json!({
1134 "properties": {"mode": {"type": "string", "enum": ["a", "b"]}}
1135 });
1136 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1137 let mut kwargs: HashMap<String, Value> = HashMap::new();
1138 kwargs.insert("mode".to_string(), Value::Null);
1139 let result = reconvert_enum_values(kwargs, &schema_args);
1140 assert_eq!(result["mode"], Value::Null);
1141 }
1142
1143 #[test]
1144 fn test_reconvert_preserves_non_enum_keys() {
1145 let schema = json!({
1146 "properties": {"output_type": {"type": "string", "enum": ["json"]}}
1147 });
1148 let schema_args = schema_to_clap_args(&schema, None).unwrap();
1149 let mut kwargs = make_kwargs(&[("output_type", "json")]);
1150 kwargs.insert("extra".to_string(), Value::String("untouched".to_string()));
1151 let result = reconvert_enum_values(kwargs, &schema_args);
1152 assert_eq!(result["extra"], Value::String("untouched".to_string()));
1153 }
1154
1155 #[test]
1156 fn test_reserved_property_name_rejected() {
1157 for reserved in RESERVED_PROPERTY_NAMES {
1158 let schema_str = format!(r#"{{"properties": {{"{reserved}": {{"type": "string"}}}}}}"#);
1159 let schema: Value = serde_json::from_str(&schema_str).unwrap();
1160 let result = schema_to_clap_args(&schema, None);
1161 assert!(
1162 matches!(result, Err(SchemaParserError::ReservedPropertyName { .. })),
1163 "expected ReservedPropertyName error for '{reserved}'"
1164 );
1165 }
1166 }
1167
1168 #[test]
1169 fn test_reserved_property_name_large_input_rejected() {
1170 assert!(
1174 RESERVED_PROPERTY_NAMES.contains(&"large_input"),
1175 "RESERVED_PROPERTY_NAMES must include 'large_input' for cross-language parity"
1176 );
1177 let schema: Value =
1178 serde_json::from_str(r#"{"properties": {"large_input": {"type": "string"}}}"#).unwrap();
1179 let result = schema_to_clap_args(&schema, None);
1180 assert!(
1181 matches!(
1182 result,
1183 Err(SchemaParserError::ReservedPropertyName { ref name }) if name == "large_input"
1184 ),
1185 "expected ReservedPropertyName error for 'large_input', got {result:?}"
1186 );
1187 }
1188}