cli icon indicating copy to clipboard operation
cli copied to clipboard

How to set up a cli.StringMapFlag of Slices (v3)

Open aristosvo opened this issue 6 months ago • 1 comments

My question is how to set up a string map of a string slice.

Input: --cidrs "dev=10.0.0.0/8,192.168.1.1/32" --ip-list "prod=10.1.0.0/8"

Currently configured like this:

&cli.StringMapFlag{
	Name:  "cidrs",
	Value: map[string]string{},
	
	Validator: func(m map[string]string) error {
		for _, value := range m {
			cidrs := strings.Split(value, ",")
			if len(cidrs) < 1 {
				return fmt.Errorf("expected at least on cidr")
			}
			for _, cidr := range cidrs {
				_, _, err := net.ParseCIDR(cidr)
				if err != nil {
					return fmt.Errorf("%w, parsing at least one of the entered CIDR was unsuccessful, expect format per CIDR of '<IP>/<suffix>'", err)
				}
			}
		}

		return nil
	},
},

This results now in this error: Incorrect Usage: invalid value "dev=10.0.0.0/8,192.168.1.1/32" for flag -cidrs: item "192.168.1.1/32" is missing separator "="

aristosvo avatar Jun 23 '25 14:06 aristosvo

I ended up implementing it like this:

Own implementation

package commands

import (
	"encoding/json"
	"fmt"
	"sort"
	"strings"

	"github.com/urfave/cli/v3"
)

// StringMapSliceFlag is a flag that represents a map of string slices
type StringMapSliceFlag struct {
	Name        string
	Aliases     []string
	Usage       string
	EnvVars     []string
	Value       map[string][]string
	Destination *map[string][]string
	Required    bool
	Hidden      bool
	Validator   func(map[string][]string) error
	hasBeenSet  bool
}

// Names returns the flag name and aliases
func (f *StringMapSliceFlag) Names() []string {
	return append([]string{f.Name}, f.Aliases...)
}

// IsSet checks if this flag is set
func (f *StringMapSliceFlag) IsSet() bool {
	return f.hasBeenSet
}

// String returns the stringified flag value
func (f *StringMapSliceFlag) String() string {
	if len(f.Value) == 0 {
		return ""
	}

	// Create sorted keys for consistent output
	keys := make([]string, 0, len(f.Value))
	for key := range f.Value {
		keys = append(keys, key)
	}
	sort.Strings(keys)

	parts := make([]string, 0, len(f.Value))
	for _, key := range keys {
		values := f.Value[key]
		parts = append(parts, fmt.Sprintf("%s=%s", key, strings.Join(values, ",")))
	}
	return strings.Join(parts, ";")
}

// Get returns the flag's value
func (f *StringMapSliceFlag) Get() any {
	return f.Value
}

// PreParse is called before parsing
func (f *StringMapSliceFlag) PreParse() error {
	// Initialize default value if needed
	if f.Value == nil {
		f.Value = make(map[string][]string)
	}
	return nil
}

// PostParse is called after parsing
func (f *StringMapSliceFlag) PostParse() error {
	if f.Validator != nil && f.Value != nil {
		return f.Validator(f.Value)
	}
	return nil
}

// Set sets the flag value
func (f *StringMapSliceFlag) Set(name, value string) error {
	if value == "" {
		return nil
	}

	// Check if it's a JSON serialized value (with slPfx prefix)
	if strings.HasPrefix(value, "[") {
		var mapSlice map[string][]string
		if err := json.Unmarshal([]byte(value), &mapSlice); err == nil {
			f.Value = mapSlice
			f.hasBeenSet = true

			if f.Destination != nil {
				*f.Destination = f.Value
			}

			return nil
		}
	}

	// If not initialized yet
	if f.Value == nil {
		f.Value = make(map[string][]string)
	}

	// Parse the string like "key1=val1,val2;key2=val3,val4"
	for _, part := range strings.Split(value, ";") {
		if part == "" {
			continue
		}

		keyValue := strings.SplitN(part, "=", 2)
		if len(keyValue) != 2 {
			return fmt.Errorf("invalid format for key-value pair: %s (expected key=value format)", part)
		}

		key := keyValue[0]
		valueStr := keyValue[1]
		values := strings.Split(valueStr, ",")

		f.Value[key] = values
	}

	f.hasBeenSet = true

	if f.Destination != nil {
		*f.Destination = f.Value
	}

	return nil
}

// GetStringMapSlice returns the map string slice value from the context
func GetStringMapSlice(cmd *cli.Command, name string) map[string][]string {
	if v, ok := cmd.Value(name).(map[string][]string); ok {
		return v
	}

	return nil
}

// GetJSONStringMapSlice parses a JSON string into a map of string slices
func GetJSONStringMapSlice(c *cli.Command, name string) map[string][]string {
	val := c.String(name)
	if val == "" {
		return map[string][]string{}
	}

	var result map[string][]string
	if err := json.Unmarshal([]byte(val), &result); err != nil {
		return map[string][]string{}
	}

	return result
}

There are probably better ways? It does the job for me though 😄

Previously I implemented it like this (v2), which failed on me (panic: interface conversion: cli.Value is nil, not *commands.CIDRMapFlag while providing a valid config which worked perfectly with v2):

v2 Implementation

package commands

import (
	"fmt"
	"net"
	"path/filepath"
	"strings"
)

// CIDRMapFlag is used to add an advanced flag for CIDR maps.
type CIDRMapFlag struct {
	M map[string][]string
}

// Set creates the expected CIDR map structure based on the initial string structure.
func (k *CIDRMapFlag) Set(value string) error {
	if k.M == nil {
		k.M = make(map[string][]string)
	}
	expectedElements := 2
	parts := strings.SplitN(value, "=", expectedElements)

	// Validate the major formatting of flags
	if len(parts) != expectedElements {
		return fmt.Errorf("expects the format '--cidrs <env>=<CIDR 0>[,<CIDR n>]'")
	}

	// Split and validate CIDRs
	cidrs := strings.Split(parts[1], ",")
	if len(cidrs) < 1 {
		return fmt.Errorf("expected at least on cidr")
	}
	for _, cidr := range cidrs {
		_, _, err := net.ParseCIDR(cidr)
		if err != nil {
			return fmt.Errorf("%w, parsing at least one of the entered CIDR was unsuccessful, expect format per CIDR of '<IP>/<suffix>'", err)
		}
	}

	k.M[parts[0]] = cidrs
	return nil
}

// String returns the CIDR map as string.
func (k *CIDRMapFlag) String() string {
	return fmt.Sprintf("%s", k.M)
}

// Get returns the CIDR map as object.
func (k *CIDRMapFlag) Get() any {
	return k.M
}
v2 usage via GenericFlag

&cli.GenericFlag{
	Name:  "cidrs",
	Value: &CIDRMapFlag{},
}

aristosvo avatar Jun 23 '25 17:06 aristosvo