"""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 import tomllib 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*"([^"]+)"') SOUND_OVERRIDES_STRUCT = "AgentSoundOverrides" SOUND_SETTING_ENUM = "AgentSoundSetting" @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"(? list[str]: """Read the optional [sound] config key from one agent.toml package.""" package = tomllib.loads(text) if "sound" not in package: return [] sound = package["sound"] if not isinstance(sound, dict) or not isinstance(sound.get("key"), str): raise ValueError("sound profile must have a string config key") key = sound["key"] if not key.strip(): raise ValueError("sound profile config keys must not be empty") return [key] def augment_sound_override_fields(model: Model, sound_keys: list[str]) -> None: """Expose registry-owned sound keys to the config reference walker.""" if not sound_keys: return if SOUND_OVERRIDES_STRUCT not in model.structs: raise KeyError( f"struct {SOUND_OVERRIDES_STRUCT} not found in config model" ) fields = model.structs[SOUND_OVERRIDES_STRUCT] existing = {struct_field.name for struct_field in fields} for key in sound_keys: if key in existing: raise ValueError( f"sound profile config key duplicates a literal config field: {key}" ) fields.append(StructField(name=key, rust_type=SOUND_SETTING_ENUM, doc="")) existing.add(key) def agent_profile_paths(source: Path) -> list[Path]: """Return one TOML fixture or the exact per-agent package paths.""" if source.is_file(): return [source] if source.is_dir(): paths = sorted(source.glob("*/agent.toml")) if not paths: raise ValueError(f"no agent.toml packages found in {source}") return paths raise FileNotFoundError(f"agent profile source does not exist: {source}") def parse_sound_profile_source(source: Path) -> list[str]: keys: list[str] = [] seen: set[str] = set() for path in agent_profile_paths(source): for key in parse_sound_profile_keys(path.read_text(encoding="utf-8")): if key in seen: raise ValueError(f"duplicate sound profile config key: {key}") seen.add(key) keys.append(key) return keys def inferred_agents_directory(paths: list[Path]) -> Path | None: if not paths: return None model_roots = {path.parent for path in paths} if len(model_roots) != 1: return None src = next(iter(model_roots)).parent candidate = src.parent / "vendor" / "agent-registry" / "agents" return candidate if candidate.is_dir() or src.name == "src" else None def parse_model(paths: list[Path], agent_catalog: Path | None = None) -> Model: model = Model() for path in paths: parse_file(path.read_text(encoding="utf-8"), model) profile_source = agent_catalog or inferred_agents_directory(paths) if profile_source is not None: sound_keys = parse_sound_profile_source(profile_source) augment_sound_override_fields(model, sound_keys) 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 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")) depth += stripped.count("{") - stripped.count("}") 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, agent_catalog: Path | None = None, ) -> list[str]: model = parse_model( sorted(model_root.glob("*.rs")), agent_catalog=agent_catalog, ) 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( "--agent-profiles", "--agent-catalog", dest="agent_catalog", type=Path, help=( "Optional agents directory or single agent.toml fixture. By default, " "vendor/agent-registry/agents is discovered from the model root." ), ) 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")), agent_catalog=args.agent_catalog, ) print(json.dumps(collect_entries(model), indent=2)) return 0 errors = check( args.model_root, args.reference, agent_catalog=args.agent_catalog, ) 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())