Compare commits

..

No commits in common. "81dfdacb33d25d91bce7e2069e7119272f29b80c" and "e72c317bbb1b93d5418e2c6fce335362f032a5e0" have entirely different histories.

15 changed files with 788 additions and 1302 deletions

File diff suppressed because it is too large Load diff

View file

@ -3,17 +3,8 @@
## Unreleased
- Add support for TOML configuration files
- Add support for HCL configuration files
- Use stdlib for tests instead of convey
- Public functions now panic when the data to be marshaled or unmarshaled is not
a pointer to a struct. These errors should be caught during deelopment (with
unit tests).
- Update golangci-lint configuration
## v0.3.0 (2025-01-14)
- The repo has moded from code.bcarlin.xyz/go/conf to code.bcarlin.net/go/conf.
The old module has been deprecated in favor of the new one.
- Update golangci-lint config
## v0.2.0 (2021-12-19)

View file

@ -2,14 +2,9 @@ package conf
import (
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/hashicorp/hcl/v2"
"github.com/hashicorp/hcl/v2/gohcl"
"github.com/hashicorp/hcl/v2/hclsimple"
"github.com/hashicorp/hcl/v2/hclwrite"
"github.com/pelletier/go-toml/v2"
)
@ -19,55 +14,42 @@ const (
typeInvalid filetype = iota
typeJSON
typeTOML
typeHCL
)
// getType returns the type of the config file.
func getType(filename string) filetype {
switch {
case strings.HasSuffix(filename, ".json"):
return typeJSON
case strings.HasSuffix(filename, ".toml"):
return typeTOML
case strings.HasSuffix(filename, ".hcl"):
return typeHCL
default:
return typeInvalid
}
}
// unmarshal unmarshals the given data to the given struct.
func unmarshal(filepath string, data []byte, v any) error {
ft := getType(filepath)
func unmarshal(ft filetype, data []byte, v interface{}) error {
switch ft {
case typeJSON:
return unmarshalJSON(data, v)
case typeTOML:
return unmarshalTOML(data, v)
case typeHCL:
return unmarshalHCL(filepath, data, v)
default:
return ErrUnsupportedFileType
}
}
// marshal marshals the given struct to bytes.
func marshal(ft filetype, v any) ([]byte, error) {
func marshal(ft filetype, v interface{}) ([]byte, error) {
switch ft {
case typeJSON:
return marshalJSON(v)
case typeTOML:
return marshalTOML(v)
case typeHCL:
return marshalHCL(v)
default:
return nil, ErrUnsupportedFileType
}
}
// unmarshalJSON unmarshals the given data to the given struct.
func unmarshalJSON(data []byte, v any) error {
func unmarshalJSON(data []byte, v interface{}) error {
err := json.Unmarshal(data, v)
if err != nil {
return fmt.Errorf("cannot parse config file: %w", err)
@ -76,8 +58,7 @@ func unmarshalJSON(data []byte, v any) error {
return nil
}
// marshalJSON marshals the given struct to bytes.
func marshalJSON(v any) ([]byte, error) {
func marshalJSON(v interface{}) ([]byte, error) {
data, err := json.MarshalIndent(v, "", " ")
if err != nil {
return nil, fmt.Errorf("cannot generate config content: %w", err)
@ -86,8 +67,7 @@ func marshalJSON(v any) ([]byte, error) {
return data, nil
}
// unmarshalTOML unmarshals the given data to the given struct.
func unmarshalTOML(data []byte, v any) error {
func unmarshalTOML(data []byte, v interface{}) error {
err := toml.Unmarshal(data, v)
if err != nil {
return fmt.Errorf("cannot parse config file: %w", err)
@ -96,8 +76,7 @@ func unmarshalTOML(data []byte, v any) error {
return nil
}
// marshalTOML marshals the given struct to bytes.
func marshalTOML(v any) ([]byte, error) {
func marshalTOML(v interface{}) ([]byte, error) {
data, err := toml.Marshal(v)
if err != nil {
return nil, fmt.Errorf("cannot generate config content: %w", err)
@ -105,43 +84,3 @@ func marshalTOML(v any) ([]byte, error) {
return data, nil
}
// unmarshalHCL unmarshals the given data to the given struct.
func unmarshalHCL(filepath string, data []byte, v any) error {
err := hclsimple.Decode(filepath, data, nil, v)
var diags hcl.Diagnostics
errors.As(err, &diags)
newDiags := hclFilterDiagnostics(diags)
if len(newDiags) > 0 {
return fmt.Errorf("cannot parse config file: %w", newDiags)
}
return nil
}
// marshalHCL marshals the given struct to bytes.
func marshalHCL(v any) (b []byte, err error) { //nolint:nonamedreturns // need named return to convert a panic to error
f := hclwrite.NewEmptyFile()
gohcl.EncodeIntoBody(v, f.Body())
return f.Bytes(), nil
}
func hclFilterDiagnostics(diags hcl.Diagnostics) hcl.Diagnostics {
var newDiags hcl.Diagnostics
for _, diag := range diags {
if diag.Summary != "Unsupported argument" {
newDiags = append(newDiags, diag)
}
}
if len(newDiags) > 0 {
return newDiags
}
return nil
}

View file

@ -7,7 +7,6 @@
//
// - JSON: ".json"
// - TOML: ".toml"
// - HCL: ".hcl"
package conf
import (
@ -15,31 +14,15 @@ import (
"fmt"
"os"
"path/filepath"
"reflect"
)
var (
// ErrUnsupportedFileType is returned when the type of the config file is
// not supported.
ErrUnsupportedFileType = errors.New("unsupported config type")
// ErrInvalidMarshalData is returned when the marshaled value is not a
// struct.
ErrInvalidMarshalData = errors.New("the marshaled value must be a struct")
// ErrInvalidUnmarshalData is returned when the marshaled value is not a
// struct.
ErrInvalidUnmarshalData = errors.New(
"the unmarshaled value must be a pointer to a struct",
)
)
// ErrUnsupportedFileType is returned when the type of the config file is not
// supported.
var ErrUnsupportedFileType = errors.New("unsupported config type")
// LoadFile reads the file at path, parses its json content and fills the struct
// with the content of the file.
//
// LoadFile panics with [ErrInvalidUnmarshalData] if data is not a pointer to a
// struct, as this error should be caught during dev.
func LoadFile(path string, data any) error {
func LoadFile(path string, data interface{}) error {
return read(path, data)
}
@ -49,10 +32,7 @@ func LoadFile(path string, data any) error {
//
// It returns an error only if the content of a file is invalid, i.e. it
// cannot be unmarshaled to the struct.
//
// LoadFiles panics with [ErrInvalidUnmarshalData] if data is not a pointer to a
// struct, as this error should be caught during dev.
func LoadFiles(data any, paths ...string) error {
func LoadFiles(data interface{}, paths ...string) error {
for _, p := range paths {
err := read(p, data)
if err != nil && !errors.Is(err, os.ErrNotExist) {
@ -64,27 +44,21 @@ func LoadFiles(data any, paths ...string) error {
}
// SaveFile writes the given data serialized in JSON in the given path.
//
// SaveFile panics with [ErrInvalidMarshalData] if data is not a struct, as
// this error should be caught during dev.
func SaveFile(path string, data any) error {
func SaveFile(path string, data interface{}) error {
return write(path, data)
}
// LoadAndUpdateFile reads the config fileat path and
// updates it, meaning that it adds new options, removes
// old ones, and update it by calling the Update method of
// data if it implements the interface [Updater].
// data if it implements the interface Updater.
//
// If no file is found at path, it is created and
// initialized with the default values.
//
// An error is returned only if the config file cannot be
// written.
//
// LoadAndUpdateFile panics with [ErrInvalidUnmarshalData] if data is not a
// pointer to a struct, as this error should be caught during dev.
func LoadAndUpdateFile(path string, data any) error {
func LoadAndUpdateFile(path string, data interface{}) error {
if _, err := os.Stat(path); !os.IsNotExist(err) {
err2 := read(path, data)
if err2 != nil {
@ -101,36 +75,23 @@ func LoadAndUpdateFile(path string, data any) error {
// Updater is the interface that can be implemented by
// config structs. If it is implemented, Update() is
// called by [LoadAndUpdateFile]. It allows one to modify
// called by LoadAndUpdateFile(). It allows one to modify
// the data and persist those changes, for example to
// change default values.
type Updater interface {
// Update is called by LoadAndUpdateFile
Update()
}
func read(path string, data any) error {
val := reflect.ValueOf(data)
indVal := reflect.Indirect(val)
if val.Kind() != reflect.Ptr || indVal.Kind() != reflect.Struct {
panic(ErrInvalidUnmarshalData)
}
func read(path string, data interface{}) error {
content, err := os.ReadFile(filepath.Clean(path))
if err != nil {
return fmt.Errorf("cannot read config file: %w", err)
}
return unmarshal(path, content, data)
return unmarshal(getType(path), content, data)
}
func write(path string, data any) error {
v := reflect.Indirect(reflect.ValueOf(data))
if v.Kind() != reflect.Struct {
panic(ErrInvalidMarshalData)
}
func write(path string, data interface{}) error {
content, err := marshal(getType(path), data)
if err != nil {
return err

View file

@ -8,7 +8,7 @@ import (
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"code.bcarlin.net/go/conf"
"code.bcarlin.xyz/go/conf"
)
func TestJSONFiles(t *testing.T) {
@ -23,12 +23,6 @@ func TestTOMLFiles(t *testing.T) {
runTestSuite(t, "toml")
}
func TestHCLFiles(t *testing.T) {
t.Parallel()
runTestSuite(t, "hcl")
}
func TestUnknownFiles(t *testing.T) {
t.Parallel()
@ -81,9 +75,9 @@ func runTestSuite(t *testing.T, ext string) {
type testconf struct {
inUpdate func()
String string `hcl:"String,optional"`
Invariant string `hcl:"Invariant,optional"`
Int int `hcl:"Int,optional"`
String string
Invariant string
Int int
}
func (t testconf) Update() {
@ -117,31 +111,6 @@ func testLoadFile(t *testing.T, ext string) {
assert.Equal(t, "should not change", c.Invariant)
})
t.Run("with a valid file and invalid data", func(t *testing.T) {
t.Parallel()
file := "test_data/valid." + ext
assert.PanicsWithError(t, conf.ErrInvalidUnmarshalData.Error(), func() {
conf.LoadFile(file, func() {})
})
})
t.Run("with a valid file and data is not a pointer", func(t *testing.T) {
t.Parallel()
file := "test_data/valid." + ext
c := testconf{
String: "default string",
Int: 1,
Invariant: "should not change",
}
assert.PanicsWithError(t, conf.ErrInvalidUnmarshalData.Error(), func() {
conf.LoadFile(file, c)
})
})
t.Run("with an invalid file", func(t *testing.T) {
t.Parallel()
@ -231,31 +200,6 @@ func testLoadFiles(t *testing.T, ext string) {
assert.Equal(t, "", c.String)
})
t.Run("with a valid file and invalid data", func(t *testing.T) {
t.Parallel()
file := "test_data/valid." + ext
assert.PanicsWithError(t, conf.ErrInvalidUnmarshalData.Error(), func() {
conf.LoadFiles(func() {}, file)
})
})
t.Run("with a valid file and data is not a pointer", func(t *testing.T) {
t.Parallel()
file := "test_data/valid." + ext
c := testconf{
String: "default string",
Int: 1,
Invariant: "should not change",
}
assert.PanicsWithError(t, conf.ErrInvalidUnmarshalData.Error(), func() {
conf.LoadFiles(c, file)
})
})
})
}
@ -295,9 +239,8 @@ func testSaveFile(t *testing.T, ext string) {
tmpDir := t.TempDir()
file := filepath.Join(tmpDir, "test."+ext)
assert.PanicsWithError(t, conf.ErrInvalidMarshalData.Error(), func() {
conf.SaveFile(file, func() error { return nil })
})
err := conf.SaveFile(file, func() error { return nil })
require.Error(t, err)
assert.NoFileExists(t, file)
})
@ -488,32 +431,7 @@ func testLoadAndUpdateFile(t *testing.T, ext string) {
require.NoError(t, err)
assert.NotContains(t, string(newContent), "Unknown")
assert.True(t, updated, "the config file has not been updated")
})
t.Run("with a valid file and invalid data", func(t *testing.T) {
t.Parallel()
file := "test_data/valid." + ext
assert.PanicsWithError(t, conf.ErrInvalidUnmarshalData.Error(), func() {
conf.LoadAndUpdateFile(file, func() {})
})
})
t.Run("with a valid file and data is not a pointer", func(t *testing.T) {
t.Parallel()
file := "test_data/valid." + ext
c := testconf{
String: "default string",
Int: 1,
Invariant: "should not change",
}
assert.PanicsWithError(t, conf.ErrInvalidUnmarshalData.Error(), func() {
conf.LoadAndUpdateFile(file, c)
})
assert.True(t, updated)
})
})
}

23
go.mod
View file

@ -1,27 +1,12 @@
module code.bcarlin.net/go/conf
module code.bcarlin.xyz/go/conf
go 1.22
go 1.17
toolchain go1.23.4
require github.com/stretchr/testify v1.9.0
require (
github.com/pelletier/go-toml/v2 v2.2.3
github.com/stretchr/testify v1.10.0
)
require (
github.com/agext/levenshtein v1.2.1 // indirect
github.com/apparentlymart/go-textseg/v13 v13.0.0 // indirect
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.6.0 // indirect
github.com/hashicorp/hcl/v2 v2.23.0 // indirect
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 // indirect
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/zclconf/go-cty v1.13.0 // indirect
golang.org/x/mod v0.8.0 // indirect
golang.org/x/sys v0.5.0 // indirect
golang.org/x/text v0.11.0 // indirect
golang.org/x/tools v0.6.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

39
go.sum
View file

@ -1,34 +1,21 @@
github.com/agext/levenshtein v1.2.1 h1:QmvMAjj2aEICytGiWzmxoE0x2KZvE0fvmqMOfy2tjT8=
github.com/agext/levenshtein v1.2.1/go.mod h1:JEDfjyjHDjOF/1e4FlBE/PkbqA9OfWu2ki2W0IB5558=
github.com/apparentlymart/go-textseg/v13 v13.0.0 h1:Y+KvPE1NYz0xl601PVImeQfFyEy6iT90AvPUL1NNfNw=
github.com/apparentlymart/go-textseg/v13 v13.0.0/go.mod h1:ZK2fH7c4NqDTLtiYLvIkEghdlcqw7yxLeM89kiTRPUo=
github.com/apparentlymart/go-textseg/v15 v15.0.0 h1:uYvfpb3DyLSCGWnctWKGj857c6ew1u1fNQOlOtuGxQY=
github.com/apparentlymart/go-textseg/v15 v15.0.0/go.mod h1:K8XmNZdhEBkdlyDdvbmmsvpAG721bKi0joRfFdHIWJ4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/hashicorp/hcl/v2 v2.23.0 h1:Fphj1/gCylPxHutVSEOf2fBOh1VE4AuLV7+kbJf3qos=
github.com/hashicorp/hcl/v2 v2.23.0/go.mod h1:62ZYHrXgPoX8xBnzl8QzbWq4dyDsDtfCRgIq1rbJEvA=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7 h1:DpOJ2HYzCv8LZP15IdmG+YdwD2luVPHITV96TkirNBM=
github.com/mitchellh/go-wordwrap v0.0.0-20150314170334-ad45545899c7/go.mod h1:ZXFpozHsX6DPmq2I0TCekCxypsnAUbP2oI0UX1GXzOo=
github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/zclconf/go-cty v1.13.0 h1:It5dfKTTZHe9aeppbNOda3mN7Ag7sg6QkBNm6TkyFa0=
github.com/zclconf/go-cty v1.13.0/go.mod h1:YKQzy/7pZ7iq2jNFzy5go57xdxdWoLLpaEp4u238AE0=
golang.org/x/mod v0.8.0 h1:LUYupSeNrTNCGzR/hVBk2NHZO4hXcVaW1k4Qx7rjPx8=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/sys v0.5.0 h1:MUK/U/4lj1t1oPg0HfuXDN/Z1wv31ZJ/YcPiGccS4DU=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/text v0.11.0 h1:LAntKIrcmeSKERyiOh0XMV39LXS8IE9UL2yP7+f5ij4=
golang.org/x/text v0.11.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
golang.org/x/tools v0.6.0 h1:BOw41kyTf3PuCW1pVQf8+Cyg8pMlkYB1oo9iJ6D/lKM=
golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View file

@ -1,3 +0,0 @@
String = "default string"
Invariant = "should not change"
Int = 1

View file

@ -1 +0,0 @@
String: not hcl

View file

@ -1 +0,0 @@
String = "foo"

View file

@ -1 +0,0 @@
Int = 42

View file

@ -1 +0,0 @@
String = "foo"

View file

@ -1 +0,0 @@
String = "bar"

View file

@ -1,3 +0,0 @@
String = "config string"
Int = 42
Unknown = "foo"

View file

@ -1,2 +0,0 @@
String = "config string"
Int = 42