---
phase: 03-expansion-engine
plan: "02"
type: tdd
wave: 2
depends_on:
  - "03-01"
files_modified:
  - src/expand.rs
  - Cargo.toml
autonomous: true
requirements:
  - MODEL-03
  - EXPAND-02
  - EXPAND-03
  - TEST-01

must_haves:
  truths:
    - "expand() only includes joins needed by requested dimensions/metrics (not all declared joins)"
    - "Transitive join dependencies are resolved automatically (if B depends on A, requesting B includes A)"
    - "Unknown dimension/metric names produce errors with the view name, bad name, available names, and a 'did you mean' suggestion"
    - "Duplicate dimension/metric names in a request produce clear errors"
    - "Joins are emitted in their original declaration order"
  artifacts:
    - path: "src/expand.rs"
      provides: "Join dependency resolution, name validation with fuzzy matching"
      contains: "suggest_closest"
    - path: "Cargo.toml"
      provides: "strsim dependency for edit distance"
      contains: "strsim"
  key_links:
    - from: "src/expand.rs"
      to: "strsim"
      via: "levenshtein distance for fuzzy matching"
      pattern: "strsim::levenshtein"
    - from: "src/expand.rs"
      to: "src/model.rs"
      via: "reads Join.table, Dimension.source_table, Metric.source_table"
      pattern: "source_table"
---

<objective>
Add join dependency resolution and name validation with fuzzy matching to the expansion engine.

Purpose: Users define multi-table semantic views with JOINs. The engine must include only the joins needed by the requested dimensions/metrics, resolve transitive dependencies (if join B's ON clause references table A, include A too), and provide helpful error messages when dimension/metric names are misspelled.

Output: Updated `src/expand.rs` with join resolution logic, name validation, and fuzzy "did you mean" suggestions powered by strsim. Fully unit-tested via TDD.
</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/03-expansion-engine/03-CONTEXT.md
@.planning/phases/03-expansion-engine/03-RESEARCH.md
@.planning/phases/03-expansion-engine/03-01-SUMMARY.md
@src/expand.rs
@src/model.rs
</context>

<tasks>

<task type="auto">
  <name>Task 1: Add strsim dependency and implement name validation with fuzzy matching</name>
  <files>Cargo.toml, src/expand.rs</files>
  <action>
**Cargo.toml:**
- Add `strsim = "0.11"` to `[dependencies]`

**TDD RED -- write failing tests first in src/expand.rs:**

1. `test_unknown_dimension_error` -- request dimension "reigon" (typo) when definition has "region". Assert `ExpandError::UnknownDimension` with `suggestion: Some("region")`.

2. `test_unknown_metric_error` -- request metric "totl_revenue" when definition has "total_revenue". Assert `ExpandError::UnknownMetric` with `suggestion: Some("total_revenue")`.

3. `test_unknown_dimension_no_suggestion` -- request dimension "xyzzy" (no close match). Assert `ExpandError::UnknownDimension` with `suggestion: None`.

4. `test_duplicate_dimension_error` -- request `dimensions: ["region", "region"]`. Assert `ExpandError::DuplicateDimension`.

5. `test_duplicate_metric_error` -- request `metrics: ["total_revenue", "total_revenue"]`. Assert `ExpandError::DuplicateMetric`.

6. `test_case_insensitive_dimension_lookup` -- definition has dimension "Region", request "region". Assert expansion succeeds (not an error).

7. `test_case_insensitive_metric_lookup` -- definition has metric "Total_Revenue", request "total_revenue". Assert expansion succeeds.

8. `test_error_display_messages` -- verify Display impl for each ExpandError variant produces human-readable messages containing the view name, bad name, and available names.

**TDD GREEN -- implement validation:**

Add to `expand()` before SQL generation:
1. **Duplicate check:** For both dimensions and metrics lists, check for duplicates (case-insensitive). Return `DuplicateDimension`/`DuplicateMetric` on first duplicate found.
2. **Name validation:** For each requested dimension name, find the matching definition dimension using case-insensitive comparison (`.eq_ignore_ascii_case()`). If not found, compute the closest match using `strsim::levenshtein` with a threshold of 3. Return `UnknownDimension` with available names and optional suggestion.
3. Same for metrics.

Add `suggest_closest(name: &str, available: &[String]) -> Option<String>` helper function:
- Lowercase both `name` and each available name for comparison
- Use `strsim::levenshtein` to compute edit distance
- Return the available name (original casing) if distance <= 3
- If multiple candidates within threshold, return the closest (lowest distance)

Update Display impl for ExpandError to include all context:
- `EmptyMetrics`: "semantic view '{view_name}': at least one metric is required"
- `UnknownDimension`: "semantic view '{view_name}': unknown dimension '{name}'. Available: {available}. Did you mean '{suggestion}'?" (omit "Did you mean" if suggestion is None)
- `UnknownMetric`: same pattern as UnknownDimension
- `DuplicateDimension`: "semantic view '{view_name}': duplicate dimension '{name}'"
- `DuplicateMetric`: "semantic view '{view_name}': duplicate metric '{name}'"
  </action>
  <verify>
    <automated>cargo test --lib -- expand::tests 2>&1 | tail -30</automated>
    <manual>All validation tests pass. Error messages include view name, bad name, available names, and fuzzy suggestion.</manual>
  </verify>
  <done>
- strsim added to Cargo.toml
- Unknown dimension/metric names produce ExpandError with suggestion when edit distance &lt;= 3
- Duplicate names produce clear errors
- Case-insensitive name matching works (definition "Region" matches request "region")
- Display impl produces human-readable messages with all context
- All 8+ validation unit tests pass
  </done>
</task>

<task type="auto">
  <name>Task 2: Implement join dependency resolution with TDD</name>
  <files>src/expand.rs</files>
  <action>
**TDD RED -- write failing tests first:**

1. `test_join_included_when_dimension_needs_it` -- definition has base_table "orders" with a join to "customers" (ON "orders"."customer_id" = "customers"."id"). Dimension "customer_name" has `source_table: Some("customers")`. Request ["customer_name"]. Assert the emitted SQL includes `JOIN "customers" ON ...` in the base CTE.

2. `test_join_excluded_when_not_needed` -- same definition as above but request only dimension "region" (no source_table set, meaning it comes from base table). Assert the emitted SQL does NOT contain `JOIN "customers"`.

3. `test_join_included_when_metric_needs_it` -- metric "customer_count" has `source_table: Some("customers")`. Request that metric. Assert JOIN is included.

4. `test_transitive_join_resolution` -- three tables: base "orders", join "customers" (ON orders.customer_id = customers.id), join "regions" (ON customers.region_id = regions.id). Dimension "region_name" has `source_table: Some("regions")`. The "regions" join ON clause references "customers", so "customers" join must also be included. Assert both joins appear in the SQL.

5. `test_joins_emitted_in_declaration_order` -- same transitive scenario. Assert "customers" JOIN appears before "regions" JOIN in the SQL output (declaration order preserved).

6. `test_no_joins_declared_no_error` -- definition with no joins, all dims/metrics from base table. Assert expansion succeeds without any JOIN clauses.

7. `test_mixed_base_and_joined_dimensions` -- request one dimension from base table and one from a joined table. Assert only the needed join is included.

**TDD GREEN -- implement join resolution:**

Add join resolution logic to `expand()`:

1. After name validation, build a `needed_tables: HashSet<String>` by collecting `source_table` values from requested dimensions and metrics (case-insensitive match). Only include non-None source_table values.

2. Resolve transitive dependencies using a fixed-point loop:
   - For each declared join (in declaration order), if `join.table` is in `needed_tables` (case-insensitive), mark it as included
   - For each included join, scan ALL other declared joins to see if their ON clause contains the table name of a join not yet included. If so, add that table to `needed_tables`.
   - Repeat until no new tables are added (fixed-point convergence)

   **Implementation note on transitive resolution:** Since ON clauses are opaque SQL strings, use a simple substring check: if `join.on` contains the table name of another declared join, consider it a dependency. This is a heuristic but sufficient for v0.1 where users declare joins in dependency order per CONTEXT.md.

3. Filter the declared joins to only those whose `join.table` is in `needed_tables`, preserving declaration order.

4. Update the CTE builder to use the resolved join list instead of all declared joins.

**After GREEN, refactor if needed:** Extract the join resolution into a separate `fn resolve_joins(...)` for clarity, keeping expand() focused on SQL assembly.
  </action>
  <verify>
    <automated>cargo test --lib -- expand::tests 2>&1 | tail -30</automated>
    <manual>All join resolution tests pass. Transitive dependencies are resolved. Unneeded joins are excluded.</manual>
  </verify>
  <done>
- Joins are only included when requested dimensions/metrics have matching source_table
- Transitive join dependencies are resolved (fixed-point loop)
- Joins are emitted in declaration order
- Base-table-only queries produce no JOIN clauses
- All 7+ join resolution unit tests pass
- All prior unit tests still pass (regression)
  </done>
</task>

</tasks>

<verification>
```bash
# All expand module tests pass (including Plan 01 tests)
cargo test --lib -- expand::tests

# All model tests still pass
cargo test --lib -- model::tests

# Clippy clean (includes strsim import)
cargo clippy -- -D warnings

# Full test suite green
cargo test
```
</verification>

<success_criteria>
- Name validation catches unknown and duplicate dimension/metric names with helpful messages
- Fuzzy "did you mean" suggestions use strsim edit distance (threshold 3)
- Join resolution only includes needed joins, resolves transitive dependencies
- All unit tests from Plan 01 + Plan 02 pass (20+ tests total in expand module)
- `cargo test` and `cargo clippy` both pass cleanly
</success_criteria>

<output>
After completion, create `.planning/phases/03-expansion-engine/03-02-SUMMARY.md`
</output>
