apcore-cli 0.7.0

Command-line interface for apcore modules
Documentation
# Task: enum-choices

**Feature**: FE-02 Schema Parser
**File**: `src/schema_parser.rs`
**Type**: RED-GREEN-REFACTOR
**Estimate**: ~2h
**Depends on**: `type-mapping`
**Required by**: `help-text-and-collision`, `reconvert-enum-values`

---

## Context

Properties with an `"enum"` field (and `type` != `"boolean"`) must constrain the accepted values to the listed variants. Clap v4 enforces this via `clap::builder::PossibleValuesParser`. All enum values are converted to `String` for clap; the original typed values (int, float, bool, string) are stored in `SchemaArgs.enum_maps` for post-parse reconversion.

Empty enum arrays produce a warning log and fall through to a plain string `Arg` (matching Python behaviour).

---

## RED — Write Failing Tests First

Add to `tests/test_schema_parser.rs`:

```rust
#[test]
fn test_enum_string_choices() {
    let schema = json!({
        "properties": {
            "format": {"type": "string", "enum": ["json", "csv", "xml"]}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let arg = find_arg(&result.args, "format").expect("--format must exist");
    // Verify possible values via clap's possible_values() accessor.
    let possible: Vec<&str> = arg
        .get_possible_values()
        .iter()
        .map(|pv| pv.get_name())
        .collect();
    assert_eq!(possible, vec!["json", "csv", "xml"]);
}

#[test]
fn test_enum_integer_choices_as_strings() {
    let schema = json!({
        "properties": {
            "level": {"type": "integer", "enum": [1, 2, 3]}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let arg = find_arg(&result.args, "level").expect("--level must exist");
    let possible: Vec<&str> = arg
        .get_possible_values()
        .iter()
        .map(|pv| pv.get_name())
        .collect();
    assert_eq!(possible, vec!["1", "2", "3"]);
    // enum_maps must record the original Value types.
    let map = result.enum_maps.get("level").expect("enum_maps must have 'level'");
    assert_eq!(map[0], serde_json::Value::Number(1.into()));
}

#[test]
fn test_enum_float_choices_as_strings() {
    let schema = json!({
        "properties": {
            "ratio": {"type": "number", "enum": [0.5, 1.0, 1.5]}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let possible: Vec<&str> = find_arg(&result.args, "ratio")
        .unwrap()
        .get_possible_values()
        .iter()
        .map(|pv| pv.get_name())
        .collect();
    // JSON serialisation of floats: serde_json renders 0.5 as "0.5", 1.0 as "1.0".
    assert!(possible.contains(&"0.5"));
}

#[test]
fn test_enum_bool_choices_as_strings() {
    // Non-boolean type with bool enum values (unusual but valid per spec).
    let schema = json!({
        "properties": {
            "flag": {"type": "string", "enum": [true, false]}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let arg = find_arg(&result.args, "flag").expect("--flag must exist");
    let possible: Vec<&str> = arg
        .get_possible_values()
        .iter()
        .map(|pv| pv.get_name())
        .collect();
    assert!(possible.contains(&"true"));
    assert!(possible.contains(&"false"));
}

#[test]
fn test_enum_empty_array_falls_through_to_string() {
    // Empty enum → warning (not tested here) + plain string Arg.
    let schema = json!({
        "properties": {
            "x": {"type": "string", "enum": []}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let arg = find_arg(&result.args, "x").expect("--x must exist");
    // No possible_values should be set.
    assert!(arg.get_possible_values().is_empty());
    // Not in enum_maps.
    assert!(!result.enum_maps.contains_key("x"));
}

#[test]
fn test_enum_with_default() {
    let schema = json!({
        "properties": {
            "format": {"type": "string", "enum": ["json", "table"], "default": "json"}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let arg = find_arg(&result.args, "format").unwrap();
    assert_eq!(
        arg.get_default_values().first().and_then(|v| v.to_str()),
        Some("json")
    );
}

#[test]
fn test_enum_required_property() {
    let schema = json!({
        "properties": {
            "mode": {"type": "string", "enum": ["a", "b"]}
        },
        "required": ["mode"]
    });
    let result = schema_to_clap_args(&schema).unwrap();
    // Enum properties obey required just like standard args.
    // Per Python implementation: required is set to false at clap level (STDIN deferral);
    // the [required] annotation is added to help text instead.
    // Rust approach: also set required=false; help text carries "[required]".
    let arg = find_arg(&result.args, "mode").unwrap();
    assert!(!arg.is_required_set(), "required enforced post-parse, not at clap level");
}

#[test]
fn test_enum_stored_in_enum_maps() {
    let schema = json!({
        "properties": {
            "priority": {"type": "integer", "enum": [1, 2, 3]}
        }
    });
    let result = schema_to_clap_args(&schema).unwrap();
    assert!(result.enum_maps.contains_key("priority"));
    let map = &result.enum_maps["priority"];
    assert_eq!(map.len(), 3);
}
```

Run `cargo test test_enum` — all fail.

---

## GREEN — Implement

Remove the `continue` placeholder in `schema_to_clap_args` for the enum branch and replace it. Note: the enum branch comes after the boolean branch (boolean `continue`s first, so booleans never reach this code):

```rust
if let Some(enum_values) = prop_schema.get("enum").and_then(|v| v.as_array()) {
    let flag_long = prop_name.replace('_', "-");
    let is_required = required_list.contains(&prop_name.as_str());
    let help_text = extract_help(prop_schema);
    let default_val = prop_schema.get("default");

    if enum_values.is_empty() {
        warn!("Empty enum for property '{}', no values allowed.", prop_name);
        // Fall through to plain string arg below.
    } else {
        // Convert all values to String for clap.
        let string_values: Vec<String> = enum_values
            .iter()
            .map(|v| match v {
                Value::String(s) => s.clone(),
                other => other.to_string(),
            })
            .collect();

        // Store original typed values for reconversion.
        enum_maps.insert(prop_name.clone(), enum_values.to_vec());

        let mut arg = Arg::new(prop_name.clone())
            .long(flag_long)
            .value_parser(clap::builder::PossibleValuesParser::new(&string_values))
            .required(false); // required enforced post-parse for STDIN compatibility

        if let Some(help) = &help_text {
            let help_with_required = if is_required {
                format!("{} [required]", help)
            } else {
                help.clone()
            };
            arg = arg.help(help_with_required);
        } else if is_required {
            arg = arg.help("[required]");
        }

        if let Some(dv) = default_val {
            let dv_str = match dv {
                Value::String(s) => s.clone(),
                other => other.to_string(),
            };
            arg = arg.default_value(dv_str);
        }

        args.push(arg);
        continue;
    }
}
```

After the enum block (when enum is empty or not present), execution falls through to the standard `map_type` Arg builder from the `type-mapping` task.

Note on `required` behaviour: per the Python implementation, required fields are not enforced at the clap level (`required=False` in Python) to allow `--input -` (STDIN) to satisfy them. The `[required]` annotation is added to help text as a hint. The same pattern is followed here: enum args always have `.required(false)`, but `[required]` appears in the help string.

---

## REFACTOR

- The `string_values` slice reference in `PossibleValuesParser::new(&string_values)` requires that `string_values` owns the data. Confirm the borrow checker is satisfied. If lifetime issues arise, convert to `clap::builder::PossibleValuesParser::new(string_values.iter().map(String::as_str).collect::<Vec<_>>())`.
- Run `cargo clippy -- -D warnings`.

---

## Verification

```bash
cargo test test_enum 2>&1
# Expected: test result: ok. 8 passed; 0 failed
```