apcore-cli 0.10.0

Command-line interface for apcore modules
# Task: boolean-flag-pairs

**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`

---

## Context

Properties with `"type": "boolean"` must produce a `--flag` / `--no-flag` pair rather than a single `--flag` with a string value. The Python implementation uses `click.Option(["--verbose/--no-verbose"])`. Clap v4 has no built-in equivalent; the approach is:

1. Create two separate `clap::Arg`s:
   - `--<flag>` with `ArgAction::SetTrue`
   - `--no-<flag>` with `ArgAction::SetFalse`
2. Record a `BoolFlagPair` in `SchemaArgs.bool_pairs` so `collect_input` (in `cli.rs`) can reconcile the pair into a single `bool` value.

The `id` of both Args must be distinct (`"<prop_name>"` and `"no-<prop_name>"`) so clap does not raise a duplicate-id error. The reconciliation in `collect_input` is: if `--flag` is present → `true`; if `--no-flag` is present → `false`; if neither → `BoolFlagPair.default_val`.

Edge case from spec: if `"enum": [true]` appears alongside `"type": "boolean"`, treat as a standard boolean flag pair (ignore the enum constraint).

---

## RED — Write Failing Tests First

Add to `tests/test_schema_parser.rs`:

```rust
#[test]
fn test_boolean_flag_pair_produced() {
    let schema = json!({
        "properties": {"verbose": {"type": "boolean"}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    // Both --verbose and --no-verbose must be in args.
    assert!(
        find_arg(&result.args, "verbose").is_some(),
        "--verbose must be present"
    );
    assert!(
        find_arg(&result.args, "no-verbose").is_some(),
        "--no-verbose must be present"
    );
}

#[test]
fn test_boolean_pair_actions() {
    let schema = json!({
        "properties": {"verbose": {"type": "boolean"}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let pos_arg = find_arg(&result.args, "verbose").unwrap();
    let neg_arg = find_arg(&result.args, "no-verbose").unwrap();
    assert_eq!(pos_arg.get_action(), &clap::ArgAction::SetTrue);
    assert_eq!(neg_arg.get_action(), &clap::ArgAction::SetFalse);
}

#[test]
fn test_boolean_default_false() {
    let schema = json!({
        "properties": {"debug": {"type": "boolean"}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let pair = result.bool_pairs.iter().find(|p| p.prop_name == "debug");
    assert!(pair.is_some());
    assert!(!pair.unwrap().default_val, "default must be false when not specified");
}

#[test]
fn test_boolean_default_true() {
    let schema = json!({
        "properties": {"enabled": {"type": "boolean", "default": true}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let pair = result
        .bool_pairs
        .iter()
        .find(|p| p.prop_name == "enabled")
        .expect("BoolFlagPair must be recorded");
    assert!(pair.default_val, "default must be true when schema says true");
}

#[test]
fn test_boolean_pair_recorded_in_bool_pairs() {
    let schema = json!({
        "properties": {"dry_run": {"type": "boolean"}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    let pair = result.bool_pairs.iter().find(|p| p.prop_name == "dry_run");
    assert!(pair.is_some(), "BoolFlagPair must be recorded for dry_run");
    assert_eq!(
        pair.unwrap().flag_long,
        "dry-run",
        "flag_long must use hyphen form"
    );
}

#[test]
fn test_boolean_underscore_to_hyphen() {
    let schema = json!({
        "properties": {"dry_run": {"type": "boolean"}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    assert!(find_arg(&result.args, "dry-run").is_some(), "--dry-run");
    assert!(find_arg(&result.args, "no-dry-run").is_some(), "--no-dry-run");
}

#[test]
fn test_boolean_with_enum_true_treated_as_flag() {
    // Boolean with enum: [true] must still produce a flag pair, not an enum choice.
    let schema = json!({
        "properties": {"strict": {"type": "boolean", "enum": [true]}}
    });
    let result = schema_to_clap_args(&schema).unwrap();
    assert!(find_arg(&result.args, "strict").is_some());
    assert!(find_arg(&result.args, "no-strict").is_some());
    // Must NOT be in enum_maps.
    assert!(!result.enum_maps.contains_key("strict"));
}

#[test]
fn test_boolean_not_counted_as_required_arg() {
    // Booleans become two args but neither must be `required`.
    let schema = json!({
        "properties": {"active": {"type": "boolean"}},
        "required": ["active"]
    });
    let result = schema_to_clap_args(&schema).unwrap();
    // Booleans are never required at the clap level (default covers absent case).
    let pos = find_arg(&result.args, "active").unwrap();
    let neg = find_arg(&result.args, "no-active").unwrap();
    assert!(!pos.is_required_set());
    assert!(!neg.is_required_set());
}
```

Run `cargo test test_boolean` — all fail.

---

## GREEN — Implement

Remove the `continue` placeholder in `schema_to_clap_args` for the boolean branch and replace it:

```rust
if schema_type == Some("boolean") {
    let flag_long = prop_name.replace('_', "-");
    let default_val = prop_schema
        .get("default")
        .and_then(|v| v.as_bool())
        .unwrap_or(false);
    let help_text = extract_help(prop_schema);

    let mut pos_arg = Arg::new(prop_name.clone())
        .long(flag_long.clone())
        .action(ArgAction::SetTrue);

    let mut neg_arg = Arg::new(format!("no-{}", prop_name))
        .long(format!("no-{}", flag_long))
        .action(ArgAction::SetFalse);

    if let Some(ref help) = help_text {
        pos_arg = pos_arg.help(help.clone());
        neg_arg = neg_arg.help(format!("Disable --{flag_long}"));
    }

    args.push(pos_arg);
    args.push(neg_arg);

    bool_pairs.push(BoolFlagPair {
        prop_name: prop_name.clone(),
        flag_long,
        default_val,
    });

    continue;
}
```

The `enum: [true]` edge case is handled by placing the boolean `type` check before the general `enum` check: since `schema_type == Some("boolean")` fires first and `continue`s, the enum branch is never reached for boolean properties.

---

## REFACTOR

- Ensure `--no-<flag>` long name for properties with underscores is `--no-<hyphen-form>` (e.g., `dry_run``--no-dry-run`), confirmed by the `test_boolean_underscore_to_hyphen` test.
- Run `cargo clippy -- -D warnings`.

---

## Verification

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