Add shared ConfigKit loading and runtime settings conversion

This commit is contained in:
2026-09-26 18:00:50 +00:00
parent e6e7d5c52b
commit 207c75dda3
4 changed files with 279 additions and 1 deletions

View File

@@ -34,6 +34,15 @@ mutating the document or exposing rejected values. Application path values keep
working-directory-relative meaning. ConfigKit owns strict YAML decoding and
presence semantics, including dynamic integer values in backend parameters.
The new `Load` boundary selects an explicit file, the first existing implicit
configuration file, or ConfigKit's defaults-only source. Only absence advances
implicit discovery. One ConfigKit load applies explicit application overrides
before validation and returns a result containing the resolved document and
its frozen inspection records. `Settings` converts that document into the
existing runtime settings with backend IDs sorted; it does not read files or
apply another merge. Imports and selected overlays remain disabled until their
own user-facing integration is ready.
The executable commands still use the existing loader. The new definition is
internal preparation and is not yet an alternative user-selectable configuration
mode or a change to the current [configuration contract](../config.md).

View File

@@ -147,7 +147,9 @@ policy. Existing executable behavior still passes its tests.
## Stage 2: Build Discovery And The Shared Loading Boundary
**Status: Not started.**
**Status: Complete.** Ordered source discovery, one ConfigKit load, typed
diagnostics, and settings conversion are covered by focused file/default and
override tests. Existing commands still use the old entry point until cutover.
Primary files: `internal/config` and its tests; `docs/internal/sources.md`.

91
internal/config/load.go Normal file
View File

@@ -0,0 +1,91 @@
package config
import (
"errors"
"fmt"
"os"
"sort"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
)
// LoadOptions selects one application source and explicit configuration values.
type LoadOptions struct {
Path string
Explicit bool
Overlay string
Overrides []configkit.Override
}
// Load resolves one ConfigKit document without changing the executable's legacy loader.
func Load(options LoadOptions) (*configkit.Result[Document], error) {
var source configkit.Source
if options.Explicit {
source = configkit.File(options.Path)
} else {
selected := ""
for _, candidate := range defaultConfigSearchPaths {
if _, err := os.Stat(candidate); err == nil {
selected = candidate
break
} else if !errors.Is(err, os.ErrNotExist) {
reason := "filesystem error"
if errors.Is(err, os.ErrPermission) {
reason = "permission denied"
}
return nil, fmt.Errorf("cannot inspect configuration path %q: %s", candidate, reason)
}
}
if selected == "" {
source = configkit.NoSource()
} else {
source = configkit.File(selected)
}
}
result, err := configkit.Load(source, configkit.Options[Document]{
Definition: documentDefinition(),
Overrides: options.Overrides,
})
if err != nil {
return nil, fmt.Errorf("load application configuration: %w", err)
}
return result, nil
}
// Settings converts a validated document without another merge or normalization.
func Settings(document Document) AppSettings {
settings := AppSettings{
PromptDir: document.PromptDir,
ProfileDir: document.ProfileDir,
SchemaDir: document.SchemaDir,
ServerAddr: document.Server.Addr,
ArtifactRoot: document.Server.ArtifactRoot,
MaxRequestBytes: document.Server.MaxRequestBytes,
MaxArtifactBytes: document.Server.MaxArtifactBytes,
MaxResponseBytes: document.Server.MaxResponseBytes,
DefaultRenderFormat: renderformat.PreparedRunOutputFormat(document.Defaults.RenderFormat),
TransientRetries: document.TransientRetries,
}
if document.Backends != nil {
ids := make([]string, 0, len(document.Backends))
for id := range document.Backends {
ids = append(ids, id)
}
sort.Strings(ids)
settings.Backends = make([]BackendSettings, 0, len(ids))
for _, id := range ids {
backend := document.Backends[id]
settings.Backends = append(settings.Backends, BackendSettings{
ID: id,
Endpoint: backend.Endpoint,
APIKeyEnv: backend.APIKeyEnv,
ExtraParams: backend.ExtraParams,
ConcurrencyLimit: backend.ConcurrencyLimit,
QueueCapacity: backend.QueueCapacity,
})
}
}
return settings
}

View File

@@ -0,0 +1,176 @@
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)
}
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)
}
}