config: add default path, customized configs.
A customised config is an ini file with a [default] section and some other name sections; a config file is loaded from the default section with any keys in the named section being added in, overriding keys in the host. This allows for, e.g. setting different paths based on the host name or operating system.
This commit is contained in:
parent
c7c51568d8
commit
b893e99864
|
@ -10,9 +10,12 @@ package config
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"bufio"
|
"bufio"
|
||||||
|
"fmt"
|
||||||
"log"
|
"log"
|
||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
"git.sr.ht/~kisom/goutils/config/iniconf"
|
||||||
)
|
)
|
||||||
|
|
||||||
// NB: Rather than define a singleton type, everything is defined at
|
// NB: Rather than define a singleton type, everything is defined at
|
||||||
|
@ -67,6 +70,34 @@ func LoadFile(path string) error {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadFileFor scans the ini file at path, loading the default section
|
||||||
|
// and overriding any keys found under section. If strict is true, the
|
||||||
|
// named section must exist (i.e. to catch typos in the section name).
|
||||||
|
func LoadFileFor(path, section string, strict bool) error {
|
||||||
|
cmap, err := iniconf.ParseFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range cmap[iniconf.DefaultSection] {
|
||||||
|
vars[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
smap, ok := cmap[section]
|
||||||
|
if !ok {
|
||||||
|
if strict {
|
||||||
|
return fmt.Errorf("config: section '%s' wasn't found in the config file", section)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for key, value := range smap {
|
||||||
|
vars[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// Get retrieves a value from either a configuration file or the
|
// Get retrieves a value from either a configuration file or the
|
||||||
// environment. Note that values from a file will override environment
|
// environment. Note that values from a file will override environment
|
||||||
// variables.
|
// variables.
|
||||||
|
|
|
@ -0,0 +1,223 @@
|
||||||
|
package iniconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"regexp"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ConfigMap is shorthand for the type used as a config struct.
|
||||||
|
type ConfigMap map[string]map[string]string
|
||||||
|
|
||||||
|
var (
|
||||||
|
configSection = regexp.MustCompile(`^\s*\[\s*(\w+)\s*\]\s*$`)
|
||||||
|
quotedConfigLine = regexp.MustCompile(`^\s*(\w+)\s*=\s*["'](.*)["']\s*$`)
|
||||||
|
configLine = regexp.MustCompile(`^\s*(\w+)\s*=\s*(.*)\s*$`)
|
||||||
|
commentLine = regexp.MustCompile(`^#.*$`)
|
||||||
|
blankLine = regexp.MustCompile(`^\s*$`)
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultSection is the label for the default ini file section.
|
||||||
|
var DefaultSection = "default"
|
||||||
|
|
||||||
|
// ParseFile attempts to load the named config file.
|
||||||
|
func ParseFile(fileName string) (cfg ConfigMap, err error) {
|
||||||
|
var file *os.File
|
||||||
|
file, err = os.Open(fileName)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
return ParseReader(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseReader reads a configuration from an io.Reader.
|
||||||
|
func ParseReader(r io.Reader) (cfg ConfigMap, err error) {
|
||||||
|
cfg = ConfigMap{}
|
||||||
|
buf := bufio.NewReader(r)
|
||||||
|
|
||||||
|
var (
|
||||||
|
line string
|
||||||
|
longLine bool
|
||||||
|
currentSection string
|
||||||
|
lineBytes []byte
|
||||||
|
isPrefix bool
|
||||||
|
)
|
||||||
|
|
||||||
|
for {
|
||||||
|
err = nil
|
||||||
|
lineBytes, isPrefix, err = buf.ReadLine()
|
||||||
|
if io.EOF == err {
|
||||||
|
err = nil
|
||||||
|
break
|
||||||
|
} else if err != nil {
|
||||||
|
break
|
||||||
|
} else if isPrefix {
|
||||||
|
line += string(lineBytes)
|
||||||
|
|
||||||
|
longLine = true
|
||||||
|
continue
|
||||||
|
} else if longLine {
|
||||||
|
line += string(lineBytes)
|
||||||
|
longLine = false
|
||||||
|
} else {
|
||||||
|
line = string(lineBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
if commentLine.MatchString(line) {
|
||||||
|
continue
|
||||||
|
} else if blankLine.MatchString(line) {
|
||||||
|
continue
|
||||||
|
} else if configSection.MatchString(line) {
|
||||||
|
section := configSection.ReplaceAllString(line,
|
||||||
|
"$1")
|
||||||
|
if section == "" {
|
||||||
|
err = fmt.Errorf("invalid structure in file")
|
||||||
|
break
|
||||||
|
} else if !cfg.SectionInConfig(section) {
|
||||||
|
cfg[section] = make(map[string]string, 0)
|
||||||
|
}
|
||||||
|
currentSection = section
|
||||||
|
} else if configLine.MatchString(line) {
|
||||||
|
regex := configLine
|
||||||
|
if quotedConfigLine.MatchString(line) {
|
||||||
|
regex = quotedConfigLine
|
||||||
|
}
|
||||||
|
if currentSection == "" {
|
||||||
|
currentSection = DefaultSection
|
||||||
|
if !cfg.SectionInConfig(currentSection) {
|
||||||
|
cfg[currentSection] = map[string]string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
key := regex.ReplaceAllString(line, "$1")
|
||||||
|
val := regex.ReplaceAllString(line, "$2")
|
||||||
|
if key == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
cfg[currentSection][key] = val
|
||||||
|
} else {
|
||||||
|
err = fmt.Errorf("invalid config file")
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionInConfig determines whether a section is in the configuration.
|
||||||
|
func (c ConfigMap) SectionInConfig(section string) bool {
|
||||||
|
_, ok := c[section]
|
||||||
|
return ok
|
||||||
|
}
|
||||||
|
|
||||||
|
// ListSections returns the list of sections in the config map.
|
||||||
|
func (c ConfigMap) ListSections() (sections []string) {
|
||||||
|
for section := range c {
|
||||||
|
sections = append(sections, section)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteFile writes out the configuration to a file.
|
||||||
|
func (c ConfigMap) WriteFile(filename string) (err error) {
|
||||||
|
file, err := os.Create(filename)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
for _, section := range c.ListSections() {
|
||||||
|
sName := fmt.Sprintf("[ %s ]\n", section)
|
||||||
|
_, err = file.Write([]byte(sName))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
for k, v := range c[section] {
|
||||||
|
line := fmt.Sprintf("%s = %s\n", k, v)
|
||||||
|
_, err = file.Write([]byte(line))
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err = file.Write([]byte{0x0a})
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddSection creates a new section in the config map.
|
||||||
|
func (c ConfigMap) AddSection(section string) {
|
||||||
|
if nil != c[section] {
|
||||||
|
c[section] = map[string]string{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddKeyVal adds a key value pair to a config map.
|
||||||
|
func (c ConfigMap) AddKeyVal(section, key, val string) {
|
||||||
|
if section == "" {
|
||||||
|
section = DefaultSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if nil == c[section] {
|
||||||
|
c.AddSection(section)
|
||||||
|
}
|
||||||
|
|
||||||
|
c[section][key] = val
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValue retrieves the value from a key map.
|
||||||
|
func (c ConfigMap) GetValue(section, key string) (val string, present bool) {
|
||||||
|
if c == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if section == "" {
|
||||||
|
section = DefaultSection
|
||||||
|
}
|
||||||
|
|
||||||
|
_, ok := c[section]
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
val, present = c[section][key]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetValueDefault retrieves the value from a key map if present,
|
||||||
|
// otherwise the default value.
|
||||||
|
func (c ConfigMap) GetValueDefault(section, key, value string) (val string) {
|
||||||
|
kval, ok := c.GetValue(section, key)
|
||||||
|
if !ok {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
return kval
|
||||||
|
}
|
||||||
|
|
||||||
|
// SectionKeys returns the sections in the config map.
|
||||||
|
func (c ConfigMap) SectionKeys(section string) (keys []string, present bool) {
|
||||||
|
if c == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
if section == "" {
|
||||||
|
section = DefaultSection
|
||||||
|
}
|
||||||
|
|
||||||
|
cm := c
|
||||||
|
s, ok := cm[section]
|
||||||
|
if !ok {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
keys = make([]string, 0, len(s))
|
||||||
|
for key := range s {
|
||||||
|
keys = append(keys, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
return keys, true
|
||||||
|
}
|
|
@ -0,0 +1,142 @@
|
||||||
|
package iniconf
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"sort"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FailWithError is a utility for dumping errors and failing the test.
|
||||||
|
func FailWithError(t *testing.T, err error) {
|
||||||
|
fmt.Println("failed")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println("[!] ", err.Error())
|
||||||
|
}
|
||||||
|
t.FailNow()
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnlinkIfExists removes a file if it exists.
|
||||||
|
func UnlinkIfExists(file string) {
|
||||||
|
_, err := os.Stat(file)
|
||||||
|
if err != nil && os.IsNotExist(err) {
|
||||||
|
panic("failed to remove " + file)
|
||||||
|
}
|
||||||
|
os.Remove(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
// stringSlicesEqual compares two string lists, checking that they
|
||||||
|
// contain the same elements.
|
||||||
|
func stringSlicesEqual(slice1, slice2 []string) bool {
|
||||||
|
if len(slice1) != len(slice2) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range slice1 {
|
||||||
|
if slice1[i] != slice2[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for i := range slice2 {
|
||||||
|
if slice1[i] != slice2[i] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoodConfig(t *testing.T) {
|
||||||
|
testFile := "testdata/test.conf"
|
||||||
|
fmt.Printf("[+] validating known-good config... ")
|
||||||
|
cmap, err := ParseFile(testFile)
|
||||||
|
if err != nil {
|
||||||
|
FailWithError(t, err)
|
||||||
|
} else if len(cmap) != 2 {
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
fmt.Println("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGoodConfig2(t *testing.T) {
|
||||||
|
testFile := "testdata/test2.conf"
|
||||||
|
fmt.Printf("[+] validating second known-good config... ")
|
||||||
|
cmap, err := ParseFile(testFile)
|
||||||
|
if err != nil {
|
||||||
|
FailWithError(t, err)
|
||||||
|
} else if len(cmap) != 1 {
|
||||||
|
FailWithError(t, err)
|
||||||
|
} else if len(cmap["default"]) != 3 {
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
fmt.Println("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBadConfig(t *testing.T) {
|
||||||
|
testFile := "testdata/bad.conf"
|
||||||
|
fmt.Printf("[+] ensure invalid config file fails... ")
|
||||||
|
_, err := ParseFile(testFile)
|
||||||
|
if err == nil {
|
||||||
|
err = fmt.Errorf("invalid config file should fail")
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
fmt.Println("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWriteConfigFile(t *testing.T) {
|
||||||
|
fmt.Printf("[+] ensure config file is written properly... ")
|
||||||
|
const testFile = "testdata/test.conf"
|
||||||
|
const testOut = "testdata/test.out"
|
||||||
|
|
||||||
|
cmap, err := ParseFile(testFile)
|
||||||
|
if err != nil {
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
defer UnlinkIfExists(testOut)
|
||||||
|
err = cmap.WriteFile(testOut)
|
||||||
|
if err != nil {
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
cmap2, err := ParseFile(testOut)
|
||||||
|
if err != nil {
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sectionList1 := cmap.ListSections()
|
||||||
|
sectionList2 := cmap2.ListSections()
|
||||||
|
sort.Strings(sectionList1)
|
||||||
|
sort.Strings(sectionList2)
|
||||||
|
if !stringSlicesEqual(sectionList1, sectionList2) {
|
||||||
|
err = fmt.Errorf("section lists don't match")
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, section := range sectionList1 {
|
||||||
|
for _, k := range cmap[section] {
|
||||||
|
if cmap[section][k] != cmap2[section][k] {
|
||||||
|
err = fmt.Errorf("config key doesn't match")
|
||||||
|
FailWithError(t, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println("ok")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestQuotedValue(t *testing.T) {
|
||||||
|
testFile := "testdata/test.conf"
|
||||||
|
fmt.Printf("[+] validating quoted value... ")
|
||||||
|
cmap, _ := ParseFile(testFile)
|
||||||
|
val := cmap["sectionName"]["key4"]
|
||||||
|
if val != " space at beginning and end " {
|
||||||
|
FailWithError(t, errors.New("Wrong value in double quotes ["+val+"]"))
|
||||||
|
}
|
||||||
|
|
||||||
|
val = cmap["sectionName"]["key5"]
|
||||||
|
if val != " is quoted with single quotes " {
|
||||||
|
FailWithError(t, errors.New("Wrong value in single quotes ["+val+"]"))
|
||||||
|
}
|
||||||
|
fmt.Println("ok")
|
||||||
|
}
|
|
@ -0,0 +1,19 @@
|
||||||
|
//go:build ignore
|
||||||
|
// +build ignore
|
||||||
|
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/user"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DefaultConfigPath returns a sensible default configuration file path.
|
||||||
|
func DefaultConfigPath(dir, base string) string {
|
||||||
|
user, err := user.Current()
|
||||||
|
if err != nil || user.HomeDir == "" {
|
||||||
|
return filepath.Join(dir, base)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(user.HomeDir, dir, base)
|
||||||
|
}
|
|
@ -0,0 +1,43 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// canUseXDGConfigDir checks whether the XDG config directory exists
|
||||||
|
// and is accessible by the current user. If it is present, it will
|
||||||
|
// be returned. Note that if the directory does not exist, it is
|
||||||
|
// presumed unusable.
|
||||||
|
func canUseXDGConfigDir() (string, bool) {
|
||||||
|
xdgDir := os.Getenv("XDG_CONFIG_DIR")
|
||||||
|
if xdgDir == "" {
|
||||||
|
userDir := os.Getenv("HOME")
|
||||||
|
if userDir == "" {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
xdgDir = filepath.Join(userDir, ".config")
|
||||||
|
}
|
||||||
|
|
||||||
|
fi, err := os.Stat(xdgDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
if !fi.IsDir() {
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
return xdgDir, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultConfigPath returns a sensible default configuration file path.
|
||||||
|
func DefaultConfigPath(dir, base string) string {
|
||||||
|
dirPath, ok := canUseXDGConfigDir()
|
||||||
|
if !ok {
|
||||||
|
dirPath = "/etc"
|
||||||
|
}
|
||||||
|
|
||||||
|
return filepath.Join(dirPath, dir, base)
|
||||||
|
}
|
|
@ -0,0 +1,7 @@
|
||||||
|
package config
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestDefaultPath(t *testing.T) {
|
||||||
|
t.Log(DefaultConfigPath("demoapp", "app.conf"))
|
||||||
|
}
|
Loading…
Reference in New Issue