package config import ( "fmt" "os" "strings" "github.com/hashicorp/hcl/v2/hclsimple" ) type CapacityConfig struct { CPU int `hcl:"cpu,optional"` MemoryMB int `hcl:"memory_mb,optional"` } type Config struct { DBPath string `hcl:"db_path,optional"` ListenAddr string `hcl:"listen_addr,optional"` CAPath string `hcl:"ca_path,optional"` ServerCertPath string `hcl:"server_cert_path,optional"` ServerKeyPath string `hcl:"server_key_path,optional"` NodeCapacity *CapacityConfig `hcl:"node_capacity,block"` } type Flags struct { DBPath *string ListenAddr *string CAPath *string ServerCertPath *string ServerKeyPath *string CPU *int MemoryMB *int } type Environ map[string]string // Load is the config dispatcher (R-014). It tries each path in order, // skipping missing files, and dispatches to the appropriate loader // based on file extension: .hcl → LoadHCL (legacy, R-013), // .md → LoadMarkdown (new Markdown-frontmatter loader), and // .yaml/.yml → LoadMarkdown with an empty body. The first successfully // decoded file wins. If no path exists or decodes, a zero Config is // returned. // // The signature is preserved from the v0.8 single-loader API so // internal/cli/root.go requires no changes yet. func Load(paths ...string) (*Config, error) { for _, p := range paths { if _, err := os.Stat(p); err != nil { continue } cfg, err := loadByExtension(p) if err != nil { return nil, err } return cfg, nil } return &Config{}, nil } func loadByExtension(p string) (*Config, error) { ext := strings.ToLower(filepathExt(p)) switch ext { case ".hcl": return LoadHCL(p) case ".md", ".markdown": return LoadMarkdown(p) case ".yaml", ".yml": return LoadMarkdownYAML(p) default: // Unknown extension: hclsimple.Decode rejects non-.hcl // suffixes, so for backward compat with the v0.8 single-loader // behavior (which assumed HCL), decode the file content as HCL // against a synthesized .hcl path. data, err := os.ReadFile(p) if err != nil { return nil, fmt.Errorf("read config %s: %w", p, err) } var cfg Config if err := hclsimple.Decode(p+".hcl", data, nil, &cfg); err != nil { return nil, fmt.Errorf("decode config %s: %w", p, err) } return &cfg, nil } } // filepathExt is a thin wrapper around filepath.Ext to keep the import // localized to the dispatcher. Returns the extension including the dot, // lowercased by the caller. func filepathExt(p string) string { i := strings.LastIndex(p, ".") if i < 0 { return "" } return p[i:] } // LoadHCL decodes a legacy HCL config file (R-013). // // Deprecated: use LoadMarkdown or the dispatcher. HCL is legacy per // R-013. Retained for the v0.9 dual-write window (REQ-090); new // deployments should author config.md with YAML frontmatter. func LoadHCL(path string) (*Config, error) { data, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } var cfg Config if err := hclsimple.Decode(path, data, nil, &cfg); err != nil { return nil, fmt.Errorf("decode config %s: %w", path, err) } return &cfg, nil } func (c *Config) MergeOverrides(flags Flags, env Environ) *Config { out := &Config{ DBPath: c.DBPath, ListenAddr: c.ListenAddr, CAPath: c.CAPath, ServerCertPath: c.ServerCertPath, ServerKeyPath: c.ServerKeyPath, NodeCapacity: c.NodeCapacity, } applyStr := func(flag *string, envKey, fileVal string) string { if flag != nil { return *flag } if v, ok := env[envKey]; ok && v != "" { return v } return fileVal } out.DBPath = applyStr(flags.DBPath, "ORCA_DB", out.DBPath) out.ListenAddr = applyStr(flags.ListenAddr, "ORCA_LISTEN_ADDR", out.ListenAddr) out.CAPath = applyStr(flags.CAPath, "ORCA_CA_PATH", out.CAPath) out.ServerCertPath = applyStr(flags.ServerCertPath, "ORCA_SERVER_CERT_PATH", out.ServerCertPath) out.ServerKeyPath = applyStr(flags.ServerKeyPath, "ORCA_SERVER_KEY_PATH", out.ServerKeyPath) if out.NodeCapacity == nil { out.NodeCapacity = &CapacityConfig{} } else { nc := *out.NodeCapacity out.NodeCapacity = &nc } if flags.CPU != nil { out.NodeCapacity.CPU = *flags.CPU } else if v, ok := env["ORCA_NODE_CPU"]; ok && v != "" { if n, err := atoi(v); err == nil { out.NodeCapacity.CPU = n } } if flags.MemoryMB != nil { out.NodeCapacity.MemoryMB = *flags.MemoryMB } else if v, ok := env["ORCA_NODE_MEMORY_MB"]; ok && v != "" { if n, err := atoi(v); err == nil { out.NodeCapacity.MemoryMB = n } } return out } func atoi(s string) (int, error) { n := 0 if s == "" { return 0, fmt.Errorf("empty") } neg := false i := 0 if s[0] == '-' { neg = true i = 1 } for ; i < len(s); i++ { if s[i] < '0' || s[i] > '9' { return 0, fmt.Errorf("bad") } n = n*10 + int(s[i]-'0') } if neg { n = -n } return n, nil }