---
phase: 64
plan: 01
type: execute
wave: 1
depends_on: []
files_modified:
  - src/ident.rs
  - src/lib.rs
autonomous: true
requirements:
  - QID-01
  - QID-02
  - QID-07
must_haves:
  truths:
    - "src/ident.rs exists and exposes parse_qualified_identifier + normalize_view_name as pub functions"
    - "parse_qualified_identifier handles bare, single-quoted, multi-part, mixed-quoting, and embedded-quote-escape (\"\") inputs"
    - "parse_qualified_identifier rejects empty input, unterminated quotes, empty parts (a..b), and trailing garbage after a closing quote"
    - "normalize_view_name returns the bare unquoted last part for valid input"
    - "Proptest property holds: parse(emit(parse(x))) == parse(x) for legal inputs"
  artifacts:
    - path: "src/ident.rs"
      provides: "Identifier parser + normalisation helpers + tests"
      contains: "pub fn parse_qualified_identifier"
    - path: "src/lib.rs"
      provides: "Module declaration for ident"
      contains: "pub mod ident;"
  key_links:
    - from: "src/lib.rs"
      to: "src/ident.rs"
      via: "pub mod ident; declaration"
      pattern: "pub mod ident"
---

<objective>
Introduce a leaf module `src/ident.rs` that owns SQL identifier parsing for double-quoted, dot-qualified identifiers. This is the foundation that subsequent plans (64-02 capture-site wiring, 64-03 expansion fix) build on.

The module exposes:

1. `parse_qualified_identifier(&str) -> Result<Vec<String>, String>` — splits a dot-qualified identifier into UNQUOTED parts, honouring `"..."` quoting (with `""` escape) and treating `.` inside quotes as part of the identifier rather than a separator.
2. `normalize_view_name(&str) -> Result<String, String>` — convenience wrapper returning the bare unquoted last part (the lookup key stored in `semantic_layer._definitions(name)`).
3. `find_identifier_end(&str, allow_paren: bool) -> usize` — helper that locates the byte offset of the FIRST delimiter (whitespace / `;` / optionally `(`) that is NOT inside a quoted region. Used by 64-02 to fix the capture-site delimiter scan so that `"my table"` doesn't truncate at the inner space.

Coverage: QID-01 (fully-quoted FQN parsing), QID-02 (partial / mixed quoting), QID-07 (helper-level unit & proptest coverage).

Purpose: Eliminates duplicated, half-correct identifier handling. All five capture sites in `src/parse.rs` and the expansion-side `quote_table_ref` will delegate to these helpers in subsequent plans.

Output: A single new file + a one-line `lib.rs` edit. Unit tests + one proptest live in the same file under `#[cfg(test)] mod tests`. No dependency changes.
</objective>

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

<context>
@.planning/STATE.md
@.planning/ROADMAP.md
@.planning/REQUIREMENTS.md
@.planning/phases/64-fix-quoted-identifier-handling/64-RESEARCH.md
@./CLAUDE.md

<interfaces>
<!-- Existing inverse function. The new parser's emit-side equivalent.
     Located at src/expand/resolution.rs:16. -->

```rust
// src/expand/resolution.rs
#[must_use]
pub fn quote_ident(ident: &str) -> String {
    format!("\"{}\"", ident.replace('"', "\"\""))
}
```

Round-trip property invariant (used by proptest in Task 2):
  for any legal identifier-vector v,
    parse_qualified_identifier(v.iter().map(quote_ident).join(".")) == Ok(v)

<!-- Existing single-quote string-literal helper, NOT used here but referenced
     by the research as the "shape to follow". Located at src/parse.rs:362. -->

```rust
// src/parse.rs (existing pattern — read but do not modify in this plan)
// extract_quoted_string(input: &str) -> Result<(String, usize), String>
//   Parses a SINGLE-quoted SQL string literal ('...'') from the START of input.
//   Returns (content, bytes_consumed). Honors '' escape.
```

<!-- The new helper follows the SAME shape but for DOUBLE-quoted IDENTIFIERS. -->
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Create src/ident.rs with parse_qualified_identifier, normalize_view_name, find_identifier_end + unit tests</name>
  <files>src/ident.rs, src/lib.rs</files>
  <read_first>
    - .planning/phases/64-fix-quoted-identifier-handling/64-RESEARCH.md (§Pattern 1, §Example A, §Anti-Patterns — read these first; the helper signatures + state-machine sketch are spelled out)
    - src/expand/resolution.rs (lines 1–25, to study quote_ident — the inverse function)
    - src/lib.rs (lines 1–15, to see existing module declaration style)
    - src/parse.rs (lines 362–410, to study extract_quoted_string shape — the single-quote literal parser that ours mirrors for double-quoted identifiers)
  </read_first>
  <behavior>
    Helper API (all functions live in src/ident.rs):

    pub fn parse_qualified_identifier(input: &str) -> Result&lt;Vec&lt;String&gt;, String&gt;
      - Bare:               "orders_sv"        -> Ok(vec!["orders_sv"])
      - Empty bare in mid:  "a..b"             -> Err(...)            (empty part)
      - Fully quoted:       "\"orders_sv\""    -> Ok(vec!["orders_sv"])
      - Quoted FQN:         "\"db\".\"sch\".\"v\"" -> Ok(vec!["db","sch","v"])
      - Mixed quoting:      "db.\"sch\".v"     -> Ok(vec!["db","sch","v"])
      - Embedded `""`:      "\"with\"\"q\""    -> Ok(vec![r#"with"q"#])
      - Dot inside quotes:  "\"a.b\""          -> Ok(vec!["a.b"])
      - Whitespace inside:  "\"my table\""     -> Ok(vec!["my table"])
      - Empty input:        ""                 -> Err("empty identifier")
      - Unterminated:       "\"foo"            -> Err("unterminated quoted identifier")
      - Trailing garbage after closing quote not followed by `.` or end:
                            "\"foo\"bar"       -> Err(...)
      - Leading whitespace is the CALLER's responsibility (helper consumes input verbatim).

    pub fn normalize_view_name(input: &str) -> Result&lt;String, String&gt;
      - Returns the LAST part of parse_qualified_identifier (the bare unquoted view name used as catalog key).
      - "orders_sv"                       -> Ok("orders_sv".to_string())
      - "\"memory\".\"main\".\"orders_sv\"" -> Ok("orders_sv".to_string())
      - "main.\"orders_sv\""              -> Ok("orders_sv".to_string())
      - Empty / malformed propagates the parse error.

    pub fn find_identifier_end(input: &str, allow_paren: bool) -> usize
      - Returns byte offset of the FIRST delimiter that is NOT inside a quoted region.
      - Delimiters: ASCII whitespace, ';', '(' (only if allow_paren=true).
      - If no delimiter found, returns input.len().
      - Inside `"..."` (with `""` escape), all bytes are part of the identifier — whitespace, `.`, `;`, `(` are not delimiters.
      - Unterminated quote: scan reaches end of input → returns input.len() (let the parser surface the error).

    Examples for find_identifier_end:
      - "orders_sv AS ..."         -> 9
      - "\"my table\" AS ..."      -> 10 (the inner space is inside the quote)
      - "v(foo)" with allow_paren=true -> 1
      - "\"a.b\".c PRIMARY"        -> 7

    Tests (write FIRST in #[cfg(test)] mod tests):

    Module tests::parse_qualified_identifier_tests:
      - bare_simple
      - bare_with_underscore_and_digits
      - fully_quoted_single_part
      - fully_quoted_fqn_three_parts
      - mixed_quoting_db_quoted_only
      - mixed_quoting_middle_quoted_only
      - embedded_double_quote_escape  ("\"with\"\"q\"" -> [r#"with"q"#])
      - dot_inside_quoted_part        ("\"a.b\"" -> ["a.b"])
      - whitespace_inside_quoted_part ("\"my table\"" -> ["my table"])
      - error_empty_input
      - error_unterminated_quote
      - error_empty_part_between_dots ("a..b")
      - error_trailing_garbage_after_quote
      - error_leading_dot

    Module tests::normalize_view_name_tests:
      - bare_returns_self
      - quoted_fqn_returns_bare_last_part
      - mixed_quoting_returns_bare_last_part
      - error_propagates_from_parser

    Module tests::find_identifier_end_tests:
      - bare_until_whitespace
      - quoted_skips_inner_whitespace
      - quoted_skips_inner_dot
      - paren_terminator_when_allowed
      - paren_inert_when_not_allowed
      - semicolon_terminator
      - unterminated_quote_returns_input_len
      - reaches_end_of_input

    Wire into src/lib.rs by adding `pub mod ident;` IN ALPHABETICAL ORDER between `pub mod graph;` (line 5) and `pub mod model;` (line 6). The existing declarations are already alphabetised — preserve that.
  </behavior>
  <action>
    1. Create `src/ident.rs` with the three public functions above.

    Implementation hints (do not just paste — adapt to the codebase's style):

    - Use a byte-level state machine over `input.as_bytes()` for both `parse_qualified_identifier` and `find_identifier_end`. The codebase prefers this over `chars().peekable()` for parsers (see `extract_quoted_string` at src/parse.rs:362).
    - State for parse_qualified_identifier: `{ ExpectPart, InBare, InQuoted, AfterPart }`.
      - ExpectPart at start and after each `.` — error if next byte is `.` or end-of-input.
      - InQuoted: scan to next `"`. If followed by another `"` it is an escape — append `"` and stay in InQuoted. Otherwise transition to AfterPart.
      - AfterPart: must be `.` (back to ExpectPart) or end-of-input (success).
    - For `find_identifier_end`, walk the bytes tracking a single `in_quotes: bool`. When `in_quotes && byte == b'"'`: peek next; if also `"`, advance both (escape); otherwise toggle out. Outside quotes, the first whitespace / `;` / (optional) `(` is the answer.

    - Add `#[must_use]` on the parse helpers; they return owned `String`/`Vec<String>`.

    - The error type is `String` (matches existing convention in `src/parse.rs::extract_quoted_string` and `src/body_parser.rs` helpers). Do NOT introduce a new error enum for this leaf module.

    - Add a doc-comment header block (~10 lines) on each function explaining the SQL identifier grammar, the role of `""` escape, and the round-trip property with `quote_ident`.

    2. Edit `src/lib.rs` line 5-6 region: insert `pub mod ident;` between `pub mod graph;` and `pub mod model;`. Single-line insert, preserving alphabetical order.

    3. Write all unit tests (~25 tests across the three modules) BEFORE the implementations pass — verify with `cargo test --lib ident::tests` showing all-red on a stub implementation, then implement, then green.
  </action>
  <verify>
    <automated>cargo test --lib ident::tests -- --nocapture</automated>
  </verify>
  <acceptance_criteria>
    - File `src/ident.rs` exists.
    - `grep -n "pub fn parse_qualified_identifier" src/ident.rs` returns one match.
    - `grep -n "pub fn normalize_view_name" src/ident.rs` returns one match.
    - `grep -n "pub fn find_identifier_end" src/ident.rs` returns one match.
    - `grep -n "pub mod ident" src/lib.rs` returns one match.
    - `cargo test --lib ident::tests` exits 0 with at least 20 passing tests.
    - `cargo build --lib` succeeds (no clippy run yet — saved for the closing plan).
    - No changes outside `src/ident.rs` and `src/lib.rs` (verified by `git diff --name-only`).
  </acceptance_criteria>
  <done>
    src/ident.rs provides the three public helpers with comprehensive unit-test coverage; src/lib.rs exposes the module; cargo test --lib ident::tests passes.
  </done>
</task>

<task type="auto" tdd="true">
  <name>Task 2: Add round-trip proptest in src/ident.rs (parse ∘ emit ∘ parse = parse)</name>
  <files>src/ident.rs</files>
  <read_first>
    - src/ident.rs (the module just created in Task 1)
    - src/expand/resolution.rs (lines 14–22 — quote_ident, the emit side of the round trip)
    - Cargo.toml (lines containing `proptest` — confirm proptest 1.x is already a dev-dep)
    - .planning/phases/64-fix-quoted-identifier-handling/64-RESEARCH.md §Validation Architecture (Test Framework row: proptest exists)
    - Any existing proptest in src/ for style reference: `grep -rn "proptest!" src/ | head -5` then read one (e.g. src/parse.rs proptests if any, otherwise src/body_parser.rs)
  </read_first>
  <behavior>
    Round-trip property (proptest, in src/ident.rs under a new `tests::proptests` submodule):

    ```
    for any Vec<String> parts where each part is non-empty and contains only
        printable ASCII (range 0x20..=0x7E) and is not "" (empty string):
      let emitted = parts.iter()
        .map(|p| crate::expand::resolution::quote_ident(p))
        .collect::<Vec<_>>()
        .join(".");
      assert_eq!(parse_qualified_identifier(&emitted), Ok(parts));
    ```

    Strategy generation (use proptest's `prop::collection::vec`):
      - parts.len() in 1..=4
      - each part: `"[\\x20-\\x7E]{1,16}"` (printable ASCII, 1-16 chars, includes `"`, `.`, ` ` — those are the bytes that round-trip depends on handling)

    Add a second proptest for the normalize_view_name invariant:
      - For any legal parts vector v, normalize_view_name(quoted_emit(v)) == Ok(parts.last())

    Run with the standard proptest cases setting (default 256; do not bump).
  </behavior>
  <action>
    1. Open `src/ident.rs`. Inside the existing `#[cfg(test)] mod tests { ... }` block, add a `mod proptests { ... }` submodule.
    2. Add `use proptest::prelude::*;` at the top of the proptests submodule.
    3. Define a helper inside the proptests module: `fn emit_via_quote_ident(parts: &[String]) -> String` that uses `crate::expand::resolution::quote_ident` and joins with `.`.
    4. Write two `proptest!` blocks:
       a. `parse_emit_roundtrip_is_identity` — generates a Vec<String> of 1..=4 parts (each 1..=16 printable-ASCII chars, BUT exclude bare empty string), emits via quote_ident, parses, asserts equal to the input vector.
       b. `normalize_returns_last_part` — same generator, asserts `normalize_view_name(&emitted) == Ok(parts.last().unwrap().clone())`.
    5. Verify with `cargo test --lib ident::tests::proptests` (the default 256 cases run < 1s).

    Note on the generator: include `.` and `"` characters in the alphabet — those are exactly the bytes whose handling we are exercising. `quote_ident` will wrap them in `"..."` and double any `"` inside, and the round trip must recover the original.
  </action>
  <verify>
    <automated>cargo test --lib ident::tests::proptests -- --nocapture</automated>
  </verify>
  <acceptance_criteria>
    - `grep -n "proptest!" src/ident.rs` returns at least two matches.
    - `grep -n "parse_emit_roundtrip_is_identity\|normalize_returns_last_part" src/ident.rs` returns at least two matches.
    - `cargo test --lib ident::tests::proptests` exits 0 with both proptests passing 256 default cases each.
    - `cargo test --lib ident::` (full module test run) still passes; no regressions in Task 1 tests.
    - `git diff --name-only` shows only `src/ident.rs` modified relative to the post-Task-1 commit.
  </acceptance_criteria>
  <done>
    Round-trip property holds across 256 generated cases for parse_qualified_identifier and normalize_view_name.
  </done>
</task>

</tasks>

<verification>
- `cargo test --lib ident::` runs all unit tests + proptests; all green.
- `cargo build --lib` succeeds.
- Files modified: exactly two (`src/ident.rs` created, `src/lib.rs` one-line insert).
- No edits to `src/parse.rs`, `src/expand/`, or any test/ file in this plan (those belong to 64-02 / 64-03 / 64-04).
</verification>

<success_criteria>
- src/ident.rs exports parse_qualified_identifier, normalize_view_name, find_identifier_end.
- Comprehensive unit test coverage of bare / quoted / mixed / escaped / error inputs (≥20 tests).
- Round-trip proptest passes for arbitrary 1..=4-part printable-ASCII identifiers.
- src/lib.rs declares `pub mod ident;` in alphabetical position.
- `cargo test --lib ident::tests` exits 0.
</success_criteria>

<output>
After completion, create `.planning/phases/64-fix-quoted-identifier-handling/64-01-SUMMARY.md` capturing:

- The exact public signatures of the three helpers (so downstream plans can quote them verbatim).
- Test count (unit + proptest).
- Any deviations from the planned signatures (none expected — but record if any).
- Any edge cases discovered during proptest shrinking that required additional unit test coverage.
</output>
