// Package conf defines utils to simplify configuration management. // // It provides functions to load and save config files. // // Several formats are supported. The encoders/decoders are selected according // to the extension of the paths passed to functions: // // - JSON: ".json" // - TOML: ".toml" // - HCL: ".hcl" package conf import ( "errors" "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", ) ) // 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 { return read(path, data) } // LoadFiles tries to load all the given paths in the given order. // // If a path does not exist, it is ignored. // // 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 { for _, p := range paths { err := read(p, data) if err != nil && !errors.Is(err, os.ErrNotExist) { return fmt.Errorf("cannot load %q: %w", p, err) } } return nil } // 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 { 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]. // // 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 { if _, err := os.Stat(path); !os.IsNotExist(err) { err2 := read(path, data) if err2 != nil { return err2 } if data, ok := data.(Updater); ok { data.Update() } } return write(path, data) } // 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 // 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) } content, err := os.ReadFile(filepath.Clean(path)) if err != nil { return fmt.Errorf("cannot read config file: %w", err) } return unmarshal(path, content, data) } func write(path string, data any) error { v := reflect.Indirect(reflect.ValueOf(data)) if v.Kind() != reflect.Struct { panic(ErrInvalidMarshalData) } content, err := marshal(getType(path), data) if err != nil { return err } err = os.WriteFile(path, content, 0o600) if err != nil { return fmt.Errorf("cannot write config file '%s': %w", path, err) } return nil }