From 2a40dc10303a914c27b9f311887e00e0db4dba54 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Wed, 6 May 2026 16:42:27 +0000 Subject: [PATCH] Add application config loading --- internal/config/config.go | 168 +++++++++++++++++++++++++++++++ internal/config/config_test.go | 177 +++++++++++++++++++++++++++++++++ 2 files changed, 345 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..2fef250 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,168 @@ +package config + +import ( + "bytes" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "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" + +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"` + Server ServerConfig `yaml:"server"` + Defaults DefaultsConfig `yaml:"defaults"` +} + +type ServerConfig struct { + Addr string `yaml:"addr"` +} + +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 + DefaultRenderFormat renderformat.PreparedRunOutputFormat +} + +// CLIOverrides can be applied after config load to enforce precedence. +type CLIOverrides struct { + PromptDir string + ProfileDir string + SchemaDir string + ServerAddr string + RenderFormat string +} + +// BuiltInDefaults returns compile-time application defaults. +func BuiltInDefaults() AppSettings { + return AppSettings{ + SchemaDir: defaults.SchemaDirDefault, + ServerAddr: defaults.HTTPAddrDefault, + 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 == "" { + configPath = DefaultConfigPath + } + 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 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 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 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 + } + + return out, nil +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..f5d8cc3 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,177 @@ +package config + +import ( + "errors" + "os" + "path/filepath" + "testing" + + 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 got != want { + t.Fatalf("unexpected settings: got=%+v want=%+v", got, want) + } +} + +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 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 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 +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.DefaultRenderFormat != renderformat.PreparedRunFormatJSON { + t.Fatalf("unexpected defaults.render_format: %q", got.DefaultRenderFormat) + } +} + +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 got != want { + t.Fatalf("unexpected settings: got=%+v want=%+v", got, want) + } +} + +func TestApplyCLIOverridesAppliesPrecedence(t *testing.T) { + base := AppSettings{ + PromptDir: "/from/config/prompts", + ProfileDir: "/from/config/profiles", + SchemaDir: "/from/config/schemas", + ServerAddr: ":1234", + DefaultRenderFormat: renderformat.PreparedRunFormatJSON, + } + + got, err := ApplyCLIOverrides(base, CLIOverrides{ + PromptDir: "./prompts-cli", + ProfileDir: "./profiles-cli", + SchemaDir: "./schemas-cli", + ServerAddr: ":8081", + 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.DefaultRenderFormat != renderformat.PreparedRunFormatText { + t.Fatalf("unexpected render format: %q", got.DefaultRenderFormat) + } +} + +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 +}