Add reflective struct-tag config loader
Implements conf.Load to populate tagged structs from a chain of Sources (env, .env, YAML/JSON/TOML, custom). Supports default values, slice separators, nested structs, pointer fields, and a Validator hook. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
3c806e6803
commit
c4ebd80669
15 changed files with 941 additions and 0 deletions
103
dotenv.go
Normal file
103
dotenv.go
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
package conf
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"git.juancwu.dev/juancwu/errx"
|
||||
)
|
||||
|
||||
// DotEnvFile loads KEY=VALUE pairs from path. Lines starting with # and blank
|
||||
// lines are ignored. Values may be wrapped in single or double quotes; double
|
||||
// quotes honor \n, \t, \r, \\, and \" escapes.
|
||||
func DotEnvFile(path string) (Source, error) {
|
||||
const op = "conf.DotEnvFile"
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
return nil, errx.Wrapf(op, err, "open %s", path)
|
||||
}
|
||||
defer f.Close()
|
||||
return DotEnvReader(f)
|
||||
}
|
||||
|
||||
// DotEnvReader is DotEnvFile for an arbitrary reader.
|
||||
func DotEnvReader(r io.Reader) (Source, error) {
|
||||
const op = "conf.DotEnvReader"
|
||||
|
||||
m := map[string]string{}
|
||||
sc := bufio.NewScanner(r)
|
||||
lineNo := 0
|
||||
for sc.Scan() {
|
||||
lineNo++
|
||||
line := strings.TrimSpace(sc.Text())
|
||||
if line == "" || strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
line = strings.TrimPrefix(line, "export ")
|
||||
eq := strings.IndexByte(line, '=')
|
||||
if eq < 0 {
|
||||
return nil, errx.Newf(op, "line %d: missing '='", lineNo)
|
||||
}
|
||||
key := strings.TrimSpace(line[:eq])
|
||||
val := strings.TrimSpace(line[eq+1:])
|
||||
// strip trailing inline comment for unquoted values
|
||||
if !isQuoted(val) {
|
||||
if i := strings.Index(val, " #"); i >= 0 {
|
||||
val = strings.TrimSpace(val[:i])
|
||||
}
|
||||
}
|
||||
v, err := unquote(val)
|
||||
if err != nil {
|
||||
return nil, errx.Wrapf(op, err, "line %d", lineNo)
|
||||
}
|
||||
m[key] = v
|
||||
}
|
||||
if err := sc.Err(); err != nil {
|
||||
return nil, errx.Wrap(op, err)
|
||||
}
|
||||
return MapSource(m), nil
|
||||
}
|
||||
|
||||
func isQuoted(s string) bool {
|
||||
if len(s) < 2 {
|
||||
return false
|
||||
}
|
||||
return (s[0] == '"' && s[len(s)-1] == '"') || (s[0] == '\'' && s[len(s)-1] == '\'')
|
||||
}
|
||||
|
||||
func unquote(s string) (string, error) {
|
||||
if len(s) >= 2 && s[0] == '\'' && s[len(s)-1] == '\'' {
|
||||
return s[1 : len(s)-1], nil
|
||||
}
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
inner := s[1 : len(s)-1]
|
||||
var b strings.Builder
|
||||
for i := 0; i < len(inner); i++ {
|
||||
c := inner[i]
|
||||
if c != '\\' || i+1 >= len(inner) {
|
||||
b.WriteByte(c)
|
||||
continue
|
||||
}
|
||||
i++
|
||||
switch inner[i] {
|
||||
case 'n':
|
||||
b.WriteByte('\n')
|
||||
case 't':
|
||||
b.WriteByte('\t')
|
||||
case 'r':
|
||||
b.WriteByte('\r')
|
||||
case '\\':
|
||||
b.WriteByte('\\')
|
||||
case '"':
|
||||
b.WriteByte('"')
|
||||
default:
|
||||
b.WriteByte('\\')
|
||||
b.WriteByte(inner[i])
|
||||
}
|
||||
}
|
||||
return b.String(), nil
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue