|
| 1 | +# Feature N11: Config Validation |
| 2 | + |
| 3 | +## Status: Not Started (Phase B) |
| 4 | + |
| 5 | +## Goal |
| 6 | + |
| 7 | +Provide a `validate` CLI command that checks a `rockgarden.toml` for problems: unknown keys (likely typos), invalid values, missing required values, and theme-specific config issues. Themes should be able to declare what config keys they use so that unexpected or missing theme config can also be reported. |
| 8 | + |
| 9 | +--- |
| 10 | + |
| 11 | +## Design |
| 12 | + |
| 13 | +### Validation Categories |
| 14 | + |
| 15 | +**Errors** (exit code 1): |
| 16 | +- TOML syntax errors |
| 17 | +- Invalid timezone (ZoneInfo lookup fails) |
| 18 | +- Theme declared as required config missing from TOML |
| 19 | + |
| 20 | +**Warnings** (exit code 0, printed to stderr): |
| 21 | +- Unknown key in any config section (probable typo) |
| 22 | +- Source directory does not exist |
| 23 | +- `theme.name` set but `_themes/<name>/` directory not found |
| 24 | +- Theme manifest declares a required key but it's absent from `[theme]` |
| 25 | + |
| 26 | +### Known-Key Validation |
| 27 | + |
| 28 | +The existing dataclasses define all valid keys per section. Validation extracts field names via `dataclasses.fields()` and compares against what the TOML dict actually contains. Unknown keys get a warning. |
| 29 | + |
| 30 | +Known sections: `site`, `build`, `theme`, `nav`, `toc`, `search`, `dates`. Unknown top-level sections also warn. |
| 31 | + |
| 32 | +### Theme Manifest (`theme.toml`) |
| 33 | + |
| 34 | +A theme can optionally ship a `theme.toml` in its directory (`_themes/<name>/theme.toml`) to declare metadata and any additional config keys it reads from the `[theme]` section. |
| 35 | + |
| 36 | +```toml |
| 37 | +[theme] |
| 38 | +name = "pyohio" |
| 39 | +description = "PyOhio conference theme" |
| 40 | +version = "1.0.0" |
| 41 | + |
| 42 | +[[theme.config]] |
| 43 | +key = "show_sponsors" |
| 44 | +type = "bool" |
| 45 | +required = true |
| 46 | +description = "Whether to show sponsor logos in the footer" |
| 47 | + |
| 48 | +[[theme.config]] |
| 49 | +key = "primary_color" |
| 50 | +type = "string" |
| 51 | +required = false |
| 52 | +default = "#3490dc" |
| 53 | +description = "Primary brand color (CSS value)" |
| 54 | +``` |
| 55 | + |
| 56 | +When a theme manifest exists: |
| 57 | +- Its declared keys are added to the known-key set for `[theme]`, so they don't produce "unknown key" warnings |
| 58 | +- Any key marked `required = true` that is absent from `[theme]` produces a warning |
| 59 | +- Themes without a manifest are still valid — manifest is optional |
| 60 | + |
| 61 | +--- |
| 62 | + |
| 63 | +## Implementation Plan |
| 64 | + |
| 65 | +### 1. New module: `src/rockgarden/validation.py` |
| 66 | + |
| 67 | +```python |
| 68 | +@dataclass |
| 69 | +class ValidationIssue: |
| 70 | + level: Literal["error", "warning"] |
| 71 | + message: str |
| 72 | + |
| 73 | +def validate_config(config_dict: dict, source_dir: Path | None = None) -> list[ValidationIssue]: |
| 74 | + """Validate a parsed TOML dict against known config schema.""" |
| 75 | + ... |
| 76 | + |
| 77 | +def load_theme_manifest(theme_dir: Path) -> dict: |
| 78 | + """Load and parse theme.toml from a theme directory. Returns {} if absent.""" |
| 79 | + ... |
| 80 | +``` |
| 81 | + |
| 82 | +Key checks in `validate_config()`: |
| 83 | +- Unknown top-level sections |
| 84 | +- Unknown keys per section (via `dataclasses.fields()` on each config class) |
| 85 | +- Timezone: `ZoneInfo(dates_data.get("timezone", "UTC"))` in try/except |
| 86 | +- Source dir existence (if provided / resolvable) |
| 87 | +- Theme dir existence + manifest loading if `theme.name` is set |
| 88 | +- Theme manifest required-key check |
| 89 | + |
| 90 | +### 2. New CLI command in `src/rockgarden/cli.py` |
| 91 | + |
| 92 | +```python |
| 93 | +@app.command() |
| 94 | +def validate( |
| 95 | + source: Annotated[Path | None, typer.Option("--source", "-s")] = None, |
| 96 | + config_file: Annotated[Path | None, typer.Option("--config", "-c")] = None, |
| 97 | +) -> None: |
| 98 | + """Validate rockgarden configuration.""" |
| 99 | +``` |
| 100 | + |
| 101 | +Same config/source resolution logic as `build` (auto-discovers `rockgarden.toml` in source dir). Reports issues to stderr, exits 1 on any errors. |
| 102 | + |
| 103 | +Output format: |
| 104 | +``` |
| 105 | +✓ No issues found. |
| 106 | +
|
| 107 | +# or: |
| 108 | +
|
| 109 | +Warning: [site] unknown key "titl" (did you mean "title"?) |
| 110 | +Warning: source directory "content" does not exist |
| 111 | +Error: [dates] invalid timezone "US/Easten" — ZoneInfoNotFoundError |
| 112 | +``` |
| 113 | + |
| 114 | +### 3. Known-key sets |
| 115 | + |
| 116 | +Rather than hard-coding lists, derive from dataclasses at runtime: |
| 117 | + |
| 118 | +```python |
| 119 | +import dataclasses |
| 120 | +from rockgarden.config import SiteConfig, BuildConfig, ThemeConfig, ... |
| 121 | + |
| 122 | +KNOWN_KEYS = { |
| 123 | + "site": {f.name for f in dataclasses.fields(SiteConfig)}, |
| 124 | + "build": {f.name for f in dataclasses.fields(BuildConfig)}, |
| 125 | + ... |
| 126 | +} |
| 127 | +``` |
| 128 | + |
| 129 | +Built-in `ThemeConfig` fields are always valid. Theme manifest declared keys are added on top. |
| 130 | + |
| 131 | +--- |
| 132 | + |
| 133 | +## Key Files |
| 134 | + |
| 135 | +- **New**: `src/rockgarden/validation.py` |
| 136 | +- **Modified**: `src/rockgarden/cli.py` — add `validate` command |
| 137 | +- **New (per-theme, optional)**: `_themes/<name>/theme.toml` |
| 138 | + |
| 139 | +--- |
| 140 | + |
| 141 | +## Verification |
| 142 | + |
| 143 | +- `rockgarden validate` on a clean config → exits 0, "No issues found" |
| 144 | +- Introduce a typo (`titl = "foo"`) → warning reported |
| 145 | +- Set `timezone = "Bad/Zone"` → error reported, exit 1 |
| 146 | +- Set `theme.name` to nonexistent dir → warning reported |
| 147 | +- Create a theme manifest with a required key, omit it from TOML → warning reported |
| 148 | +- `uv run pytest` still passes (no regressions) |
0 commit comments