package compass import ( "fmt" "io/fs" "os" "path/filepath" "sort" "strings" "sync" "github.com/knadh/koanf/parsers/yaml" "github.com/knadh/koanf/providers/confmap" "github.com/knadh/koanf/providers/file" "github.com/knadh/koanf/v2" ) // Options controls how configuration is loaded. type Options struct { // Dir is the app config directory, typically "config". Dir string // Env is the environment name. Empty means detect from SUMMER_ENV, // defaulting to "production". An explicit value wins over SUMMER_ENV. Env string // Environ is the KEY=VALUE list used for SUMMER_ variables and // SUMMER_ENV detection. Nil means os.Environ(). A .env file fills // only keys absent from this list and never mutates the process // environment. Environ []string } type pluginFS struct { id string fsys fs.FS } // Config is a YAML configuration tree with layered overrides and // dot-path access. type Config struct { mu sync.RWMutex opts Options dir string env string environ []string plugins []pluginFS k *koanf.Koanf runtime *koanf.Koanf } // Load reads config from dir using SUMMER_ENV (default production). func Load(dir string) (*Config, error) { return Open(Options{Dir: dir}) } // Open loads configuration using opts. func Open(opts Options) (*Config, error) { if opts.Dir == "" { return nil, fmt.Errorf("compass: config directory is empty") } c := &Config{ opts: opts, dir: opts.Dir, runtime: koanf.New("."), } if err := c.refreshSources(); err != nil { return nil, err } if err := c.rebuild(); err != nil { return nil, err } return c, nil } // Environment returns the active environment name. func (c *Config) Environment() string { if c == nil { return "" } c.mu.RLock() defer c.mu.RUnlock() return c.env } // MergePlugin merges an embedded plugin config filesystem at the bare // plugin ID path. config/config.yaml keys become .. func (c *Config) MergePlugin(id string, fsys fs.FS) error { if c == nil { return fmt.Errorf("compass: config is nil") } if id == "" { return fmt.Errorf("compass: plugin id is empty") } c.mu.Lock() defer c.mu.Unlock() replaced := false for i, p := range c.plugins { if p.id == id { c.plugins[i] = pluginFS{id: id, fsys: fsys} replaced = true break } } if !replaced { c.plugins = append(c.plugins, pluginFS{id: id, fsys: fsys}) } if c.runtime == nil { c.runtime = koanf.New(".") } return c.rebuild() } // Lookup returns the value at path and whether it exists. func (c *Config) Lookup(path string) (any, bool) { v := c.view() if v == nil || !v.Exists(path) { return nil, false } return v.Get(path), true } // String returns the string value at path, or "" if unset. func (c *Config) String(path string) string { v := c.view() if v == nil { return "" } return v.String(path) } // Int returns the int value at path, or 0 if unset. func (c *Config) Int(path string) int { v := c.view() if v == nil { return 0 } return v.Int(path) } // Bool returns the bool value at path, or false if unset. func (c *Config) Bool(path string) bool { v := c.view() if v == nil { return false } return v.Bool(path) } // Has reports whether path exists in the merged tree. func (c *Config) Has(path string) bool { v := c.view() if v == nil { return false } return v.Exists(path) } // LoadSection unmarshals the map at path into out using `koanf` tags. func (c *Config) LoadSection(path string, out any) error { v := c.view() if v == nil { return fmt.Errorf("compass: config is empty") } return v.UnmarshalWithConf(path, out, koanf.UnmarshalConf{Tag: "koanf"}) } func (c *Config) view() *koanf.Koanf { if c == nil { return nil } c.mu.RLock() defer c.mu.RUnlock() if c.k == nil { return nil } out := c.k.Copy() if c.runtime != nil { _ = out.Merge(c.runtime) } return out } func (c *Config) refreshSources() error { base := c.opts.Environ if c.opts.Environ == nil { base = os.Environ() } dotenv, err := readDotEnv(filepath.Join(filepath.Dir(c.dir), ".env")) if err != nil { return err } c.environ = mergeEnviron(base, dotenv) envName := c.opts.Env if envName == "" { envName = lookupEnv(c.environ, "SUMMER_ENV") } if envName == "" { envName = "production" } envName, err = sanitizeEnv(envName) if err != nil { return err } c.env = envName return nil } func (c *Config) rebuild() error { k := koanf.New(".") for _, p := range c.plugins { if err := mergePluginFS(k, p.id, p.fsys); err != nil { return err } } if c.dir != "" { if err := loadSortedSections(k, c.dir, false); err != nil { return err } envDir := filepath.Join(c.dir, "env", c.env) if err := loadSortedSections(k, envDir, true); err != nil { return err } } if err := loadEnvVars(k, c.environ); err != nil { return err } if c.dir != "" && c.env != "" { overrides := filepath.Join(c.dir, "env", c.env, "overrides.yaml") if err := loadOverrides(k, overrides); err != nil { return err } } c.k = k return nil } func loadSortedSections(k *koanf.Koanf, dir string, skipOverrides bool) error { entries, err := os.ReadDir(dir) if err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("compass: read config dir: %w", err) } names := make([]string, 0, len(entries)) for _, entry := range entries { if entry.IsDir() { continue } name := entry.Name() if skipOverrides && isOverridesFile(name) { continue } if _, ok := sectionName(name); !ok { continue } names = append(names, name) } sort.Strings(names) parser := yaml.Parser() for _, name := range names { section, _ := sectionName(name) path := filepath.Join(dir, name) tmp := koanf.New(".") if err := tmp.Load(file.Provider(path), parser); err != nil { return fmt.Errorf("compass: parse %s: %w", filepath.Base(path), err) } if err := k.MergeAt(tmp, section); err != nil { return fmt.Errorf("compass: merge %s: %w", filepath.Base(path), err) } } return nil } func loadOverrides(k *koanf.Koanf, path string) error { if _, err := os.Stat(path); err != nil { if os.IsNotExist(err) { return nil } return fmt.Errorf("compass: stat overrides: %w", err) } if err := k.Load(file.Provider(path), yaml.Parser()); err != nil { return fmt.Errorf("compass: parse overrides: %w", err) } return nil } func mergePluginFS(k *koanf.Koanf, id string, fsys fs.FS) error { if fsys == nil { return nil } var files []string err := fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { if err != nil { return err } if d.IsDir() { return nil } if _, ok := sectionName(filepath.Base(path)); !ok { return nil } files = append(files, path) return nil }) if err != nil { return fmt.Errorf("compass: read plugin config %s: %w", id, err) } sort.Strings(files) parser := yaml.Parser() for _, path := range files { raw, err := fs.ReadFile(fsys, path) if err != nil { return fmt.Errorf("compass: read plugin config %s: %w", id, err) } parsed, err := parser.Unmarshal(raw) if err != nil { return fmt.Errorf("compass: parse plugin config %s: %w", id, err) } if parsed == nil { parsed = map[string]any{} } tmp := koanf.New(".") if err := tmp.Load(confmap.Provider(parsed, ""), nil); err != nil { return fmt.Errorf("compass: load plugin config %s: %w", id, err) } at := pluginMergePath(id, path) if err := k.MergeAt(tmp, at); err != nil { return fmt.Errorf("compass: merge plugin config %s: %w", id, err) } } return nil } func pluginMergePath(id, path string) string { rel := filepath.ToSlash(path) base := filepath.Base(rel) section, _ := sectionName(base) if section == "config" { return id } return id + "." + section } func isOverridesFile(name string) bool { return name == "overrides.yaml" || name == "overrides.yml" } func sectionName(filename string) (string, bool) { switch { case strings.HasSuffix(filename, ".yaml"): return strings.TrimSuffix(filename, ".yaml"), true case strings.HasSuffix(filename, ".yml"): return strings.TrimSuffix(filename, ".yml"), true default: return "", false } }