diff --git a/config/config.go b/config/config.go index 235db3e..1552c69 100644 --- a/config/config.go +++ b/config/config.go @@ -10,9 +10,12 @@ package config import ( "bufio" + "fmt" "log" "os" "strings" + + "git.sr.ht/~kisom/goutils/config/iniconf" ) // NB: Rather than define a singleton type, everything is defined at @@ -67,6 +70,34 @@ func LoadFile(path string) error { 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 // environment. Note that values from a file will override environment // variables. diff --git a/config/iniconf/iniconf.go b/config/iniconf/iniconf.go new file mode 100644 index 0000000..2c28cf2 --- /dev/null +++ b/config/iniconf/iniconf.go @@ -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 +} diff --git a/config/iniconf/iniconf_test.go b/config/iniconf/iniconf_test.go new file mode 100644 index 0000000..26fa570 --- /dev/null +++ b/config/iniconf/iniconf_test.go @@ -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") +} diff --git a/config/path.go b/config/path.go new file mode 100644 index 0000000..2da08bc --- /dev/null +++ b/config/path.go @@ -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) +} diff --git a/config/path_linux.go b/config/path_linux.go new file mode 100644 index 0000000..584de56 --- /dev/null +++ b/config/path_linux.go @@ -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) +} diff --git a/config/path_test.go b/config/path_test.go new file mode 100644 index 0000000..95f20d4 --- /dev/null +++ b/config/path_test.go @@ -0,0 +1,7 @@ +package config + +import "testing" + +func TestDefaultPath(t *testing.T) { + t.Log(DefaultConfigPath("demoapp", "app.conf")) +}