1
0
Fork 0

Adding upstream version 0.8.9.

Signed-off-by: Daniel Baumann <daniel@debian.org>
This commit is contained in:
Daniel Baumann 2025-05-22 10:16:14 +02:00
parent 3b2c48b5e4
commit c0c4addb85
Signed by: daniel
GPG key ID: FBB4F0E80A80222F
285 changed files with 25880 additions and 0 deletions

View file

@ -0,0 +1,49 @@
package format
import (
"errors"
"fmt"
"reflect"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var ErrNotConfigProp = errors.New("struct field cannot be used as a prop")
// GetConfigPropFromString deserializes a config property from a string representation using the ConfigProp interface.
func GetConfigPropFromString(structType reflect.Type, value string) (reflect.Value, error) {
valuePtr := reflect.New(structType)
configProp, ok := valuePtr.Interface().(types.ConfigProp)
if !ok {
return reflect.Value{}, ErrNotConfigProp
}
if err := configProp.SetFromProp(value); err != nil {
return reflect.Value{}, fmt.Errorf("failed to set config prop from string: %w", err)
}
return valuePtr, nil
}
// GetConfigPropString serializes a config property to a string representation using the ConfigProp interface.
func GetConfigPropString(propPtr reflect.Value) (string, error) {
if propPtr.Kind() != reflect.Ptr {
propVal := propPtr
propPtr = reflect.New(propVal.Type())
propPtr.Elem().Set(propVal)
}
if propPtr.CanInterface() {
if configProp, ok := propPtr.Interface().(types.ConfigProp); ok {
s, err := configProp.GetPropValue()
if err != nil {
return "", fmt.Errorf("failed to get config prop string: %w", err)
}
return s, nil
}
}
return "", ErrNotConfigProp
}

View file

@ -0,0 +1,71 @@
package format
import (
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// EnumInvalid is the constant value that an enum gets assigned when it could not be parsed.
const EnumInvalid = -1
// EnumFormatter is the helper methods for enum-like types.
type EnumFormatter struct {
names []string
firstOffset int
aliases map[string]int
}
// Names is the list of the valid Enum string values.
func (ef EnumFormatter) Names() []string {
return ef.names[ef.firstOffset:]
}
// Print takes a enum mapped int and returns it's string representation or "Invalid".
func (ef EnumFormatter) Print(e int) string {
if e >= len(ef.names) || e < 0 {
return "Invalid"
}
return ef.names[e]
}
// Parse takes an enum mapped string and returns it's int representation or EnumInvalid (-1).
func (ef EnumFormatter) Parse(mappedString string) int {
target := strings.ToLower(mappedString)
for index, name := range ef.names {
if target == strings.ToLower(name) {
return index
}
}
if index, found := ef.aliases[mappedString]; found {
return index
}
return EnumInvalid
}
// CreateEnumFormatter creates a EnumFormatter struct.
func CreateEnumFormatter(names []string, optAliases ...map[string]int) types.EnumFormatter {
aliases := map[string]int{}
if len(optAliases) > 0 {
aliases = optAliases[0]
}
firstOffset := 0
for i, name := range names {
if name != "" {
firstOffset = i
break
}
}
return &EnumFormatter{
names,
firstOffset,
aliases,
}
}

134
pkg/format/field_info.go Normal file
View file

@ -0,0 +1,134 @@
package format
import (
"reflect"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// DefaultBase represents the default numeric base (decimal) for fields.
const DefaultBase = 10
// FieldInfo is the meta data about a config field.
type FieldInfo struct {
Name string
Type reflect.Type
EnumFormatter types.EnumFormatter
Description string
DefaultValue string
Template string
Required bool
URLParts []URLPart
Title bool
Base int
Keys []string
ItemSeparator rune
}
// IsEnum returns whether a EnumFormatter has been assigned to the field and that it is of a suitable type.
func (fi *FieldInfo) IsEnum() bool {
return fi.EnumFormatter != nil && fi.Type.Kind() == reflect.Int
}
// IsURLPart returns whether the field is serialized as the specified part of an URL.
func (fi *FieldInfo) IsURLPart(part URLPart) bool {
for _, up := range fi.URLParts {
if up == part {
return true
}
}
return false
}
func getStructFieldInfo(structType reflect.Type, enums map[string]types.EnumFormatter) []FieldInfo {
numFields := structType.NumField()
fields := make([]FieldInfo, 0, numFields)
maxKeyLen := 0
for i := range numFields {
fieldDef := structType.Field(i)
if isHiddenField(fieldDef) {
// This is an embedded or private field, which should not be part of the Config output
continue
}
info := FieldInfo{
Name: fieldDef.Name,
Type: fieldDef.Type,
Required: true,
Title: false,
ItemSeparator: ',',
}
if util.IsNumeric(fieldDef.Type.Kind()) {
info.Base = getFieldBase(fieldDef)
}
if tag, ok := fieldDef.Tag.Lookup("desc"); ok {
info.Description = tag
}
if tag, ok := fieldDef.Tag.Lookup("tpl"); ok {
info.Template = tag
}
if tag, ok := fieldDef.Tag.Lookup("default"); ok {
info.Required = false
info.DefaultValue = tag
}
if _, ok := fieldDef.Tag.Lookup("optional"); ok {
info.Required = false
}
if _, ok := fieldDef.Tag.Lookup("title"); ok {
info.Title = true
}
if tag, ok := fieldDef.Tag.Lookup("url"); ok {
info.URLParts = ParseURLParts(tag)
}
if tag, ok := fieldDef.Tag.Lookup("key"); ok {
tag := strings.ToLower(tag)
info.Keys = strings.Split(tag, ",")
}
if tag, ok := fieldDef.Tag.Lookup("sep"); ok {
info.ItemSeparator = rune(tag[0])
}
if ef, isEnum := enums[fieldDef.Name]; isEnum {
info.EnumFormatter = ef
}
fields = append(fields, info)
keyLen := len(fieldDef.Name)
if keyLen > maxKeyLen {
maxKeyLen = keyLen
}
}
return fields
}
func isHiddenField(field reflect.StructField) bool {
return field.Anonymous || strings.ToUpper(field.Name[0:1]) != field.Name[0:1]
}
func getFieldBase(field reflect.StructField) int {
if tag, ok := field.Tag.Lookup("base"); ok {
if base, err := strconv.ParseUint(tag, 10, 8); err == nil {
return int(base)
}
}
// Default to base 10 if not tagged
return DefaultBase
}

34
pkg/format/format.go Normal file
View file

@ -0,0 +1,34 @@
package format
import (
"strconv"
"strings"
)
// ParseBool returns true for "1","true","yes" or false for "0","false","no" or defaultValue for any other value.
func ParseBool(value string, defaultValue bool) (bool, bool) {
switch strings.ToLower(value) {
case "true", "1", "yes", "y":
return true, true
case "false", "0", "no", "n":
return false, true
default:
return defaultValue, false
}
}
// PrintBool returns "Yes" if value is true, otherwise returns "No".
func PrintBool(value bool) string {
if value {
return "Yes"
}
return "No"
}
// IsNumber returns whether the specified string is number-like.
func IsNumber(value string) bool {
_, err := strconv.ParseFloat(value, 64)
return err == nil
}

View file

@ -0,0 +1,80 @@
package format
import "github.com/fatih/color"
// ColorizeDesc colorizes the input string as "Description".
var ColorizeDesc = color.New(color.FgHiBlack).SprintFunc()
// ColorizeTrue colorizes the input string as "True".
var ColorizeTrue = color.New(color.FgHiGreen).SprintFunc()
// ColorizeFalse colorizes the input string as "False".
var ColorizeFalse = color.New(color.FgHiRed).SprintFunc()
// ColorizeNumber colorizes the input string as "Number".
var ColorizeNumber = color.New(color.FgHiBlue).SprintFunc()
// ColorizeString colorizes the input string as "String".
var ColorizeString = color.New(color.FgHiYellow).SprintFunc()
// ColorizeEnum colorizes the input string as "Enum".
var ColorizeEnum = color.New(color.FgHiCyan).SprintFunc()
// ColorizeProp colorizes the input string as "Prop".
var ColorizeProp = color.New(color.FgHiMagenta).SprintFunc()
// ColorizeError colorizes the input string as "Error".
var ColorizeError = ColorizeFalse
// ColorizeContainer colorizes the input string as "Container".
var ColorizeContainer = ColorizeDesc
// ColorizeLink colorizes the input string as "Link".
var ColorizeLink = color.New(color.FgHiBlue).SprintFunc()
// ColorizeValue colorizes the input string according to what type appears to be.
func ColorizeValue(value string, isEnum bool) string {
if isEnum {
return ColorizeEnum(value)
}
if isTrue, isType := ParseBool(value, false); isType {
if isTrue {
return ColorizeTrue(value)
}
return ColorizeFalse(value)
}
if IsNumber(value) {
return ColorizeNumber(value)
}
return ColorizeString(value)
}
// ColorizeToken colorizes the value according to the tokenType.
func ColorizeToken(value string, tokenType NodeTokenType) string {
switch tokenType {
case NumberToken:
return ColorizeNumber(value)
case EnumToken:
return ColorizeEnum(value)
case TrueToken:
return ColorizeTrue(value)
case FalseToken:
return ColorizeFalse(value)
case PropToken:
return ColorizeProp(value)
case ErrorToken:
return ColorizeError(value)
case ContainerToken:
return ColorizeContainer(value)
case StringToken:
return ColorizeString(value)
case UnknownToken:
default:
}
return value
}

View file

@ -0,0 +1,95 @@
package format
import (
"net/url"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
// BuildQuery converts the fields of a config object to a delimited query string.
func BuildQuery(cqr types.ConfigQueryResolver) string {
return BuildQueryWithCustomFields(cqr, url.Values{}).Encode()
}
// escaping any custom fields that share the same key as a config prop using a "__" prefix.
func BuildQueryWithCustomFields(cqr types.ConfigQueryResolver, query url.Values) url.Values {
fields := cqr.QueryFields()
skipEscape := len(query) < 1
pkr, isPkr := cqr.(*PropKeyResolver)
for _, key := range fields {
if !skipEscape {
// Escape any webhook query keys using the same name as service props
escValues := query[key]
if len(escValues) > 0 {
query.Del(key)
query[EscapeKey(key)] = escValues
}
}
if isPkr && !pkr.KeyIsPrimary(key) {
continue
}
value, err := cqr.Get(key)
if err != nil || isPkr && pkr.IsDefault(key, value) {
continue
}
query.Set(key, value)
}
return query
}
// SetConfigPropsFromQuery iterates over all the config prop keys and sets the config prop to the corresponding
// query value based on the key.
// SetConfigPropsFromQuery returns a non-nil url.Values query with all config prop keys removed, even if any of
// them could not be used to set a config field, and with any escaped keys unescaped.
// The error returned is the first error that occurred, subsequent errors are just discarded.
func SetConfigPropsFromQuery(cqr types.ConfigQueryResolver, query url.Values) (url.Values, error) {
var firstError error
if query == nil {
return url.Values{}, nil
}
for _, key := range cqr.QueryFields() {
// Retrieve the service-related prop value
values := query[key]
if len(values) > 0 {
if err := cqr.Set(key, values[0]); err != nil && firstError == nil {
firstError = err
}
}
// Remove it from the query Values
query.Del(key)
// If an escaped version of the key exist, unescape it
escKey := EscapeKey(key)
escValues := query[escKey]
if len(escValues) > 0 {
query.Del(escKey)
query[key] = escValues
}
}
return query, firstError
}
// EscapeKey adds the KeyPrefix to custom URL query keys that conflict with service config prop keys.
func EscapeKey(key string) string {
return KeyPrefix + key
}
// UnescapeKey removes the KeyPrefix from custom URL query keys that conflict with service config prop keys.
func UnescapeKey(key string) string {
return strings.TrimPrefix(key, KeyPrefix)
}
// consisting of two underscore characters ("__").
const KeyPrefix = "__"

View file

@ -0,0 +1,50 @@
package format
import (
"net/url"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
var _ = ginkgo.Describe("Query Formatter", func() {
var pkr PropKeyResolver
ginkgo.BeforeEach(func() {
ts = &testStruct{}
pkr = NewPropKeyResolver(ts)
_ = pkr.SetDefaultProps(ts)
})
ginkgo.Describe("Creating a service URL query from a config", func() {
ginkgo.When("a config property has been changed from default", func() {
ginkgo.It("should be included in the query string", func() {
ts.Str = "test"
query := BuildQuery(&pkr)
// (pkr, )
gomega.Expect(query).To(gomega.Equal("str=test"))
})
})
ginkgo.When("a custom query key conflicts with a config property key", func() {
ginkgo.It("should include both values, with the custom escaped", func() {
ts.Str = "service"
customQuery := url.Values{"str": {"custom"}}
query := BuildQueryWithCustomFields(&pkr, customQuery)
gomega.Expect(query.Encode()).To(gomega.Equal("__str=custom&str=service"))
})
})
})
ginkgo.Describe("Setting prop values from query", func() {
ginkgo.When("a custom query key conflicts with a config property key", func() {
ginkgo.It(
"should set the config prop from the regular and return the custom one unescaped",
func() {
ts.Str = "service"
serviceQuery := url.Values{"__str": {"custom"}, "str": {"service"}}
query, err := SetConfigPropsFromQuery(&pkr, serviceQuery)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(ts.Str).To(gomega.Equal("service"))
gomega.Expect(query.Get("str")).To(gomega.Equal("custom"))
},
)
})
})
})

157
pkg/format/format_test.go Normal file
View file

@ -0,0 +1,157 @@
package format
import (
"errors"
"net/url"
"testing"
"github.com/fatih/color"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/services/standard"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
func TestFormat(t *testing.T) {
gomega.RegisterFailHandler(ginkgo.Fail)
ginkgo.RunSpecs(t, "Shoutrrr Format Suite")
}
var _ = ginkgo.BeforeSuite(func() {
// Disable color output for tests to have them match the string format rather than the colors
color.NoColor = true
})
var _ = ginkgo.Describe("the format package", func() {
ginkgo.Describe("Generic Format Utils", func() {
ginkgo.When("parsing a bool", func() {
testParseValidBool := func(raw string, expected bool) {
parsed, ok := ParseBool(raw, !expected)
gomega.Expect(parsed).To(gomega.Equal(expected))
gomega.Expect(ok).To(gomega.BeTrue())
}
ginkgo.It("should parse truthy values as true", func() {
testParseValidBool("true", true)
testParseValidBool("1", true)
testParseValidBool("yes", true)
})
ginkgo.It("should parse falsy values as false", func() {
testParseValidBool("false", false)
testParseValidBool("0", false)
testParseValidBool("no", false)
})
ginkgo.It("should match regardless of case", func() {
testParseValidBool("trUE", true)
})
ginkgo.It("should return the default if no value matches", func() {
parsed, ok := ParseBool("bad", true)
gomega.Expect(parsed).To(gomega.BeTrue())
gomega.Expect(ok).To(gomega.BeFalse())
parsed, ok = ParseBool("values", false)
gomega.Expect(parsed).To(gomega.BeFalse())
gomega.Expect(ok).To(gomega.BeFalse())
})
})
ginkgo.When("printing a bool", func() {
ginkgo.It("should return yes or no", func() {
gomega.Expect(PrintBool(true)).To(gomega.Equal("Yes"))
gomega.Expect(PrintBool(false)).To(gomega.Equal("No"))
})
})
ginkgo.When("checking for number-like strings", func() {
ginkgo.It("should be true for numbers", func() {
gomega.Expect(IsNumber("1.5")).To(gomega.BeTrue())
gomega.Expect(IsNumber("0")).To(gomega.BeTrue())
gomega.Expect(IsNumber("NaN")).To(gomega.BeTrue())
})
ginkgo.It("should be false for non-numbers", func() {
gomega.Expect(IsNumber("baNaNa")).To(gomega.BeFalse())
})
})
})
ginkgo.Describe("Enum Formatter", func() {
ginkgo.It("should return all enum values on listing", func() {
gomega.Expect(testEnum.Names()).To(gomega.ConsistOf("None", "Foo", "Bar"))
})
})
})
type testStruct struct {
Signed int `default:"0" key:"signed"`
Unsigned uint
Str string `default:"notempty" key:"str"`
StrSlice []string
StrArray [3]string
Sub subStruct
TestEnum int `default:"None" key:"testenum"`
SubProp subPropStruct
SubSlice []subStruct
SubPropSlice []subPropStruct
SubPropPtrSlice []*subPropStruct
StrMap map[string]string
IntMap map[string]int
Int8Map map[string]int8
Int16Map map[string]int16
Int32Map map[string]int32
Int64Map map[string]int64
UintMap map[string]uint
Uint8Map map[string]int8
Uint16Map map[string]int16
Uint32Map map[string]int32
Uint64Map map[string]int64
}
func (t *testStruct) GetURL() *url.URL {
panic("not implemented")
}
func (t *testStruct) SetURL(_ *url.URL) error {
panic("not implemented")
}
func (t *testStruct) Enums() map[string]types.EnumFormatter {
return enums
}
type subStruct struct {
Value string
}
type subPropStruct struct {
Value string
}
func (s *subPropStruct) SetFromProp(propValue string) error {
if len(propValue) < 1 || propValue[0] != '@' {
return errors.New("invalid value")
}
s.Value = propValue[1:]
return nil
}
func (s *subPropStruct) GetPropValue() (string, error) {
return "@" + s.Value, nil
}
var (
testEnum = CreateEnumFormatter([]string{"None", "Foo", "Bar"})
enums = map[string]types.EnumFormatter{
"TestEnum": testEnum,
}
)
type testStructBadDefault struct {
standard.EnumlessConfig
Value int `default:"NaN" key:"value"`
}
func (t *testStructBadDefault) GetURL() *url.URL {
panic("not implemented")
}
func (t *testStructBadDefault) SetURL(_ *url.URL) error {
panic("not implemented")
}

509
pkg/format/formatter.go Normal file
View file

@ -0,0 +1,509 @@
package format
import (
"errors"
"fmt"
"math"
"reflect"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Constants for map parsing and type sizes.
const (
KeyValuePairSize = 2 // Number of elements in a key:value pair
Int32BitSize = 32 // Bit size for 32-bit integers
Int64BitSize = 64 // Bit size for 64-bit integers
)
// Errors defined as static variables for better error handling.
var (
ErrInvalidEnumValue = errors.New("not a valid enum value")
ErrInvalidBoolValue = errors.New("accepted values are 1, true, yes or 0, false, no")
ErrUnsupportedFieldKey = errors.New("field key format is not supported")
ErrInvalidFieldValue = errors.New("invalid field value format")
ErrUnsupportedField = errors.New("field format is not supported")
ErrInvalidFieldCount = errors.New("invalid field value count")
ErrInvalidFieldKind = errors.New("invalid field kind")
ErrUnsupportedMapValue = errors.New("map value format is not supported")
ErrInvalidFieldValueData = errors.New("invalid field value")
ErrFailedToSetEnumValue = errors.New("failed to set enum value")
ErrUnexpectedUintKind = errors.New("unexpected uint kind")
ErrUnexpectedIntKind = errors.New("unexpected int kind")
ErrParseIntFailed = errors.New("failed to parse integer")
ErrParseUintFailed = errors.New("failed to parse unsigned integer")
)
// GetServiceConfig extracts the inner config from a service.
func GetServiceConfig(service types.Service) types.ServiceConfig {
serviceValue := reflect.Indirect(reflect.ValueOf(service))
configField, ok := serviceValue.Type().FieldByName("Config")
if !ok {
panic("service does not have a Config field")
}
configRef := serviceValue.FieldByIndex(configField.Index)
if configRef.IsNil() {
configType := configField.Type
if configType.Kind() == reflect.Ptr {
configType = configType.Elem()
}
newConfig := reflect.New(configType).Interface()
if config, ok := newConfig.(types.ServiceConfig); ok {
return config
}
panic("failed to create new config instance")
}
if config, ok := configRef.Interface().(types.ServiceConfig); ok {
return config
}
panic("config reference is not a ServiceConfig")
}
// ColorFormatTree generates a color-highlighted string representation of a node tree.
func ColorFormatTree(rootNode *ContainerNode, withValues bool) string {
return ConsoleTreeRenderer{WithValues: withValues}.RenderTree(rootNode, "")
}
// GetServiceConfigFormat retrieves type and field information from a service's config.
func GetServiceConfigFormat(service types.Service) *ContainerNode {
return GetConfigFormat(GetServiceConfig(service))
}
// GetConfigFormat retrieves type and field information from a ServiceConfig.
func GetConfigFormat(config types.ServiceConfig) *ContainerNode {
return getRootNode(config)
}
// SetConfigField updates a config field with a deserialized value from a string.
func SetConfigField(config reflect.Value, field FieldInfo, inputValue string) (bool, error) {
configField := config.FieldByName(field.Name)
if field.EnumFormatter != nil {
return setEnumField(configField, field, inputValue)
}
switch field.Type.Kind() {
case reflect.String:
configField.SetString(inputValue)
return true, nil
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return setIntField(configField, field, inputValue)
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return setUintField(configField, field, inputValue)
case reflect.Bool:
return setBoolField(configField, inputValue)
case reflect.Map:
return setMapField(configField, field, inputValue)
case reflect.Struct:
return setStructField(configField, field, inputValue)
case reflect.Slice, reflect.Array:
return setSliceOrArrayField(configField, field, inputValue)
case reflect.Invalid,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Pointer,
reflect.UnsafePointer:
return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind())
default:
return false, fmt.Errorf("%w: %v", ErrInvalidFieldKind, field.Type.Kind())
}
}
// setIntField handles integer field setting.
func setIntField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
number, base := util.StripNumberPrefix(inputValue)
value, err := strconv.ParseInt(number, base, field.Type.Bits())
if err != nil {
return false, fmt.Errorf("%w: %w", ErrParseIntFailed, err)
}
configField.SetInt(value)
return true, nil
}
// setUintField handles unsigned integer field setting.
func setUintField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
number, base := util.StripNumberPrefix(inputValue)
value, err := strconv.ParseUint(number, base, field.Type.Bits())
if err != nil {
return false, fmt.Errorf("%w: %w", ErrParseUintFailed, err)
}
configField.SetUint(value)
return true, nil
}
// setBoolField handles boolean field setting.
func setBoolField(configField reflect.Value, inputValue string) (bool, error) {
value, ok := ParseBool(inputValue, false)
if !ok {
return false, ErrInvalidBoolValue
}
configField.SetBool(value)
return true, nil
}
// setMapField handles map field setting.
func setMapField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
if field.Type.Key().Kind() != reflect.String {
return false, ErrUnsupportedFieldKey
}
mapValue := reflect.MakeMap(field.Type)
pairs := strings.Split(inputValue, ",")
for _, pair := range pairs {
elems := strings.Split(pair, ":")
if len(elems) != KeyValuePairSize {
return false, ErrInvalidFieldValue
}
key, valueRaw := elems[0], elems[1]
value, err := getMapValue(field.Type.Elem(), valueRaw)
if err != nil {
return false, err
}
mapValue.SetMapIndex(reflect.ValueOf(key), value)
}
configField.Set(mapValue)
return true, nil
}
// setStructField handles struct field setting.
func setStructField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
valuePtr, err := GetConfigPropFromString(field.Type, inputValue)
if err != nil {
return false, err
}
configField.Set(valuePtr.Elem())
return true, nil
}
// setSliceOrArrayField handles slice or array field setting.
func setSliceOrArrayField(
configField reflect.Value,
field FieldInfo,
inputValue string,
) (bool, error) {
elemType := field.Type.Elem()
elemKind := elemType.Kind()
if elemKind == reflect.Ptr {
elemKind = elemType.Elem().Kind()
}
if elemKind != reflect.Struct && elemKind != reflect.String {
return false, ErrUnsupportedField
}
values := strings.Split(inputValue, string(field.ItemSeparator))
if field.Type.Kind() == reflect.Array && len(values) != field.Type.Len() {
return false, fmt.Errorf("%w: needs to be %d", ErrInvalidFieldCount, field.Type.Len())
}
return setSliceOrArrayValues(configField, field, elemType, values)
}
// setSliceOrArrayValues sets the actual values for slice or array fields.
func setSliceOrArrayValues(
configField reflect.Value,
field FieldInfo,
elemType reflect.Type,
values []string,
) (bool, error) {
isPtrSlice := elemType.Kind() == reflect.Ptr
baseType := elemType
if isPtrSlice {
baseType = elemType.Elem()
}
if baseType.Kind() == reflect.Struct {
slice := reflect.MakeSlice(reflect.SliceOf(elemType), 0, len(values))
for _, v := range values {
propPtr, err := GetConfigPropFromString(baseType, v)
if err != nil {
return false, err
}
if isPtrSlice {
slice = reflect.Append(slice, propPtr)
} else {
slice = reflect.Append(slice, propPtr.Elem())
}
}
configField.Set(slice)
return true, nil
}
// Handle string slice/array
value := reflect.ValueOf(values)
if field.Type.Kind() == reflect.Array {
arr := reflect.Indirect(reflect.New(field.Type))
reflect.Copy(arr, value)
configField.Set(arr)
} else {
configField.Set(value)
}
return true, nil
}
// setEnumField handles enum field setting.
func setEnumField(configField reflect.Value, field FieldInfo, inputValue string) (bool, error) {
value := field.EnumFormatter.Parse(inputValue)
if value == EnumInvalid {
return false, fmt.Errorf(
"%w: accepted values are %v",
ErrInvalidEnumValue,
field.EnumFormatter.Names(),
)
}
configField.SetInt(int64(value))
if actual := int(configField.Int()); actual != value {
return false, fmt.Errorf(
"%w: expected %d, got %d (canSet: %v)",
ErrFailedToSetEnumValue,
value,
actual,
configField.CanSet(),
)
}
return true, nil
}
// getMapValue converts a raw string to a map value based on type.
func getMapValue(valueType reflect.Type, valueRaw string) (reflect.Value, error) {
switch valueType.Kind() {
case reflect.String:
return reflect.ValueOf(valueRaw), nil
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
return getMapUintValue(valueRaw, valueType)
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return getMapIntValue(valueRaw, valueType)
case reflect.Invalid,
reflect.Bool,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Array,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.Slice,
reflect.Struct,
reflect.UnsafePointer:
return reflect.Value{}, ErrUnsupportedMapValue
default:
return reflect.Value{}, ErrUnsupportedMapValue
}
}
// getMapUintValue converts a string to an unsigned integer map value.
func getMapUintValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) {
number, base := util.StripNumberPrefix(valueRaw)
numValue, err := strconv.ParseUint(number, base, valueType.Bits())
if err != nil {
return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseUintFailed, err)
}
switch valueType.Kind() {
case reflect.Uint:
return reflect.ValueOf(uint(numValue)), nil
case reflect.Uint8:
if numValue > math.MaxUint8 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds uint8 range",
ErrParseUintFailed,
numValue,
)
}
return reflect.ValueOf(uint8(numValue)), nil
case reflect.Uint16:
if numValue > math.MaxUint16 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds uint16 range",
ErrParseUintFailed,
numValue,
)
}
return reflect.ValueOf(uint16(numValue)), nil
case reflect.Uint32:
if numValue > math.MaxUint32 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds uint32 range",
ErrParseUintFailed,
numValue,
)
}
return reflect.ValueOf(uint32(numValue)), nil
case reflect.Uint64:
return reflect.ValueOf(numValue), nil
case reflect.Invalid,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Array,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.Slice,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
return reflect.Value{}, ErrUnexpectedUintKind
default:
return reflect.Value{}, ErrUnexpectedUintKind
}
}
// getMapIntValue converts a string to a signed integer map value.
func getMapIntValue(valueRaw string, valueType reflect.Type) (reflect.Value, error) {
number, base := util.StripNumberPrefix(valueRaw)
numValue, err := strconv.ParseInt(number, base, valueType.Bits())
if err != nil {
return reflect.Value{}, fmt.Errorf("%w: %w", ErrParseIntFailed, err)
}
switch valueType.Kind() {
case reflect.Int:
bits := valueType.Bits()
if bits == Int32BitSize {
if numValue < math.MinInt32 || numValue > math.MaxInt32 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int%d range",
ErrParseIntFailed,
numValue,
bits,
)
}
}
return reflect.ValueOf(int(numValue)), nil
case reflect.Int8:
if numValue < math.MinInt8 || numValue > math.MaxInt8 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int8 range",
ErrParseIntFailed,
numValue,
)
}
return reflect.ValueOf(int8(numValue)), nil
case reflect.Int16:
if numValue < math.MinInt16 || numValue > math.MaxInt16 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int16 range",
ErrParseIntFailed,
numValue,
)
}
return reflect.ValueOf(int16(numValue)), nil
case reflect.Int32:
if numValue < math.MinInt32 || numValue > math.MaxInt32 {
return reflect.Value{}, fmt.Errorf(
"%w: value %d exceeds int32 range",
ErrParseIntFailed,
numValue,
)
}
return reflect.ValueOf(int32(numValue)), nil
case reflect.Int64:
return reflect.ValueOf(numValue), nil
case reflect.Invalid,
reflect.Bool,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Array,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Map,
reflect.Pointer,
reflect.Slice,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
return reflect.Value{}, ErrUnexpectedIntKind
default:
return reflect.Value{}, ErrUnexpectedIntKind
}
}
// GetConfigFieldString converts a config field value to its string representation.
func GetConfigFieldString(config reflect.Value, field FieldInfo) (string, error) {
configField := config.FieldByName(field.Name)
if field.IsEnum() {
return field.EnumFormatter.Print(int(configField.Int())), nil
}
strVal, token := getValueNodeValue(configField, &field)
if token == ErrorToken {
return "", ErrInvalidFieldValueData
}
return strVal, nil
}

View file

@ -0,0 +1,288 @@
package format
import (
"reflect"
"strings"
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
)
// logger *log.Logger.
var (
ts *testStruct
tv reflect.Value
nodeMap map[string]Node
)
var _ = ginkgo.Describe("SetConfigField", func() {
testConfig := testStruct{}
tt := reflect.TypeOf(testConfig)
ginkgo.When("updating a struct", func() {
ginkgo.BeforeEach(func() {
tsPtr := reflect.New(tt)
tv = tsPtr.Elem()
ts = tsPtr.Interface().(*testStruct)
gomega.Expect(tv.CanSet()).To(gomega.BeTrue())
gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue())
rootNode := getRootNode(ts)
nodeMap = make(map[string]Node, len(rootNode.Items))
for _, item := range rootNode.Items {
field := item.Field()
nodeMap[field.Name] = item
}
gomega.Expect(int(tv.FieldByName("TestEnum").Int())).
To(gomega.Equal(0), "TestEnum initial value")
})
ginkgo.When("setting an integer value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "3")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.Signed).To(gomega.Equal(3))
})
})
ginkgo.When("the value is invalid", func() {
ginkgo.It("should return an error", func() {
ts.Signed = 2
valid, err := SetConfigField(tv, *nodeMap["Signed"].Field(), "z7")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
gomega.Expect(ts.Signed).To(gomega.Equal(2))
})
})
})
ginkgo.When("setting an unsigned integer value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "6")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(6)))
})
})
ginkgo.When("the value is invalid", func() {
ginkgo.It("should return an error", func() {
ts.Unsigned = 2
valid, err := SetConfigField(tv, *nodeMap["Unsigned"].Field(), "-3")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
gomega.Expect(ts.Unsigned).To(gomega.Equal(uint(2)))
})
})
})
ginkgo.When("setting a string slice value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["StrSlice"].Field(),
"meawannowalkalitabitalleh,meawannofeelalitabitstrongah",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.StrSlice).To(gomega.HaveLen(2))
})
})
})
ginkgo.When("setting a string array value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["StrArray"].Field(),
"meawannowalkalitabitalleh,meawannofeelalitabitstrongah,meawannothinkalitabitsmartah",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
})
})
ginkgo.When("the value has too many elements", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(
tv,
*nodeMap["StrArray"].Field(),
"one,two,three,four?",
)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
})
})
ginkgo.When("the value has too few elements", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(tv, *nodeMap["StrArray"].Field(), "oneassis,two")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeFalse())
})
})
})
ginkgo.When("setting a struct value", func() {
ginkgo.When("it doesn't implement ConfigProp", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(tv, *nodeMap["Sub"].Field(), "@awol")
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).NotTo(gomega.BeTrue())
})
})
ginkgo.When("it implements ConfigProp", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(tv, *nodeMap["SubProp"].Field(), "@awol")
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.SubProp.Value).To(gomega.Equal("awol"))
})
})
ginkgo.When("the value is invalid", func() {
ginkgo.It("should return an error", func() {
valid, err := SetConfigField(
tv,
*nodeMap["SubProp"].Field(),
"missing initial at symbol",
)
gomega.Expect(err).To(gomega.HaveOccurred())
gomega.Expect(valid).NotTo(gomega.BeTrue())
})
})
})
})
ginkgo.When("setting a struct slice value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["SubPropSlice"].Field(),
"@alice,@merton",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.SubPropSlice).To(gomega.HaveLen(2))
})
})
})
ginkgo.When("setting a struct pointer slice value", func() {
ginkgo.When("the value is valid", func() {
ginkgo.It("should set it", func() {
valid, err := SetConfigField(
tv,
*nodeMap["SubPropPtrSlice"].Field(),
"@the,@best",
)
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(valid).To(gomega.BeTrue())
gomega.Expect(ts.SubPropPtrSlice).To(gomega.HaveLen(2))
})
})
})
})
ginkgo.When("formatting stuct values", func() {
ginkgo.BeforeEach(func() {
tsPtr := reflect.New(tt)
tv = tsPtr.Elem()
ts = tsPtr.Interface().(*testStruct)
gomega.Expect(tv.CanSet()).To(gomega.BeTrue())
gomega.Expect(tv.FieldByName("TestEnum").CanSet()).To(gomega.BeTrue())
rootNode := getRootNode(ts)
nodeMap = make(map[string]Node, len(rootNode.Items))
for _, item := range rootNode.Items {
field := item.Field()
nodeMap[field.Name] = item
}
gomega.Expect(int(tv.FieldByName("TestEnum").Int())).
To(gomega.Equal(0), "TestEnum initial value")
})
ginkgo.When("setting and formatting", func() {
ginkgo.It("should format signed integers identical to input", func() {
testSetAndFormat(tv, nodeMap["Signed"], "-45", "-45")
})
ginkgo.It("should format unsigned integers identical to input", func() {
testSetAndFormat(tv, nodeMap["Unsigned"], "5", "5")
})
ginkgo.It("should format structs identical to input", func() {
testSetAndFormat(tv, nodeMap["SubProp"], "@whoa", "@whoa")
})
ginkgo.It("should format enums identical to input", func() {
testSetAndFormat(tv, nodeMap["TestEnum"], "Foo", "Foo")
})
ginkgo.It("should format string slices identical to input", func() {
testSetAndFormat(
tv,
nodeMap["StrSlice"],
"one,two,three,four",
"[ one, two, three, four ]",
)
})
ginkgo.It("should format string arrays identical to input", func() {
testSetAndFormat(tv, nodeMap["StrArray"], "one,two,three", "[ one, two, three ]")
})
ginkgo.It("should format prop struct slices identical to input", func() {
testSetAndFormat(
tv,
nodeMap["SubPropSlice"],
"@be,@the,@best",
"[ @be, @the, @best ]",
)
})
ginkgo.It("should format prop struct pointer slices identical to input", func() {
testSetAndFormat(tv, nodeMap["SubPropPtrSlice"], "@diet,@glue", "[ @diet, @glue ]")
})
ginkgo.It("should format string maps identical to input", func() {
testSetAndFormat(tv, nodeMap["StrMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int maps identical to input", func() {
testSetAndFormat(tv, nodeMap["IntMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int8 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int16 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int32 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format int64 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Int64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint maps identical to input", func() {
testSetAndFormat(tv, nodeMap["UintMap"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint8 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint8Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint16 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint16Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint32 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint32Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
ginkgo.It("should format uint64 maps identical to input", func() {
testSetAndFormat(tv, nodeMap["Uint64Map"], "a:1,b:2,c:3", "{ a: 1, b: 2, c: 3 }")
})
})
})
})
func testSetAndFormat(tv reflect.Value, node Node, value string, prettyFormat string) {
field := node.Field()
valid, err := SetConfigField(tv, *field, value)
if !valid {
gomega.Expect(err).To(gomega.HaveOccurred(), "SetConfigField returned false but no error")
}
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "SetConfigField error: %v", err)
gomega.Expect(valid).To(gomega.BeTrue(), "SetConfigField failed")
formatted, err := GetConfigFieldString(tv, *field)
gomega.Expect(err).NotTo(gomega.HaveOccurred(), "GetConfigFieldString error: %v", err)
gomega.Expect(formatted).To(gomega.Equal(value), "Expected %q, got %q", value, formatted)
node.Update(tv.FieldByName(field.Name))
sb := strings.Builder{}
renderer := ConsoleTreeRenderer{}
renderer.writeNodeValue(&sb, node)
gomega.Expect(sb.String()).To(gomega.Equal(prettyFormat))
}

390
pkg/format/node.go Normal file
View file

@ -0,0 +1,390 @@
package format
import (
"fmt"
"reflect"
"sort"
"strconv"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// NodeTokenType is used to represent the type of value that a node has for syntax highlighting.
type NodeTokenType int
const (
// UnknownToken represents all unknown/unspecified tokens.
UnknownToken NodeTokenType = iota
// NumberToken represents all numbers.
NumberToken
// StringToken represents strings and keys.
StringToken
// EnumToken represents enum values.
EnumToken
// TrueToken represent boolean true.
TrueToken
// FalseToken represent boolean false.
FalseToken
// PropToken represent a serializable struct prop.
PropToken
// ErrorToken represent a value that was not serializable or otherwise invalid.
ErrorToken
// ContainerToken is used for Array/Slice and Map tokens.
ContainerToken
)
// Constants for number bases.
const (
BaseDecimalLen = 10
BaseHexLen = 16
)
// Node is the generic config tree item.
type Node interface {
Field() *FieldInfo
TokenType() NodeTokenType
Update(tv reflect.Value)
}
// ValueNode is a Node without any child items.
type ValueNode struct {
*FieldInfo
Value string
tokenType NodeTokenType
}
// Field returns the inner FieldInfo.
func (n *ValueNode) Field() *FieldInfo {
return n.FieldInfo
}
// TokenType returns a NodeTokenType that matches the value.
func (n *ValueNode) TokenType() NodeTokenType {
return n.tokenType
}
// Update updates the value string from the provided value.
func (n *ValueNode) Update(tv reflect.Value) {
value, token := getValueNodeValue(tv, n.FieldInfo)
n.Value = value
n.tokenType = token
}
// ContainerNode is a Node with child items.
type ContainerNode struct {
*FieldInfo
Items []Node
MaxKeyLength int
}
// Field returns the inner FieldInfo.
func (n *ContainerNode) Field() *FieldInfo {
return n.FieldInfo
}
// TokenType always returns ContainerToken for ContainerNode.
func (n *ContainerNode) TokenType() NodeTokenType {
return ContainerToken
}
// Update updates the items to match the provided value.
func (n *ContainerNode) Update(tv reflect.Value) {
switch n.Type.Kind() {
case reflect.Array, reflect.Slice:
n.updateArrayNode(tv)
case reflect.Map:
n.updateMapNode(tv)
case reflect.Invalid,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Pointer,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
// No-op for unsupported kinds
default:
// No-op for any remaining kinds
}
}
func (n *ContainerNode) updateArrayNode(arrayValue reflect.Value) {
itemCount := arrayValue.Len()
n.Items = make([]Node, 0, itemCount)
elemType := arrayValue.Type().Elem()
for i := range itemCount {
key := strconv.Itoa(i)
val := arrayValue.Index(i)
n.Items = append(n.Items, getValueNode(val, &FieldInfo{
Name: key,
Type: elemType,
}))
}
}
func getArrayNode(arrayValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode {
node := &ContainerNode{
FieldInfo: fieldInfo,
MaxKeyLength: 0,
}
node.updateArrayNode(arrayValue)
return node
}
func sortNodeItems(nodeItems []Node) {
sort.Slice(nodeItems, func(i, j int) bool {
return nodeItems[i].Field().Name < nodeItems[j].Field().Name
})
}
func (n *ContainerNode) updateMapNode(mapValue reflect.Value) {
base := n.Base
if base == 0 {
base = BaseDecimalLen
}
elemType := mapValue.Type().Elem()
mapKeys := mapValue.MapKeys()
nodeItems := make([]Node, len(mapKeys))
maxKeyLength := 0
for i, keyVal := range mapKeys {
// The keys will always be strings
key := keyVal.String()
val := mapValue.MapIndex(keyVal)
nodeItems[i] = getValueNode(val, &FieldInfo{
Name: key,
Type: elemType,
Base: base,
})
maxKeyLength = util.Max(len(key), maxKeyLength)
}
sortNodeItems(nodeItems)
n.Items = nodeItems
n.MaxKeyLength = maxKeyLength
}
func getMapNode(mapValue reflect.Value, fieldInfo *FieldInfo) *ContainerNode {
if mapValue.Kind() == reflect.Ptr {
mapValue = mapValue.Elem()
}
node := &ContainerNode{
FieldInfo: fieldInfo,
}
node.updateMapNode(mapValue)
return node
}
func getNode(fieldVal reflect.Value, fieldInfo *FieldInfo) Node {
switch fieldInfo.Type.Kind() {
case reflect.Array, reflect.Slice:
return getArrayNode(fieldVal, fieldInfo)
case reflect.Map:
return getMapNode(fieldVal, fieldInfo)
case reflect.Invalid,
reflect.Bool,
reflect.Int,
reflect.Int8,
reflect.Int16,
reflect.Int32,
reflect.Int64,
reflect.Uint,
reflect.Uint8,
reflect.Uint16,
reflect.Uint32,
reflect.Uint64,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.Pointer,
reflect.String,
reflect.Struct,
reflect.UnsafePointer:
return getValueNode(fieldVal, fieldInfo)
default:
return getValueNode(fieldVal, fieldInfo)
}
}
func getRootNode(value any) *ContainerNode {
structValue := reflect.ValueOf(value)
if structValue.Kind() == reflect.Ptr {
structValue = structValue.Elem()
}
structType := structValue.Type()
enums := map[string]types.EnumFormatter{}
if enummer, isEnummer := value.(types.Enummer); isEnummer {
enums = enummer.Enums()
}
infoFields := getStructFieldInfo(structType, enums)
nodeItems := make([]Node, 0, len(infoFields))
maxKeyLength := 0
for _, fieldInfo := range infoFields {
fieldValue := structValue.FieldByName(fieldInfo.Name)
if !fieldValue.IsValid() {
fieldValue = reflect.Zero(fieldInfo.Type)
}
nodeItems = append(nodeItems, getNode(fieldValue, &fieldInfo))
maxKeyLength = util.Max(len(fieldInfo.Name), maxKeyLength)
}
sortNodeItems(nodeItems)
return &ContainerNode{
FieldInfo: &FieldInfo{Type: structType},
Items: nodeItems,
MaxKeyLength: maxKeyLength,
}
}
func getValueNode(fieldVal reflect.Value, fieldInfo *FieldInfo) *ValueNode {
value, tokenType := getValueNodeValue(fieldVal, fieldInfo)
return &ValueNode{
FieldInfo: fieldInfo,
Value: value,
tokenType: tokenType,
}
}
func getValueNodeValue(fieldValue reflect.Value, fieldInfo *FieldInfo) (string, NodeTokenType) {
kind := fieldValue.Kind()
base := fieldInfo.Base
if base == 0 {
base = BaseDecimalLen
}
if fieldInfo.IsEnum() {
return fieldInfo.EnumFormatter.Print(int(fieldValue.Int())), EnumToken
}
switch kind {
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
val := strconv.FormatUint(fieldValue.Uint(), base)
if base == BaseHexLen {
val = "0x" + val
}
return val, NumberToken
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
return strconv.FormatInt(fieldValue.Int(), base), NumberToken
case reflect.String:
return fieldValue.String(), StringToken
case reflect.Bool:
val := fieldValue.Bool()
if val {
return PrintBool(val), TrueToken
}
return PrintBool(val), FalseToken
case reflect.Array, reflect.Slice, reflect.Map:
return getContainerValueString(fieldValue, fieldInfo), UnknownToken
case reflect.Ptr, reflect.Struct:
if val, err := GetConfigPropString(fieldValue); err == nil {
return val, PropToken
}
return "<ERR>", ErrorToken
case reflect.Invalid,
reflect.Uintptr,
reflect.Float32,
reflect.Float64,
reflect.Complex64,
reflect.Complex128,
reflect.Chan,
reflect.Func,
reflect.Interface,
reflect.UnsafePointer:
return fmt.Sprintf("<?%s>", kind.String()), UnknownToken
default:
return fmt.Sprintf("<?%s>", kind.String()), UnknownToken
}
}
func getContainerValueString(fieldValue reflect.Value, fieldInfo *FieldInfo) string {
itemSeparator := fieldInfo.ItemSeparator
sliceLength := fieldValue.Len()
var mapKeys []reflect.Value
if fieldInfo.Type.Kind() == reflect.Map {
mapKeys = fieldValue.MapKeys()
sort.Slice(mapKeys, func(a, b int) bool {
return mapKeys[a].String() < mapKeys[b].String()
})
}
stringBuilder := strings.Builder{}
var itemFieldInfo *FieldInfo
for i := range sliceLength {
var itemValue reflect.Value
if i > 0 {
stringBuilder.WriteRune(itemSeparator)
}
if mapKeys != nil {
mapKey := mapKeys[i]
stringBuilder.WriteString(mapKey.String())
stringBuilder.WriteRune(':')
itemValue = fieldValue.MapIndex(mapKey)
} else {
itemValue = fieldValue.Index(i)
}
if i == 0 {
itemFieldInfo = &FieldInfo{
Type: itemValue.Type(),
// Inherit the base from the container
Base: fieldInfo.Base,
}
if itemFieldInfo.Base == 0 {
itemFieldInfo.Base = BaseDecimalLen
}
}
strVal, _ := getValueNodeValue(itemValue, itemFieldInfo)
stringBuilder.WriteString(strVal)
}
return stringBuilder.String()
}

View file

@ -0,0 +1,176 @@
package format
import (
"errors"
"fmt"
"reflect"
"sort"
"strings"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var (
ErrInvalidConfigKey = errors.New("not a valid config key")
ErrInvalidValueForType = errors.New("invalid value for type")
)
// PropKeyResolver implements the ConfigQueryResolver interface for services that uses key tags for query props.
type PropKeyResolver struct {
confValue reflect.Value
keyFields map[string]FieldInfo
keys []string
}
// NewPropKeyResolver creates a new PropKeyResolver and initializes it using the provided config.
func NewPropKeyResolver(config types.ServiceConfig) PropKeyResolver {
configNode := GetConfigFormat(config)
items := configNode.Items
keyFields := make(map[string]FieldInfo, len(items))
keys := make([]string, 0, len(items))
for _, item := range items {
field := *item.Field()
if len(field.Keys) == 0 {
continue // Skip fields without explicit 'key' tags
}
for _, key := range field.Keys {
key = strings.ToLower(key)
if key != "" {
keys = append(keys, key)
keyFields[key] = field
}
}
}
sort.Strings(keys)
confValue := reflect.ValueOf(config)
if confValue.Kind() == reflect.Ptr {
confValue = confValue.Elem()
}
return PropKeyResolver{
keyFields: keyFields,
confValue: confValue,
keys: keys,
}
}
// QueryFields returns a list of tagged keys.
func (pkr *PropKeyResolver) QueryFields() []string {
return pkr.keys
}
// Get returns the value of a config property tagged with the corresponding key.
func (pkr *PropKeyResolver) Get(key string) (string, error) {
if field, found := pkr.keyFields[strings.ToLower(key)]; found {
return GetConfigFieldString(pkr.confValue, field)
}
return "", fmt.Errorf("%w: %v", ErrInvalidConfigKey, key)
}
// Set sets the value of its bound struct's property, tagged with the corresponding key.
func (pkr *PropKeyResolver) Set(key string, value string) error {
return pkr.set(pkr.confValue, key, value)
}
// set sets the value of a target struct tagged with the corresponding key.
func (pkr *PropKeyResolver) set(target reflect.Value, key string, value string) error {
if field, found := pkr.keyFields[strings.ToLower(key)]; found {
valid, err := SetConfigField(target, field, value)
if !valid && err == nil {
return ErrInvalidValueForType
}
return err
}
return fmt.Errorf("%w: %v (valid keys: %v)", ErrInvalidConfigKey, key, pkr.keys)
}
// UpdateConfigFromParams mutates the provided config, updating the values from its corresponding params.
// If the provided config is nil, the internal config will be updated instead.
// The error returned is the first error that occurred; subsequent errors are discarded.
func (pkr *PropKeyResolver) UpdateConfigFromParams(
config types.ServiceConfig,
params *types.Params,
) error {
var firstError error
confValue := pkr.configValueOrInternal(config)
if params != nil {
for key, val := range *params {
if err := pkr.set(confValue, key, val); err != nil && firstError == nil {
firstError = err
}
}
}
return firstError
}
// SetDefaultProps mutates the provided config, setting the tagged fields with their default values.
// If the provided config is nil, the internal config will be updated instead.
// The error returned is the first error that occurred; subsequent errors are discarded.
func (pkr *PropKeyResolver) SetDefaultProps(config types.ServiceConfig) error {
var firstError error
confValue := pkr.configValueOrInternal(config)
for key, info := range pkr.keyFields {
if err := pkr.set(confValue, key, info.DefaultValue); err != nil && firstError == nil {
firstError = err
}
}
return firstError
}
// Bind creates a new instance of the PropKeyResolver with the internal config reference
// set to the provided config. This should only be used for configs of the same type.
func (pkr *PropKeyResolver) Bind(config types.ServiceConfig) PropKeyResolver {
bound := *pkr
bound.confValue = configValue(config)
return bound
}
// GetConfigQueryResolver returns the ConfigQueryResolver interface for the config if it implements it,
// otherwise it creates and returns a PropKeyResolver that implements it.
func GetConfigQueryResolver(config types.ServiceConfig) types.ConfigQueryResolver {
var resolver types.ConfigQueryResolver
var ok bool
if resolver, ok = config.(types.ConfigQueryResolver); !ok {
pkr := NewPropKeyResolver(config)
resolver = &pkr
}
return resolver
}
// KeyIsPrimary returns whether the key is the primary (and not an alias).
func (pkr *PropKeyResolver) KeyIsPrimary(key string) bool {
return pkr.keyFields[key].Keys[0] == key
}
func (pkr *PropKeyResolver) configValueOrInternal(config types.ServiceConfig) reflect.Value {
if config != nil {
return configValue(config)
}
return pkr.confValue
}
func configValue(config types.ServiceConfig) reflect.Value {
return reflect.Indirect(reflect.ValueOf(config))
}
// IsDefault returns whether the specified key value is the default value.
func (pkr *PropKeyResolver) IsDefault(key string, value string) bool {
return pkr.keyFields[key].DefaultValue == value
}

View file

@ -0,0 +1,58 @@
package format
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/types"
)
var _ = ginkgo.Describe("Prop Key Resolver", func() {
var (
ts *testStruct
pkr PropKeyResolver
)
ginkgo.BeforeEach(func() {
ts = &testStruct{}
pkr = NewPropKeyResolver(ts)
_ = pkr.SetDefaultProps(ts)
})
ginkgo.Describe("Updating config props from params", func() {
ginkgo.When("a param matches a prop key", func() {
ginkgo.It("should be updated in the config", func() {
err := pkr.UpdateConfigFromParams(nil, &types.Params{"str": "newValue"})
gomega.Expect(err).NotTo(gomega.HaveOccurred())
gomega.Expect(ts.Str).To(gomega.Equal("newValue"))
})
})
ginkgo.When("a param does not match a prop key", func() {
ginkgo.It("should report the first error", func() {
err := pkr.UpdateConfigFromParams(nil, &types.Params{"a": "z"})
gomega.Expect(err).To(gomega.HaveOccurred())
})
ginkgo.It("should process the other keys", func() {
_ = pkr.UpdateConfigFromParams(
nil,
&types.Params{"signed": "1", "b": "c", "str": "val"},
)
gomega.Expect(ts.Signed).To(gomega.Equal(1))
gomega.Expect(ts.Str).To(gomega.Equal("val"))
})
})
})
ginkgo.Describe("Setting default props", func() {
ginkgo.When("a default tag are set for a field", func() {
ginkgo.It("should have that value as default", func() {
gomega.Expect(ts.Str).To(gomega.Equal("notempty"))
})
})
ginkgo.When("a default tag have an invalid value", func() {
ginkgo.It("should have that value as default", func() {
tsb := &testStructBadDefault{}
pkr = NewPropKeyResolver(tsb)
err := pkr.SetDefaultProps(tsb)
gomega.Expect(err).To(gomega.HaveOccurred())
})
})
})
})

View file

@ -0,0 +1,201 @@
package format
import (
"fmt"
"strings"
"github.com/fatih/color"
"github.com/nicholas-fedor/shoutrrr/pkg/util"
)
// Constants for console rendering.
const (
DescriptionColumnWidth = 60 // Width of the description column in console output
ItemSeparatorLength = 2 // Length of the ", " separator between container items
DefaultValueOffset = 16 // Minimum offset before description when no values are shown
ValueOffset = 30 // Offset before description when values are shown
ContainerBracketLength = 4 // Length of container delimiters (e.g., "{ }" or "[ ]")
KeySeparatorLength = 2 // Length of the ": " separator after a key in containers
)
// ConsoleTreeRenderer renders a ContainerNode tree into an ansi-colored console string.
type ConsoleTreeRenderer struct {
WithValues bool
}
// RenderTree renders a ContainerNode tree into an ansi-colored console string.
func (r ConsoleTreeRenderer) RenderTree(root *ContainerNode, _ string) string {
stringBuilder := strings.Builder{}
for _, node := range root.Items {
fieldKey := node.Field().Name
stringBuilder.WriteString(fieldKey)
for i := len(fieldKey); i <= root.MaxKeyLength; i++ {
stringBuilder.WriteRune(' ')
}
var valueLen int // Initialize without assignment; set later
preLen := DefaultValueOffset // Default spacing before the description when no values are rendered
field := node.Field()
if r.WithValues {
preLen = ValueOffset // Adjusts the spacing when values are included
valueLen = r.writeNodeValue(&stringBuilder, node)
} else {
// Since no values were supplied, substitute the value with the type
typeName := field.Type.String()
// If the value is an enum type, providing the name is a bit pointless
// Instead, use a common string "option" to signify the type
if field.EnumFormatter != nil {
typeName = "option"
}
valueLen = len(typeName)
stringBuilder.WriteString(color.CyanString(typeName))
}
stringBuilder.WriteString(strings.Repeat(" ", util.Max(preLen-valueLen, 1)))
stringBuilder.WriteString(ColorizeDesc(field.Description))
stringBuilder.WriteString(
strings.Repeat(" ", util.Max(DescriptionColumnWidth-len(field.Description), 1)),
)
if len(field.URLParts) > 0 && field.URLParts[0] != URLQuery {
stringBuilder.WriteString(" <URL: ")
for i, part := range field.URLParts {
if i > 0 {
stringBuilder.WriteString(", ")
}
if part > URLPath {
part = URLPath
}
stringBuilder.WriteString(ColorizeEnum(part))
}
stringBuilder.WriteString(">")
}
if len(field.Template) > 0 {
stringBuilder.WriteString(
fmt.Sprintf(" <Template: %s>", ColorizeString(field.Template)),
)
}
if len(field.DefaultValue) > 0 {
stringBuilder.WriteString(
fmt.Sprintf(
" <Default: %s>",
ColorizeValue(field.DefaultValue, field.EnumFormatter != nil),
),
)
}
if field.Required {
stringBuilder.WriteString(fmt.Sprintf(" <%s>", ColorizeFalse("Required")))
}
if len(field.Keys) > 1 {
stringBuilder.WriteString(" <Aliases: ")
for i, key := range field.Keys {
if i == 0 {
// Skip primary alias (as it's the same as the field name)
continue
}
if i > 1 {
stringBuilder.WriteString(", ")
}
stringBuilder.WriteString(ColorizeString(key))
}
stringBuilder.WriteString(">")
}
if field.EnumFormatter != nil {
stringBuilder.WriteString(ColorizeContainer(" ["))
for i, name := range field.EnumFormatter.Names() {
if i != 0 {
stringBuilder.WriteString(", ")
}
stringBuilder.WriteString(ColorizeEnum(name))
}
stringBuilder.WriteString(ColorizeContainer("]"))
}
stringBuilder.WriteRune('\n')
}
return stringBuilder.String()
}
func (r ConsoleTreeRenderer) writeNodeValue(stringBuilder *strings.Builder, node Node) int {
if contNode, isContainer := node.(*ContainerNode); isContainer {
return r.writeContainer(stringBuilder, contNode)
}
if valNode, isValue := node.(*ValueNode); isValue {
stringBuilder.WriteString(ColorizeToken(valNode.Value, valNode.tokenType))
return len(valNode.Value)
}
stringBuilder.WriteRune('?')
return 1
}
func (r ConsoleTreeRenderer) writeContainer(
stringBuilder *strings.Builder,
node *ContainerNode,
) int {
kind := node.Type.Kind()
hasKeys := !util.IsCollection(kind)
totalLen := ContainerBracketLength // Length of the opening and closing brackets ({ } or [ ])
if hasKeys {
stringBuilder.WriteString("{ ")
} else {
stringBuilder.WriteString("[ ")
}
for i, itemNode := range node.Items {
if i != 0 {
stringBuilder.WriteString(", ")
totalLen += KeySeparatorLength // Accounts for the : separator between keys and values in containers
}
if hasKeys {
itemKey := itemNode.Field().Name
stringBuilder.WriteString(itemKey)
stringBuilder.WriteString(": ")
totalLen += len(itemKey) + ItemSeparatorLength
}
valLen := r.writeNodeValue(stringBuilder, itemNode)
totalLen += valLen
}
if hasKeys {
stringBuilder.WriteString(" }")
} else {
stringBuilder.WriteString(" ]")
}
return totalLen
}

View file

@ -0,0 +1,54 @@
package format
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
)
var _ = ginkgo.Describe("RenderConsole", func() {
format.CharactersAroundMismatchToInclude = 30
renderer := ConsoleTreeRenderer{WithValues: false}
ginkgo.It("should render the expected output based on config reflection/tags", func() {
actual := testRenderTree(renderer, &struct {
Name string `default:"notempty"`
Host string `url:"host"`
}{})
expected := `
Host string <URL: Host> <Required>
Name string <Default: notempty>
`[1:]
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It(`should render enum types as "option"`, func() {
actual := testRenderTree(renderer, &testEnummer{})
expected := `
Choice option <Default: Maybe> [Yes, No, Maybe]
`[1:]
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render url paths in sorted order", func() {
actual := testRenderTree(renderer, &struct {
Host string `url:"host"`
Path1 string `url:"path1"`
Path3 string `url:"path3"`
Path2 string `url:"path2"`
}{})
expected := `
Host string <URL: Host> <Required>
Path1 string <URL: Path> <Required>
Path2 string <URL: Path> <Required>
Path3 string <URL: Path> <Required>
`[1:]
gomega.Expect(actual).To(gomega.Equal(expected))
})
})

View file

@ -0,0 +1,259 @@
package format
import (
"reflect"
"sort"
"strings"
)
// MarkdownTreeRenderer renders a ContainerNode tree into a markdown documentation string.
type MarkdownTreeRenderer struct {
HeaderPrefix string
PropsDescription string
PropsEmptyMessage string
}
// Constants for dynamic path segment offsets.
const (
PathOffset1 = 1
PathOffset2 = 2
PathOffset3 = 3
)
// RenderTree renders a ContainerNode tree into a markdown documentation string.
func (r MarkdownTreeRenderer) RenderTree(root *ContainerNode, scheme string) string {
stringBuilder := strings.Builder{}
queryFields := make([]*FieldInfo, 0, len(root.Items))
urlFields := make([]*FieldInfo, 0, len(root.Items)) // Zero length, capacity for all fields
dynamicURLFields := make([]*FieldInfo, 0, len(root.Items))
for _, node := range root.Items {
field := node.Field()
for _, urlPart := range field.URLParts {
switch urlPart {
case URLQuery:
queryFields = append(queryFields, field)
case URLPath + PathOffset1,
URLPath + PathOffset2,
URLPath + PathOffset3:
dynamicURLFields = append(dynamicURLFields, field)
case URLUser, URLPassword, URLHost, URLPort, URLPath:
urlFields = append(urlFields, field)
}
}
if len(field.URLParts) < 1 {
queryFields = append(queryFields, field)
}
}
// Append dynamic fields to urlFields
urlFields = append(urlFields, dynamicURLFields...)
// Sort by primary URLPart
sort.SliceStable(urlFields, func(i, j int) bool {
urlPartA := URLQuery
if len(urlFields[i].URLParts) > 0 {
urlPartA = urlFields[i].URLParts[0]
}
urlPartB := URLQuery
if len(urlFields[j].URLParts) > 0 {
urlPartB = urlFields[j].URLParts[0]
}
return urlPartA < urlPartB
})
r.writeURLFields(&stringBuilder, urlFields, scheme)
sort.SliceStable(queryFields, func(i, j int) bool {
return queryFields[i].Required && !queryFields[j].Required
})
r.writeHeader(&stringBuilder, "Query/Param Props")
if len(queryFields) > 0 {
stringBuilder.WriteString(r.PropsDescription)
} else {
stringBuilder.WriteString(r.PropsEmptyMessage)
}
stringBuilder.WriteRune('\n')
for _, field := range queryFields {
r.writeFieldPrimary(&stringBuilder, field)
r.writeFieldExtras(&stringBuilder, field)
stringBuilder.WriteRune('\n')
}
return stringBuilder.String()
}
func (r MarkdownTreeRenderer) writeURLFields(
stringBuilder *strings.Builder,
urlFields []*FieldInfo,
scheme string,
) {
fieldsPrinted := make(map[string]bool)
r.writeHeader(stringBuilder, "URL Fields")
for _, field := range urlFields {
if field == nil || fieldsPrinted[field.Name] {
continue
}
r.writeFieldPrimary(stringBuilder, field)
stringBuilder.WriteString(" URL part: <code class=\"service-url\">")
stringBuilder.WriteString(scheme)
stringBuilder.WriteString("://")
// Check for presence of URLUser or URLPassword
hasUser := false
hasPassword := false
maxPart := URLUser // Track the highest URLPart used
for _, f := range urlFields {
if f != nil {
for _, part := range f.URLParts {
switch part {
case URLQuery, URLHost, URLPort, URLPath: // No-op for these cases
case URLUser:
hasUser = true
case URLPassword:
hasPassword = true
}
if part > maxPart {
maxPart = part
}
}
}
}
// Build URL with this field highlighted
for i := URLUser; i <= URLPath+PathOffset3; i++ {
urlPart := i
for _, fieldInfo := range urlFields {
if fieldInfo != nil && fieldInfo.IsURLPart(urlPart) {
if i > URLUser {
lastPart := i - 1
if lastPart == URLPassword && (hasUser || hasPassword) {
stringBuilder.WriteRune(
lastPart.Suffix(),
) // ':' only if credentials present
} else if lastPart != URLPassword {
stringBuilder.WriteRune(lastPart.Suffix()) // '/' or '@'
}
}
slug := strings.ToLower(fieldInfo.Name)
if slug == "host" && urlPart == URLPort {
slug = "port"
}
if fieldInfo == field {
stringBuilder.WriteString("<strong>")
stringBuilder.WriteString(slug)
stringBuilder.WriteString("</strong>")
} else {
stringBuilder.WriteString(slug)
}
break
}
}
}
// Add trailing '/' if no dynamic path segments follow
if maxPart < URLPath+PathOffset1 {
stringBuilder.WriteRune('/')
}
stringBuilder.WriteString("</code> \n")
fieldsPrinted[field.Name] = true
}
}
func (MarkdownTreeRenderer) writeFieldExtras(stringBuilder *strings.Builder, field *FieldInfo) {
if len(field.Keys) > 1 {
stringBuilder.WriteString(" Aliases: `")
for i, key := range field.Keys {
if i == 0 {
// Skip primary alias (as it's the same as the field name)
continue
}
if i > 1 {
stringBuilder.WriteString("`, `")
}
stringBuilder.WriteString(key)
}
stringBuilder.WriteString("` \n")
}
if field.EnumFormatter != nil {
stringBuilder.WriteString(" Possible values: `")
for i, name := range field.EnumFormatter.Names() {
if i != 0 {
stringBuilder.WriteString("`, `")
}
stringBuilder.WriteString(name)
}
stringBuilder.WriteString("` \n")
}
}
func (MarkdownTreeRenderer) writeFieldPrimary(stringBuilder *strings.Builder, field *FieldInfo) {
fieldKey := field.Name
stringBuilder.WriteString("* __")
stringBuilder.WriteString(fieldKey)
stringBuilder.WriteString("__")
if field.Description != "" {
stringBuilder.WriteString(" - ")
stringBuilder.WriteString(field.Description)
}
if field.Required {
stringBuilder.WriteString(" (**Required**) \n")
} else {
stringBuilder.WriteString(" \n Default: ")
if field.DefaultValue == "" {
stringBuilder.WriteString("*empty*")
} else {
if field.Type.Kind() == reflect.Bool {
defaultValue, _ := ParseBool(field.DefaultValue, false)
if defaultValue {
stringBuilder.WriteString("✔ ")
} else {
stringBuilder.WriteString("❌ ")
}
}
stringBuilder.WriteRune('`')
stringBuilder.WriteString(field.DefaultValue)
stringBuilder.WriteRune('`')
}
stringBuilder.WriteString(" \n")
}
}
func (r MarkdownTreeRenderer) writeHeader(stringBuilder *strings.Builder, text string) {
stringBuilder.WriteString(r.HeaderPrefix)
stringBuilder.WriteString(text)
stringBuilder.WriteString("\n\n")
}

View file

@ -0,0 +1,149 @@
package format
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/onsi/gomega/format"
)
var _ = ginkgo.Describe("RenderMarkdown", func() {
format.CharactersAroundMismatchToInclude = 10
ginkgo.It("should render the expected output based on config reflection/tags", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
Name string `default:"notempty"`
Host string `url:"host"`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
### Query/Param Props
* __Name__
Default: `[1:] + "`notempty`" + `
`
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render url paths in sorted order", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
Host string `url:"host"`
Path1 string `url:"path1"`
Path3 string `url:"path3"`
Path2 string `url:"path2"`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/path1/path2/path3</code>
* __Path1__ (**Required**)
URL part: <code class="service-url">mock://host/<strong>path1</strong>/path2/path3</code>
* __Path2__ (**Required**)
URL part: <code class="service-url">mock://host/path1/<strong>path2</strong>/path3</code>
* __Path3__ (**Required**)
URL part: <code class="service-url">mock://host/path1/path2/<strong>path3</strong></code>
### Query/Param Props
`[1:] // Remove initial newline
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render prop aliases", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &struct {
Name string `key:"name,handle,title,target"`
}{})
expected := `
### URL Fields
### Query/Param Props
* __Name__ (**Required**)
Aliases: `[1:] + "`handle`, `title`, `target`" + `
`
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.It("should render possible enum values", func() {
actual := testRenderTree(MarkdownTreeRenderer{HeaderPrefix: `### `}, &testEnummer{})
expected := `
### URL Fields
### Query/Param Props
* __Choice__
Default: `[1:] + "`Maybe`" + `
Possible values: ` + "`Yes`, `No`, `Maybe`" + `
`
gomega.Expect(actual).To(gomega.Equal(expected))
})
ginkgo.When("there are no query props", func() {
ginkgo.It("should prepend an empty-message instead of props description", func() {
actual := testRenderTree(MarkdownTreeRenderer{
HeaderPrefix: `### `,
PropsDescription: "Feel free to set these:",
PropsEmptyMessage: "There is nothing to set!",
}, &struct {
Host string `url:"host"`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
### Query/Param Props
There is nothing to set!
`[1:] // Remove initial newline
gomega.Expect(actual).To(gomega.Equal(expected))
})
})
ginkgo.When("there are query props", func() {
ginkgo.It("should prepend the props description", func() {
actual := testRenderTree(MarkdownTreeRenderer{
HeaderPrefix: `### `,
PropsDescription: "Feel free to set these:",
PropsEmptyMessage: "There is nothing to set!",
}, &struct {
Host string `url:"host"`
CoolMode bool `key:"coolmode" optional:""`
}{})
expected := `
### URL Fields
* __Host__ (**Required**)
URL part: <code class="service-url">mock://<strong>host</strong>/</code>
### Query/Param Props
Feel free to set these:
* __CoolMode__
Default: *empty*
`[1:] // Remove initial newline
gomega.Expect(actual).To(gomega.Equal(expected))
})
})
})

17
pkg/format/render_test.go Normal file
View file

@ -0,0 +1,17 @@
package format
import t "github.com/nicholas-fedor/shoutrrr/pkg/types"
type testEnummer struct {
Choice int `default:"Maybe" key:"choice"`
}
func (testEnummer) Enums() map[string]t.EnumFormatter {
return map[string]t.EnumFormatter{
"Choice": CreateEnumFormatter([]string{"Yes", "No", "Maybe"}),
}
}
func testRenderTree(r TreeRenderer, v any) string {
return r.RenderTree(getRootNode(v), "mock")
}

View file

@ -0,0 +1,6 @@
package format
// TreeRenderer renders a ContainerNode tree into a string.
type TreeRenderer interface {
RenderTree(root *ContainerNode, scheme string) string
}

84
pkg/format/urlpart.go Normal file
View file

@ -0,0 +1,84 @@
//go:generate stringer -type=URLPart -trimprefix URL
package format
import (
"log"
"strconv"
"strings"
)
// URLPart is an indicator as to what part of an URL a field is serialized to.
type URLPart int
// Suffix returns the separator between the URLPart and its subsequent part.
func (u URLPart) Suffix() rune {
switch u {
case URLQuery:
return '/'
case URLUser:
return ':'
case URLPassword:
return '@'
case URLHost:
return ':'
case URLPort:
return '/'
case URLPath:
return '/'
default:
return '/'
}
}
// indicator as to what part of an URL a field is serialized to.
const (
URLQuery URLPart = iota
URLUser
URLPassword
URLHost
URLPort
URLPath // Base path; additional paths are URLPath + N
)
// ParseURLPart returns the URLPart that matches the supplied string.
func ParseURLPart(inputString string) URLPart {
lowerString := strings.ToLower(inputString)
switch lowerString {
case "user":
return URLUser
case "pass", "password":
return URLPassword
case "host":
return URLHost
case "port":
return URLPort
case "path", "path1":
return URLPath
case "query", "":
return URLQuery
}
// Handle dynamic path segments (e.g., "path2", "path3", etc.).
if strings.HasPrefix(lowerString, "path") && len(lowerString) > 4 {
if num, err := strconv.Atoi(lowerString[4:]); err == nil && num >= 2 {
return URLPath + URLPart(num-1) // Offset from URLPath; "path2" -> URLPath+1
}
}
log.Printf("invalid URLPart: %s, defaulting to URLQuery", lowerString)
return URLQuery
}
// ParseURLParts returns the URLParts that matches the supplied string.
func ParseURLParts(s string) []URLPart {
rawParts := strings.Split(s, ",")
urlParts := make([]URLPart, len(rawParts))
for i, raw := range rawParts {
urlParts[i] = ParseURLPart(raw)
}
return urlParts
}

View file

@ -0,0 +1,28 @@
// Code generated by "stringer -type=URLPart -trimprefix URL"; DO NOT EDIT.
package format
import "strconv"
func _() {
// An "invalid array index" compiler error signifies that the constant values have changed.
// Re-run the stringer command to generate them again.
var x [1]struct{}
_ = x[URLQuery-0]
_ = x[URLUser-1]
_ = x[URLPassword-2]
_ = x[URLHost-3]
_ = x[URLPort-4]
_ = x[URLPath-5]
}
const _URLPart_name = "QueryUserPasswordHostPortPath"
var _URLPart_index = [...]uint8{0, 5, 9, 17, 21, 25, 29}
func (i URLPart) String() string {
if i < 0 || i >= URLPart(len(_URLPart_index)-1) {
return "URLPart(" + strconv.FormatInt(int64(i), 10) + ")"
}
return _URLPart_name[_URLPart_index[i]:_URLPart_index[i+1]]
}

View file

@ -0,0 +1,32 @@
package format_test
import (
"github.com/onsi/ginkgo/v2"
"github.com/onsi/gomega"
"github.com/nicholas-fedor/shoutrrr/pkg/format"
)
var _ = ginkgo.Describe("URLPart", func() {
ginkgo.It("should return the expected URL part for each lookup key", func() {
gomega.Expect(format.ParseURLPart("user")).To(gomega.Equal(format.URLUser))
gomega.Expect(format.ParseURLPart("pass")).To(gomega.Equal(format.URLPassword))
gomega.Expect(format.ParseURLPart("password")).To(gomega.Equal(format.URLPassword))
gomega.Expect(format.ParseURLPart("host")).To(gomega.Equal(format.URLHost))
gomega.Expect(format.ParseURLPart("port")).To(gomega.Equal(format.URLPort))
gomega.Expect(format.ParseURLPart("path")).To(gomega.Equal(format.URLPath))
gomega.Expect(format.ParseURLPart("path1")).To(gomega.Equal(format.URLPath))
gomega.Expect(format.ParseURLPart("path2")).To(gomega.Equal(format.URLPath + 1))
gomega.Expect(format.ParseURLPart("path3")).To(gomega.Equal(format.URLPath + 2))
gomega.Expect(format.ParseURLPart("path4")).To(gomega.Equal(format.URLPath + 3))
gomega.Expect(format.ParseURLPart("query")).To(gomega.Equal(format.URLQuery))
gomega.Expect(format.ParseURLPart("")).To(gomega.Equal(format.URLQuery))
})
ginkgo.It("should return the expected suffix for each URL part", func() {
gomega.Expect(format.URLUser.Suffix()).To(gomega.Equal(':'))
gomega.Expect(format.URLPassword.Suffix()).To(gomega.Equal('@'))
gomega.Expect(format.URLHost.Suffix()).To(gomega.Equal(':'))
gomega.Expect(format.URLPort.Suffix()).To(gomega.Equal('/'))
gomega.Expect(format.URLPath.Suffix()).To(gomega.Equal('/'))
})
})