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 working-directory-relative meaning. ConfigKit owns strict YAML decoding and
presence semantics, including dynamic integer values in backend parameters. 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 The executable commands still use the existing loader. The new definition is
internal preparation and is not yet an alternative user-selectable configuration internal preparation and is not yet an alternative user-selectable configuration
mode or a change to the current [configuration contract](../config.md). 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 ## 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`. 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)
}
}