Files
silo-server/cmd/settingsgen/main.go
T
QuickandClaude Opus 5 3d039e163c feat(settings): resolve catalog playback preferences through the contract
catalog/detail.go held the two hardest ladders in the codebase: subtitles
resolved across four levels by hand, audio across three, each partially
overriding the last through Has* flags. Both now call the canonical
resolver, so the precedence lives in the manifest and this file cannot
disagree with the contract about which override wins. Adding a scope is a
manifest change rather than another branch here.

The subtitle track signature stays on its specialized table — it identifies
a concrete track rather than expressing a preference, so it is not a
setting.

Resolution keeps the memoization the old lookups had: the audio resolver
still reads once per profile and once per library rather than once per
file, which is what kept a many-track audiobook detail page fast. The test
that guards it now counts resolver reads instead of GetProfile calls, since
the guarantee is about scaling with file count rather than about which
method does the reading.

Four tests seeded the profile column directly. That column is a migration
source now, not a read path, so they seed the canonical value instead —
they were passing against storage nothing reads.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-28 00:42:12 +00:00

382 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}},
} {
// Remote only: these tables drive how a value read back from the
// server is parsed, and a client_local key never comes back from the
// server at all. Listing one would describe a wire format that has no
// wire.
fmt.Fprintf(&out, "\n val %s: Set<String> = setOf(\n", group.name)
for _, def := range sortedDefinitions(contract) {
if !def.IsRemote() {
continue
}
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")
}