apcore-cli 0.7.0

Command-line interface for apcore modules
Documentation
# Task: schema-composition

**Feature**: FE-02 Schema Parser
**File**: `src/ref_resolver.rs`
**Type**: RED-GREEN-REFACTOR
**Estimate**: ~2h
**Depends on**: `ref-resolver-core`
**Required by**: `type-mapping`

---

## Context

Extend `resolve_node` (implemented in `ref-resolver-core`) to handle the three JSON Schema composition keywords: `allOf`, `anyOf`, and `oneOf`. These mirror the Python `_resolve_node` composition branches.

Rules:

| Keyword | Properties | `required` |
|---------|-----------|-----------|
| `allOf` | Union of all sub-schemas; later sub-schema wins on key conflict | Concatenation (extend) |
| `anyOf` | Union of all sub-schemas | Intersection (only fields required in ALL branches) |
| `oneOf` | Same as `anyOf` | Intersection |

Non-composition keys from the parent node (e.g., `"description"`, `"title"`) are carried into the merged result if not already set by the merge.

This task adds branches to `resolve_node` before the generic map-recursion fallthrough that was added in `ref-resolver-core`.

---

## RED — Write Failing Tests First

Add to `tests/test_ref_resolver.rs`:

```rust
#[test]
fn test_allof_merges_properties() {
    let mut schema = json!({
        "allOf": [
            {
                "properties": {"a": {"type": "string"}},
                "required": ["a"]
            },
            {
                "properties": {"b": {"type": "integer"}},
                "required": ["b"]
            }
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    assert_eq!(result["properties"]["a"]["type"], "string");
    assert_eq!(result["properties"]["b"]["type"], "integer");
    let required: Vec<&str> = result["required"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    assert!(required.contains(&"a"));
    assert!(required.contains(&"b"));
}

#[test]
fn test_allof_later_schema_wins_on_conflict() {
    let mut schema = json!({
        "allOf": [
            {"properties": {"x": {"type": "string"}}},
            {"properties": {"x": {"type": "integer"}}}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    // Later sub-schema wins: x must be integer.
    assert_eq!(result["properties"]["x"]["type"], "integer");
}

#[test]
fn test_allof_copies_non_composition_keys() {
    let mut schema = json!({
        "description": "My type",
        "allOf": [
            {"properties": {"a": {"type": "string"}}}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    // "description" must survive in the merged result.
    assert_eq!(result["description"], "My type");
}

#[test]
fn test_anyof_unions_properties() {
    let mut schema = json!({
        "anyOf": [
            {"properties": {"a": {"type": "string"}}, "required": ["a"]},
            {"properties": {"b": {"type": "integer"}}, "required": ["b"]}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    // Both properties must appear.
    assert!(result["properties"].get("a").is_some());
    assert!(result["properties"].get("b").is_some());
}

#[test]
fn test_anyof_required_is_intersection() {
    let mut schema = json!({
        "anyOf": [
            {"properties": {"a": {"type": "string"}, "b": {"type": "string"}}, "required": ["a", "b"]},
            {"properties": {"a": {"type": "string"}, "c": {"type": "string"}}, "required": ["a", "c"]}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    let required: Vec<&str> = result["required"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    // Only "a" appears in both branches — it is the intersection.
    assert!(required.contains(&"a"), "a must be required (in both branches)");
    assert!(!required.contains(&"b"), "b must not be required (only in first branch)");
    assert!(!required.contains(&"c"), "c must not be required (only in second branch)");
}

#[test]
fn test_anyof_empty_required_when_no_overlap() {
    let mut schema = json!({
        "anyOf": [
            {"properties": {"a": {"type": "string"}}, "required": ["a"]},
            {"properties": {"b": {"type": "integer"}}, "required": ["b"]}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    let required = result["required"].as_array().unwrap();
    assert!(required.is_empty(), "no fields are required in both branches");
}

#[test]
fn test_oneof_behaves_like_anyof() {
    let mut schema = json!({
        "oneOf": [
            {"properties": {"x": {"type": "string"}}, "required": ["x"]},
            {"properties": {"y": {"type": "integer"}}, "required": ["y"]}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    assert!(result["properties"].get("x").is_some());
    assert!(result["properties"].get("y").is_some());
    assert!(result["required"].as_array().unwrap().is_empty());
}

#[test]
fn test_allof_with_nested_ref() {
    // allOf sub-schema that itself contains a $ref.
    let mut schema = json!({
        "$defs": {
            "Base": {"properties": {"id": {"type": "integer"}}, "required": ["id"]}
        },
        "allOf": [
            {"$ref": "#/$defs/Base"},
            {"properties": {"name": {"type": "string"}}}
        ]
    });
    let result = resolve_refs(&mut schema, 32, "mod").unwrap();
    assert_eq!(result["properties"]["id"]["type"], "integer");
    assert_eq!(result["properties"]["name"]["type"], "string");
    let required: Vec<&str> = result["required"]
        .as_array()
        .unwrap()
        .iter()
        .filter_map(|v| v.as_str())
        .collect();
    assert!(required.contains(&"id"));
}
```

Run `cargo test test_allof test_anyof test_oneof` — all fail.

---

## GREEN — Implement

Add composition branches to `resolve_node` in `src/ref_resolver.rs`, inserted before the generic map-recursion fallthrough:

```rust
// Inside resolve_node, after the $ref branch and before the generic fallthrough:

// Handle allOf.
if obj.contains_key("allOf") {
    let sub_schemas = obj
        .get("allOf")
        .and_then(|v| v.as_array())
        .cloned()
        .unwrap_or_default();

    let mut merged_props = Map::new();
    let mut merged_required: Vec<Value> = Vec::new();

    for sub in sub_schemas {
        let resolved_sub =
            resolve_node(sub, defs, visited, depth + 1, max_depth, module_id)?;
        if let Some(props) = resolved_sub.get("properties").and_then(|v| v.as_object()) {
            for (k, v) in props {
                merged_props.insert(k.clone(), v.clone());
            }
        }
        if let Some(req) = resolved_sub.get("required").and_then(|v| v.as_array()) {
            merged_required.extend(req.iter().cloned());
        }
    }

    let mut result_map = Map::new();
    result_map.insert("properties".to_string(), Value::Object(merged_props));
    result_map.insert("required".to_string(), Value::Array(merged_required));

    // Carry over non-composition keys from the parent node.
    for (k, v) in &obj {
        if k != "allOf" && !result_map.contains_key(k) {
            result_map.insert(k.clone(), v.clone());
        }
    }

    return Ok(Value::Object(result_map));
}

// Handle anyOf / oneOf (same logic).
for keyword in &["anyOf", "oneOf"] {
    if obj.contains_key(*keyword) {
        let sub_schemas = obj
            .get(*keyword)
            .and_then(|v| v.as_array())
            .cloned()
            .unwrap_or_default();

        let mut merged_props = Map::new();
        let mut all_required_sets: Vec<std::collections::HashSet<String>> = Vec::new();

        for sub in sub_schemas {
            let resolved_sub =
                resolve_node(sub, defs, visited, depth + 1, max_depth, module_id)?;
            if let Some(props) = resolved_sub.get("properties").and_then(|v| v.as_object()) {
                for (k, v) in props {
                    merged_props.insert(k.clone(), v.clone());
                }
            }
            if let Some(req) = resolved_sub.get("required").and_then(|v| v.as_array()) {
                let set: std::collections::HashSet<String> = req
                    .iter()
                    .filter_map(|v| v.as_str().map(str::to_string))
                    .collect();
                all_required_sets.push(set);
            } else {
                // A branch with no required means the intersection is empty.
                all_required_sets.push(std::collections::HashSet::new());
            }
        }

        // Intersection of all required sets.
        let intersection: Vec<Value> = if all_required_sets.is_empty() {
            Vec::new()
        } else {
            let mut iter = all_required_sets.into_iter();
            let first = iter.next().unwrap();
            iter.fold(first, |acc, set| {
                acc.intersection(&set).cloned().collect()
            })
            .into_iter()
            .map(Value::String)
            .collect()
        };

        let mut result_map = Map::new();
        result_map.insert("properties".to_string(), Value::Object(merged_props));
        result_map.insert("required".to_string(), Value::Array(intersection));

        for (k, v) in &obj {
            if k != *keyword && !result_map.contains_key(k) {
                result_map.insert(k.clone(), v.clone());
            }
        }

        return Ok(Value::Object(result_map));
    }
}
```

---

## REFACTOR

- Factor out the `required`-intersection logic into a private `intersect_required_sets(sets: Vec<HashSet<String>>) -> Vec<Value>` function to avoid duplication between `anyOf` and `oneOf`.
- Confirm that `visited` is correctly threaded through sub-schema recursion for `allOf`/`anyOf`/`oneOf` so that circular refs within composition are still detected.
- Run `cargo clippy -- -D warnings`.

---

## Verification

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