Switch CLI configuration to ConfigKit
This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
37
internal/config/settings.go
Normal file
37
internal/config/settings.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user