Switch CLI configuration to ConfigKit

This commit is contained in:
2026-09-26 18:08:36 +00:00
parent 207c75dda3
commit 36e7e857f5
16 changed files with 295 additions and 959 deletions

View File

@@ -1,312 +0,0 @@
package config
import (
"bytes"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
"gopkg.in/yaml.v3"
)
const (
DefaultConfigPath = "/etc/scriptorium/config.yml"
DefaultConfigPathLocal = "/usr/local/etc/scriptorium/config.yml"
)
var defaultConfigSearchPaths = []string{
DefaultConfigPathLocal,
DefaultConfigPath,
}
var (
ErrConfigNotFound = errors.New("config file not found")
ErrInvalidConfigYAML = errors.New("invalid config YAML")
ErrInvalidConfig = errors.New("invalid config")
)
// Config is the on-disk YAML shape for application-level settings.
type Config struct {
PromptDir string `yaml:"prompt_dir"`
ProfileDir string `yaml:"profile_dir"`
SchemaDir string `yaml:"schema_dir"`
TransientRetries *strictInteger `yaml:"transient_retries"`
Server ServerConfig `yaml:"server"`
Defaults DefaultsConfig `yaml:"defaults"`
Backends map[string]BackendConfig `yaml:"backends"`
}
type BackendConfig struct {
Endpoint string `yaml:"endpoint"`
APIKeyEnv string `yaml:"api_key_env"`
ExtraParams map[string]any `yaml:"extra_params"`
ConcurrencyLimit int `yaml:"concurrency_limit"`
QueueCapacity *int `yaml:"queue_capacity"`
}
type strictInteger int
func (value *strictInteger) UnmarshalYAML(node *yaml.Node) error {
if node.Tag != "!!int" {
return fmt.Errorf("expected integer, got %s", node.Tag)
}
var decoded int
if err := node.Decode(&decoded); err != nil {
return err
}
*value = strictInteger(decoded)
return nil
}
type BackendSettings struct {
ID string
Endpoint string
APIKeyEnv string
ExtraParams map[string]any
ConcurrencyLimit int
QueueCapacity *int
}
type ServerConfig struct {
Addr string `yaml:"addr"`
ArtifactRoot string `yaml:"artifact_root"`
MaxRequestBytes *int64 `yaml:"max_request_bytes"`
MaxArtifactBytes *int64 `yaml:"max_artifact_bytes"`
MaxResponseBytes *int64 `yaml:"max_response_bytes"`
}
type DefaultsConfig struct {
RenderFormat string `yaml:"render_format"`
}
// AppSettings is the resolved application settings used by adapters.
type AppSettings struct {
PromptDir string
ProfileDir string
SchemaDir string
ServerAddr string
ArtifactRoot string
MaxRequestBytes int64
MaxArtifactBytes int64
MaxResponseBytes int64
DefaultRenderFormat renderformat.PreparedRunOutputFormat
Backends []BackendSettings
TransientRetries *int
}
// CLIOverrides can be applied after config load to enforce precedence.
type CLIOverrides struct {
PromptDir string
ProfileDir string
SchemaDir string
ServerAddr string
ArtifactRoot string
MaxRequestBytes *int64
MaxArtifactBytes *int64
MaxResponseBytes *int64
RenderFormat string
}
// BuiltInDefaults returns compile-time application defaults.
func BuiltInDefaults() AppSettings {
return AppSettings{
SchemaDir: defaults.SchemaDirDefault,
ServerAddr: defaults.HTTPAddrDefault,
MaxRequestBytes: defaults.HTTPMaxRequestBytesDefault,
MaxArtifactBytes: defaults.HTTPMaxArtifactBytesDefault,
MaxResponseBytes: defaults.HTTPMaxResponseBytesDefault,
DefaultRenderFormat: renderformat.DefaultPreparedRunOutputFormat,
}
}
// LoadConfig loads and resolves app settings from YAML.
//
// If explicit is false and the file does not exist, built-in defaults are returned.
// If explicit is true, missing file is an error.
func LoadConfig(path string, explicit bool) (AppSettings, error) {
resolved := BuiltInDefaults()
configPath := strings.TrimSpace(path)
if configPath == "" {
if explicit {
configPath = DefaultConfigPath
} else {
for _, candidate := range defaultConfigSearchPaths {
if _, err := os.Stat(candidate); err == nil {
configPath = candidate
break
} else if !errors.Is(err, os.ErrNotExist) {
return AppSettings{}, fmt.Errorf("failed to stat config file %q: %w", candidate, err)
}
}
}
}
if configPath == "" {
if explicit {
return AppSettings{}, fmt.Errorf("%w: %s", ErrConfigNotFound, DefaultConfigPath)
}
return resolved, nil
}
configPath = filepath.Clean(configPath)
raw, err := os.ReadFile(configPath)
if err != nil {
if errors.Is(err, os.ErrNotExist) {
if explicit {
return AppSettings{}, fmt.Errorf("%w: %s", ErrConfigNotFound, configPath)
}
return resolved, nil
}
return AppSettings{}, fmt.Errorf("failed to read config file %q: %w", configPath, err)
}
cfg, err := decodeConfig(raw)
if err != nil {
return AppSettings{}, err
}
resolved, err = applyConfig(resolved, cfg)
if err != nil {
return AppSettings{}, err
}
return resolved, nil
}
// ApplyCLIOverrides applies CLI-provided overrides over resolved config settings.
func ApplyCLIOverrides(base AppSettings, overrides CLIOverrides) (AppSettings, error) {
out := base
if v := strings.TrimSpace(overrides.PromptDir); v != "" {
out.PromptDir = filepath.Clean(v)
}
if v := strings.TrimSpace(overrides.ProfileDir); v != "" {
out.ProfileDir = filepath.Clean(v)
}
if v := strings.TrimSpace(overrides.SchemaDir); v != "" {
out.SchemaDir = filepath.Clean(v)
}
if v := strings.TrimSpace(overrides.ServerAddr); v != "" {
out.ServerAddr = v
}
if v := strings.TrimSpace(overrides.ArtifactRoot); v != "" {
out.ArtifactRoot = filepath.Clean(v)
}
if overrides.MaxRequestBytes != nil {
if *overrides.MaxRequestBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxRequestBytes = *overrides.MaxRequestBytes
}
if overrides.MaxArtifactBytes != nil {
if *overrides.MaxArtifactBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxArtifactBytes = *overrides.MaxArtifactBytes
}
if overrides.MaxResponseBytes != nil {
if *overrides.MaxResponseBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxResponseBytes = *overrides.MaxResponseBytes
}
if rawFormat := strings.TrimSpace(overrides.RenderFormat); rawFormat != "" {
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
if err != nil {
return AppSettings{}, fmt.Errorf("%w: defaults.render_format: %v", ErrInvalidConfig, err)
}
out.DefaultRenderFormat = parsed
}
return out, nil
}
func decodeConfig(raw []byte) (Config, error) {
var cfg Config
decoder := yaml.NewDecoder(bytes.NewReader(raw))
decoder.KnownFields(true)
if err := decoder.Decode(&cfg); err != nil {
if errors.Is(err, io.EOF) {
return Config{}, nil
}
return Config{}, fmt.Errorf("%w: %v", ErrInvalidConfigYAML, err)
}
return cfg, nil
}
func applyConfig(base AppSettings, cfg Config) (AppSettings, error) {
out := base
if cfg.TransientRetries != nil {
if *cfg.TransientRetries < 0 {
return AppSettings{}, fmt.Errorf("%w: transient_retries must be greater than or equal to 0", ErrInvalidConfig)
}
value := int(*cfg.TransientRetries)
out.TransientRetries = &value
}
if v := strings.TrimSpace(cfg.PromptDir); v != "" {
out.PromptDir = filepath.Clean(v)
}
if v := strings.TrimSpace(cfg.ProfileDir); v != "" {
out.ProfileDir = filepath.Clean(v)
}
if v := strings.TrimSpace(cfg.SchemaDir); v != "" {
out.SchemaDir = filepath.Clean(v)
}
if v := strings.TrimSpace(cfg.Server.Addr); v != "" {
out.ServerAddr = v
}
if v := strings.TrimSpace(cfg.Server.ArtifactRoot); v != "" {
out.ArtifactRoot = filepath.Clean(v)
}
if cfg.Server.MaxRequestBytes != nil {
if *cfg.Server.MaxRequestBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_request_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxRequestBytes = *cfg.Server.MaxRequestBytes
}
if cfg.Server.MaxArtifactBytes != nil {
if *cfg.Server.MaxArtifactBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_artifact_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxArtifactBytes = *cfg.Server.MaxArtifactBytes
}
if cfg.Server.MaxResponseBytes != nil {
if *cfg.Server.MaxResponseBytes < 0 {
return AppSettings{}, fmt.Errorf("%w: server.max_response_bytes must be greater than or equal to 0", ErrInvalidConfig)
}
out.MaxResponseBytes = *cfg.Server.MaxResponseBytes
}
if rawFormat := strings.TrimSpace(cfg.Defaults.RenderFormat); rawFormat != "" {
parsed, err := renderformat.ParsePreparedRunOutputFormat(rawFormat)
if err != nil {
return AppSettings{}, fmt.Errorf("%w: defaults.render_format: %v", ErrInvalidConfig, err)
}
out.DefaultRenderFormat = parsed
}
if len(cfg.Backends) > 0 {
ids := make([]string, 0, len(cfg.Backends))
for id := range cfg.Backends {
ids = append(ids, id)
}
sort.Strings(ids)
out.Backends = make([]BackendSettings, 0, len(ids))
for _, id := range ids {
backend := cfg.Backends[id]
out.Backends = append(out.Backends, BackendSettings{
ID: id,
Endpoint: backend.Endpoint,
APIKeyEnv: backend.APIKeyEnv,
ExtraParams: backend.ExtraParams,
ConcurrencyLimit: backend.ConcurrencyLimit,
QueueCapacity: backend.QueueCapacity,
})
}
}
return out, nil
}

View File

@@ -1,461 +0,0 @@
package config
import (
"errors"
"os"
"path/filepath"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/scriptorium/internal/defaults"
renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
)
func TestLoadConfigMissingImplicitPathUsesBuiltInDefaults(t *testing.T) {
tmp := t.TempDir()
missing := filepath.Join(tmp, "missing.yml")
got, err := LoadConfig(missing, false)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
want := BuiltInDefaults()
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected settings: got=%+v want=%+v", got, want)
}
}
func TestBuiltInDefaultsIncludeHTTPSizeLimits(t *testing.T) {
got := BuiltInDefaults()
if got.MaxRequestBytes != defaults.HTTPMaxRequestBytesDefault {
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
}
if got.MaxArtifactBytes != defaults.HTTPMaxArtifactBytesDefault {
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
}
if got.MaxResponseBytes != defaults.HTTPMaxResponseBytesDefault {
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
}
}
func TestLoadConfigMissingExplicitPathReturnsError(t *testing.T) {
tmp := t.TempDir()
missing := filepath.Join(tmp, "missing.yml")
_, err := LoadConfig(missing, true)
if err == nil {
t.Fatal("expected error for missing explicit config")
}
if !errors.Is(err, ErrConfigNotFound) {
t.Fatalf("expected ErrConfigNotFound, got %v", err)
}
}
func TestLoadConfigInvalidYAMLReturnsError(t *testing.T) {
path := writeConfigFile(t, "config.yml", "prompt_dir: [")
_, err := LoadConfig(path, true)
if err == nil {
t.Fatal("expected invalid YAML error")
}
if !errors.Is(err, ErrInvalidConfigYAML) {
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
}
}
func TestLoadConfigInvalidYAMLImplicitPathReturnsError(t *testing.T) {
path := writeConfigFile(t, "config.yml", "prompt_dir: [")
_, err := LoadConfig(path, false)
if err == nil {
t.Fatal("expected invalid YAML error")
}
if !errors.Is(err, ErrInvalidConfigYAML) {
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
}
}
func TestLoadConfigUnknownFieldReturnsError(t *testing.T) {
path := writeConfigFile(t, "config.yml", "unknown_field: true\n")
_, err := LoadConfig(path, true)
if err == nil {
t.Fatal("expected unknown field error")
}
if !errors.Is(err, ErrInvalidConfigYAML) {
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
}
}
func TestLoadConfigAPIKeyFieldIsRejectedAsUnknown(t *testing.T) {
path := writeConfigFile(t, "config.yml", "api_key: secret\n")
_, err := LoadConfig(path, true)
if err == nil {
t.Fatal("expected unknown field error for api_key")
}
if !errors.Is(err, ErrInvalidConfigYAML) {
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
}
}
func TestLoadConfigBackendsRetainsResolvedSettingsInSortedOrder(t *testing.T) {
path := writeConfigFile(t, "config.yml", `
backends:
zebra:
endpoint: https://zebra.example/v1
api_key_env: ZEBRA_API_KEY
extra_params:
provider_option: enabled
nested:
enabled: true
attempts: 2
concurrency_limit: 2
alpha:
endpoint: http://alpha.example/v1
queue_capacity: 0
`)
got, err := LoadConfig(path, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if len(got.Backends) != 2 {
t.Fatalf("expected two backends, got %#v", got.Backends)
}
if got.Backends[0].ID != "alpha" || got.Backends[1].ID != "zebra" {
t.Fatalf("expected sorted backend IDs, got %#v", got.Backends)
}
if got.Backends[0].QueueCapacity == nil || *got.Backends[0].QueueCapacity != 0 {
t.Fatalf("expected explicit zero queue capacity, got %#v", got.Backends[0].QueueCapacity)
}
if got.Backends[1].QueueCapacity != nil {
t.Fatalf("expected omitted queue capacity to remain nil, got %#v", got.Backends[1].QueueCapacity)
}
wantParams := map[string]any{
"provider_option": "enabled",
"nested": map[string]any{
"enabled": true,
"attempts": 2,
},
}
if !reflect.DeepEqual(got.Backends[1].ExtraParams, wantParams) {
t.Fatalf("unexpected extra params: got=%#v want=%#v", got.Backends[1].ExtraParams, wantParams)
}
if got.Backends[1].Endpoint != "https://zebra.example/v1" || got.Backends[1].APIKeyEnv != "ZEBRA_API_KEY" || got.Backends[1].ConcurrencyLimit != 2 {
t.Fatalf("unexpected zebra backend: %#v", got.Backends[1])
}
}
func TestLoadConfigRejectsUnknownOrSecretBackendFields(t *testing.T) {
for name, body := range map[string]string{
"unknown": "backends:\n local:\n endpoint: http://localhost:11434/v1\n unexpected: value\n",
"secret": "backends:\n local:\n endpoint: http://localhost:11434/v1\n api_key: secret\n",
} {
t.Run(name, func(t *testing.T) {
path := writeConfigFile(t, "config.yml", body)
_, err := LoadConfig(path, true)
if !errors.Is(err, ErrInvalidConfigYAML) {
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
}
})
}
}
func TestLoadConfigRejectsInvalidBackendFieldTypes(t *testing.T) {
path := writeConfigFile(t, "config.yml", "backends:\n local:\n endpoint: http://localhost:11434/v1\n queue_capacity: not-a-number\n")
_, err := LoadConfig(path, true)
if !errors.Is(err, ErrInvalidConfigYAML) {
t.Fatalf("expected ErrInvalidConfigYAML, got %v", err)
}
}
func TestLoadConfigValidConfigSetsDirectoriesAndServerAddr(t *testing.T) {
path := writeConfigFile(t, "config.yml", `
prompt_dir: ./prompts
profile_dir: ./profiles
schema_dir: ./schemas
server:
addr: 127.0.0.1:9090
artifact_root: ./artifacts
max_request_bytes: 1024
max_artifact_bytes: 2048
max_response_bytes: 4096
defaults:
render_format: json
`)
got, err := LoadConfig(path, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.PromptDir != filepath.Clean("./prompts") {
t.Fatalf("unexpected prompt_dir: %q", got.PromptDir)
}
if got.ProfileDir != filepath.Clean("./profiles") {
t.Fatalf("unexpected profile_dir: %q", got.ProfileDir)
}
if got.SchemaDir != filepath.Clean("./schemas") {
t.Fatalf("unexpected schema_dir: %q", got.SchemaDir)
}
if got.ServerAddr != "127.0.0.1:9090" {
t.Fatalf("unexpected server.addr: %q", got.ServerAddr)
}
if got.ArtifactRoot != filepath.Clean("./artifacts") {
t.Fatalf("unexpected server.artifact_root: %q", got.ArtifactRoot)
}
if got.MaxRequestBytes != 1024 {
t.Fatalf("unexpected server.max_request_bytes: %d", got.MaxRequestBytes)
}
if got.MaxArtifactBytes != 2048 {
t.Fatalf("unexpected server.max_artifact_bytes: %d", got.MaxArtifactBytes)
}
if got.MaxResponseBytes != 4096 {
t.Fatalf("unexpected server.max_response_bytes: %d", got.MaxResponseBytes)
}
if got.DefaultRenderFormat != renderformat.PreparedRunFormatJSON {
t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat)
}
}
func TestLoadConfigAcceptsZeroHTTPSizeLimits(t *testing.T) {
path := writeConfigFile(t, "config.yml", `
server:
max_request_bytes: 0
max_artifact_bytes: 0
max_response_bytes: 0
`)
got, err := LoadConfig(path, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.MaxRequestBytes != 0 || got.MaxArtifactBytes != 0 || got.MaxResponseBytes != 0 {
t.Fatalf("expected zero limits to be preserved, got request=%d artifact=%d response=%d", got.MaxRequestBytes, got.MaxArtifactBytes, got.MaxResponseBytes)
}
}
func TestLoadConfigPreservesTransientRetryPresence(t *testing.T) {
for _, tc := range []struct {
name string
body string
want *int
}{
{name: "omitted", body: "prompt_dir: ./prompts\n"},
{name: "null", body: "transient_retries: null\n"},
{name: "zero", body: "transient_retries: 0\n", want: intPointer(0)},
{name: "positive", body: "transient_retries: 3\n", want: intPointer(3)},
} {
t.Run(tc.name, func(t *testing.T) {
settings, err := LoadConfig(writeConfigFile(t, "config.yml", tc.body), true)
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(settings.TransientRetries, tc.want) {
t.Fatalf("retry setting: got %v, want %v", settings.TransientRetries, tc.want)
}
})
}
for _, tc := range []struct {
body string
want error
}{
{body: "transient_retries: -1\n", want: ErrInvalidConfig},
{body: "transient_retries: wrong\n", want: ErrInvalidConfigYAML},
{body: "transient_retries: 1.5\n", want: ErrInvalidConfigYAML},
} {
_, err := LoadConfig(writeConfigFile(t, "config.yml", tc.body), true)
if !errors.Is(err, tc.want) {
t.Fatalf("body %q: expected %v, got %v", tc.body, tc.want, err)
}
}
}
func intPointer(value int) *int { return &value }
func TestLoadConfigRejectsNegativeHTTPSizeLimits(t *testing.T) {
tests := []struct {
name string
body string
}{
{name: "request", body: "server:\n max_request_bytes: -1\n"},
{name: "artifact", body: "server:\n max_artifact_bytes: -1\n"},
{name: "response", body: "server:\n max_response_bytes: -1\n"},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
path := writeConfigFile(t, "config.yml", tc.body)
_, err := LoadConfig(path, true)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestLoadConfigEmptyFileResolvesToBuiltInDefaults(t *testing.T) {
path := writeConfigFile(t, "config.yml", "")
got, err := LoadConfig(path, true)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
want := BuiltInDefaults()
if !reflect.DeepEqual(got, want) {
t.Fatalf("unexpected settings: got=%+v want=%+v", got, want)
}
}
func TestLoadConfigImplicitSearchPrefersUsrLocalEtcOverEtc(t *testing.T) {
tmp := t.TempDir()
localPath := filepath.Join(tmp, "usr-local.yml")
etcPath := filepath.Join(tmp, "etc.yml")
if err := os.WriteFile(localPath, []byte("prompt_dir: ./from-usr-local\n"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(etcPath, []byte("prompt_dir: ./from-etc\n"), 0o644); err != nil {
t.Fatal(err)
}
orig := defaultConfigSearchPaths
defaultConfigSearchPaths = []string{localPath, etcPath}
t.Cleanup(func() { defaultConfigSearchPaths = orig })
got, err := LoadConfig("", false)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.PromptDir != filepath.Clean("./from-usr-local") {
t.Fatalf("expected usr-local config to win, got prompt_dir=%q", got.PromptDir)
}
}
func TestLoadConfigImplicitSearchFallsBackToEtcWhenUsrLocalMissing(t *testing.T) {
tmp := t.TempDir()
missingLocal := filepath.Join(tmp, "missing-local.yml")
etcPath := filepath.Join(tmp, "etc.yml")
if err := os.WriteFile(etcPath, []byte("prompt_dir: ./from-etc\n"), 0o644); err != nil {
t.Fatal(err)
}
orig := defaultConfigSearchPaths
defaultConfigSearchPaths = []string{missingLocal, etcPath}
t.Cleanup(func() { defaultConfigSearchPaths = orig })
got, err := LoadConfig("", false)
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.PromptDir != filepath.Clean("./from-etc") {
t.Fatalf("expected etc fallback config, got prompt_dir=%q", got.PromptDir)
}
}
func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) {
base := AppSettings{
PromptDir: "/from/config/prompts",
ProfileDir: "/from/config/profiles",
SchemaDir: "/from/config/schemas",
ServerAddr: ":1234",
ArtifactRoot: "/from/config/artifacts",
MaxRequestBytes: 111,
MaxArtifactBytes: 222,
MaxResponseBytes: 333,
DefaultRenderFormat: renderformat.PreparedRunFormatJSON,
}
maxRequestBytes := int64(0)
maxArtifactBytes := int64(444)
maxResponseBytes := int64(555)
got, err := ApplyCLIOverrides(base, CLIOverrides{
PromptDir: "./prompts-cli",
ProfileDir: "./profiles-cli",
SchemaDir: "./schemas-cli",
ServerAddr: ":8081",
ArtifactRoot: "./artifacts-cli",
MaxRequestBytes: &maxRequestBytes,
MaxArtifactBytes: &maxArtifactBytes,
MaxResponseBytes: &maxResponseBytes,
RenderFormat: "text",
})
if err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got.PromptDir != filepath.Clean("./prompts-cli") {
t.Fatalf("unexpected prompt dir: %q", got.PromptDir)
}
if got.ProfileDir != filepath.Clean("./profiles-cli") {
t.Fatalf("unexpected profile dir: %q", got.ProfileDir)
}
if got.SchemaDir != filepath.Clean("./schemas-cli") {
t.Fatalf("unexpected schema dir: %q", got.SchemaDir)
}
if got.ServerAddr != ":8081" {
t.Fatalf("unexpected server addr: %q", got.ServerAddr)
}
if got.ArtifactRoot != filepath.Clean("./artifacts-cli") {
t.Fatalf("unexpected artifact root: %q", got.ArtifactRoot)
}
if got.MaxRequestBytes != 0 {
t.Fatalf("unexpected max request bytes: %d", got.MaxRequestBytes)
}
if got.MaxArtifactBytes != 444 {
t.Fatalf("unexpected max artifact bytes: %d", got.MaxArtifactBytes)
}
if got.MaxResponseBytes != 555 {
t.Fatalf("unexpected max response bytes: %d", got.MaxResponseBytes)
}
if got.DefaultRenderFormat != renderformat.PreparedRunFormatText {
t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat)
}
}
func TestApplyCLIOverridesRejectsNegativeHTTPSizeLimits(t *testing.T) {
negative := int64(-1)
tests := []struct {
name string
overrides CLIOverrides
}{
{name: "request", overrides: CLIOverrides{MaxRequestBytes: &negative}},
{name: "artifact", overrides: CLIOverrides{MaxArtifactBytes: &negative}},
{name: "response", overrides: CLIOverrides{MaxResponseBytes: &negative}},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
_, err := ApplyCLIOverrides(BuiltInDefaults(), tc.overrides)
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
})
}
}
func TestApplyCLIOverridesInvalidRenderFormatReturnsError(t *testing.T) {
_, err := ApplyCLIOverrides(BuiltInDefaults(), CLIOverrides{RenderFormat: "yaml"})
if err == nil {
t.Fatal("expected invalid render format error")
}
if !errors.Is(err, ErrInvalidConfig) {
t.Fatalf("expected ErrInvalidConfig, got %v", err)
}
}
func writeConfigFile(t *testing.T, name, content string) string {
t.Helper()
path := filepath.Join(t.TempDir(), name)
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("failed to write config file: %v", err)
}
return path
}

View File

@@ -18,7 +18,7 @@ type LoadOptions struct {
Overrides []configkit.Override
}
// Load resolves one ConfigKit document without changing the executable's legacy loader.
// Load resolves one ConfigKit document and retains its inspection snapshot.
func Load(options LoadOptions) (*configkit.Result[Document], error) {
var source configkit.Source
if options.Explicit {

View File

@@ -0,0 +1,37 @@
package config
import renderformat "gitea.maximumdirect.net/eric/scriptorium/internal/format"
const (
DefaultConfigPath = "/etc/scriptorium/config.yml"
DefaultConfigPathLocal = "/usr/local/etc/scriptorium/config.yml"
)
var defaultConfigSearchPaths = []string{
DefaultConfigPathLocal,
DefaultConfigPath,
}
// AppSettings holds the validated application values used by executable adapters.
type AppSettings struct {
PromptDir string
ProfileDir string
SchemaDir string
ServerAddr string
ArtifactRoot string
MaxRequestBytes int64
MaxArtifactBytes int64
MaxResponseBytes int64
DefaultRenderFormat renderformat.PreparedRunOutputFormat
Backends []BackendSettings
TransientRetries *int
}
type BackendSettings struct {
ID string
Endpoint string
APIKeyEnv string
ExtraParams map[string]any
ConcurrencyLimit int
QueueCapacity *int
}