---
phase: 02-driver-management
plan: "02"
type: execute
wave: 2
depends_on: ["02-01"]
files_modified:
  - imptune/api/drivers.py
  - imptune/api/pages.py
  - imptune/main.py
  - imptune/templates/drivers.html
  - imptune/templates/partials/driver_list.html
  - tests/test_driver_upload.py
autonomous: true
requirements: [DRV-01, DRV-03, DRV-04, DRV-05]

must_haves:
  truths:
    - "User can upload a ZIP file via the /drivers page and receive a success response"
    - "After upload, the response contains a populated select dropdown with driver names from the INF"
    - "Uploading a non-ZIP file or a ZIP with no INF returns a 400 error displayed in-page"
    - "Uploaded driver file is persisted to DRIVERS_DIR via DriverStore (survives restart)"
    - "Re-uploading the same ZIP does not create a duplicate Driver record (SHA256 dedup)"
    - "Upload response shows count of unused files not referenced by the INF"
    - "GET /drivers renders the drivers page with upload form and existing driver list"
  artifacts:
    - path: "imptune/api/drivers.py"
      provides: "POST /drivers/upload endpoint returning HTMX partial"
      exports: ["router"]
    - path: "imptune/templates/drivers.html"
      provides: "Drivers page with upload form and driver list container"
      contains: "hx-post"
    - path: "imptune/templates/partials/driver_list.html"
      provides: "HTMX partial fragment with driver table and select dropdown"
      contains: "<select"
    - path: "tests/test_driver_upload.py"
      provides: "Integration tests for upload endpoint and drivers page"
      min_lines: 80
  key_links:
    - from: "imptune/api/drivers.py"
      to: "imptune/services/inf_parser.py"
      via: "import parse_inf, _detect_encoding"
      pattern: "from imptune\\.services\\.inf_parser import"
    - from: "imptune/api/drivers.py"
      to: "imptune/storage/driver_store.py"
      via: "DriverStore(DRIVERS_DIR).save(data)"
      pattern: "DriverStore.*save"
    - from: "imptune/api/drivers.py"
      to: "imptune/db/models.py"
      via: "Driver.get_or_create(sha256=...)"
      pattern: "Driver\\.get_or_create"
    - from: "imptune/templates/drivers.html"
      to: "/drivers/upload"
      via: "hx-post with multipart/form-data"
      pattern: "hx-post.*drivers/upload"
    - from: "imptune/main.py"
      to: "imptune/api/drivers.py"
      via: "app.include_router(drivers.router)"
      pattern: "include_router.*drivers"
---

<objective>
Create the driver upload endpoint, drivers page, and HTMX-driven UI that lets technicians upload driver ZIPs, see parsed driver names in a dropdown, and view unused-file hints.

Purpose: This wires the INF parser (from plan 02-01) into a working upload flow with persistence and UI feedback. After this plan, the full DRV-01 through DRV-05 feature set is functional.
Output: Upload API endpoint, drivers page template, HTMX partial for driver list, integration tests.
</objective>

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

<context>
@.planning/PROJECT.md
@.planning/ROADMAP.md
@.planning/STATE.md
@.planning/phases/02-driver-management/02-RESEARCH.md
@.planning/phases/02-driver-management/02-01-SUMMARY.md

<interfaces>
<!-- From Plan 02-01 (INF parser) -->
From imptune/services/inf_parser.py:
```python
from dataclasses import dataclass

@dataclass
class ParsedInf:
    driver_names: list[str]      # resolved DriverDesc values, deduplicated, sorted
    inf_filename: str            # which .inf file inside the ZIP
    architecture: str | None     # 'x64', 'x86', 'arm64', or None
    has_cat_file: bool           # whether a .cat file exists in the ZIP
    unused_files: list[str]      # ZIP members not referenced by the INF

def _detect_encoding(raw: bytes) -> str: ...
def parse_inf(inf_text: str, inf_filename: str, zip_names: list[str]) -> ParsedInf: ...
```

<!-- Existing Phase 1 interfaces -->
From imptune/config.py:
```python
DATA_DIR = os.environ.get("DATA_DIR", "/data")
DRIVERS_DIR = str(Path(DATA_DIR) / "drivers")
```

From imptune/storage/driver_store.py:
```python
class DriverStore:
    def __init__(self, base_dir: str) -> None: ...
    def save(self, data: bytes) -> str: ...  # returns SHA256 hex
```

From imptune/db/models.py:
```python
class Driver(BaseModel):
    sha256 = CharField(unique=True, index=True)
    original_filename = CharField()
    size_bytes = IntegerField()
    uploaded_at = DateTimeField(default=datetime.utcnow)
    driver_desc = CharField(null=True)    # json.dumps(list) for multi-model
    inf_filename = CharField(null=True)
    architecture = CharField(null=True)
    has_cat_file = BooleanField(default=False)
```

From imptune/main.py:
```python
app = FastAPI(title="ImpTune", lifespan=lifespan)
app.include_router(health.router)
app.include_router(pages.router)
# Add: app.include_router(drivers.router)
```

From imptune/api/pages.py:
```python
router = APIRouter()
templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))
```

From imptune/templates/base.html:
```html
<!-- Sidebar already has /drivers link -->
<li><a href="/drivers" ...>Drivers</a></li>
<!-- Content block: {% block content %}{% endblock %} -->
```
</interfaces>
</context>

<tasks>

<task type="auto" tdd="true">
  <name>Task 1: Upload endpoint, drivers page route, and integration tests</name>
  <files>imptune/api/drivers.py, imptune/api/pages.py, imptune/main.py, tests/test_driver_upload.py</files>
  <behavior>
    - test_drivers_page: GET /drivers returns 200 with HTML containing upload form (input type="file", hx-post="/drivers/upload")
    - test_upload_valid_zip: POST /drivers/upload with a valid ZIP containing sample.inf returns 200, HTML contains driver name from INF
    - test_upload_non_zip: POST /drivers/upload with a .txt file returns 400
    - test_upload_no_inf: POST /drivers/upload with a ZIP containing no .inf returns 400
    - test_upload_returns_select: POST /drivers/upload with valid ZIP returns HTML containing a select element with driver names as options
    - test_driver_persisted: After upload, Driver.select().where(Driver.sha256==expected).count() == 1, and DriverStore file exists on disk
    - test_dedup_upload: Uploading same ZIP twice creates only one Driver record
    - test_unused_files_in_response: Upload a ZIP with an extra file not in INF text; response HTML contains "unused" or the count
  </behavior>
  <action>
**RED phase first:**

1. Create `tests/test_driver_upload.py` with all 8 integration tests. Tests use the `client` fixture from conftest.py. For test fixtures, create valid ZIP bytes in-memory using `zipfile.ZipFile(io.BytesIO(), 'w')`:
   - Build a helper `_make_driver_zip(inf_content: str, extra_files: dict[str, bytes] = None) -> bytes` that creates a ZIP with the INF and optional extra files
   - Use the same sample INF content from tests/fixtures/sample.inf (read it or inline it)
   - For `test_upload_non_zip`, send raw text bytes with filename="test.zip"
   - For `test_upload_no_inf`, create a ZIP with only a .txt file
   - For `test_unused_files_in_response`, add a "readme.txt" to the ZIP that the INF does not reference
   - All tests use `client.post("/drivers/upload", files={"file": ("driver.zip", zip_bytes, "application/zip")})` 
   - Import `Driver` from `imptune.db.models` and `init_db` from `imptune.db.database` for persistence checks. Call `init_db()` in tests that check DB state (the `client` fixture triggers lifespan which calls init_db).

2. Run `pytest tests/test_driver_upload.py -x` — all MUST FAIL. Commit: `test(02-02): add failing integration tests for driver upload`

**GREEN phase:**

3. Create `imptune/api/drivers.py`:
   - `router = APIRouter(prefix="/drivers")`
   - `templates = Jinja2Templates(directory=str(Path(__file__).parent.parent / "templates"))`
   - `MAX_UPLOAD_BYTES = 100 * 1024 * 1024`
   - `POST /upload` endpoint (sync def, not async — Peewee is sync):
     - Read file bytes, validate size <= 100MB
     - Validate filename ends with `.zip`
     - Validate `zipfile.is_zipfile(io.BytesIO(data))`
     - Open ZIP, validate no zip-slip paths (reject `..` or absolute paths)
     - Find `.inf` files in namelist; raise 400 if none
     - Prefer INF whose path contains `amd64`/`x64` if multiple exist; else first alphabetically
     - Read INF bytes, detect encoding with `_detect_encoding()`, decode
     - Call `parse_inf(inf_text, inf_filename, zip_names)`
     - Save via `DriverStore(DRIVERS_DIR).save(data)`
     - Upsert `Driver.get_or_create(sha256=sha256, defaults={...})` — store `json.dumps(parsed.driver_names)` in `driver_desc`
     - Query all drivers: `Driver.select().order_by(Driver.uploaded_at.desc())`
     - Return `templates.TemplateResponse(request=request, name="partials/driver_list.html", context={...})`
     - On validation errors, return HTMX-friendly error: `HTMLResponse(content="<div id='driver-list' class='error'>Error message</div>", status_code=400)` — so HTMX can swap the error into the target area

4. Add GET /drivers route to `imptune/api/pages.py`:
   ```python
   @router.get("/drivers", response_class=HTMLResponse)
   def drivers_page(request: Request):
       from imptune.db.models import Driver
       drivers = list(Driver.select().order_by(Driver.uploaded_at.desc()))
       return templates.TemplateResponse(
           request=request, name="drivers.html",
           context={"drivers": drivers}
       )
   ```

5. Register the drivers router in `imptune/main.py`:
   - Add `from imptune.api import drivers` to imports
   - Add `app.include_router(drivers.router)` after the pages router

6. Run `pytest tests/test_driver_upload.py -x` — all MUST PASS. Commit: `feat(02-02): add driver upload endpoint with INF parsing and dedup`
  </action>
  <verify>
    <automated>pytest tests/test_driver_upload.py -v</automated>
  </verify>
  <done>All 8 integration tests pass. POST /drivers/upload accepts ZIPs, parses INFs, persists via DriverStore + Peewee, returns HTMX partial. GET /drivers renders the page. Error cases return 400.</done>
</task>

<task type="auto">
  <name>Task 2: Drivers page template and HTMX partial</name>
  <files>imptune/templates/drivers.html, imptune/templates/partials/driver_list.html</files>
  <action>
1. Create `imptune/templates/partials/` directory (if not exists).

2. Create `imptune/templates/drivers.html` extending base.html:
   ```html
   {% extends "base.html" %}
   {% block content %}
   <h1>Drivers</h1>
   <section>
     <h2>Upload Driver Package</h2>
     <form
       hx-post="/drivers/upload"
       hx-encoding="multipart/form-data"
       hx-target="#driver-list"
       hx-swap="outerHTML"
       hx-indicator="#upload-spinner"
     >
       <label for="driver-file">Driver Package (ZIP containing .inf + driver files)</label>
       <input type="file" id="driver-file" name="file" accept=".zip" required>
       <button type="submit">Upload</button>
       <span id="upload-spinner" class="htmx-indicator" aria-busy="true">Uploading...</span>
     </form>
   </section>
   <section>
     <h2>Driver Library</h2>
     <div id="driver-list">
       {% include "partials/driver_list.html" %}
     </div>
   </section>
   {% endblock %}
   ```

3. Create `imptune/templates/partials/driver_list.html`:
   - Wrap everything in `<div id="driver-list">` (for HTMX outerHTML swap)
   - If `drivers` list is empty, show "No drivers uploaded yet."
   - If `drivers` exist, render a table with columns: Filename, Driver Name(s), Architecture, Uploaded, Unused Files
   - For each driver, parse `driver.driver_desc` as JSON to get the list of driver names. Display as a `<select>` dropdown if multiple names, or plain text if single name. Use Jinja2: `{% set names = driver.driver_desc | tojson | default('[]') %}` — actually, since driver_desc is already a JSON string, parse it in template or pass parsed data from the route.
   - Show unused files count if `parsed` context variable is available (on fresh upload): "N files may be unused" with a details/summary for the list
   - For the "new_driver" highlight (if present in context), add a CSS class to indicate success

   The partial must work both as an include (initial page load, no `parsed` variable) and as a standalone HTMX response (after upload, `parsed` available).

   Template approach for driver names: In the route, pass `driver_names_map` — a dict mapping driver.id to the parsed list. Or simpler: add a property/method. Simplest approach for Jinja2: use a custom filter or pass a helper. Actually, simplest: in the route handler, build a list of dicts with pre-parsed data:
   ```python
   import json
   driver_data = []
   for d in drivers:
       names = json.loads(d.driver_desc) if d.driver_desc else []
       driver_data.append({"driver": d, "names": names})
   ```
   Pass `driver_data` to template. Template iterates `driver_data` and renders `item.names` as select options.

   Update both the drivers.py upload endpoint AND the pages.py GET /drivers route to pass `driver_data` in this format.

4. Run full test suite to verify no regressions: `pytest tests/ -x -q`
  </action>
  <verify>
    <automated>pytest tests/ -v</automated>
  </verify>
  <done>GET /drivers renders a page with upload form and driver table. After upload, HTMX swaps in updated driver list with select dropdown containing parsed driver names. Unused file count visible. All tests green.</done>
</task>

</tasks>

<verification>
```bash
pytest tests/ -v                    # Full suite green
pytest tests/test_driver_upload.py -v  # All upload integration tests
pytest tests/test_inf_parser.py -v     # All parser unit tests
```
</verification>

<success_criteria>
- POST /drivers/upload with valid driver ZIP returns 200 with HTML containing driver name dropdown
- POST /drivers/upload with invalid input returns 400 with clear error
- Driver records persisted in SQLite with json.dumps(driver_names) in driver_desc
- Driver files persisted in DRIVERS_DIR via SHA256 content-addressed storage
- Duplicate uploads produce no duplicate records
- GET /drivers renders upload form and existing driver list
- Unused files flagged in upload response
- All integration + unit tests pass, zero regressions
</success_criteria>

<output>
After completion, create `.planning/phases/02-driver-management/02-02-SUMMARY.md`
</output>
