// Cthulhu screen reader. // Copyright (c) 2026 Storm Dragon // Authors: Storm Dragon, Angus Kola // // This program is free software: you can redistribute it and/or modify it // under the terms of the GNU General Public License as published by the Free // Software Foundation, either version 3 of the License, or (at your option) // any later version. // // This program is distributed in the hope that it will be useful, but WITHOUT // ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or // FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for // more details. // // You should have received a copy of the GNU General Public License along with // this program. If not, see . package config import ( "bytes" "errors" "fmt" "sort" "strings" "github.com/pelletier/go-toml/v2" ) func defaultDocument() writableDocument { return writableDocument{ SchemaVersion: CurrentSchemaVersion, ActiveProfile: "default", Profiles: map[string]writableProfile{ "default": profileFromSettings(DefaultSettings()), }, Applications: make(map[string]string), } } func decodeWritable(contents []byte) (Settings, error) { _, settings, err := decodeDocument(contents) return settings, err } func decodeDocument(contents []byte) (writableDocument, Settings, error) { var envelope struct { SchemaVersion int `toml:"schema-version"` } if err := toml.Unmarshal(contents, &envelope); err != nil { return writableDocument{}, Settings{}, fmt.Errorf("parse settings: %w", err) } if envelope.SchemaVersion != CurrentSchemaVersion { return writableDocument{}, Settings{}, fmt.Errorf("unsupported settings schema version %d; expected %d", envelope.SchemaVersion, CurrentSchemaVersion) } var document writableDocument decoder := toml.NewDecoder(bytes.NewReader(contents)) decoder.DisallowUnknownFields() if err := decoder.Decode(&document); err != nil { var strictError *toml.StrictMissingError if errors.As(err, &strictError) { return writableDocument{}, Settings{}, fmt.Errorf("parse settings schema %d: %s", CurrentSchemaVersion, strictError.String()) } return writableDocument{}, Settings{}, fmt.Errorf("parse settings schema %d: %w", CurrentSchemaVersion, err) } settings, err := effectiveSettings(document) if err != nil { return writableDocument{}, Settings{}, err } return document, settings, nil } func effectiveSettings(document writableDocument) (Settings, error) { if strings.TrimSpace(document.ActiveProfile) == "" { return Settings{}, errors.New("validate configuration: active-profile is empty") } profile, exists := document.Profiles[document.ActiveProfile] if !exists { return Settings{}, fmt.Errorf("validate configuration: active profile %q does not exist", document.ActiveProfile) } for _, name := range sortedKeys(document.Profiles) { candidate := document.Profiles[name] if strings.TrimSpace(name) != name || name == "" { return Settings{}, fmt.Errorf("validate configuration: invalid profile name %q", name) } if err := validateCommandValue("profile name", name); err != nil { return Settings{}, err } candidateSettings := DefaultSettings() applyProfile(&candidateSettings, candidate) if err := validate(candidateSettings); err != nil { return Settings{}, fmt.Errorf("profile %q: %w", name, err) } } for _, application := range sortedKeys(document.Applications) { profileName := document.Applications[application] if strings.TrimSpace(application) != application || application == "" { return Settings{}, fmt.Errorf("validate configuration: invalid application identifier %q", application) } if err := validateCommandValue("application identifier", application); err != nil { return Settings{}, err } if _, exists := document.Profiles[profileName]; !exists { return Settings{}, fmt.Errorf("validate configuration: application %q selects missing profile %q", application, profileName) } } settings := DefaultSettings() applyProfile(&settings, profile) settings.ActiveProfile = document.ActiveProfile return settings, nil } func sortedKeys[Value any](values map[string]Value) []string { keys := make([]string, 0, len(values)) for key := range values { keys = append(keys, key) } sort.Strings(keys) return keys } func encodeWritable(settings Settings) ([]byte, error) { document := defaultDocument() profileName := settings.ActiveProfile if profileName == "" { profileName = document.ActiveProfile } document.ActiveProfile = profileName document.Profiles = map[string]writableProfile{profileName: profileFromSettings(settings)} return encodeDocument(document) } func encodeDocument(document writableDocument) ([]byte, error) { if document.SchemaVersion != CurrentSchemaVersion { return nil, fmt.Errorf("encode settings schema version %d; expected %d", document.SchemaVersion, CurrentSchemaVersion) } if _, err := effectiveSettings(document); err != nil { return nil, err } contents, err := toml.Marshal(document) if err != nil { return nil, fmt.Errorf("encode settings: %w", err) } return contents, nil }