aboutsummaryrefslogtreecommitdiff
path: root/scripts
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 02:24:09 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-13 02:24:09 +0300
commit7f3364a468f7dcfd8c9c84e5737fe5699f0e3a93 (patch)
tree2a96ec79fe1c500972697c3198a1a6dc4441ee92 /scripts
parent2c444eee351e9269b270e215cfbf4696e7c6179b (diff)
docs: add versioned config reference
Diffstat (limited to 'scripts')
-rw-r--r--scripts/config_reference_check.py319
-rw-r--r--scripts/test_config_reference_check.py245
-rw-r--r--scripts/test_preview.py5
3 files changed, 569 insertions, 0 deletions
diff --git a/scripts/config_reference_check.py b/scripts/config_reference_check.py
new file mode 100644
index 00000000..f9aa5e72
--- /dev/null
+++ b/scripts/config_reference_check.py
@@ -0,0 +1,319 @@
+"""Check the website config reference against the Rust config model.
+
+Walks the serde structs in src/config/*.rs starting from the root `Config`
+struct, builds the set of canonical dotted TOML key paths, and compares it
+against the keys documented in the preview config reference. Compatibility
+aliases are intentionally documented on their canonical entries rather than
+as separate rows.
+
+The comparison checks key names and serde-derived enum values, so failures
+name exact missing, stale, duplicated, or value-drifted entries.
+
+Open-ended surfaces (arrays of tables such as [[keys.command]]) are not
+enumerable per-key and are skipped; they are listed in SKIPPED_SUBTREES so
+the skip stays explicit.
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import re
+import sys
+from dataclasses import dataclass, field
+from pathlib import Path
+
+
+DEFAULT_MODEL_ROOT = Path("src/config")
+DEFAULT_REFERENCE = Path("docs/next/website/src/data/config-reference.json")
+ROOT_STRUCT = "Config"
+
+# Dotted key prefixes that are open-ended (user-defined tables/arrays) and
+# therefore not enumerable in a flat reference table.
+SKIPPED_SUBTREES = ("keys.command",)
+
+FIELD_RE = re.compile(r"^\s*pub ([a-z_][a-z0-9_]*):\s*(.+?),?\s*$")
+STRUCT_RE = re.compile(r"^\s*pub(?:\(crate\))? struct ([A-Za-z0-9_]+)\s*\{\s*$")
+ENUM_RE = re.compile(r"^\s*pub(?:\(crate\))? enum ([A-Za-z0-9_]+)\s*\{\s*$")
+VARIANT_RE = re.compile(r"^\s*([A-Z][A-Za-z0-9_]*)\s*(?:\(.*\))?\s*,?\s*$")
+RENAME_ALL_RE = re.compile(r'rename_all\s*=\s*"([^"]+)"')
+RENAME_RE = re.compile(r'rename\s*=\s*"([^"]+)"')
+
+
+@dataclass
+class StructField:
+ name: str
+ rust_type: str
+ doc: str
+
+
+@dataclass
+class Model:
+ structs: dict[str, list[StructField]] = field(default_factory=dict)
+ enums: dict[str, list[str]] = field(default_factory=dict)
+
+
+def apply_rename_all(name: str, style: str | None) -> str:
+ if style is None:
+ return name
+ if style == "lowercase":
+ return name.lower()
+ if style == "snake_case":
+ return re.sub(r"(?<!^)([A-Z])", r"_\1", name).lower()
+ if style == "kebab-case":
+ return re.sub(r"(?<!^)([A-Z])", r"-\1", name).lower()
+ raise ValueError(f"unsupported serde rename_all style: {style}")
+
+
+def parse_model(paths: list[Path]) -> Model:
+ model = Model()
+ for path in paths:
+ parse_file(path.read_text(encoding="utf-8"), model)
+ return model
+
+
+def parse_file(text: str, model: Model) -> None:
+ lines = text.splitlines()
+ index = 0
+ pending_attrs: list[str] = []
+
+ while index < len(lines):
+ line = lines[index]
+ stripped = line.strip()
+
+ if stripped.startswith("#["):
+ pending_attrs.append(stripped)
+ index += 1
+ continue
+
+ struct_match = STRUCT_RE.match(line)
+ enum_match = ENUM_RE.match(line)
+ if struct_match:
+ rename_all = find_rename_all(pending_attrs)
+ index = parse_struct_body(lines, index + 1, struct_match.group(1), rename_all, model)
+ pending_attrs = []
+ continue
+ if enum_match:
+ rename_all = find_rename_all(pending_attrs)
+ untagged = any("untagged" in attr for attr in pending_attrs if attr.startswith("#[serde"))
+ index = parse_enum_body(lines, index + 1, enum_match.group(1), rename_all, model)
+ if untagged:
+ model.enums.pop(enum_match.group(1), None)
+ pending_attrs = []
+ continue
+
+ if not stripped.startswith("///"):
+ pending_attrs = []
+ index += 1
+
+
+def find_rename_all(attrs: list[str]) -> str | None:
+ for attr in attrs:
+ if not attr.startswith("#[serde"):
+ continue
+ match = RENAME_ALL_RE.search(attr)
+ if match:
+ return match.group(1)
+ return None
+
+
+def parse_struct_body(
+ lines: list[str], start: int, name: str, rename_all: str | None, model: Model
+) -> int:
+ fields: list[StructField] = []
+ doc_lines: list[str] = []
+ field_attrs: list[str] = []
+ index = start
+
+ while index < len(lines):
+ stripped = lines[index].strip()
+ if stripped == "}":
+ index += 1
+ break
+
+ if stripped.startswith("///"):
+ doc_lines.append(stripped.lstrip("/").strip())
+ index += 1
+ continue
+ if stripped.startswith("#["):
+ field_attrs.append(stripped)
+ index += 1
+ continue
+
+ match = FIELD_RE.match(lines[index])
+ if match:
+ field_name = serde_field_name(match.group(1), field_attrs, rename_all)
+ if not is_skipped_field(field_attrs):
+ fields.append(
+ StructField(
+ name=field_name,
+ rust_type=match.group(2).strip(),
+ doc=" ".join(doc_lines),
+ )
+ )
+ doc_lines = []
+ field_attrs = []
+ index += 1
+
+ model.structs[name] = fields
+ return index
+
+
+def serde_field_name(name: str, attrs: list[str], rename_all: str | None) -> str:
+ for attr in attrs:
+ if attr.startswith("#[serde"):
+ match = RENAME_RE.search(attr)
+ if match and "rename_all" not in attr:
+ return match.group(1)
+ return apply_rename_all(name, rename_all) if rename_all else name
+
+
+def is_skipped_field(attrs: list[str]) -> bool:
+ for attr in attrs:
+ if not attr.startswith("#[serde"):
+ continue
+ if re.search(r"\bskip\b|\bskip_deserializing\b", attr):
+ return True
+ return False
+
+
+def parse_enum_body(
+ lines: list[str], start: int, name: str, rename_all: str | None, model: Model
+) -> int:
+ variants: list[str] = []
+ index = start
+ depth = 0
+
+ while index < len(lines):
+ stripped = lines[index].strip()
+ if depth == 0 and stripped == "}":
+ index += 1
+ break
+
+ depth += stripped.count("{") - stripped.count("}")
+ if depth == 0 and not stripped.startswith(("#[", "///")):
+ match = VARIANT_RE.match(stripped)
+ if match:
+ variants.append(apply_rename_all(match.group(1), rename_all or "lowercase"))
+ index += 1
+
+ model.enums[name] = variants
+ return index
+
+
+def strip_wrappers(rust_type: str) -> tuple[str, bool]:
+ """Return the innermost type name and whether it was wrapped in Vec."""
+ current = rust_type
+ is_vec = False
+ while True:
+ match = re.fullmatch(r"(Option|Vec|Box)<(.+)>", current)
+ if not match:
+ break
+ if match.group(1) == "Vec":
+ is_vec = True
+ current = match.group(2)
+ # std::collections::BTreeSet<...> and friends stay as-is; they are leaves.
+ return current.strip(), is_vec
+
+
+def collect_keys(model: Model) -> set[str]:
+ return {entry["key"] for entry in collect_entries(model)}
+
+
+def collect_entries(model: Model, struct_name: str = ROOT_STRUCT, prefix: str = "") -> list[dict]:
+ """Dotted keys with type/doc details; --emit prints these for bootstrapping."""
+ if struct_name not in model.structs:
+ raise KeyError(f"struct {struct_name} not found in config model")
+
+ entries: list[dict] = []
+ for struct_field in model.structs[struct_name]:
+ dotted = f"{prefix}{struct_field.name}"
+ inner, is_vec = strip_wrappers(struct_field.rust_type)
+ if inner in model.structs:
+ if dotted in SKIPPED_SUBTREES:
+ continue
+ if is_vec:
+ raise ValueError(
+ f"{dotted} is an open-ended array of tables; add it to "
+ "SKIPPED_SUBTREES and document it in prose"
+ )
+ entries.extend(collect_entries(model, inner, f"{dotted}."))
+ else:
+ entry = {"key": dotted, "rust_type": struct_field.rust_type, "doc": struct_field.doc}
+ if inner in model.enums:
+ entry["values"] = model.enums[inner]
+ entries.append(entry)
+ return entries
+
+
+def reference_entries(reference_path: Path) -> tuple[dict[str, dict], list[str]]:
+ data = json.loads(reference_path.read_text(encoding="utf-8"))
+ entries: dict[str, dict] = {}
+ errors: list[str] = []
+ for section in data["sections"]:
+ for entry in section["keys"]:
+ key = entry["key"]
+ if key in entries:
+ errors.append(f"{key}: duplicated in {reference_path}")
+ continue
+ entries[key] = entry
+ return entries, errors
+
+
+def check(model_root: Path, reference_path: Path) -> list[str]:
+ model = parse_model(sorted(model_root.glob("*.rs")))
+ code_entries = {entry["key"]: entry for entry in collect_entries(model)}
+ doc_entries, errors = reference_entries(reference_path)
+ code_keys = set(code_entries)
+ doc_keys = set(doc_entries)
+
+ for missing in sorted(code_keys - doc_keys):
+ errors.append(f"{missing}: in src/config but missing from {reference_path}")
+ for stale in sorted(doc_keys - code_keys):
+ errors.append(f"{stale}: in {reference_path} but not in src/config")
+ for key in sorted(code_keys & doc_keys):
+ expected_values = code_entries[key].get("values")
+ if expected_values is None:
+ continue
+ documented_values = doc_entries[key].get("values")
+ if documented_values != expected_values:
+ errors.append(
+ f"{key}: allowed values in {reference_path} are "
+ f"{documented_values!r}; expected {expected_values!r} from src/config"
+ )
+ return errors
+
+
+def parse_args(argv: list[str]) -> argparse.Namespace:
+ parser = argparse.ArgumentParser(
+ description="Check website config reference keys against src/config structs."
+ )
+ parser.add_argument("--model-root", default=DEFAULT_MODEL_ROOT, type=Path)
+ parser.add_argument("--reference", default=DEFAULT_REFERENCE, type=Path)
+ parser.add_argument(
+ "--emit",
+ action="store_true",
+ help="Print extracted keys with types and doc comments as JSON and exit.",
+ )
+ return parser.parse_args(argv)
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = parse_args(sys.argv[1:] if argv is None else argv)
+
+ if args.emit:
+ model = parse_model(sorted(args.model_root.glob("*.rs")))
+ print(json.dumps(collect_entries(model), indent=2))
+ return 0
+
+ errors = check(args.model_root, args.reference)
+ if errors:
+ print("error: config reference is out of sync with src/config", file=sys.stderr)
+ for error in errors:
+ print(f"- {error}", file=sys.stderr)
+ return 1
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/test_config_reference_check.py b/scripts/test_config_reference_check.py
new file mode 100644
index 00000000..317aae8a
--- /dev/null
+++ b/scripts/test_config_reference_check.py
@@ -0,0 +1,245 @@
+from __future__ import annotations
+
+import json
+import tempfile
+import unittest
+from pathlib import Path
+
+from scripts.config_reference_check import (
+ Model,
+ StructField,
+ check,
+ collect_entries,
+ collect_keys,
+ parse_file,
+ parse_model,
+)
+
+
+SAMPLE_MODEL = """
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct Config {
+ pub onboarding: Option<bool>,
+ pub ui: UiConfig,
+ pub keys: KeysConfig,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct UiConfig {
+ /// Sidebar width in columns. Default: 26.
+ pub sidebar_width: u16,
+ /// Host cursor policy. Default: auto.
+ pub host_cursor: HostCursorModeConfig,
+ #[serde(rename = "accent_color")]
+ pub accent: String,
+ #[serde(skip)]
+ pub internal_cache: usize,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(default)]
+pub struct KeysConfig {
+ /// Prefix key. Default: "ctrl+b".
+ pub prefix: String,
+ pub zoom: BindingConfig,
+ /// Prefix-mode custom command bindings.
+ pub command: Vec<CommandKeybindConfig>,
+ pub(crate) user_fields: BTreeSet<&'static str>,
+}
+
+#[derive(Debug, Deserialize)]
+pub struct CommandKeybindConfig {
+ pub key: String,
+ pub command: String,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(rename_all = "kebab-case")]
+pub enum HostCursorModeConfig {
+ Auto,
+ NativeCursor,
+ Drawn,
+}
+
+#[derive(Debug, Deserialize)]
+#[serde(untagged)]
+pub enum BindingConfig {
+ One(String),
+ Many(Vec<String>),
+}
+"""
+
+
+def sample_model() -> Model:
+ model = Model()
+ parse_file(SAMPLE_MODEL, model)
+ return model
+
+
+class CollectKeysTests(unittest.TestCase):
+ def test_walks_nested_structs_into_dotted_keys(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertIn("onboarding", keys)
+ self.assertIn("ui.sidebar_width", keys)
+ self.assertIn("keys.prefix", keys)
+ self.assertIn("keys.zoom", keys)
+
+ def test_serde_rename_wins_over_field_name(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertIn("ui.accent_color", keys)
+ self.assertNotIn("ui.accent", keys)
+
+ def test_skips_serde_skip_and_private_fields(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertNotIn("ui.internal_cache", keys)
+ self.assertNotIn("keys.user_fields", keys)
+
+ def test_skips_listed_vec_of_struct_subtrees(self) -> None:
+ keys = collect_keys(sample_model())
+
+ self.assertNotIn("keys.command", keys)
+ self.assertNotIn("keys.command.key", keys)
+
+ def test_unlisted_vec_of_struct_subtree_is_an_error(self) -> None:
+ model = sample_model()
+ parse_file(
+ "pub struct ExtraConfig {\n"
+ " pub items: Vec<CommandKeybindConfig>,\n"
+ "}\n",
+ model,
+ )
+ model.structs["Config"].append(
+ StructField(name="extra", rust_type="ExtraConfig", doc="")
+ )
+
+ with self.assertRaises(ValueError) as raised:
+ collect_keys(model)
+
+ self.assertIn("extra.items", str(raised.exception))
+ self.assertIn("SKIPPED_SUBTREES", str(raised.exception))
+
+ def test_enum_values_respect_rename_all_and_untagged_enums_have_none(self) -> None:
+ entries = {entry["key"]: entry for entry in collect_entries(sample_model())}
+
+ self.assertEqual(
+ entries["ui.host_cursor"]["values"], ["auto", "native-cursor", "drawn"]
+ )
+ self.assertNotIn("values", entries["keys.zoom"])
+
+
+class CheckTests(unittest.TestCase):
+ def run_check(
+ self,
+ documented_keys: list[str],
+ *,
+ value_overrides: dict[str, list[str]] | None = None,
+ ) -> list[str]:
+ with tempfile.TemporaryDirectory() as tmp:
+ root = Path(tmp)
+ model_root = root / "config"
+ model_root.mkdir()
+ (model_root / "model.rs").write_text(SAMPLE_MODEL, encoding="utf-8")
+
+ model_entries = {entry["key"]: entry for entry in collect_entries(sample_model())}
+ entries = []
+ for key in documented_keys:
+ entry = {"key": key}
+ if key in model_entries and "values" in model_entries[key]:
+ entry["values"] = model_entries[key]["values"]
+ if value_overrides and key in value_overrides:
+ entry["values"] = value_overrides[key]
+ entries.append(entry)
+
+ reference = root / "config-reference.json"
+ reference.write_text(
+ json.dumps(
+ {
+ "sections": [
+ {
+ "id": "all",
+ "title": "All",
+ "keys": entries,
+ }
+ ]
+ }
+ ),
+ encoding="utf-8",
+ )
+ return check(model_root, reference)
+
+ def all_keys(self) -> list[str]:
+ return sorted(collect_keys(sample_model()))
+
+ def test_in_sync_reference_passes(self) -> None:
+ self.assertEqual(self.run_check(self.all_keys()), [])
+
+ def test_missing_key_is_named(self) -> None:
+ documented = [key for key in self.all_keys() if key != "ui.sidebar_width"]
+
+ errors = self.run_check(documented)
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("ui.sidebar_width", errors[0])
+ self.assertIn("missing", errors[0])
+
+ def test_stale_key_is_named(self) -> None:
+ errors = self.run_check(self.all_keys() + ["ui.removed_option"])
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("ui.removed_option", errors[0])
+ self.assertIn("not in src/config", errors[0])
+
+ def test_swapped_key_fails_despite_equal_count(self) -> None:
+ documented = [
+ "ui.renamed_option" if key == "ui.sidebar_width" else key
+ for key in self.all_keys()
+ ]
+
+ errors = self.run_check(documented)
+
+ self.assertEqual(len(errors), 2)
+
+ def test_duplicate_key_is_rejected(self) -> None:
+ errors = self.run_check(self.all_keys() + ["ui.sidebar_width"])
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("duplicated", errors[0])
+
+ def test_changed_enum_values_are_rejected(self) -> None:
+ errors = self.run_check(
+ self.all_keys(),
+ value_overrides={"ui.host_cursor": ["auto", "native"]},
+ )
+
+ self.assertEqual(len(errors), 1)
+ self.assertIn("ui.host_cursor", errors[0])
+ self.assertIn("allowed values", errors[0])
+
+
+class RealModelTests(unittest.TestCase):
+ def test_real_config_model_parses_and_yields_keys(self) -> None:
+ model = parse_model(sorted(Path("src/config").glob("*.rs")))
+ keys = collect_keys(model)
+
+ self.assertGreater(len(keys), 100)
+ self.assertIn("keys.prefix", keys)
+ self.assertIn("ui.sound.agents.claude", keys)
+ self.assertNotIn("keys.command", keys)
+
+ def test_preview_reference_matches_real_config_model(self) -> None:
+ self.assertEqual(
+ check(
+ Path("src/config"),
+ Path("docs/next/website/src/data/config-reference.json"),
+ ),
+ [],
+ )
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/scripts/test_preview.py b/scripts/test_preview.py
index db80b1a7..87bd52eb 100644
--- a/scripts/test_preview.py
+++ b/scripts/test_preview.py
@@ -141,6 +141,9 @@ class PreviewNotesTests(unittest.TestCase):
title: Install Herdr
---
+import ConfigReference from '../../components/ConfigReference.astro';
+import LocaleWidget from '../../../components/LocaleWidget.astro';
+
[Install](/docs/install/)
file: ../../../public/assets/logo.svg
"""
@@ -151,6 +154,8 @@ file: ../../../public/assets/logo.svg
)
self.assertIn("[Install](/docs/preview/install/)", output)
self.assertIn("file: ../../../../public/assets/logo.svg", output)
+ self.assertIn("from '../../../components/ConfigReference.astro'", output)
+ self.assertIn("from '../../../../components/LocaleWidget.astro'", output)
self.assertIn("Preview docs describe unreleased preview builds", output)