conf/dotenv.go
juancwu c4ebd80669 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>
2026-04-27 20:35:51 +00:00

103 lines
2.3 KiB
Go

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
}