234 lines
9.2 KiB
Go
234 lines
9.2 KiB
Go
package config
|
|
|
|
import (
|
|
"errors"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"strings"
|
|
"testing"
|
|
|
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
|
)
|
|
|
|
func useConfigSearchPaths(t *testing.T, paths ...string) {
|
|
t.Helper()
|
|
previous := defaultConfigSearchPaths
|
|
defaultConfigSearchPaths = paths
|
|
t.Cleanup(func() { defaultConfigSearchPaths = previous })
|
|
}
|
|
|
|
func writeApplicationFile(t *testing.T, path, body string) {
|
|
t.Helper()
|
|
if err := os.WriteFile(path, []byte(body), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
func TestLoadSelectsOrderedSourceAndKeepsItsSnapshot(t *testing.T) {
|
|
dir := t.TempDir()
|
|
first := filepath.Join(dir, "first.yml")
|
|
second := filepath.Join(dir, "second.yml")
|
|
writeApplicationFile(t, first, "prompt_dir: ./first\n")
|
|
writeApplicationFile(t, second, "prompt_dir: ./second\n")
|
|
useConfigSearchPaths(t, first, second)
|
|
|
|
result, err := Load(LoadOptions{Path: filepath.Join(dir, "ignored.yml")})
|
|
if err != nil {
|
|
t.Fatalf("load first implicit source: %v", err)
|
|
}
|
|
if result.Config.PromptDir != "first" || len(result.Sources()) != 1 || result.Sources()[0].Path != first {
|
|
t.Fatalf("unexpected implicit source: config=%+v sources=%+v", result.Config, result.Sources())
|
|
}
|
|
writeApplicationFile(t, first, "prompt_dir: ./changed\n")
|
|
if result.Config.PromptDir != "first" {
|
|
t.Fatalf("result did not retain resolved snapshot: %+v", result.Config)
|
|
}
|
|
|
|
if err := os.Remove(first); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
result, err = Load(LoadOptions{})
|
|
if err != nil || result.Config.PromptDir != "second" || result.Sources()[0].Path != second {
|
|
t.Fatalf("second implicit source not selected: result=%+v err=%v", result, err)
|
|
}
|
|
|
|
result, err = Load(LoadOptions{Path: first, Explicit: true})
|
|
if result != nil || err == nil {
|
|
t.Fatalf("missing explicit source was accepted: result=%+v err=%v", result, err)
|
|
}
|
|
var loadError *configkit.LoadError
|
|
if !errors.As(err, &loadError) {
|
|
t.Fatalf("typed load error was lost: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsExplicitEmptyAndDistinguishesEmptyFile(t *testing.T) {
|
|
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
|
|
if _, err := Load(LoadOptions{Explicit: true, Path: ""}); err == nil {
|
|
t.Fatal("explicit empty path was accepted")
|
|
} else {
|
|
var loadError *configkit.LoadError
|
|
if !errors.As(err, &loadError) {
|
|
t.Fatalf("expected upstream source diagnostic, got %v", err)
|
|
}
|
|
}
|
|
|
|
dir := t.TempDir()
|
|
empty := filepath.Join(dir, "empty.yml")
|
|
writeApplicationFile(t, empty, "")
|
|
if _, err := Load(LoadOptions{Path: empty, Explicit: true}); err == nil {
|
|
t.Fatal("empty file was treated as defaults-only")
|
|
}
|
|
writeApplicationFile(t, empty, "{}\n")
|
|
result, err := Load(LoadOptions{Path: empty, Explicit: true})
|
|
if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) {
|
|
t.Fatalf("empty mapping did not load defaults: result=%+v err=%v", result, err)
|
|
}
|
|
}
|
|
|
|
func TestLoadDefaultsOnlyAndOverridesBeforeValidation(t *testing.T) {
|
|
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
|
|
result, err := Load(LoadOptions{})
|
|
if err != nil || !reflect.DeepEqual(result.Config, documentDefaults()) || len(result.Sources()) != 0 {
|
|
t.Fatalf("defaults-only load: result=%+v err=%v", result, err)
|
|
}
|
|
var effective strings.Builder
|
|
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "render_format: text") || len(result.Sources()) != 0 {
|
|
t.Fatalf("defaults-only inspection changed: effective=%q err=%v sources=%+v", effective.String(), err, result.Sources())
|
|
}
|
|
|
|
result, err = Load(LoadOptions{Overrides: []configkit.Override{{
|
|
Path: configkit.Keys("server", "max_request_bytes"), Value: int64(0), Flag: "--max-request-bytes",
|
|
}}})
|
|
if err != nil || result.Config.Server.MaxRequestBytes != 0 || len(result.Sources()) != 1 || result.Sources()[0].Layer != configkit.LayerCLI {
|
|
t.Fatalf("defaults-only override lost: result=%+v err=%v", result, err)
|
|
}
|
|
|
|
path := filepath.Join(t.TempDir(), "invalid.yml")
|
|
writeApplicationFile(t, path, "defaults:\n render_format: invalid-choice\n")
|
|
result, err = Load(LoadOptions{Path: path, Explicit: true, Overrides: []configkit.Override{{
|
|
Path: configkit.Keys("defaults", "render_format"), Value: "json", Flag: "--render-format",
|
|
}}})
|
|
if err != nil || result.Config.Defaults.RenderFormat != "json" {
|
|
t.Fatalf("override did not precede validation: result=%+v err=%v", result, err)
|
|
}
|
|
if len(result.Sources()) != 2 || result.Sources()[0].Layer != configkit.LayerBase || result.Sources()[1].Layer != configkit.LayerCLI {
|
|
t.Fatalf("source order changed: %+v", result.Sources())
|
|
}
|
|
writeApplicationFile(t, path, "defaults: [\n")
|
|
if _, err := Load(LoadOptions{Path: path, Explicit: true, Overrides: []configkit.Override{{
|
|
Path: configkit.Keys("defaults", "render_format"), Value: "json", Flag: "--render-format",
|
|
}}}); err == nil {
|
|
t.Fatal("override bypassed malformed lower-level YAML")
|
|
}
|
|
}
|
|
|
|
func TestSettingsCopiesResolvedValuesAndSortsBackends(t *testing.T) {
|
|
result, err := loadDocumentBytes(`schema_dir: ""
|
|
transient_retries: 0
|
|
server:
|
|
addr: ""
|
|
artifact_root: ""
|
|
max_request_bytes: 0
|
|
defaults:
|
|
render_format: json
|
|
backends:
|
|
zed:
|
|
endpoint: http://zed.invalid/v1
|
|
extra_params:
|
|
count: 4
|
|
alpha:
|
|
queue_capacity: 0
|
|
`)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
settings := Settings(result.Config)
|
|
if settings.SchemaDir != "" || settings.ServerAddr != "" || settings.MaxRequestBytes != 0 || settings.DefaultRenderFormat != "json" {
|
|
t.Fatalf("resolved scalar values changed: %+v", settings)
|
|
}
|
|
if settings.TransientRetries == nil || *settings.TransientRetries != 0 {
|
|
t.Fatalf("retry presence lost: %+v", settings.TransientRetries)
|
|
}
|
|
if len(settings.Backends) != 2 || settings.Backends[0].ID != "alpha" || settings.Backends[1].ID != "zed" {
|
|
t.Fatalf("backend order changed: %+v", settings.Backends)
|
|
}
|
|
if settings.Backends[0].QueueCapacity == nil || *settings.Backends[0].QueueCapacity != 0 || settings.Backends[1].QueueCapacity != nil {
|
|
t.Fatalf("queue presence changed: %+v", settings.Backends)
|
|
}
|
|
if value := settings.Backends[1].ExtraParams["count"]; value != int64(4) {
|
|
t.Fatalf("dynamic number changed shape: %T %v", value, value)
|
|
}
|
|
|
|
if got := Settings(documentDefaults()); got.TransientRetries != nil || got.Backends != nil {
|
|
t.Fatalf("default nullable values changed: %+v", got)
|
|
}
|
|
}
|
|
|
|
func TestLoadDiscoveryErrorQuotesPath(t *testing.T) {
|
|
dir := t.TempDir()
|
|
loop := filepath.Join(dir, "loop-\n.yml")
|
|
if err := os.Symlink(loop, loop); err != nil {
|
|
t.Skipf("symlink unavailable: %v", err)
|
|
}
|
|
useConfigSearchPaths(t, loop)
|
|
_, err := Load(LoadOptions{})
|
|
if err == nil || !strings.Contains(err.Error(), `\n`) || strings.Contains(err.Error(), "loop-\n.yml") {
|
|
t.Fatalf("discovery error did not quote path safely: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadComposesExplicitImportsAndSelectedOverlay(t *testing.T) {
|
|
dir := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Join(dir, "fragments"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Join(dir, "overlays"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
base := filepath.Join(dir, "config.yml")
|
|
writeApplicationFile(t, base, "configkit:\n imports:\n - glob: fragments/*.yml\nprompt_dir: ./base-prompts\nserver:\n addr: :8081\n")
|
|
writeApplicationFile(t, filepath.Join(dir, "fragments", "a.yml"), "backends:\n alpha:\n endpoint: http://alpha.invalid/v1\n")
|
|
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n beta:\n endpoint: http://beta.invalid/v1\n")
|
|
writeApplicationFile(t, filepath.Join(dir, "overlays", "local.yml"), "prompt_dir: ./overlay-prompts\nserver:\n addr: :9090\n")
|
|
|
|
result, err := Load(LoadOptions{Path: base, Explicit: true})
|
|
if err != nil || result.Config.PromptDir != "base-prompts" || len(result.Config.Backends) != 2 || len(result.Sources()) != 3 {
|
|
t.Fatalf("disjoint imports did not compose: result=%+v err=%v", result, err)
|
|
}
|
|
result, err = Load(LoadOptions{Path: base, Explicit: true, Overlay: "local", Overrides: []configkit.Override{{
|
|
Path: configkit.Keys("server", "addr"), Value: ":7070", Flag: "--addr",
|
|
}}})
|
|
if err != nil || result.Config.PromptDir != "overlay-prompts" || result.Config.Server.Addr != ":7070" || len(result.Sources()) != 5 {
|
|
t.Fatalf("overlay or explicit override changed: result=%+v err=%v", result, err)
|
|
}
|
|
if _, err := Load(LoadOptions{Path: base, Explicit: true, Overlay: "missing"}); err == nil {
|
|
t.Fatal("missing selected overlay accepted")
|
|
}
|
|
writeApplicationFile(t, filepath.Join(dir, "fragments", "b.yml"), "backends:\n alpha:\n endpoint: http://conflict.invalid/v1\n")
|
|
if _, err := Load(LoadOptions{Path: base, Explicit: true}); err == nil {
|
|
t.Fatal("conflicting import accepted")
|
|
} else {
|
|
var loadError *configkit.LoadError
|
|
if !errors.As(err, &loadError) {
|
|
t.Fatalf("conflict lost structured diagnostic: %v", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestLoadRejectsOverlayWithoutBase(t *testing.T) {
|
|
useConfigSearchPaths(t, filepath.Join(t.TempDir(), "absent.yml"))
|
|
if _, err := Load(LoadOptions{Overlay: "local"}); err == nil {
|
|
t.Fatal("overlay without base accepted")
|
|
} else {
|
|
var loadError *configkit.LoadError
|
|
if !errors.As(err, &loadError) {
|
|
t.Fatalf("no-base overlay lost structured diagnostic: %v", err)
|
|
}
|
|
}
|
|
if _, err := Load(LoadOptions{Overlay: "default"}); err != nil {
|
|
t.Fatalf("default must select no overlay: %v", err)
|
|
}
|
|
}
|