One generator rather than one per repo. The point of the contract is that four codebases agree on keys, types, scopes and defaults, and four independently written generators would be four chances to disagree. Go and TypeScript land in this repo; Kotlin and Swift are written into the sibling client checkouts, skipped with a note when they are not present so a server-only developer can still run it. Output is sorted by key so an unrelated manifest edit does not produce spurious diffs. The Kotlin output is the interesting one: it generates the DeviceSettings allowlist Android maintained by hand, plus the BOOLEAN_KEYS/INT_KEYS/ DOUBLE_KEYS classification it kept as a *second* hand-maintained table that had to agree with the first. Both are manifest questions now, so the whole class of "wrote a local key to the server" and "flushed a value the store could not parse" bugs stops being possible by construction. The TypeScript output carries the full definition table — labels, controls, enum members, bounds — so web/src/lib/settingsManifest.ts can be deleted rather than kept in sync: it declared 17 definitions against the contract's 49, with its own two-scope model that does not match the contract's five. make verify-settings-bindings fails when the committed output disagrees with the manifest, wired into CI, so a manifest change cannot merge leaving every client reading stale keys. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
375 lines
13 KiB
Go
375 lines
13 KiB
Go
// Command settingsgen emits typed bindings for the settings contract.
|
|
//
|
|
// One generator for every language rather than one per repo: the whole point of
|
|
// the contract is that four codebases agree on keys, types, scopes and
|
|
// defaults, and four independently-written generators would be four chances to
|
|
// disagree. Each client repo vendors the manifest and runs this to regenerate.
|
|
//
|
|
// Usage:
|
|
//
|
|
// settingsgen -lang go -out internal/settingskeys/keys.go
|
|
// settingsgen -lang ts -out web/src/lib/settingsContract.ts
|
|
// settingsgen -lang kotlin -out <path> -package org.siloserver.silo.model.settings
|
|
// settingsgen -lang swift -out <path>
|
|
package main
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/json"
|
|
"flag"
|
|
"fmt"
|
|
"os"
|
|
"sort"
|
|
"strings"
|
|
|
|
"github.com/Silo-Server/silo-server/internal/settingscontract"
|
|
)
|
|
|
|
func main() {
|
|
lang := flag.String("lang", "", "go, ts, kotlin or swift")
|
|
out := flag.String("out", "", "file to write (default stdout)")
|
|
pkg := flag.String("package", "", "package or namespace for the generated code")
|
|
flag.Parse()
|
|
|
|
contract, err := settingscontract.Load()
|
|
if err != nil {
|
|
fail("loading contract: %v", err)
|
|
}
|
|
|
|
var body []byte
|
|
switch *lang {
|
|
case "go":
|
|
body, err = generateGo(contract, defaultString(*pkg, "settingskeys"))
|
|
case "ts":
|
|
body, err = generateTypeScript(contract)
|
|
case "kotlin":
|
|
body, err = generateKotlin(contract,
|
|
defaultString(*pkg, "org.siloserver.silo.model.settings"))
|
|
case "swift":
|
|
body, err = generateSwift(contract)
|
|
default:
|
|
fail("unknown -lang %q: want go, ts, kotlin or swift", *lang)
|
|
}
|
|
if err != nil {
|
|
fail("generating %s: %v", *lang, err)
|
|
}
|
|
|
|
if *out == "" {
|
|
_, _ = os.Stdout.Write(body)
|
|
return
|
|
}
|
|
if err := os.WriteFile(*out, body, 0o644); err != nil { //nolint:gosec // generated source
|
|
fail("writing %s: %v", *out, err)
|
|
}
|
|
}
|
|
|
|
func fail(format string, args ...any) {
|
|
fmt.Fprintf(os.Stderr, "settingsgen: "+format+"\n", args...)
|
|
os.Exit(1)
|
|
}
|
|
|
|
func defaultString(value, fallback string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
// remoteAndLocal returns every definition, sorted by key so the output is
|
|
// stable: a generator whose output depends on manifest authoring order would
|
|
// produce spurious diffs on every unrelated manifest edit.
|
|
func sortedDefinitions(contract *settingscontract.Manifest) []*settingscontract.Definition {
|
|
defs := make([]*settingscontract.Definition, 0, len(contract.Definitions))
|
|
for i := range contract.Definitions {
|
|
defs = append(defs, &contract.Definitions[i])
|
|
}
|
|
sort.Slice(defs, func(i, j int) bool { return defs[i].Key < defs[j].Key })
|
|
return defs
|
|
}
|
|
|
|
// identifierFor turns a dotted key into a language identifier:
|
|
// playback.subtitle_language becomes PlaybackSubtitleLanguage.
|
|
func identifierFor(key string) string {
|
|
var out strings.Builder
|
|
for _, part := range strings.FieldsFunc(key, func(r rune) bool {
|
|
return r == '.' || r == '_' || r == '-'
|
|
}) {
|
|
out.WriteString(strings.ToUpper(part[:1]))
|
|
out.WriteString(part[1:])
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
// screamingCase turns a dotted key into PLAYBACK_SUBTITLE_LANGUAGE.
|
|
func screamingCase(key string) string {
|
|
replaced := strings.NewReplacer(".", "_", "-", "_").Replace(key)
|
|
return strings.ToUpper(replaced)
|
|
}
|
|
|
|
const generatedHeader = `Code generated by cmd/settingsgen from contracts/settings/v1/manifest.json. DO NOT EDIT.
|
|
|
|
Regenerate with: make settings-bindings
|
|
|
|
Every key, type, scope and default here comes from the manifest, so a client
|
|
cannot drift from the server's contract by editing a constant. Adding a setting
|
|
is a manifest change plus a regeneration, never a hand-written key.`
|
|
|
|
func generateGo(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
|
|
var out bytes.Buffer
|
|
for _, line := range strings.Split(generatedHeader, "\n") {
|
|
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
|
|
}
|
|
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
|
|
fmt.Fprintf(&out, "// Revision is the manifest revision these bindings were generated from.\nconst Revision = %d\n\n",
|
|
contract.Revision)
|
|
|
|
out.WriteString("// Setting keys, one constant per definition.\nconst (\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
fmt.Fprintf(&out, "\t// %s\n", def.Label)
|
|
fmt.Fprintf(&out, "\t%s = %q\n", identifierFor(def.Key), def.Key)
|
|
}
|
|
out.WriteString(")\n\n")
|
|
|
|
out.WriteString("// Remote lists every key the server stores.\nvar Remote = []string{\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
if def.IsRemote() {
|
|
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
|
|
}
|
|
}
|
|
out.WriteString("}\n\n")
|
|
|
|
out.WriteString("// ClientLocal lists keys the contract defines but the server never stores.\n")
|
|
out.WriteString("var ClientLocal = []string{\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
if !def.IsRemote() {
|
|
fmt.Fprintf(&out, "\t%s,\n", identifierFor(def.Key))
|
|
}
|
|
}
|
|
out.WriteString("}\n")
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func generateTypeScript(contract *settingscontract.Manifest) ([]byte, error) {
|
|
var out bytes.Buffer
|
|
out.WriteString("/**\n")
|
|
for _, line := range strings.Split(generatedHeader, "\n") {
|
|
out.WriteString(strings.TrimRight(" * "+line, " ") + "\n")
|
|
}
|
|
out.WriteString(" */\n\n")
|
|
|
|
fmt.Fprintf(&out, "export const SETTINGS_REVISION = %d;\n\n", contract.Revision)
|
|
|
|
out.WriteString("export const SETTING_KEYS = {\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
fmt.Fprintf(&out, " /** %s */\n", def.Label)
|
|
fmt.Fprintf(&out, " %s: %q,\n", screamingCase(def.Key), def.Key)
|
|
}
|
|
out.WriteString("} as const;\n\n")
|
|
out.WriteString("export type SettingKey = (typeof SETTING_KEYS)[keyof typeof SETTING_KEYS];\n\n")
|
|
|
|
// The full definition table, so the UI can render controls from the
|
|
// contract rather than a hand-kept parallel manifest.
|
|
out.WriteString("export interface SettingDefinition {\n")
|
|
out.WriteString(" key: SettingKey;\n")
|
|
out.WriteString(" type: string;\n")
|
|
out.WriteString(" nullable: boolean;\n")
|
|
out.WriteString(" persistence: \"remote\" | \"client_local\";\n")
|
|
out.WriteString(" scopes: readonly string[];\n")
|
|
out.WriteString(" resolutionOrder: readonly string[];\n")
|
|
out.WriteString(" defaultValue: unknown;\n")
|
|
out.WriteString(" label: string;\n")
|
|
out.WriteString(" description: string;\n")
|
|
out.WriteString(" category: string;\n")
|
|
out.WriteString(" control?: string;\n")
|
|
out.WriteString(" unit?: string;\n")
|
|
out.WriteString(" values?: readonly { value: unknown; label: string }[];\n")
|
|
out.WriteString(" minimum?: number;\n")
|
|
out.WriteString(" maximum?: number;\n")
|
|
out.WriteString(" step?: number;\n")
|
|
out.WriteString("}\n\n")
|
|
|
|
out.WriteString("export const SETTING_DEFINITIONS: Record<SettingKey, SettingDefinition> = {\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
fmt.Fprintf(&out, " %q: {\n", def.Key)
|
|
fmt.Fprintf(&out, " key: %q,\n", def.Key)
|
|
fmt.Fprintf(&out, " type: %q,\n", def.ValueSchema.Type)
|
|
fmt.Fprintf(&out, " nullable: %t,\n", def.ValueSchema.Nullable)
|
|
fmt.Fprintf(&out, " persistence: %q,\n", def.Persistence)
|
|
fmt.Fprintf(&out, " scopes: [%s],\n", quotedScopes(def))
|
|
fmt.Fprintf(&out, " resolutionOrder: [%s],\n", quotedResolution(def))
|
|
fmt.Fprintf(&out, " defaultValue: %s,\n", defaultLiteral(def))
|
|
fmt.Fprintf(&out, " label: %s,\n", jsString(def.Label))
|
|
fmt.Fprintf(&out, " description: %s,\n", jsString(def.Description))
|
|
fmt.Fprintf(&out, " category: %q,\n", def.Category)
|
|
if def.Control != "" {
|
|
fmt.Fprintf(&out, " control: %q,\n", def.Control)
|
|
}
|
|
if def.Unit != "" {
|
|
fmt.Fprintf(&out, " unit: %q,\n", def.Unit)
|
|
}
|
|
if len(def.ValueSchema.Values) > 0 {
|
|
out.WriteString(" values: [\n")
|
|
for _, member := range def.ValueSchema.Values {
|
|
encoded, err := json.Marshal(member.Value)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
fmt.Fprintf(&out, " { value: %s, label: %s },\n",
|
|
encoded, jsString(member.Label))
|
|
}
|
|
out.WriteString(" ],\n")
|
|
}
|
|
if minimum, ok := def.ValueSchema.Minimum.Current(); ok {
|
|
fmt.Fprintf(&out, " minimum: %s,\n", trimFloat(minimum))
|
|
}
|
|
if maximum, ok := def.ValueSchema.Maximum.Current(); ok {
|
|
fmt.Fprintf(&out, " maximum: %s,\n", trimFloat(maximum))
|
|
}
|
|
if def.ValueSchema.Step != nil {
|
|
fmt.Fprintf(&out, " step: %s,\n", trimFloat(*def.ValueSchema.Step))
|
|
}
|
|
out.WriteString(" },\n")
|
|
}
|
|
out.WriteString("};\n")
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func generateKotlin(contract *settingscontract.Manifest, pkg string) ([]byte, error) {
|
|
var out bytes.Buffer
|
|
for _, line := range strings.Split(generatedHeader, "\n") {
|
|
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
|
|
}
|
|
fmt.Fprintf(&out, "\npackage %s\n\n", pkg)
|
|
|
|
out.WriteString("object SettingKeys {\n")
|
|
fmt.Fprintf(&out, " const val REVISION = %d\n\n", contract.Revision)
|
|
for _, def := range sortedDefinitions(contract) {
|
|
fmt.Fprintf(&out, " /** %s */\n", def.Label)
|
|
fmt.Fprintf(&out, " const val %s = %q\n", screamingCase(def.Key), def.Key)
|
|
}
|
|
|
|
// The allowlist Android maintained by hand, generated instead. The whole
|
|
// class of "wrote a local key to the server" bug is a manifest question now.
|
|
out.WriteString("\n /** Every key the server stores. Safe to flush. */\n")
|
|
out.WriteString(" val REMOTE: List<String> = listOf(\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
if def.IsRemote() {
|
|
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
|
|
}
|
|
}
|
|
out.WriteString(" )\n\n")
|
|
|
|
out.WriteString(" /** Contract-known keys that never leave the device. */\n")
|
|
out.WriteString(" val CLIENT_LOCAL: List<String> = listOf(\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
if !def.IsRemote() {
|
|
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
|
|
}
|
|
}
|
|
out.WriteString(" )\n")
|
|
|
|
// Type classification, which Android kept as a second hand-maintained table
|
|
// that had to agree with the first.
|
|
for _, group := range []struct {
|
|
name string
|
|
types []settingscontract.ValueType
|
|
}{
|
|
{"BOOLEAN_KEYS", []settingscontract.ValueType{settingscontract.TypeBoolean}},
|
|
{"INT_KEYS", []settingscontract.ValueType{settingscontract.TypeInteger}},
|
|
{"DOUBLE_KEYS", []settingscontract.ValueType{settingscontract.TypeNumber}},
|
|
} {
|
|
fmt.Fprintf(&out, "\n val %s: Set<String> = setOf(\n", group.name)
|
|
for _, def := range sortedDefinitions(contract) {
|
|
for _, want := range group.types {
|
|
if def.ValueSchema.Type == want {
|
|
fmt.Fprintf(&out, " %s,\n", screamingCase(def.Key))
|
|
}
|
|
}
|
|
}
|
|
out.WriteString(" )\n")
|
|
}
|
|
|
|
out.WriteString("}\n")
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func generateSwift(contract *settingscontract.Manifest) ([]byte, error) {
|
|
var out bytes.Buffer
|
|
for _, line := range strings.Split(generatedHeader, "\n") {
|
|
out.WriteString(strings.TrimRight("// "+line, " ") + "\n")
|
|
}
|
|
out.WriteString("\nimport Foundation\n\n")
|
|
|
|
out.WriteString("/// Every setting the contract defines.\n")
|
|
out.WriteString("public enum SettingKey: String, CaseIterable, Sendable {\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
fmt.Fprintf(&out, " /// %s\n", def.Label)
|
|
fmt.Fprintf(&out, " case %s = %q\n", lowerFirst(identifierFor(def.Key)), def.Key)
|
|
}
|
|
out.WriteString("}\n\n")
|
|
|
|
out.WriteString("public extension SettingKey {\n")
|
|
fmt.Fprintf(&out, " static let revision = %d\n\n", contract.Revision)
|
|
|
|
out.WriteString(" /// Keys the server stores. The rest never leave the device.\n")
|
|
out.WriteString(" static let remote: [SettingKey] = [\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
if def.IsRemote() {
|
|
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
|
|
}
|
|
}
|
|
out.WriteString(" ]\n\n")
|
|
|
|
out.WriteString(" static let clientLocal: [SettingKey] = [\n")
|
|
for _, def := range sortedDefinitions(contract) {
|
|
if !def.IsRemote() {
|
|
fmt.Fprintf(&out, " .%s,\n", lowerFirst(identifierFor(def.Key)))
|
|
}
|
|
}
|
|
out.WriteString(" ]\n")
|
|
out.WriteString("}\n")
|
|
return out.Bytes(), nil
|
|
}
|
|
|
|
func lowerFirst(value string) string {
|
|
if value == "" {
|
|
return value
|
|
}
|
|
return strings.ToLower(value[:1]) + value[1:]
|
|
}
|
|
|
|
func quotedScopes(def *settingscontract.Definition) string {
|
|
parts := make([]string, 0, len(def.AllowedScopes))
|
|
for _, entry := range def.AllowedScopes {
|
|
parts = append(parts, fmt.Sprintf("%q", entry.Scope))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func quotedResolution(def *settingscontract.Definition) string {
|
|
parts := make([]string, 0, len(def.ResolutionOrder))
|
|
for _, scope := range def.ResolutionOrder {
|
|
parts = append(parts, fmt.Sprintf("%q", scope))
|
|
}
|
|
return strings.Join(parts, ", ")
|
|
}
|
|
|
|
func defaultLiteral(def *settingscontract.Definition) string {
|
|
if len(def.DefaultValue) == 0 {
|
|
return "null"
|
|
}
|
|
return string(bytes.TrimSpace(def.DefaultValue))
|
|
}
|
|
|
|
func jsString(value string) string {
|
|
encoded, err := json.Marshal(value)
|
|
if err != nil {
|
|
return `""`
|
|
}
|
|
return string(encoded)
|
|
}
|
|
|
|
func trimFloat(value float64) string {
|
|
return strings.TrimSuffix(fmt.Sprintf("%g", value), ".0")
|
|
}
|