1use std::collections::HashMap;
5use std::path::PathBuf;
6
7use clap::Arg;
8use serde_json::Value;
9use thiserror::Error;
10use tracing::warn;
11
12#[derive(Debug, Error)]
18pub enum SchemaParserError {
19 #[error("Flag name collision: properties '{prop_a}' and '{prop_b}' both map to '{flag_name}'")]
22 FlagCollision {
23 prop_a: String,
24 prop_b: String,
25 flag_name: String,
26 },
27}
28
29#[derive(Debug)]
35pub struct BoolFlagPair {
36 pub prop_name: String,
38 pub flag_long: String,
40 pub default_val: bool,
42}
43
44#[derive(Debug)]
46pub struct SchemaArgs {
47 pub args: Vec<Arg>,
49 pub bool_pairs: Vec<BoolFlagPair>,
51 pub enum_maps: HashMap<String, Vec<Value>>,
54}
55
56pub const HELP_TEXT_MAX_LEN: usize = 1000;
61
62pub fn prop_name_to_flag_name(s: &str) -> String {
68 s.replace('_', "-")
69}
70
71fn is_file_property(prop_name: &str, prop_schema: &Value) -> bool {
73 prop_name.ends_with("_file")
74 || prop_schema
75 .get("x-cli-file")
76 .and_then(|v| v.as_bool())
77 .unwrap_or(false)
78}
79
80pub fn extract_help(prop_schema: &Value) -> Option<String> {
84 extract_help_with_limit(prop_schema, HELP_TEXT_MAX_LEN)
85}
86
87pub fn extract_help_with_limit(prop_schema: &Value, max_len: usize) -> Option<String> {
89 let text = prop_schema
90 .get("x-llm-description")
91 .and_then(|v| v.as_str())
92 .filter(|s| !s.is_empty())
93 .or_else(|| {
94 prop_schema
95 .get("description")
96 .and_then(|v| v.as_str())
97 .filter(|s| !s.is_empty())
98 })?;
99
100 if max_len > 0 && text.len() > max_len {
101 Some(format!("{}...", &text[..max_len - 3]))
102 } else {
103 Some(text.to_string())
104 }
105}
106
107pub fn map_type(prop_name: &str, prop_schema: &Value) -> Result<Arg, SchemaParserError> {
116 let flag_long = prop_name_to_flag_name(prop_name);
117 let schema_type = prop_schema.get("type").and_then(|v| v.as_str());
118
119 let arg = Arg::new(prop_name.to_string()).long(flag_long);
120
121 let arg = match schema_type {
125 Some("integer") | Some("number") => arg,
126 Some("string") if is_file_property(prop_name, prop_schema) => {
127 arg.value_parser(clap::value_parser!(PathBuf))
128 }
129 Some("string") | Some("object") | Some("array") => arg,
130 Some(unknown) => {
131 warn!(
132 "Unknown schema type '{}' for property '{}', defaulting to string.",
133 unknown, prop_name
134 );
135 arg
136 }
137 None => {
138 warn!(
139 "No type specified for property '{}', defaulting to string.",
140 prop_name
141 );
142 arg
143 }
144 };
145
146 Ok(arg)
147}
148
149pub fn schema_to_clap_args(schema: &Value) -> Result<SchemaArgs, SchemaParserError> {
165 schema_to_clap_args_with_limit(schema, HELP_TEXT_MAX_LEN)
166}
167
168pub fn schema_to_clap_args_with_limit(
170 schema: &Value,
171 help_max_len: usize,
172) -> Result<SchemaArgs, SchemaParserError> {
173 let properties = match schema.get("properties").and_then(|v| v.as_object()) {
174 Some(p) => p,
175 None => {
176 return Ok(SchemaArgs {
177 args: Vec::new(),
178 bool_pairs: Vec::new(),
179 enum_maps: HashMap::new(),
180 });
181 }
182 };
183
184 let required_list: Vec<&str> = schema
185 .get("required")
186 .and_then(|v| v.as_array())
187 .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect())
188 .unwrap_or_default();
189
190 for req_name in &required_list {
192 if !properties.contains_key(*req_name) {
193 warn!(
194 "Required property '{}' not found in properties, skipping.",
195 req_name
196 );
197 }
198 }
199
200 let mut args: Vec<Arg> = Vec::new();
201 let mut bool_pairs: Vec<BoolFlagPair> = Vec::new();
202 let mut enum_maps: HashMap<String, Vec<Value>> = HashMap::new();
203 let mut seen_flags: HashMap<String, String> = HashMap::new(); for (prop_name, prop_schema) in properties {
206 let flag_long = prop_name_to_flag_name(prop_name);
207
208 if let Some(existing) = seen_flags.get(&flag_long) {
210 return Err(SchemaParserError::FlagCollision {
211 prop_a: prop_name.clone(),
212 prop_b: existing.clone(),
213 flag_name: flag_long,
214 });
215 }
216 seen_flags.insert(flag_long.clone(), prop_name.clone());
217
218 let schema_type = prop_schema.get("type").and_then(|v| v.as_str());
219 let is_required = required_list.contains(&prop_name.as_str());
220 let help_text = extract_help_with_limit(prop_schema, help_max_len);
221 let default_val = prop_schema.get("default");
222
223 if schema_type == Some("boolean") {
225 let bool_default = prop_schema
226 .get("default")
227 .and_then(|v| v.as_bool())
228 .unwrap_or(false);
229
230 let mut pos_arg = Arg::new(prop_name.clone())
231 .long(flag_long.clone())
232 .action(clap::ArgAction::SetTrue);
233 let mut neg_arg = Arg::new(format!("no-{}", prop_name))
234 .long(format!("no-{}", flag_long))
235 .action(clap::ArgAction::SetFalse);
236
237 if let Some(ref help) = help_text {
238 pos_arg = pos_arg.help(help.clone());
239 neg_arg = neg_arg.help(format!("Disable --{flag_long}"));
240 }
241
242 let no_flag_long = format!("no-{}", flag_long);
244 seen_flags.insert(no_flag_long, format!("no-{}", prop_name));
245
246 args.push(pos_arg);
247 args.push(neg_arg);
248
249 bool_pairs.push(BoolFlagPair {
250 prop_name: prop_name.clone(),
251 flag_long,
252 default_val: bool_default,
253 });
254
255 let _ = is_required;
258
259 continue;
260 }
261
262 if let Some(enum_values) = prop_schema.get("enum").and_then(|v| v.as_array()) {
264 if enum_values.is_empty() {
265 warn!(
266 "Empty enum for property '{}', falling through to plain string arg.",
267 prop_name
268 );
269 } else {
271 let string_values: Vec<String> = enum_values
273 .iter()
274 .map(|v| match v {
275 Value::String(s) => s.clone(),
276 other => other.to_string(),
277 })
278 .collect();
279
280 enum_maps.insert(prop_name.clone(), enum_values.to_vec());
282
283 let mut arg = Arg::new(prop_name.clone())
284 .long(flag_long)
285 .value_parser(clap::builder::PossibleValuesParser::new(string_values))
286 .required(false); if let Some(help) = help_text {
290 let annotated = if is_required {
291 format!("{} [required]", help)
292 } else {
293 help
294 };
295 arg = arg.help(annotated);
296 } else if is_required {
297 arg = arg.help("[required]");
298 }
299
300 if let Some(dv) = default_val {
301 let dv_str = match dv {
302 Value::String(s) => s.clone(),
303 other => other.to_string(),
304 };
305 arg = arg.default_value(dv_str);
306 }
307
308 args.push(arg);
309 continue;
310 }
311 }
312
313 let mut arg = map_type(prop_name, prop_schema)?.required(is_required);
315
316 if let Some(help) = help_text {
317 arg = arg.help(help);
318 }
319
320 if let Some(dv) = default_val {
322 let dv_str = match dv {
323 Value::String(s) => s.clone(),
324 other => other.to_string(),
325 };
326 arg = arg.default_value(dv_str);
327 }
328
329 args.push(arg);
330 }
331
332 Ok(SchemaArgs {
333 args,
334 bool_pairs,
335 enum_maps,
336 })
337}
338
339pub fn reconvert_enum_values(
356 kwargs: HashMap<String, Value>,
357 schema_args: &SchemaArgs,
358) -> HashMap<String, Value> {
359 let mut result = kwargs;
360
361 for (key, original_variants) in &schema_args.enum_maps {
362 let val = match result.get(key) {
363 Some(v) => v.clone(),
364 None => continue,
365 };
366
367 let str_val = match &val {
369 Value::String(s) => s.clone(),
370 _ => continue,
371 };
372
373 let original = original_variants.iter().find(|v| {
375 let as_str = match v {
376 Value::String(s) => s.clone(),
377 other => other.to_string(),
378 };
379 as_str == str_val
380 });
381
382 if let Some(orig) = original {
383 let converted = match orig {
384 Value::Number(n) => {
385 if n.as_i64().is_some() {
386 str_val
387 .parse::<i64>()
388 .ok()
389 .map(|i| Value::Number(i.into()))
390 .unwrap_or(val.clone())
391 } else {
392 str_val
393 .parse::<f64>()
394 .ok()
395 .and_then(serde_json::Number::from_f64)
396 .map(Value::Number)
397 .unwrap_or(val.clone())
398 }
399 }
400 Value::Bool(_) => Value::Bool(str_val.to_lowercase() == "true"),
401 _ => val.clone(), };
403 result.insert(key.clone(), converted);
404 }
405 }
406
407 result
408}
409
410#[cfg(test)]
415mod tests {
416 use super::*;
417 use serde_json::json;
418
419 fn find_arg<'a>(args: &'a [clap::Arg], long: &str) -> Option<&'a clap::Arg> {
421 args.iter().find(|a| a.get_long() == Some(long))
422 }
423
424 #[test]
425 fn test_schema_to_clap_args_empty_schema() {
426 let schema = json!({});
427 let result = schema_to_clap_args(&schema).unwrap();
428 assert!(result.args.is_empty());
429 assert!(result.bool_pairs.is_empty());
430 assert!(result.enum_maps.is_empty());
431 }
432
433 #[test]
434 fn test_schema_to_clap_args_string_property() {
435 let schema = json!({
436 "properties": {"text": {"type": "string", "description": "Some text"}},
437 "required": []
438 });
439 let result = schema_to_clap_args(&schema).unwrap();
440 assert_eq!(result.args.len(), 1);
441 let arg = find_arg(&result.args, "text").expect("--text must exist");
442 assert_eq!(arg.get_id(), "text");
443 assert!(!arg.is_required_set());
444 }
445
446 #[test]
447 fn test_schema_to_clap_args_integer_property() {
448 let schema = json!({
449 "properties": {"count": {"type": "integer"}},
450 "required": ["count"]
451 });
452 let result = schema_to_clap_args(&schema).unwrap();
453 let arg = find_arg(&result.args, "count").expect("--count must exist");
454 assert!(arg.is_required_set());
455 }
456
457 #[test]
458 fn test_schema_to_clap_args_number_property() {
459 let schema = json!({
460 "properties": {"rate": {"type": "number"}}
461 });
462 let result = schema_to_clap_args(&schema).unwrap();
463 assert!(find_arg(&result.args, "rate").is_some());
464 }
465
466 #[test]
467 fn test_schema_to_clap_args_object_and_array_as_string() {
468 let schema = json!({
469 "properties": {
470 "data": {"type": "object"},
471 "items": {"type": "array"}
472 }
473 });
474 let result = schema_to_clap_args(&schema).unwrap();
475 assert!(find_arg(&result.args, "data").is_some());
476 assert!(find_arg(&result.args, "items").is_some());
477 }
478
479 #[test]
480 fn test_schema_to_clap_args_underscore_to_hyphen() {
481 let schema = json!({
482 "properties": {"input_file": {"type": "string"}}
483 });
484 let result = schema_to_clap_args(&schema).unwrap();
485 assert!(find_arg(&result.args, "input-file").is_some());
487 let arg = find_arg(&result.args, "input-file").unwrap();
489 assert_eq!(arg.get_id(), "input_file");
490 }
491
492 #[test]
493 fn test_schema_to_clap_args_file_convention_suffix() {
494 let schema = json!({
495 "properties": {"config_file": {"type": "string"}}
496 });
497 let result = schema_to_clap_args(&schema).unwrap();
498 let arg = find_arg(&result.args, "config-file").expect("must exist");
499 let _ = arg; }
501
502 #[test]
503 fn test_schema_to_clap_args_x_cli_file_flag() {
504 let schema = json!({
505 "properties": {"report": {"type": "string", "x-cli-file": true}}
506 });
507 let result = schema_to_clap_args(&schema).unwrap();
508 assert!(find_arg(&result.args, "report").is_some());
509 }
510
511 #[test]
512 fn test_schema_to_clap_args_unknown_type_defaults_to_string() {
513 let schema = json!({
514 "properties": {"x": {"type": "foobar"}}
515 });
516 let result = schema_to_clap_args(&schema).unwrap();
517 assert!(find_arg(&result.args, "x").is_some());
518 }
519
520 #[test]
521 fn test_schema_to_clap_args_missing_type_defaults_to_string() {
522 let schema = json!({
523 "properties": {"x": {"description": "no type field"}}
524 });
525 let result = schema_to_clap_args(&schema).unwrap();
526 assert!(find_arg(&result.args, "x").is_some());
527 }
528
529 #[test]
530 fn test_schema_to_clap_args_default_value_set() {
531 let schema = json!({
532 "properties": {"timeout": {"type": "integer", "default": 30}}
533 });
534 let result = schema_to_clap_args(&schema).unwrap();
535 let arg = find_arg(&result.args, "timeout").unwrap();
536 assert_eq!(
537 arg.get_default_values().first().and_then(|v| v.to_str()),
538 Some("30")
539 );
540 }
541
542 #[test]
545 fn test_extract_help_uses_description() {
546 let prop = json!({"description": "A plain description"});
547 assert_eq!(extract_help(&prop), Some("A plain description".to_string()));
548 }
549
550 #[test]
551 fn test_extract_help_prefers_x_llm_description() {
552 let prop = json!({
553 "description": "Plain description",
554 "x-llm-description": "LLM description"
555 });
556 assert_eq!(extract_help(&prop), Some("LLM description".to_string()));
557 }
558
559 #[test]
560 fn test_extract_help_truncates_at_1000() {
561 let long_text = "a".repeat(1100);
562 let prop = json!({"description": long_text});
563 let result = extract_help(&prop).unwrap();
564 assert_eq!(result.len(), 1000);
565 assert!(result.ends_with("..."));
566 }
567
568 #[test]
569 fn test_extract_help_no_truncation_within_limit() {
570 let text = "b".repeat(999);
571 let prop = json!({"description": text.clone()});
572 let result = extract_help(&prop).unwrap();
573 assert_eq!(result, text);
574 assert!(!result.ends_with("..."));
575 }
576
577 #[test]
578 fn test_extract_help_custom_max_length() {
579 let long_text = "c".repeat(300);
580 let prop = json!({"description": long_text});
581 let result = extract_help_with_limit(&prop, 200).unwrap();
582 assert_eq!(result.len(), 200);
583 assert!(result.ends_with("..."));
584 }
585
586 #[test]
587 fn test_extract_help_returns_none_when_absent() {
588 let prop = json!({"type": "string"});
589 assert_eq!(extract_help(&prop), None);
590 }
591
592 #[test]
595 fn test_prop_name_to_flag_name() {
596 assert_eq!(prop_name_to_flag_name("my_val"), "my-val");
597 assert_eq!(prop_name_to_flag_name("simple"), "simple");
598 assert_eq!(prop_name_to_flag_name("a_b_c"), "a-b-c");
599 }
600
601 #[test]
604 fn test_map_type_string() {
605 let prop = json!({"type": "string"});
606 let arg = map_type("name", &prop).unwrap();
607 assert_eq!(arg.get_long(), Some("name"));
608 assert_eq!(arg.get_id(), "name");
609 }
610
611 #[test]
612 fn test_map_type_integer() {
613 let prop = json!({"type": "integer"});
614 let arg = map_type("count", &prop).unwrap();
615 assert_eq!(arg.get_long(), Some("count"));
616 }
617
618 #[test]
619 fn test_map_type_number() {
620 let prop = json!({"type": "number"});
621 let arg = map_type("rate", &prop).unwrap();
622 assert_eq!(arg.get_long(), Some("rate"));
623 }
624
625 #[test]
626 fn test_map_type_file_suffix() {
627 let prop = json!({"type": "string"});
628 let arg = map_type("config_file", &prop).unwrap();
629 assert_eq!(arg.get_long(), Some("config-file"));
631 }
632
633 #[test]
634 fn test_map_type_x_cli_file() {
635 let prop = json!({"type": "string", "x-cli-file": true});
636 let arg = map_type("report", &prop).unwrap();
637 assert_eq!(arg.get_long(), Some("report"));
638 }
639
640 #[test]
641 fn test_map_type_object_as_string() {
642 let prop = json!({"type": "object"});
643 let arg = map_type("data", &prop).unwrap();
644 assert_eq!(arg.get_long(), Some("data"));
645 }
646
647 #[test]
648 fn test_map_type_array_as_string() {
649 let prop = json!({"type": "array"});
650 let arg = map_type("items", &prop).unwrap();
651 assert_eq!(arg.get_long(), Some("items"));
652 }
653
654 #[test]
655 fn test_map_type_unknown_defaults_to_string() {
656 let prop = json!({"type": "foobar"});
657 let arg = map_type("x", &prop).unwrap();
658 assert_eq!(arg.get_long(), Some("x"));
659 }
660
661 #[test]
664 fn test_boolean_flag_pair_produced() {
665 let schema = json!({
666 "properties": {"verbose": {"type": "boolean"}}
667 });
668 let result = schema_to_clap_args(&schema).unwrap();
669 assert!(
670 find_arg(&result.args, "verbose").is_some(),
671 "--verbose must be present"
672 );
673 assert!(
674 find_arg(&result.args, "no-verbose").is_some(),
675 "--no-verbose must be present"
676 );
677 }
678
679 #[test]
680 fn test_boolean_pair_actions() {
681 let schema = json!({
682 "properties": {"verbose": {"type": "boolean"}}
683 });
684 let result = schema_to_clap_args(&schema).unwrap();
685 let pos_arg = find_arg(&result.args, "verbose").unwrap();
686 let neg_arg = find_arg(&result.args, "no-verbose").unwrap();
687 assert!(matches!(pos_arg.get_action(), clap::ArgAction::SetTrue));
688 assert!(matches!(neg_arg.get_action(), clap::ArgAction::SetFalse));
689 }
690
691 #[test]
692 fn test_boolean_default_false() {
693 let schema = json!({
694 "properties": {"debug": {"type": "boolean"}}
695 });
696 let result = schema_to_clap_args(&schema).unwrap();
697 let pair = result.bool_pairs.iter().find(|p| p.prop_name == "debug");
698 assert!(pair.is_some());
699 assert!(
700 !pair.unwrap().default_val,
701 "default must be false when not specified"
702 );
703 }
704
705 #[test]
706 fn test_boolean_default_true() {
707 let schema = json!({
708 "properties": {"enabled": {"type": "boolean", "default": true}}
709 });
710 let result = schema_to_clap_args(&schema).unwrap();
711 let pair = result
712 .bool_pairs
713 .iter()
714 .find(|p| p.prop_name == "enabled")
715 .expect("BoolFlagPair must be recorded");
716 assert!(
717 pair.default_val,
718 "default must be true when schema says true"
719 );
720 }
721
722 #[test]
723 fn test_boolean_pair_recorded_in_bool_pairs() {
724 let schema = json!({
725 "properties": {"dry_run": {"type": "boolean"}}
726 });
727 let result = schema_to_clap_args(&schema).unwrap();
728 let pair = result.bool_pairs.iter().find(|p| p.prop_name == "dry_run");
729 assert!(pair.is_some(), "BoolFlagPair must be recorded for dry_run");
730 assert_eq!(
731 pair.unwrap().flag_long,
732 "dry-run",
733 "flag_long must use hyphen form"
734 );
735 }
736
737 #[test]
738 fn test_boolean_underscore_to_hyphen() {
739 let schema = json!({
740 "properties": {"dry_run": {"type": "boolean"}}
741 });
742 let result = schema_to_clap_args(&schema).unwrap();
743 assert!(find_arg(&result.args, "dry-run").is_some(), "--dry-run");
744 assert!(
745 find_arg(&result.args, "no-dry-run").is_some(),
746 "--no-dry-run"
747 );
748 }
749
750 #[test]
751 fn test_boolean_with_enum_true_treated_as_flag() {
752 let schema = json!({
753 "properties": {"strict": {"type": "boolean", "enum": [true]}}
754 });
755 let result = schema_to_clap_args(&schema).unwrap();
756 assert!(find_arg(&result.args, "strict").is_some());
757 assert!(find_arg(&result.args, "no-strict").is_some());
758 assert!(!result.enum_maps.contains_key("strict"));
759 }
760
761 #[test]
762 fn test_boolean_not_counted_as_required_arg() {
763 let schema = json!({
764 "properties": {"active": {"type": "boolean"}},
765 "required": ["active"]
766 });
767 let result = schema_to_clap_args(&schema).unwrap();
768 let pos = find_arg(&result.args, "active").unwrap();
769 let neg = find_arg(&result.args, "no-active").unwrap();
770 assert!(!pos.is_required_set());
771 assert!(!neg.is_required_set());
772 }
773
774 #[test]
777 fn test_enum_string_choices() {
778 let schema = json!({
779 "properties": {
780 "format": {"type": "string", "enum": ["json", "csv", "xml"]}
781 }
782 });
783 let result = schema_to_clap_args(&schema).unwrap();
784 let arg = find_arg(&result.args, "format").expect("--format must exist");
785 let pvs = arg.get_possible_values();
786 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
787 assert_eq!(possible, vec!["json", "csv", "xml"]);
788 }
789
790 #[test]
791 fn test_enum_integer_choices_as_strings() {
792 let schema = json!({
793 "properties": {
794 "level": {"type": "integer", "enum": [1, 2, 3]}
795 }
796 });
797 let result = schema_to_clap_args(&schema).unwrap();
798 let arg = find_arg(&result.args, "level").expect("--level must exist");
799 let pvs = arg.get_possible_values();
800 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
801 assert_eq!(possible, vec!["1", "2", "3"]);
802 let map = result
803 .enum_maps
804 .get("level")
805 .expect("enum_maps must have 'level'");
806 assert_eq!(map[0], serde_json::Value::Number(1.into()));
807 }
808
809 #[test]
810 fn test_enum_float_choices_as_strings() {
811 let schema = json!({
812 "properties": {
813 "ratio": {"type": "number", "enum": [0.5, 1.0, 1.5]}
814 }
815 });
816 let result = schema_to_clap_args(&schema).unwrap();
817 let arg = find_arg(&result.args, "ratio").unwrap();
818 let pvs = arg.get_possible_values();
819 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
820 assert!(possible.contains(&"0.5"));
821 }
822
823 #[test]
824 fn test_enum_bool_choices_as_strings() {
825 let schema = json!({
826 "properties": {
827 "flag": {"type": "string", "enum": [true, false]}
828 }
829 });
830 let result = schema_to_clap_args(&schema).unwrap();
831 let arg = find_arg(&result.args, "flag").expect("--flag must exist");
832 let pvs = arg.get_possible_values();
833 let possible: Vec<&str> = pvs.iter().map(|pv| pv.get_name()).collect();
834 assert!(possible.contains(&"true"));
835 assert!(possible.contains(&"false"));
836 }
837
838 #[test]
839 fn test_enum_empty_array_falls_through_to_string() {
840 let schema = json!({
841 "properties": {
842 "x": {"type": "string", "enum": []}
843 }
844 });
845 let result = schema_to_clap_args(&schema).unwrap();
846 let arg = find_arg(&result.args, "x").expect("--x must exist");
847 assert!(arg.get_possible_values().is_empty());
848 assert!(!result.enum_maps.contains_key("x"));
849 }
850
851 #[test]
852 fn test_enum_with_default() {
853 let schema = json!({
854 "properties": {
855 "format": {"type": "string", "enum": ["json", "table"], "default": "json"}
856 }
857 });
858 let result = schema_to_clap_args(&schema).unwrap();
859 let arg = find_arg(&result.args, "format").unwrap();
860 assert_eq!(
861 arg.get_default_values().first().and_then(|v| v.to_str()),
862 Some("json")
863 );
864 }
865
866 #[test]
867 fn test_enum_required_property() {
868 let schema = json!({
869 "properties": {
870 "mode": {"type": "string", "enum": ["a", "b"]}
871 },
872 "required": ["mode"]
873 });
874 let result = schema_to_clap_args(&schema).unwrap();
875 let arg = find_arg(&result.args, "mode").unwrap();
876 assert!(
877 !arg.is_required_set(),
878 "required enforced post-parse, not at clap level"
879 );
880 }
881
882 #[test]
883 fn test_enum_stored_in_enum_maps() {
884 let schema = json!({
885 "properties": {
886 "priority": {"type": "integer", "enum": [1, 2, 3]}
887 }
888 });
889 let result = schema_to_clap_args(&schema).unwrap();
890 assert!(result.enum_maps.contains_key("priority"));
891 let map = &result.enum_maps["priority"];
892 assert_eq!(map.len(), 3);
893 }
894
895 #[test]
898 fn test_help_prefers_x_llm_description() {
899 let schema = json!({
900 "properties": {
901 "q": {
902 "type": "string",
903 "description": "plain description",
904 "x-llm-description": "LLM-optimised description"
905 }
906 }
907 });
908 let result = schema_to_clap_args(&schema).unwrap();
909 let arg = find_arg(&result.args, "q").unwrap();
910 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
911 assert!(
912 help.contains("LLM-optimised"),
913 "help must come from x-llm-description, got: {help}"
914 );
915 assert!(
916 !help.contains("plain description"),
917 "help must NOT come from description when x-llm-description is present"
918 );
919 }
920
921 #[test]
922 fn test_help_falls_back_to_description() {
923 let schema = json!({
924 "properties": {
925 "q": {"type": "string", "description": "fallback text"}
926 }
927 });
928 let result = schema_to_clap_args(&schema).unwrap();
929 let arg = find_arg(&result.args, "q").unwrap();
930 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
931 assert!(help.contains("fallback text"));
932 }
933
934 #[test]
935 fn test_help_truncated_at_1000_chars() {
936 let long_desc = "A".repeat(1100);
937 let schema = json!({
938 "properties": {
939 "q": {"type": "string", "description": long_desc}
940 }
941 });
942 let result = schema_to_clap_args(&schema).unwrap();
943 let arg = find_arg(&result.args, "q").unwrap();
944 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
945 assert_eq!(
946 help.len(),
947 1000,
948 "truncated help must be exactly 1000 chars"
949 );
950 assert!(help.ends_with("..."), "truncated help must end with '...'");
951 }
952
953 #[test]
954 fn test_help_within_limit_not_truncated() {
955 let desc = "B".repeat(999);
956 let schema = json!({
957 "properties": {
958 "q": {"type": "string", "description": desc}
959 }
960 });
961 let result = schema_to_clap_args(&schema).unwrap();
962 let arg = find_arg(&result.args, "q").unwrap();
963 let help = arg.get_help().map(|s| s.to_string()).unwrap_or_default();
964 assert_eq!(help.len(), 999);
965 assert!(!help.ends_with("..."));
966 }
967
968 #[test]
969 fn test_help_none_when_no_description_fields() {
970 let schema = json!({
971 "properties": {"q": {"type": "string"}}
972 });
973 let result = schema_to_clap_args(&schema).unwrap();
974 let arg = find_arg(&result.args, "q").unwrap();
975 assert!(arg.get_help().is_none());
976 }
977
978 #[test]
979 fn test_flag_collision_detection() {
980 let schema = json!({
981 "properties": {
982 "foo_bar": {"type": "string"},
983 "foo-bar": {"type": "string"}
984 }
985 });
986 let result = schema_to_clap_args(&schema);
987 assert!(
988 matches!(result, Err(SchemaParserError::FlagCollision { .. })),
989 "expected FlagCollision, got: {result:?}"
990 );
991 }
992
993 #[test]
994 fn test_flag_collision_error_message_contains_both_names() {
995 let schema = json!({
996 "properties": {
997 "my_flag": {"type": "string"},
998 "my-flag": {"type": "string"}
999 }
1000 });
1001 let err = schema_to_clap_args(&schema).unwrap_err();
1002 let msg = err.to_string();
1003 assert!(msg.contains("my_flag") || msg.contains("my-flag"));
1004 assert!(msg.contains("my-flag") || msg.contains("--my-flag"));
1005 }
1006
1007 #[test]
1008 fn test_no_collision_for_distinct_flags() {
1009 let schema = json!({
1010 "properties": {
1011 "alpha": {"type": "string"},
1012 "beta": {"type": "string"}
1013 }
1014 });
1015 let result = schema_to_clap_args(&schema);
1016 assert!(result.is_ok());
1017 }
1018
1019 fn make_kwargs(pairs: &[(&str, &str)]) -> HashMap<String, Value> {
1022 pairs
1023 .iter()
1024 .map(|(k, v)| (k.to_string(), Value::String(v.to_string())))
1025 .collect()
1026 }
1027
1028 #[test]
1029 fn test_reconvert_string_enum_passthrough() {
1030 let schema = json!({
1031 "properties": {"format": {"type": "string", "enum": ["json", "csv"]}}
1032 });
1033 let schema_args = schema_to_clap_args(&schema).unwrap();
1034 let kwargs = make_kwargs(&[("format", "json")]);
1035 let result = reconvert_enum_values(kwargs, &schema_args);
1036 assert_eq!(result["format"], Value::String("json".to_string()));
1037 }
1038
1039 #[test]
1040 fn test_reconvert_integer_enum() {
1041 let schema = json!({
1042 "properties": {"level": {"type": "integer", "enum": [1, 2, 3]}}
1043 });
1044 let schema_args = schema_to_clap_args(&schema).unwrap();
1045 let kwargs = make_kwargs(&[("level", "2")]);
1046 let result = reconvert_enum_values(kwargs, &schema_args);
1047 assert_eq!(result["level"], json!(2));
1048 assert!(result["level"].is_number());
1049 }
1050
1051 #[test]
1052 fn test_reconvert_float_enum() {
1053 let schema = json!({
1054 "properties": {"ratio": {"type": "number", "enum": [0.5, 1.0, 1.5]}}
1055 });
1056 let schema_args = schema_to_clap_args(&schema).unwrap();
1057 let kwargs = make_kwargs(&[("ratio", "1.5")]);
1058 let result = reconvert_enum_values(kwargs, &schema_args);
1059 assert!(result["ratio"].is_number());
1060 assert_eq!(result["ratio"].as_f64(), Some(1.5));
1061 }
1062
1063 #[test]
1064 fn test_reconvert_bool_enum() {
1065 let schema = json!({
1066 "properties": {"strict": {"type": "string", "enum": [true, false]}}
1067 });
1068 let schema_args = schema_to_clap_args(&schema).unwrap();
1069 let kwargs = make_kwargs(&[("strict", "true")]);
1070 let result = reconvert_enum_values(kwargs, &schema_args);
1071 assert_eq!(result["strict"], Value::Bool(true));
1072 }
1073
1074 #[test]
1075 fn test_reconvert_non_enum_field_unchanged() {
1076 let schema = json!({
1077 "properties": {"name": {"type": "string"}}
1078 });
1079 let schema_args = schema_to_clap_args(&schema).unwrap();
1080 let kwargs = make_kwargs(&[("name", "alice")]);
1081 let result = reconvert_enum_values(kwargs, &schema_args);
1082 assert_eq!(result["name"], Value::String("alice".to_string()));
1083 }
1084
1085 #[test]
1086 fn test_reconvert_null_value_unchanged() {
1087 let schema = json!({
1088 "properties": {"mode": {"type": "string", "enum": ["a", "b"]}}
1089 });
1090 let schema_args = schema_to_clap_args(&schema).unwrap();
1091 let mut kwargs: HashMap<String, Value> = HashMap::new();
1092 kwargs.insert("mode".to_string(), Value::Null);
1093 let result = reconvert_enum_values(kwargs, &schema_args);
1094 assert_eq!(result["mode"], Value::Null);
1095 }
1096
1097 #[test]
1098 fn test_reconvert_preserves_non_enum_keys() {
1099 let schema = json!({
1100 "properties": {"format": {"type": "string", "enum": ["json"]}}
1101 });
1102 let schema_args = schema_to_clap_args(&schema).unwrap();
1103 let mut kwargs = make_kwargs(&[("format", "json")]);
1104 kwargs.insert("extra".to_string(), Value::String("untouched".to_string()));
1105 let result = reconvert_enum_values(kwargs, &schema_args);
1106 assert_eq!(result["extra"], Value::String("untouched".to_string()));
1107 }
1108}