From 049927d24228ece61c7477368915489b278fbf8e1e3de1d3cd3b7b8f149f7247 Mon Sep 17 00:00:00 2001 From: LixenWraith Date: Wed, 8 Jan 2025 15:41:41 -0500 Subject: [PATCH] 0.0.0.0 Initial commit, basic core functionality --- .gitignore | 4 ++ LICENSE | 28 +++++++++ README.md | 70 ++++++++++++++++++++++ config.go | 149 +++++++++++++++++++++++++++++++++++++++++++++++ examples/main.go | 77 ++++++++++++++++++++++++ go.mod | 7 +++ go.sum | 4 ++ test.toml | 13 +++++ 8 files changed, 352 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 config.go create mode 100644 examples/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 test.toml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..db63cda --- /dev/null +++ b/.gitignore @@ -0,0 +1,4 @@ +.idea +data +dev +logs diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..05259da --- /dev/null +++ b/LICENSE @@ -0,0 +1,28 @@ +BSD 3-Clause License + +Copyright (c) 2024, Lixen Wraith + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..97e4b6a --- /dev/null +++ b/README.md @@ -0,0 +1,70 @@ +# Config + +A simple configuration management package for Go applications that supports TOML files and CLI arguments. + +## Features + +- TOML configuration with [tinytoml](https://github.com/LixenWraith/tinytoml) +- Command line argument overrides with dot notation +- Default config handling +- Atomic file operations +- No external dependencies beyond tinytoml + +## Installation + +```bash +go get github.com/example/config +``` + +## Usage + +```go +type AppConfig struct { + Server struct { + Host string `toml:"host"` + Port int `toml:"port"` + } `toml:"server"` +} + +func main() { + cfg := AppConfig{ + Host: "localhost", + Port: 8080, + } // default config + exists, err := config.LoadConfig("config.toml", &cfg, os.Args[1:]) + if err != nil { + log.Fatal(err) + } + + if !exists { + if err := config.SaveConfig("config.toml", &cfg); err != nil { + log.Fatal(err) + } + } +} +``` + +### CLI Arguments + +Override config values using dot notation: +```bash +./app --server.host localhost --server.port 8080 +``` + +## API + +### `LoadConfig(path string, config interface{}, args []string) (bool, error)` +Loads configuration from TOML file and CLI args. Returns true if config file exists. + +### `SaveConfig(path string, config interface{}) error` +Saves configuration to TOML file atomically. + +## Limitations + +- Supports only basic Go types and structures supported by tinytoml +- CLI arguments must use `--key value` format +- Indirect dependency on [mapstructure](https://github.com/mitchellh/mapstructure) through tinytoml + +## License + +BSD-3 \ No newline at end of file diff --git a/config.go b/config.go new file mode 100644 index 0000000..a6a766e --- /dev/null +++ b/config.go @@ -0,0 +1,149 @@ +package config + +import ( + "encoding/json" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + + "github.com/LixenWraith/tinytoml" +) + +type cliArg struct { + key string + value string +} + +func Load(path string, config interface{}, args []string) (bool, error) { + if config == nil { + return false, fmt.Errorf("config cannot be nil") + } + + configExists := false + if stat, err := os.Stat(path); err == nil && !stat.IsDir() { + configExists = true + data, err := os.ReadFile(path) + if err != nil { + return false, fmt.Errorf("failed to read config file: %w", err) + } + + if err := tinytoml.Unmarshal(data, config); err != nil { + return false, fmt.Errorf("failed to parse config file: %w", err) + } + } + + if len(args) > 0 { + overrides, err := parseArgs(args) + if err != nil { + return configExists, fmt.Errorf("failed to parse CLI args: %w", err) + } + + if err := mergeConfig(config, overrides); err != nil { + return configExists, fmt.Errorf("failed to merge CLI args: %w", err) + } + } + + return configExists, nil +} + +func Save(path string, config interface{}) error { + v := reflect.ValueOf(config) + if v.Kind() == reflect.Ptr { + v = v.Elem() + } + + if v.Kind() != reflect.Struct { + return fmt.Errorf("config must be a struct or pointer to struct") + } + + data, err := tinytoml.Marshal(v.Interface()) + if err != nil { + return fmt.Errorf("failed to marshal config: %w", err) + } + + dir := filepath.Dir(path) + if err := os.MkdirAll(dir, 0755); err != nil { + return fmt.Errorf("failed to create config directory: %w", err) + } + + tempFile := path + ".tmp" + if err := os.WriteFile(tempFile, data, 0644); err != nil { + return fmt.Errorf("failed to write temp config file: %w", err) + } + + if err := os.Rename(tempFile, path); err != nil { + os.Remove(tempFile) + return fmt.Errorf("failed to save config file: %w", err) + } + + return nil +} + +func parseArgs(args []string) (map[string]interface{}, error) { + parsed := make([]cliArg, 0, len(args)) + for i := 0; i < len(args); i++ { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + continue + } + + key := strings.TrimPrefix(arg, "--") + if i+1 >= len(args) || strings.HasPrefix(args[i+1], "--") { + parsed = append(parsed, cliArg{key: key, value: "true"}) + continue + } + + parsed = append(parsed, cliArg{key: key, value: args[i+1]}) + i++ + } + + result := make(map[string]interface{}) + for _, arg := range parsed { + keys := strings.Split(arg.key, ".") + current := result + for i, k := range keys[:len(keys)-1] { + if _, exists := current[k]; !exists { + current[k] = make(map[string]interface{}) + } + if nested, ok := current[k].(map[string]interface{}); ok { + current = nested + } else { + return nil, fmt.Errorf("invalid nested key at %s", strings.Join(keys[:i+1], ".")) + } + } + + lastKey := keys[len(keys)-1] + if val, err := strconv.ParseBool(arg.value); err == nil { + current[lastKey] = val + } else if val, err := strconv.ParseInt(arg.value, 10, 64); err == nil { + current[lastKey] = val + } else if val, err := strconv.ParseFloat(arg.value, 64); err == nil { + current[lastKey] = val + } else { + current[lastKey] = arg.value + } + } + + return result, nil +} + +func mergeConfig(base interface{}, override map[string]interface{}) error { + baseValue := reflect.ValueOf(base) + if baseValue.Kind() != reflect.Ptr || baseValue.IsNil() { + return fmt.Errorf("base config must be a non-nil pointer") + } + + data, err := json.Marshal(override) + if err != nil { + return fmt.Errorf("failed to marshal override values: %w", err) + } + + if err := json.Unmarshal(data, base); err != nil { + return fmt.Errorf("failed to merge override values: %w", err) + } + + return nil +} \ No newline at end of file diff --git a/examples/main.go b/examples/main.go new file mode 100644 index 0000000..4ffe6ea --- /dev/null +++ b/examples/main.go @@ -0,0 +1,77 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "time" + + "github.com/LixenWraith/config" +) + +type SMTPConfig struct { + Host string `toml:"host"` + Port string `toml:"port"` + FromAddr string `toml:"from_addr"` + AuthUser string `toml:"auth_user"` + AuthPass string `toml:"auth_pass"` +} + +type ServerConfig struct { + Host string `toml:"host"` + Port int `toml:"port"` + ReadTimeout time.Duration `toml:"read_timeout"` + WriteTimeout time.Duration `toml:"write_timeout"` + MaxConns int `toml:"max_conns"` +} + +type AppConfig struct { + SMTP SMTPConfig `toml:"smtp"` + Server ServerConfig `toml:"server"` + Debug bool `toml:"debug"` +} + +func main() { + defaultConfig := AppConfig{ + SMTP: SMTPConfig{ + Host: "smtp.example.com", + Port: "587", + FromAddr: "noreply@example.com", + AuthUser: "admin", + AuthPass: "default123", + }, + Server: ServerConfig{ + Host: "localhost", + Port: 8080, + ReadTimeout: time.Second * 30, + WriteTimeout: time.Second * 30, + MaxConns: 1000, + }, + Debug: true, + } + + configPath := filepath.Join(".", "test.toml") + cfg := defaultConfig + + // CLI argument usage example to override SMTP host and server port of existing and default config: + // ./main --smtp.host mail.example.com --server.port 9090 + + exists, err := config.Load(configPath, &cfg, os.Args[1:]) + if err != nil { + fmt.Fprintf(os.Stderr, "Failed to load config: %v\n", err) + os.Exit(1) + } + + if !exists { + if err := config.Save(configPath, &cfg); err != nil { + fmt.Fprintf(os.Stderr, "Failed to save default config: %v\n", err) + os.Exit(1) + } + fmt.Println("Created default config at:", configPath) + } + + fmt.Printf("Running with config:\n") + fmt.Printf("Server: %s:%d\n", cfg.Server.Host, cfg.Server.Port) + fmt.Printf("SMTP: %s:%s\n", cfg.SMTP.Host, cfg.SMTP.Port) + fmt.Printf("Debug: %v\n", cfg.Debug) +} \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..0522f9b --- /dev/null +++ b/go.mod @@ -0,0 +1,7 @@ +module config + +go 1.23.4 + +require github.com/LixenWraith/tinytoml v0.0.0-20241125164826-37e61dcbf33b + +require github.com/mitchellh/mapstructure v1.5.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..95e2ec5 --- /dev/null +++ b/go.sum @@ -0,0 +1,4 @@ +github.com/LixenWraith/tinytoml v0.0.0-20241125164826-37e61dcbf33b h1:zjNL89uvvL9xB65qKXQGrzVOAH0CWkxRmcbU2uyyUk4= +github.com/LixenWraith/tinytoml v0.0.0-20241125164826-37e61dcbf33b/go.mod h1:27w5bMp6NIrEuelM/8a+htswf0Dohs/AZ9tSsQ+lnN0= +github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY= +github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo= diff --git a/test.toml b/test.toml new file mode 100644 index 0000000..19621dc --- /dev/null +++ b/test.toml @@ -0,0 +1,13 @@ +debug = true +[server] +host = "localhost" +max_conns = 1000 +port = 9090 +read_timeout = 30000000000 +write_timeout = 30000000000 +[smtp] +auth_pass = "default123" +auth_user = "admin" +from_addr = "noreply@example.com" +host = "mail.example.com" +port = "587"