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/// Built-in CLI flags that must not be shadowed by module schema properties.
17/// Matches the TypeScript RESERVED_NAMES set (schema-parser.ts) for parity.
18pub 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/// Error type for schema parsing failures.
35#[derive(Debug, Error)]
36pub enum SchemaParserError {
37    /// Two properties normalise to the same --flag-name.
38    /// Caller must exit 48.
39    #[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    /// A schema property name collides with a built-in CLI flag.
46    /// Caller must exit 48.
47    #[error("Schema property '{name}' conflicts with built-in CLI flag")]
48    ReservedPropertyName { name: String },
49}
50
51// ---------------------------------------------------------------------------
52// Output types
53// ---------------------------------------------------------------------------
54
55/// A single boolean --flag / --no-flag pair generated from a `type: boolean` property.
56#[derive(Debug)]
57pub struct BoolFlagPair {
58    /// Original schema property name (e.g. "verbose").
59    pub prop_name: String,
60    /// Long name used for the positive flag (e.g. "verbose").
61    pub flag_long: String,
62    /// Default value from the schema's `default` field (defaults to false).
63    pub default_val: bool,
64}
65
66/// Full output of schema_to_clap_args.
67#[derive(Debug)]
68pub struct SchemaArgs {
69    /// clap Args ready to attach to a clap::Command.
70    pub args: Vec<Arg>,
71    /// Boolean flag pairs; used by collect_input to reconcile --flag/--no-flag.
72    pub bool_pairs: Vec<BoolFlagPair>,
73    /// Maps property name (snake_case) → original enum values (as serde_json::Value).
74    /// Used by reconvert_enum_values for type coercion.
75    pub enum_maps: HashMap<String, Vec<Value>>,
76}
77
78// ---------------------------------------------------------------------------
79// Constants
80// ---------------------------------------------------------------------------
81
82pub const HELP_TEXT_MAX_LEN: usize = 1000;
83
84// ---------------------------------------------------------------------------
85// Helpers
86// ---------------------------------------------------------------------------
87
88/// Convert a property name (snake_case) to a CLI flag long name (kebab-case).
89pub fn prop_name_to_flag_name(s: &str) -> String {
90    s.replace('_', "-")
91}
92
93/// Determine whether a property should use PathBuf value_parser.
94fn 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
102/// Extract help text from a schema property.
103/// Prefers `x-llm-description` over `description`.
104/// Truncates to `max_len` chars (default: HELP_TEXT_MAX_LEN).
105pub fn extract_help(prop_schema: &Value) -> Option<String> {
106    extract_help_with_limit(prop_schema, HELP_TEXT_MAX_LEN)
107}
108
109/// Extract help text with a configurable max length.
110pub 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
129// ---------------------------------------------------------------------------
130// map_type
131// ---------------------------------------------------------------------------
132
133/// Map a single schema property to a clap::Arg.
134///
135/// Returns an error only for flag collisions (detected at schema_to_clap_args level).
136/// Boolean and enum types are handled by separate tasks.
137pub 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    // All types use string-based value parsers so that extract_cli_kwargs can
144    // uniformly read values with get_one::<String>. Type coercion to JSON
145    // numbers/booleans happens in reconvert_enum_values and collect_input.
146    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
171// ---------------------------------------------------------------------------
172// schema_to_clap_args
173// ---------------------------------------------------------------------------
174
175/// Translate a JSON Schema `properties` map into a SchemaArgs result.
176///
177/// Each schema property becomes one `--<name>` flag with:
178/// * `help` set to the property's `x-llm-description` or `description` field
179/// * `required` set when the property appears in the schema's `required` array
180/// * enum variants and boolean pairs deferred to later tasks
181///
182/// # Arguments
183/// * `schema` — JSON Schema object (may have `"properties"` key)
184/// * `max_help_length` — `Option<usize>` truncation budget for help text;
185///   `None` falls back to [`HELP_TEXT_MAX_LEN`] (1000), `Some(n)` selects
186///   an explicit limit. Cross-SDK parity with Python's
187///   `schema_to_click_options(schema, max_help_length=1000)` and TS's
188///   `schemaToCliOptions(schema, maxHelpLength = 1000)`.
189///
190/// Returns empty SchemaArgs for schemas without properties.
191pub 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
198/// Convert JSON Schema properties to clap Args with a configurable help text max length.
199pub 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    // Warn about required properties missing from properties map.
221    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(); // flag_long → prop_name
234
235    for (prop_name, prop_schema) in properties {
236        // Reserved-name guard: reject schema properties that shadow built-in flags.
237        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        // Collision detection.
246        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        // Boolean → --flag / --no-flag pair. Must be checked before enum.
261        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            // Also register the no- flag in seen_flags to detect collisions.
280            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            // Suppress unused variable warning; is_required is intentionally
293            // not applied to boolean flags.
294            let _ = is_required;
295
296            continue;
297        }
298
299        // Enum handling: properties with an "enum" array (and type != "boolean").
300        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                // Fall through to plain string arg below.
307            } else {
308                // Convert all enum values to String for clap's PossibleValuesParser.
309                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                // Store original typed values for post-parse reconversion.
318                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); // required enforced post-parse for STDIN compatibility
324
325                // Attach help text with optional [required] annotation.
326                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        // Build Arg using map_type.
351        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        // Default value (set as string; clap parses it through the value_parser).
358        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
376// ---------------------------------------------------------------------------
377// reconvert_enum_values
378// ---------------------------------------------------------------------------
379
380/// Re-map string enum values from CLI args back to their JSON-typed forms.
381///
382/// clap always produces `String` values; this function converts them to the
383/// correct JSON type (number, boolean, null) based on the original schema
384/// definition stored in `schema_args.enum_maps`.
385///
386/// # Arguments
387/// * `kwargs`      — raw CLI arguments map (string values from clap)
388/// * `schema_args` — the SchemaArgs produced by `schema_to_clap_args`
389///
390/// Returns a new map with enum values converted to their correct JSON types.
391/// Non-enum keys and Null values pass through unchanged.
392pub 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        // Skip null / non-string values (absent optional args arrive as Null).
405        let str_val = match &val {
406            Value::String(s) => s.clone(),
407            _ => continue,
408        };
409
410        // Find the original variant whose string representation matches str_val.
411        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(), // String: keep as-is
439            };
440            result.insert(key.clone(), converted);
441        }
442    }
443
444    result
445}
446
447// ---------------------------------------------------------------------------
448// Unit tests
449// ---------------------------------------------------------------------------
450
451#[cfg(test)]
452mod tests {
453    use super::*;
454    use serde_json::json;
455
456    // Helper: find an Arg by long name.
457    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        // Flag long name must be "input-file".
523        assert!(find_arg(&result.args, "input-file").is_some());
524        // Arg id must be "input_file" (original name, for collect_input lookup).
525        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; // Exact parser check is implementation-dependent.
537    }
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    // --- extract_help tests ---
580
581    #[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    // --- prop_name_to_flag_name tests ---
630
631    #[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    // --- map_type tests ---
639
640    #[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        // flag name should be config-file
667        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    // --- boolean flag pair tests ---
699
700    #[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    // --- enum-choices tests ---
821
822    #[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    // --- help-text-and-collision tests ---
942
943    #[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    // --- reconvert_enum_values tests ---
1066
1067    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        // D11-003: `large_input` must be reserved to match Python and TS impls.
1171        // Without this guard, schemas using "large_input" would silently shadow
1172        // the host CLI's --large-input flag.
1173        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}