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" 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"` 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 == "" { 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 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 }