From 4481060613a5696a658b94bec230280c17609221 Mon Sep 17 00:00:00 2001 From: Sean Hickey Date: Wed, 9 Nov 2022 00:16:36 -0800 Subject: [PATCH] Restructure the code a bit. Add a more complete wrapper function called Configure and ConfigureWithDefaults. Add ability to write a configmap back to a new file. Changed my mind about multiple equals signs because some data like base64 uses equals signs. Added more tests. Bump to version 0.1.0 to signal closer to completeness/stability. Changed domain name from gitea.wisellama.rocks to git.wisellama.rocks. --- Makefile | 3 +- Readme.md | 60 ++++++++++++---- config_file.go | 95 +++++++++++++++++++++++++ config_file_test.go | 104 +++++++++++++++++++++++++++ conversions.go | 36 ++++++++++ flag_overrides.go | 28 ++++++++ flag_overrides_test.go | 41 +++++++++++ go.mod | 2 +- gosimpleconf.go | 109 ++++------------------------ gosimpleconf_test.go | 158 +++++++---------------------------------- test.conf | 13 ++++ test_helper.go | 25 +++++++ 12 files changed, 429 insertions(+), 245 deletions(-) create mode 100644 config_file.go create mode 100644 config_file_test.go create mode 100644 conversions.go create mode 100644 flag_overrides.go create mode 100644 flag_overrides_test.go create mode 100644 test.conf create mode 100644 test_helper.go diff --git a/Makefile b/Makefile index f1a96dd..35bded0 100644 --- a/Makefile +++ b/Makefile @@ -1,8 +1,9 @@ all: linter test test: - go test ./ + go test +lint: linter linter: golangci-lint run diff --git a/Readme.md b/Readme.md index 5d8658b..a34d5a7 100644 --- a/Readme.md +++ b/Readme.md @@ -1,16 +1,18 @@ # gosimpleconf ```sh -go get gitea.wisellama.rocks/Wisellama/gosimpleconf@v0.0.4 +go get git.wisellama.rocks/Wisellama/gosimpleconf@v0.1.0 ``` -This is a small library for parsing super simple configuration files. +This is a small library for parsing super simple configuration files +similar to Unix conf files. It expects a file with the following format: * Lines with key-values pairs separated by `=` (e.g. `foo = bar`) * Lines that start with `#` are ignored (used for comments) -* Empty and whitespace-only lines are ignore -* Lines with data that don't have an `=` will cause an error. +* Empty and whitespace-only lines are ignored +* Lines with some data but no `=` will cause an error. +* Lines with multiple `=` are split on the literal first `=` seen, any remaining are part of the value. The key-values pairs are simply parsed as strings and plopped into a map for you to do whatever your program wants with them. If you need a @@ -25,15 +27,21 @@ url = www.wisellama.rocks number_of_widgets = 1337 pi = 3.141592653589793238462643383 environment = production + # Quotes are optional name = Dude guy description = "Just some dude it was" + +# Additional equals are just part of the value +base64_tacos = dGFjb3M= +a=b=c +# key: 'a', value: 'b=c' ``` Then to load that config file into a map: ```go -configMap, err := gosimpleconf.Load("file.conf") +configMap, err := gosimpleconf.Configure("file.conf") ``` Then everything is a string. You can parse it as needed. @@ -42,19 +50,43 @@ var piStr string = configMap["pi"] pi, err := strconv.ParseFloat(value, 64) ``` -To override values with cli flags, after parsing the conf file you can -use these functions to also parse the flags: -```go -flagMap := SetupFlagOverrides(configMap) -configMap = ParseFlags(configMap, flagmap) -``` +### Command-Line Flag Overrides -Then you could, for example, override the url value without modifying -the config file: -```go +By default, the `Configure()` function will figure out which keys +exist in the config map and set those as cli flags that you can +override. For example, with the config file above, you could override +the `url` key with a cli flag: +```sh ./program --url=newthing.wisellama.rocks ``` +If you don't want to support cli flag overrides, you can directly call +`ReadFile("file.conf")` instead. + +If you want to support other cli flags that don't map to a config +setting, they must be set up before calling `Configure()`. This is +because the `ParseFlags()` function calls `flag.Parse()` to get all +the values for the config map overrides. You could also work around +this by calling `ReadFile` and `ParseFlags` yourself and add your +other logic in between the two calls. + +### Default Config + +You can specify a default configuration to return if the given config +file doesn't exist. Here's an example: + +```go +defaultConfig := gosimpleconf.ConfigMap{ + "game.title": "Some Game", + "log.writeToFile": "false", +} +configMap, err := gosimpleconf.ConfigureWithDefaults("file_that_doesnt_exist.conf", defaultConfig) +``` + +Then if the config file can't be found, the default config map values +will be used instead. You can still override the default values with +cli flags. + ## Why? I've always seen configuration files similar to this show up in diff --git a/config_file.go b/config_file.go new file mode 100644 index 0000000..ea41942 --- /dev/null +++ b/config_file.go @@ -0,0 +1,95 @@ +package gosimpleconf + +import ( + "bufio" + "fmt" + "io" + "os" + "strings" +) + +type ConfigMap map[string]string + +func Load(filename string) (ConfigMap, error) { + return ReadFile(filename) +} + +func ReadFile(filename string) (ConfigMap, error) { + var err error + + file, err := os.Open(filename) + if err != nil { + return nil, err + } + defer file.Close() + + parsedMap, err := parseFile(file) + if err != nil { + return nil, err + } + + return parsedMap, nil +} + +func WriteFile(filename string, configMap ConfigMap) error { + file, err := os.Create(filename) + if err != nil { + return err + } + defer file.Close() + + for k, v := range configMap { + fmt.Fprintf(file, "%s = %s\n", k, v) + } + + return nil +} + +func parseFile(config io.Reader) (ConfigMap, error) { + var err error + parsedMap := make(ConfigMap) + + scanner := bufio.NewScanner(config) + for scanner.Scan() { + line := scanner.Text() + trimmed := strings.TrimSpace(line) + if len(trimmed) == 0 { + // ignore empty lines + continue + } + + if trimmed[0] == '#' { + // ignore comments + continue + } + + split := strings.Split(trimmed, "=") + if len(split) < 2 { + return nil, fmt.Errorf("failed to parse config line: %v", trimmed) + } + + key := trimToken(split[0]) + + value := strings.Join(split[1:], "=") // Re-add any ='s that were part of the value + value = trimToken(value) + + parsedMap[key] = value + } + + if err = scanner.Err(); err != nil { + return nil, err + } + + return parsedMap, nil +} + +func trimToken(value string) string { + // Trim off whitespace + v := strings.TrimSpace(value) + + // Trim off any surrounding quotes, everything is a string anyway + v = strings.TrimPrefix(v, "\"") + v = strings.TrimSuffix(v, "\"") + + return v +} diff --git a/config_file_test.go b/config_file_test.go new file mode 100644 index 0000000..7332d5e --- /dev/null +++ b/config_file_test.go @@ -0,0 +1,104 @@ +package gosimpleconf + +import ( + "strings" + "testing" +) + +func TestParseWithValidConfig(t *testing.T) { + var err error + configFileStr := ` +# Some config file + +foo=bar +asdf = 1234 + + # Things +wat.wat= thing +foo.wat =stuff + +immaempty= + +technically valid = haha hmm... + +#Done +` + configFile := strings.NewReader(configFileStr) + + expectedMap := make(ConfigMap) + expectedMap["foo"] = "bar" + expectedMap["asdf"] = "1234" + expectedMap["wat.wat"] = "thing" + expectedMap["foo.wat"] = "stuff" + expectedMap["immaempty"] = "" + expectedMap["technically valid"] = "haha hmm..." + + parsedMap, err := parseFile(configFile) + if err != nil { + t.Errorf("failed while parsing the file") + } + + validateMap(t, parsedMap, expectedMap) +} + +func TestParseWithNoEquals(t *testing.T) { + var err error + configFileStr := ` +Something here with no equals to split on +` + configFile := strings.NewReader(configFileStr) + + _, err = parseFile(configFile) + if err == nil { + t.Errorf("parse did not thrown an error when it was supposed to") + } +} + +func TestParseWithExtraQuotes(t *testing.T) { + var err error + configFileStr := ` + some.name = "This is in quotes" + extra.quotes = ""This will keep the quotes"" + internal.quotes = "Some "thing" is here" + "This should work too" = shrug I guess + ` + + configFile := strings.NewReader(configFileStr) + + expectedMap := make(ConfigMap) + expectedMap["some.name"] = "This is in quotes" + expectedMap["extra.quotes"] = "\"This will keep the quotes\"" + expectedMap["internal.quotes"] = "Some \"thing\" is here" + expectedMap["This should work too"] = "shrug I guess" + + configMap, err := parseFile(configFile) + if err != nil { + t.Errorf("failed while parsing the file") + } + + validateMap(t, configMap, expectedMap) +} + +func TestParseExtraEquals(t *testing.T) { + configFileStr := ` +foo=bar=true +base64_tacos = dGFjb3M= +"equals=splits" the key/value, quotes and other =equals= don't matter +yup=soup +` + + configFile := strings.NewReader(configFileStr) + + expectedMap := make(ConfigMap) + expectedMap["foo"] = "bar=true" + expectedMap["base64_tacos"] = "dGFjb3M=" + expectedMap["equals"] = "splits\" the key/value, quotes and other =equals= don't matter" + expectedMap["yup"] = "soup" + + configMap, err := parseFile(configFile) + if err != nil { + t.Errorf("failed while parsing the file") + } + + validateMap(t, configMap, expectedMap) +} diff --git a/conversions.go b/conversions.go new file mode 100644 index 0000000..7aa0d17 --- /dev/null +++ b/conversions.go @@ -0,0 +1,36 @@ +package gosimpleconf + +import ( + "log" + "strconv" +) + +func Bool(value string) bool { + v, err := strconv.ParseBool(value) + if err != nil { + log.Printf("error parsing bool %v - %v\n", v, err) + v = false + } + + return v +} + +func Int64(value string) int64 { + v, err := strconv.ParseInt(value, 10, 64) + if err != nil { + log.Printf("error parsing int %v - %v\n", v, err) + v = 0 + } + + return v +} + +func Float64(value string) float64 { + v, err := strconv.ParseFloat(value, 64) + if err != nil { + log.Printf("error parsing float %v - %v\n", v, err) + v = 0.0 + } + + return v +} diff --git a/flag_overrides.go b/flag_overrides.go new file mode 100644 index 0000000..3e5ebbb --- /dev/null +++ b/flag_overrides.go @@ -0,0 +1,28 @@ +package gosimpleconf + +import "flag" + +type FlagMap map[string]*string + +func SetupFlagOverrides(configMap ConfigMap) FlagMap { + flagMap := make(FlagMap) + for k, v := range configMap { + f := flag.String(k, v, "") + flagMap[k] = f + } + + return flagMap +} + +func ParseFlags(configMap ConfigMap, flagMap FlagMap) ConfigMap { + flag.Parse() + + for k, v := range flagMap { + if v != nil && len(*v) > 0 { + configMap[k] = trimToken(*v) + } + } + + return configMap + +} diff --git a/flag_overrides_test.go b/flag_overrides_test.go new file mode 100644 index 0000000..94876a9 --- /dev/null +++ b/flag_overrides_test.go @@ -0,0 +1,41 @@ +package gosimpleconf + +import ( + "flag" + "strings" + "testing" +) + +func TestFlagOverrides(t *testing.T) { + var err error + configFileStr := ` +foo=bar +asdf = 1234 +foo.wat =stuff +immaempty= +` + configFile := strings.NewReader(configFileStr) + + expectedMap := make(ConfigMap) + expectedMap["foo"] = "bar" + expectedMap["asdf"] = "1234" + // expect to override the 'foo.wat' value with a cli flag + expectedMap["foo.wat"] = "iwasoverridden" + expectedMap["immaempty"] = "" + + configMap, err := parseFile(configFile) + if err != nil { + t.Errorf("failed while parsing the file") + } + + flagMap := SetupFlagOverrides(configMap) + + err = flag.Set("foo.wat", "iwasoverridden") + if err != nil { + t.Errorf("failed to set cli flag override") + } + + configMap = ParseFlags(configMap, flagMap) + + validateMap(t, configMap, expectedMap) +} diff --git a/go.mod b/go.mod index ce1573c..ae02fb6 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,3 @@ -module gitea.wisellama.rocks/Wisellama/gosimpleconf +module git.wisellama.rocks/Wisellama/gosimpleconf go 1.17 diff --git a/gosimpleconf.go b/gosimpleconf.go index de1a537..43c89f4 100644 --- a/gosimpleconf.go +++ b/gosimpleconf.go @@ -1,112 +1,31 @@ package gosimpleconf -import ( - "bufio" - "flag" - "fmt" - "io" - "log" - "os" - "strconv" - "strings" -) +import "os" -type ConfigMap map[string]string -type FlagMap map[string]*string - -func Load(filename string) (ConfigMap, error) { +func Configure(filename string) (ConfigMap, error) { var err error - file, err := os.Open(filename) - if err != nil { - return nil, err - } - defer file.Close() - - parsedMap, err := parseFile(file) + configMap, err := ReadFile(filename) if err != nil { return nil, err } - return parsedMap, nil + flagMap := SetupFlagOverrides(configMap) + configMap = ParseFlags(configMap, flagMap) + + return configMap, nil } -func SetupFlagOverrides(configMap ConfigMap) FlagMap { - flagMap := make(FlagMap) - for k, v := range configMap { - f := flag.String(k, v, "") - flagMap[k] = f - } - - return flagMap -} - -func ParseFlags(configMap ConfigMap, flagMap FlagMap) ConfigMap { - flag.Parse() - - for k, v := range flagMap { - if v != nil && len(*v) > 0 { - configMap[k] = trimToken(*v) - } - } - - return configMap - -} - -func Bool(value string) bool { - v, err := strconv.ParseBool(value) - if err != nil { - log.Printf("error parsing bool %v - %v\n", v, err) - v = false - } - - return v -} - -func parseFile(config io.Reader) (ConfigMap, error) { +func ConfigureWithDefaults(filename string, defaultConf ConfigMap) (ConfigMap, error) { var err error - parsedMap := make(ConfigMap) - scanner := bufio.NewScanner(config) - for scanner.Scan() { - line := scanner.Text() - trimmed := strings.TrimSpace(line) - if len(trimmed) == 0 { - // ignore empty lines - continue - } - - if trimmed[0] == '#' { - // ignore comments - continue - } - - split := strings.Split(trimmed, "=") - if len(split) != 2 { - return nil, fmt.Errorf("failed to parse config line: %v", trimmed) - } - - key := trimToken(split[0]) - value := trimToken(split[1]) - - parsedMap[key] = value - } - - if err = scanner.Err(); err != nil { + _, err = os.Stat(filename) + if os.IsNotExist(err) { + // Config file does not exist, return a default configMap + return defaultConf, nil + } else if err != nil { return nil, err } - return parsedMap, nil -} - -func trimToken(value string) string { - // Trim off whitespace - v := strings.TrimSpace(value) - - // Trim off any surrounding quotes, everything is a string anyway - v = strings.TrimPrefix(v, "\"") - v = strings.TrimSuffix(v, "\"") - - return v + return Configure(filename) } diff --git a/gosimpleconf_test.go b/gosimpleconf_test.go index abe5c38..e49b3de 100644 --- a/gosimpleconf_test.go +++ b/gosimpleconf_test.go @@ -1,150 +1,40 @@ package gosimpleconf -import ( - "flag" - "strings" - "testing" -) +import "testing" -func TestParseWithValidConfig(t *testing.T) { - var err error - configFileStr := ` -# Some config file +func TestConfigureWithDefaults(t *testing.T) { + defaultMap := ConfigMap{ + "some": "thing", + "cool": "1337", + } -foo=bar -asdf = 1234 + filename := "idontexist_123456789" - # Things -wat.wat= thing -foo.wat =stuff - -immaempty= - -technically valid = haha hmm... - -#Done -` - configFile := strings.NewReader(configFileStr) - - expectedMap := make(ConfigMap) - expectedMap["foo"] = "bar" - expectedMap["asdf"] = "1234" - expectedMap["wat.wat"] = "thing" - expectedMap["foo.wat"] = "stuff" - expectedMap["immaempty"] = "" - expectedMap["technically valid"] = "haha hmm..." - - parsedMap, err := parseFile(configFile) + conf, err := ConfigureWithDefaults(filename, defaultMap) if err != nil { - t.Errorf("failed while parsing the file") + t.Errorf("unexpected error: %v", err) } - validateMap(t, parsedMap, expectedMap) + validateMap(t, conf, defaultMap) } -func TestParseWithExtraEquals(t *testing.T) { - var err error - configFileStr := ` -foo=bar=true -` - configFile := strings.NewReader(configFileStr) - - _, err = parseFile(configFile) - if err == nil { - t.Errorf("parse did not thrown an error when it was supposed to") +func TestConfigure(t *testing.T) { + filename := "test.conf" + expectedMap := ConfigMap{ + "url": "www.wisellama.rocks", + "number_of_widgets": "1337", + "pi": "3.141592653589793238462643383", + "environment": "production", + "name": "Dude guy", + "description": "Just some dude it was", + "base64_tacos": "dGFjb3M=", + "a": "b=c", } -} -func TestParseWithNoEquals(t *testing.T) { - var err error - configFileStr := ` -Something here with no equals to split on -` - configFile := strings.NewReader(configFileStr) - - _, err = parseFile(configFile) - if err == nil { - t.Errorf("parse did not thrown an error when it was supposed to") - } -} - -func TestFlagOverrides(t *testing.T) { - var err error - configFileStr := ` -foo=bar -asdf = 1234 -foo.wat =stuff -immaempty= -` - configFile := strings.NewReader(configFileStr) - - expectedMap := make(ConfigMap) - expectedMap["foo"] = "bar" - expectedMap["asdf"] = "1234" - // expect to override the 'foo.wat' value with a cli flag - expectedMap["foo.wat"] = "iwasoverridden" - expectedMap["immaempty"] = "" - - configMap, err := parseFile(configFile) + conf, err := Configure(filename) if err != nil { - t.Errorf("failed while parsing the file") + t.Errorf("unexpected error: %v", err) } - flagMap := SetupFlagOverrides(configMap) - - err = flag.Set("foo.wat", "iwasoverridden") - if err != nil { - t.Errorf("failed to set cli flag override") - } - - configMap = ParseFlags(configMap, flagMap) - - validateMap(t, configMap, expectedMap) -} - -func TestParseWithExtraQuotes(t *testing.T) { - var err error - configFileStr := ` - some.name = "This is in quotes" - extra.quotes = ""This will keep the quotes"" - internal.quotes = "Some "thing" is here" - "This should work too" = shrug I guess - ` - - configFile := strings.NewReader(configFileStr) - - expectedMap := make(ConfigMap) - expectedMap["some.name"] = "This is in quotes" - expectedMap["extra.quotes"] = "\"This will keep the quotes\"" - expectedMap["internal.quotes"] = "Some \"thing\" is here" - expectedMap["This should work too"] = "shrug I guess" - - configMap, err := parseFile(configFile) - if err != nil { - t.Errorf("failed while parsing the file") - } - - validateMap(t, configMap, expectedMap) -} - -func validateMap(t *testing.T, given map[string]string, expected map[string]string) { - if given == nil { - t.Errorf("given map was nil") - } - - if expected == nil { - t.Errorf("expected map was nil") - } - - expectedLen := len(expected) - givenLen := len(given) - if expectedLen != givenLen { - t.Errorf("size mismatch on maps - expected %v, given %v", expectedLen, givenLen) - } - - for k, v := range expected { - if v != given[k] { - t.Errorf("incorrect value for key %v - expected %v, given %v", k, expected[k], given[k]) - } - } + validateMap(t, conf, expectedMap) } diff --git a/test.conf b/test.conf new file mode 100644 index 0000000..2720458 --- /dev/null +++ b/test.conf @@ -0,0 +1,13 @@ +url = www.wisellama.rocks +number_of_widgets = 1337 +pi = 3.141592653589793238462643383 +environment = production + +# Quotes are optional +name = Dude guy +description = "Just some dude it was" + +# Additional equals are just part of the value +base64_tacos = dGFjb3M= +a=b=c +# key: 'a', value: 'b=c' diff --git a/test_helper.go b/test_helper.go new file mode 100644 index 0000000..b93d544 --- /dev/null +++ b/test_helper.go @@ -0,0 +1,25 @@ +package gosimpleconf + +import "testing" + +func validateMap(t *testing.T, given map[string]string, expected map[string]string) { + if given == nil { + t.Errorf("given map was nil") + } + + if expected == nil { + t.Errorf("expected map was nil") + } + + expectedLen := len(expected) + givenLen := len(given) + if expectedLen != givenLen { + t.Errorf("size mismatch on maps - expected %v, given %v", expectedLen, givenLen) + } + + for k, v := range expected { + if v != given[k] { + t.Errorf("incorrect value for key %v - expected %v, given %v", k, expected[k], given[k]) + } + } +}