---
phase: 25-sql-body-parser
plan: 02
type: execute
wave: 2
depends_on:
  - 25-01
files_modified:
  - src/body_parser.rs
autonomous: true
requirements:
  - DDL-02
  - DDL-03
  - DDL-04
  - DDL-05

must_haves:
  truths:
    - "TABLES clause parses alias AS physical_table PRIMARY KEY (col1, col2) into TableRef"
    - "RELATIONSHIPS clause parses name AS from_alias(fk_cols) REFERENCES to_alias into Join"
    - "DIMENSIONS clause parses alias.name AS sql_expr into (source_alias, bare_name, expr)"
    - "METRICS clause parses alias.name AS agg_expr with the same parser as DIMENSIONS"
    - "Trailing commas in any clause entry list are tolerated (not treated as extra empty entry)"
    - "Expressions with nested parens and string literals with commas parse correctly"
    - "Schema-qualified table names (main.orders) are not confused with alias.dim_name splitting"
  artifacts:
    - path: "src/body_parser.rs"
      provides: "Complete implementation of parse_keyword_body, parse_tables_clause, parse_relationships_clause, parse_qualified_entries, split_at_depth0_commas"
      exports:
        - "parse_keyword_body"
        - "parse_tables_clause"
        - "parse_relationships_clause"
        - "parse_qualified_entries"
        - "split_at_depth0_commas"
  key_links:
    - from: "src/body_parser.rs::parse_keyword_body"
      to: "src/model.rs::SemanticViewDefinition"
      via: "constructs SemanticViewDefinition from clause results"
      pattern: "SemanticViewDefinition"
    - from: "src/body_parser.rs::split_at_depth0_commas"
      to: "parse_tables_clause, parse_relationships_clause, parse_qualified_entries"
      via: "shared entry splitting with depth-0 comma awareness"
      pattern: "split_at_depth0_commas"
---

<objective>
Implement the complete keyword body parser in `src/body_parser.rs`.

Purpose: Converts the stub implementations from Plan 01 into a working recursive descent parser that produces `SemanticViewDefinition` from `AS TABLES (...) RELATIONSHIPS (...) DIMENSIONS (...) METRICS (...)` syntax. All unit tests previously marked `#[should_panic]` must pass without `should_panic` after this plan.

Output: Fully implemented `body_parser.rs` with all `#[should_panic]` stubs removed and green unit tests.
</objective>

<execution_context>
@/Users/paul/.claude/get-shit-done/workflows/execute-plan.md
@/Users/paul/.claude/get-shit-done/templates/summary.md
</execution_context>

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/25-sql-body-parser/25-CONTEXT.md
@.planning/phases/25-sql-body-parser/25-RESEARCH.md
@.planning/phases/25-sql-body-parser/25-01-SUMMARY.md
@src/body_parser.rs
@src/model.rs

<interfaces>
<!-- Existing parse.rs functions body_parser.rs reuses. -->

From src/parse.rs:
```rust
pub struct ParseError {
    pub message: String,
    pub position: Option<usize>,
}

// Reuse suggest_clause_keyword for "did you mean" in body errors:
fn suggest_clause_keyword(word: &str) -> Option<&'static str>
// Note: this is private in parse.rs — re-expose via pub(crate) or duplicate the logic
// in body_parser.rs using the same CLAUSE_KEYWORDS and strsim::levenshtein call.

// Known clause keywords (already defined in parse.rs as pub(crate)):
const CLAUSE_KEYWORDS: &[&str] = &["tables", "relationships", "dimensions", "metrics"];
```

From src/model.rs (post Phase 24):
```rust
pub struct TableRef { pub alias: String, pub table: String, pub pk_columns: Vec<String> }
pub struct Join {
    pub table: String, pub on: String, pub from_cols: Vec<String>,
    pub join_columns: Vec<JoinColumn>, pub from_alias: String,
    pub fk_columns: Vec<String>, pub name: Option<String>,
}
pub struct Dimension { pub name: String, pub expr: String, pub source_table: Option<String>, pub output_type: Option<String> }
pub struct Metric { pub name: String, pub expr: String, pub source_table: Option<String>, pub output_type: Option<String> }
pub struct SemanticViewDefinition { pub base_table: String, pub tables: Vec<TableRef>, pub dimensions: Vec<Dimension>, pub metrics: Vec<Metric>, ... }
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Implement split_at_depth0_commas and top-level clause scanner</name>
  <files>src/body_parser.rs</files>
  <behavior>
    - Test: split_at_depth0_commas("a, b, c") -> 3 entries with correct start offsets
    - Test: split_at_depth0_commas("SUM(a, b), COUNT(*)") -> 2 entries (comma inside parens is not a split)
    - Test: split_at_depth0_commas("a, 'x, y', b") -> 3 entries (comma inside string not a split)
    - Test: split_at_depth0_commas("a,") -> 1 entry (trailing comma discarded)
    - Test: split_at_depth0_commas("") -> 0 entries
    - Test: Top-level clause scanner finds TABLES, DIMENSIONS, METRICS at depth 0
    - Test: Top-level clause scanner finds RELATIONSHIPS at depth 0 when present
    - Test: Top-level clause scanner rejects unknown keyword "TABLSE" with "did you mean TABLES?" error
    - Test: Top-level clause scanner correctly finds the matching closing paren for TABLES even when PRIMARY KEY (...) creates nested depth
  </behavior>
  <action>
Implement `split_at_depth0_commas` and `find_clause_bounds` in `src/body_parser.rs`.

**split_at_depth0_commas implementation:**
```rust
pub(crate) fn split_at_depth0_commas(body: &str) -> Vec<(usize, &str)> {
    let mut entries = Vec::new();
    let mut depth: i32 = 0;
    let mut in_string = false;
    let mut start = 0;
    let bytes = body.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        let ch = bytes[i] as char;
        if ch == '\'' {
            if in_string && i + 1 < bytes.len() && bytes[i + 1] == b'\'' {
                i += 2;
                continue;
            }
            in_string = !in_string;
        } else if !in_string {
            match ch {
                '(' | '[' | '{' => depth += 1,
                ')' | ']' | '}' => depth -= 1,
                ',' if depth == 0 => {
                    let entry = body[start..i].trim();
                    if !entry.is_empty() {
                        entries.push((start, entry));
                    }
                    start = i + 1;
                }
                _ => {}
            }
        }
        i += 1;
    }
    let tail = body[start..].trim();
    if !tail.is_empty() {
        entries.push((start, tail));
    }
    entries
}
```

**find_clause_bounds: scan top-level clause keywords and extract their parenthesized content.**

This function walks `text` (the full AS-body after stripping "AS") at depth 0 to find each clause keyword followed by `(`. For each keyword found, it records the start and length of the content inside the matching `(...)`. Returns a struct or HashMap mapping clause keyword -> (content_str, content_base_offset).

```rust
struct ClauseBound<'a> {
    keyword: &'static str,
    content: &'a str,         // text inside the matching parens
    content_offset: usize,    // byte offset of content[0] in original query
}

fn find_clause_bounds<'a>(
    text: &'a str,
    base_offset: usize,
) -> Result<Vec<ClauseBound<'a>>, ParseError> {
    // Walk text at depth 0:
    // 1. Skip whitespace
    // 2. If current position looks like an identifier (alpha chars), collect word
    // 3. After word, if next non-ws char is '(', this is a clause header
    // 4. Find the MATCHING closing ')' via depth tracking
    // 5. Validate the keyword is in CLAUSE_KEYWORDS (with suggest_clause_keyword on error)
    // 6. Validate ordering: TABLES before RELATIONSHIPS before DIMENSIONS before METRICS
    // Return Vec<ClauseBound> in encounter order
}
```

Ordering validation: after collecting all clause bounds, verify they appear in order TABLES < RELATIONSHIPS < DIMENSIONS < METRICS (where RELATIONSHIPS is optional). Reject any other ordering with a clear error message.

Required clause validation: TABLES is required, at least one of DIMENSIONS or METRICS is required.

Do NOT reuse `scan_clause_keywords` from `parse.rs` — write a fresh scanner for the AS-body that looks for `keyword (` patterns (not `keyword :=` or `keyword (`). Write the AS-body scanner inline in `body_parser.rs`.

Update the unit tests in `mod tests` to remove `#[should_panic]` from the `split_at_depth0_commas` tests as they are now implemented. Add specific tests for `find_clause_bounds`.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test body_parser::tests::split 2>&1 | tail -10</automated>
  </verify>
  <done>
    - `split_at_depth0_commas` passes all 5 unit tests without `#[should_panic]`
    - `find_clause_bounds` locates TABLES, RELATIONSHIPS, DIMENSIONS, METRICS clause content
    - Ordering enforcement rejects wrong-order clauses with clear error
    - Required clause validation rejects missing TABLES with error
    - `cargo test body_parser` shows all split/clause-scanner tests green
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Implement parse_tables_clause, parse_relationships_clause, parse_qualified_entries, parse_keyword_body</name>
  <files>src/body_parser.rs</files>
  <behavior>
    - Test (from Plan 01 stubs, now without should_panic): parse_tables_clause("o AS orders PRIMARY KEY (o_orderkey)") -> TableRef {alias:"o", table:"orders", pk_columns:["o_orderkey"]}
    - Test: parse_tables_clause with schema-qualified "o AS main.orders PRIMARY KEY (o_orderkey)" -> table:"main.orders"
    - Test: parse_tables_clause with composite PK "(l_orderkey, l_linenumber)" -> pk_columns preserves order
    - Test: parse_tables_clause error on missing AS keyword: "o orders PRIMARY KEY (id)" -> Err with message about "Expected AS"
    - Test: parse_tables_clause error on missing PRIMARY KEY: "o AS orders" -> Err about "Expected PRIMARY KEY"
    - Test: parse_relationships_clause("") -> Ok(vec![])
    - Test: parse_relationships_clause("order_to_customer AS o(customer_id) REFERENCES c") -> Join {name:Some("order_to_customer"), from_alias:"o", fk_columns:["customer_id"], table:"c"}
    - Test: parse_relationships_clause with composite FK "(fk1, fk2)" -> fk_columns:["fk1","fk2"]
    - Test: parse_relationships_clause error on missing name "o(customer_id) REFERENCES c" -> Err about required name
    - Test: parse_qualified_entries("o.revenue AS SUM(amount)") -> [("o","revenue","SUM(amount)")]
    - Test: parse_qualified_entries("o.disc_price AS SUM(l_extendedprice * (1 - l_discount))") -> expr captured verbatim
    - Test: parse_qualified_entries("o.a AS x, o.b AS y,") -> 2 entries (trailing comma tolerated)
    - Test: parse_qualified_entries error on missing alias prefix "revenue AS SUM(amount)" -> Err about required alias.name
    - Test: parse_keyword_body("TABLES (o AS orders PRIMARY KEY (id)) DIMENSIONS (o.region AS region) METRICS (o.rev AS SUM(amount))", 0) -> KeywordBody with 1 table, 0 rels, 1 dim, 1 metric
    - Test: parse_keyword_body with RELATIONSHIPS clause -> rels populated
    - Test: parse_keyword_body with empty RELATIONSHIPS () -> rels is empty vec
    - Test: parse_keyword_body error on missing TABLES -> Err about required clause
  </behavior>
  <action>
Implement the four parsing functions in `src/body_parser.rs`, removing `todo!()` stubs and `#[should_panic]` attributes from existing tests.

**parse_tables_clause:**
For each entry from `split_at_depth0_commas(body)`:
1. Trim the entry. Split on first whitespace to get `alias`.
2. Skip whitespace, check for "AS" (case-insensitive). Error if not found.
3. Everything between "AS" and "PRIMARY KEY" (case-insensitive, scanning forward) is the physical table name.
4. After "PRIMARY KEY", expect `(`. Find matching `)` using depth tracking. Parse comma-separated column names from within.
5. Return `TableRef { alias, table, pk_columns }`.

Key: table name is between "AS" and "PRIMARY KEY" — do NOT dot-split the table name. The dot in `schema.table` is part of the physical name, not a qualifier.

**parse_relationships_clause:**
If `body.trim().is_empty()`, return `Ok(vec![])`.
For each entry from `split_at_depth0_commas(body)`:
1. Find "AS" keyword to extract relationship name (required). Error if "AS" not present before `(`.
2. After "AS": next token is `from_alias`. Then `(fk_cols)`. Then "REFERENCES". Then `to_alias`.
3. Parse `(fk_cols)` with comma-split to get fk_columns list.
4. Return `Join { table: to_alias, from_alias, fk_columns, name: Some(rel_name), on: String::new(), from_cols: vec![], join_columns: vec![] }`.

Validation: relationship name is required (Phase 25 keyword body only — the existing `parse_args.rs` path for function-based DDL remains unchanged and still allows unnamed relationships).

**parse_qualified_entries:**
For each entry from `split_at_depth0_commas(body)`:
1. Find the first `.` to split `qualified_name` into `(alias, bare_name)`. Error if no `.` found (alias prefix required).
2. After the qualified name, find "AS" (case-insensitive). Error if not found.
3. Everything after "AS" (trimmed) is the expression — capture verbatim.
4. Return `(alias, bare_name, expr)`.

**parse_keyword_body:**
1. Strip leading "AS" (case-insensitive) from `text`. Error if "AS" not found.
2. Call `find_clause_bounds` on the remaining text.
3. For each clause bound, call the appropriate parser.
4. Assemble `SemanticViewDefinition`:
   - `base_table`: use the first table's physical name (for backward compat with the base_table field)
   - `tables`: from parse_tables_clause
   - `joins`: from parse_relationships_clause (stored in the `joins` field of SemanticViewDefinition)
   - `dimensions`: map qualified_entries to Dimension { name: bare_name, expr, source_table: Some(alias), output_type: None }
   - `metrics`: map qualified_entries to Metric { name: bare_name, expr, source_table: Some(alias), output_type: None }
   - All other fields (filters, facts, column_type_names, column_types_inferred): default/empty

Return the fully assembled `SemanticViewDefinition` wrapped in `KeywordBody`.

**Position tracking:** Thread `base_offset` through all sub-parsers. All `ParseError { position: Some(p) }` must satisfy `original_query[p..]` starts at the error token. Use entry start offsets from `split_at_depth0_commas` plus `base_offset` to compute positions.

After implementation, remove ALL `#[should_panic]` attributes from the unit tests added in Plan 01. Add the new behavior tests from the list above.
  </action>
  <verify>
    <automated>cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test body_parser 2>&1 | tail -15</automated>
  </verify>
  <done>
    - `cargo test body_parser` shows all tests green, no `should_panic` stubs remain
    - parse_tables_clause passes 5+ tests including schema-qualified, composite PK, and error cases
    - parse_relationships_clause passes empty body, single entry, composite FK, and error cases
    - parse_qualified_entries passes simple, nested-parens, trailing comma, and error cases
    - parse_keyword_body passes full end-to-end unit tests
    - No `todo!()` macros remain in body_parser.rs
  </done>
</task>

</tasks>

<verification>
```bash
cd /Users/paul/Documents/Dev/Personal/duckdb-semantic-views && cargo test body_parser 2>&1 | grep -E "^test|FAILED|ok$|PASSED"
```
All body_parser tests green. No `#[should_panic]` stubs remain. No `todo!()` macros.
</verification>

<success_criteria>
- `cargo test body_parser` passes with 0 failures
- All clause parsers (TABLES, RELATIONSHIPS, DIMENSIONS, METRICS) have green unit tests
- `parse_keyword_body` assembles a complete `SemanticViewDefinition` from keyword body text
- Error messages include byte position for errors inside clause bodies
- Trailing commas tolerated in all clause entry lists
- Schema-qualified table names not confused with alias.dim_name splitting
</success_criteria>

<output>
After completion, create `.planning/phases/25-sql-body-parser/25-02-SUMMARY.md`
</output>
