Skip to main content

apcore_cli/
schema_parser.rs

1// apcore-cli — JSON Schema → clap Arg translator.
2// Protocol spec: FE-09 (schema_to_clap_args, reconvert_enum_values)
3
4use std::collections::HashMap;
5use std::path::PathBuf;
6
7use clap::Arg;
8use serde_json::Value;
9use thiserror::Error;
10use tracing::warn;
11
12// ---------------------------------------------------------------------------
13// Error type
14// ---------------------------------------------------------------------------
15
16/// Error type for schema parsing failures.
17#[derive(Debug, Error)]
18pub enum SchemaParserError {
19    /// Two properties normalise to the same --flag-name.
20    /// Caller must exit 48.
21    #[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// ---------------------------------------------------------------------------
30// Output types
31// ---------------------------------------------------------------------------
32
33/// A single boolean --flag / --no-flag pair generated from a `type: boolean` property.
34#[derive(Debug)]
35pub struct BoolFlagPair {
36    /// Original schema property name (e.g. "verbose").
37    pub prop_name: String,
38    /// Long name used for the positive flag (e.g. "verbose").
39    pub flag_long: String,
40    /// Default value from the schema's `default` field (defaults to false).
41    pub default_val: bool,
42}
43
44/// Full output of schema_to_clap_args.
45#[derive(Debug)]
46pub struct SchemaArgs {
47    /// clap Args ready to attach to a clap::Command.
48    pub args: Vec<Arg>,
49    /// Boolean flag pairs; used by collect_input to reconcile --flag/--no-flag.
50    pub bool_pairs: Vec<BoolFlagPair>,
51    /// Maps property name (snake_case) → original enum values (as serde_json::Value).
52    /// Used by reconvert_enum_values for type coercion.
53    pub enum_maps: HashMap<String, Vec<Value>>,
54}
55
56// ---------------------------------------------------------------------------
57// Constants
58// ---------------------------------------------------------------------------
59
60pub const HELP_TEXT_MAX_LEN: usize = 1000;
61
62// ---------------------------------------------------------------------------
63// Helpers
64// ---------------------------------------------------------------------------
65
66/// Convert a property name (snake_case) to a CLI flag long name (kebab-case).
67pub fn prop_name_to_flag_name(s: &str) -> String {
68    s.replace('_', "-")
69}
70
71/// Determine whether a property should use PathBuf value_parser.
72fn 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
80/// Extract help text from a schema property.
81/// Prefers `x-llm-description` over `description`.
82/// Truncates to `max_len` chars (default: HELP_TEXT_MAX_LEN).
83pub fn extract_help(prop_schema: &Value) -> Option<String> {
84    extract_help_with_limit(prop_schema, HELP_TEXT_MAX_LEN)
85}
86
87/// Extract help text with a configurable max length.
88pub 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
107// ---------------------------------------------------------------------------
108// map_type
109// ---------------------------------------------------------------------------
110
111/// Map a single schema property to a clap::Arg.
112///
113/// Returns an error only for flag collisions (detected at schema_to_clap_args level).
114/// Boolean and enum types are handled by separate tasks.
115pub 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    // All types use string-based value parsers so that extract_cli_kwargs can
122    // uniformly read values with get_one::<String>. Type coercion to JSON
123    // numbers/booleans happens in reconvert_enum_values and collect_input.
124    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
149// ---------------------------------------------------------------------------
150// schema_to_clap_args
151// ---------------------------------------------------------------------------
152
153/// Translate a JSON Schema `properties` map into a SchemaArgs result.
154///
155/// Each schema property becomes one `--<name>` flag with:
156/// * `help` set to the property's `x-llm-description` or `description` field
157/// * `required` set when the property appears in the schema's `required` array
158/// * enum variants and boolean pairs deferred to later tasks
159///
160/// # Arguments
161/// * `schema` — JSON Schema object (may have `"properties"` key)
162///
163/// Returns empty SchemaArgs for schemas without properties.
164pub fn schema_to_clap_args(schema: &Value) -> Result<SchemaArgs, SchemaParserError> {
165    schema_to_clap_args_with_limit(schema, HELP_TEXT_MAX_LEN)
166}
167
168/// Convert JSON Schema properties to clap Args with a configurable help text max length.
169pub 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    // Warn about required properties missing from properties map.
191    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(); // flag_long → prop_name
204
205    for (prop_name, prop_schema) in properties {
206        let flag_long = prop_name_to_flag_name(prop_name);
207
208        // Collision detection.
209        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        // Boolean → --flag / --no-flag pair. Must be checked before enum.
224        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            // Also register the no- flag in seen_flags to detect collisions.
243            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            // Suppress unused variable warning; is_required is intentionally
256            // not applied to boolean flags.
257            let _ = is_required;
258
259            continue;
260        }
261
262        // Enum handling: properties with an "enum" array (and type != "boolean").
263        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                // Fall through to plain string arg below.
270            } else {
271                // Convert all enum values to String for clap's PossibleValuesParser.
272                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                // Store original typed values for post-parse reconversion.
281                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); // required enforced post-parse for STDIN compatibility
287
288                // Attach help text with optional [required] annotation.
289                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        // Build Arg using map_type.
314        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        // Default value (set as string; clap parses it through the value_parser).
321        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
339// ---------------------------------------------------------------------------
340// reconvert_enum_values
341// ---------------------------------------------------------------------------
342
343/// Re-map string enum values from CLI args back to their JSON-typed forms.
344///
345/// clap always produces `String` values; this function converts them to the
346/// correct JSON type (number, boolean, null) based on the original schema
347/// definition stored in `schema_args.enum_maps`.
348///
349/// # Arguments
350/// * `kwargs`      — raw CLI arguments map (string values from clap)
351/// * `schema_args` — the SchemaArgs produced by `schema_to_clap_args`
352///
353/// Returns a new map with enum values converted to their correct JSON types.
354/// Non-enum keys and Null values pass through unchanged.
355pub 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        // Skip null / non-string values (absent optional args arrive as Null).
368        let str_val = match &val {
369            Value::String(s) => s.clone(),
370            _ => continue,
371        };
372
373        // Find the original variant whose string representation matches str_val.
374        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(), // String: keep as-is
402            };
403            result.insert(key.clone(), converted);
404        }
405    }
406
407    result
408}
409
410// ---------------------------------------------------------------------------
411// Unit tests
412// ---------------------------------------------------------------------------
413
414#[cfg(test)]
415mod tests {
416    use super::*;
417    use serde_json::json;
418
419    // Helper: find an Arg by long name.
420    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        // Flag long name must be "input-file".
486        assert!(find_arg(&result.args, "input-file").is_some());
487        // Arg id must be "input_file" (original name, for collect_input lookup).
488        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; // Exact parser check is implementation-dependent.
500    }
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    // --- extract_help tests ---
543
544    #[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    // --- prop_name_to_flag_name tests ---
593
594    #[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    // --- map_type tests ---
602
603    #[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        // flag name should be config-file
630        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    // --- boolean flag pair tests ---
662
663    #[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    // --- enum-choices tests ---
775
776    #[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    // --- help-text-and-collision tests ---
896
897    #[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    // --- reconvert_enum_values tests ---
1020
1021    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}