diff --git a/cli b/cli new file mode 100755 index 0000000..a0275e6 Binary files /dev/null and b/cli differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..713c34f --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/Tylerx3udv/cli + +go 1.22.2 diff --git a/main.go b/main.go index 49f4dee..989dfd5 100644 --- a/main.go +++ b/main.go @@ -1,7 +1,372 @@ package main -import "fmt" +import ( + "fmt" + "os" + "strconv" + "strings" +) + +// --- Flag Value Interface --- + +// Value is the interface to the dynamic value stored in a flag. +type Value interface { + String() string + Set(string) error +} + +// --- Slice Types --- + +// StringSlice is a flag value backed by a slice of strings. +// Tracks whether values were set by CLI args vs env vars/defaults so that +// CLI args completely override (not append to) env/default values. +type StringSlice struct { + value []string + hasEnvValue bool // true if env vars populated the slice + cliStarted bool // true once CLI Set() is called for the first time +} + +func NewStringSlice(defaults ...string) *StringSlice { + // Copy defaults to avoid mutating the caller's backing array on append + val := make([]string, len(defaults)) + copy(val, defaults) + return &StringSlice{value: val} +} + +func (s *StringSlice) Set(val string) error { + // First CLI Set(): clear any env/default values to ensure CLI overrides + if !s.cliStarted { + s.value = nil + s.cliStarted = true + } + s.value = append(s.value, val) + return nil +} + +// SetFromEnv sets values from an environment variable (comma-separated). +// Env values replace any defaults that were set during construction. +func (s *StringSlice) SetFromEnv(val string) error { + parts := strings.Split(val, ",") + s.value = make([]string, 0, len(parts)) + for _, p := range parts { + s.value = append(s.value, strings.TrimSpace(p)) + } + s.hasEnvValue = true + return nil +} + +func (s *StringSlice) String() string { + return "[" + strings.Join(s.value, ",") + "]" +} + +// IntSlice is a flag value backed by a slice of ints. +type IntSlice struct { + value []int + hasEnvValue bool + cliStarted bool +} + +func NewIntSlice(defaults ...int) *IntSlice { + // Copy defaults to avoid mutating the caller's backing array on append + val := make([]int, len(defaults)) + copy(val, defaults) + return &IntSlice{value: val} +} + +func (i *IntSlice) Set(val string) error { + n, err := strconv.Atoi(val) + if err != nil { + return err + } + if !i.cliStarted { + i.value = nil + i.cliStarted = true + } + i.value = append(i.value, n) + return nil +} + +func (i *IntSlice) SetFromEnv(val string) error { + parts := strings.Split(val, ",") + i.value = make([]int, 0, len(parts)) + for _, p := range parts { + n, err := strconv.Atoi(strings.TrimSpace(p)) + if err != nil { + return err + } + i.value = append(i.value, n) + } + i.hasEnvValue = true + return nil +} + +func (i *IntSlice) String() string { + strs := make([]string, len(i.value)) + for idx, v := range i.value { + strs[idx] = strconv.Itoa(v) + } + return "[" + strings.Join(strs, ",") + "]" +} + +// Float64Slice is a flag value backed by a slice of float64s. +type Float64Slice struct { + value []float64 + hasEnvValue bool + cliStarted bool +} + +func NewFloat64Slice(defaults ...float64) *Float64Slice { + // Copy defaults to avoid mutating the caller's backing array on append + val := make([]float64, len(defaults)) + copy(val, defaults) + return &Float64Slice{value: val} +} + +func (f *Float64Slice) Set(val string) error { + n, err := strconv.ParseFloat(val, 64) + if err != nil { + return err + } + if !f.cliStarted { + f.value = nil + f.cliStarted = true + } + f.value = append(f.value, n) + return nil +} + +func (f *Float64Slice) SetFromEnv(val string) error { + parts := strings.Split(val, ",") + f.value = make([]float64, 0, len(parts)) + for _, p := range parts { + n, err := strconv.ParseFloat(strings.TrimSpace(p), 64) + if err != nil { + return err + } + f.value = append(f.value, n) + } + f.hasEnvValue = true + return nil +} + +func (f *Float64Slice) String() string { + strs := make([]string, len(f.value)) + for idx, v := range f.value { + strs[idx] = strconv.FormatFloat(v, 'f', -1, 64) + } + return "[" + strings.Join(strs, ",") + "]" +} + +// --- Flag Types --- + +// Flag is the interface for all flag types. +type Flag interface { + Names() []string + Apply(set *FlagSet) error +} + +// StringSliceFlag represents a --flag that accepts multiple string values. +type StringSliceFlag struct { + Name string + EnvVars []string + Default []string + value *StringSlice +} + +func (f *StringSliceFlag) Names() []string { return []string{f.Name} } + +func (f *StringSliceFlag) Apply(set *FlagSet) error { + f.value = NewStringSlice(f.Default...) + + // Apply env vars first (lowest precedence above defaults) + for _, env := range f.EnvVars { + if val, ok := os.LookupEnv(env); ok { + if err := f.value.SetFromEnv(val); err != nil { + return err + } + break + } + } + + set.flags[f.Name] = f.value + return nil +} + +func (f *StringSliceFlag) Value() []string { return f.value.value } + +// IntSliceFlag represents a --flag that accepts multiple int values. +type IntSliceFlag struct { + Name string + EnvVars []string + Default []int + value *IntSlice +} + +func (f *IntSliceFlag) Names() []string { return []string{f.Name} } + +func (f *IntSliceFlag) Apply(set *FlagSet) error { + f.value = NewIntSlice(f.Default...) + for _, env := range f.EnvVars { + if val, ok := os.LookupEnv(env); ok { + if err := f.value.SetFromEnv(val); err != nil { + return err + } + break + } + } + set.flags[f.Name] = f.value + return nil +} + +func (f *IntSliceFlag) Value() []int { return f.value.value } + +// Float64SliceFlag represents a --flag that accepts multiple float64 values. +type Float64SliceFlag struct { + Name string + EnvVars []string + Default []float64 + value *Float64Slice +} + +func (f *Float64SliceFlag) Names() []string { return []string{f.Name} } + +func (f *Float64SliceFlag) Apply(set *FlagSet) error { + f.value = NewFloat64Slice(f.Default...) + for _, env := range f.EnvVars { + if val, ok := os.LookupEnv(env); ok { + if err := f.value.SetFromEnv(val); err != nil { + return err + } + break + } + } + set.flags[f.Name] = f.value + return nil +} + +func (f *Float64SliceFlag) Value() []float64 { return f.value.value } + +// --- FlagSet --- + +// FlagSet holds parsed flags. +type FlagSet struct { + flags map[string]Value +} + +func NewFlagSet() *FlagSet { + return &FlagSet{flags: make(map[string]Value)} +} + +// StringSlice returns the []string value of a named flag. +func (fs *FlagSet) StringSlice(name string) []string { + if v, ok := fs.flags[name].(*StringSlice); ok { + return v.value + } + return nil +} + +// IntSlice returns the []int value of a named flag. +func (fs *FlagSet) IntSlice(name string) []int { + if v, ok := fs.flags[name].(*IntSlice); ok { + return v.value + } + return nil +} + +// Float64Slice returns the []float64 value of a named flag. +func (fs *FlagSet) Float64Slice(name string) []float64 { + if v, ok := fs.flags[name].(*Float64Slice); ok { + return v.value + } + return nil +} + +// --- App --- + +// App is the main CLI application. +type App struct { + Flags []Flag + Action func(*Context) error +} + +// Context provides access to parsed flags during Action. +type Context struct { + flagSet *FlagSet +} + +func (c *Context) StringSlice(name string) []string { return c.flagSet.StringSlice(name) } +func (c *Context) IntSlice(name string) []int { return c.flagSet.IntSlice(name) } +func (c *Context) Float64Slice(name string) []float64 { return c.flagSet.Float64Slice(name) } + +// Run parses args and executes the app's action. +func (a *App) Run(args []string) error { + fs := NewFlagSet() + + // Apply all flags (reads env vars + sets defaults) + for _, flag := range a.Flags { + if err := flag.Apply(fs); err != nil { + return err + } + } + + // Parse CLI args (overrides env vars) + remaining, err := parseArgs(args[1:], fs) + if err != nil { + return err + } + _ = remaining + + ctx := &Context{flagSet: fs} + return a.Action(ctx) +} + +// parseArgs parses command-line arguments and applies them to the flag set. +// When a slice flag receives its first CLI value, it clears any env/default +// values that were set during Apply, ensuring CLI args override completely. +func parseArgs(args []string, fs *FlagSet) ([]string, error) { + i := 0 + for i < len(args) { + arg := args[i] + if !strings.HasPrefix(arg, "--") { + i++ + continue + } + + name := strings.TrimPrefix(arg, "--") + var val string + hasVal := false + + if idx := strings.Index(name, "="); idx >= 0 { + val = name[idx+1:] + name = name[:idx] + hasVal = true + } + + v, ok := fs.flags[name] + if !ok { + i++ + continue + } + + if hasVal { + if err := v.Set(val); err != nil { + return nil, err + } + } else if i+1 < len(args) && !strings.HasPrefix(args[i+1], "--") { + // Consume the next argument as the value + if err := v.Set(args[i+1]); err != nil { + return nil, err + } + i++ + } else { + // Flag with no value (boolean-like), skip + } + i++ + } + + return nil, nil +} func main() { - fmt.Println("Hello, Bounty Hunter!") + fmt.Println("CLI flag parser with precedence fix.") } diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..e0264f7 --- /dev/null +++ b/main_test.go @@ -0,0 +1,283 @@ +package main + +import ( + "os" + "reflect" + "testing" +) + +// Test 1: CLI args override env vars completely (not append) +func TestStringSliceFlag_EnvVarAndCLIPrecedence(t *testing.T) { + os.Setenv("MY_APP_ALLOWED_ORIGINS", "http://env1.com,http://env2.com") + defer os.Unsetenv("MY_APP_ALLOWED_ORIGINS") + + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "origin", + EnvVars: []string{"MY_APP_ALLOWED_ORIGINS"}, + }, + }, + Action: func(ctx *Context) error { + origins := ctx.StringSlice("origin") + expected := []string{"http://cli-only.com"} + if !reflect.DeepEqual(origins, expected) { + t.Errorf("expected %v, got %v", expected, origins) + } + return nil + }, + } + + err := app.Run([]string{"app", "--origin", "http://cli-only.com"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 2: Multiple CLI args override env vars completely +func TestStringSliceFlag_MultipleCLIArgsOverrideEnvVar(t *testing.T) { + os.Setenv("TARGETS", "a,b") + defer os.Unsetenv("TARGETS") + + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "target", + EnvVars: []string{"TARGETS"}, + }, + }, + Action: func(ctx *Context) error { + targets := ctx.StringSlice("target") + expected := []string{"c", "d"} + if !reflect.DeepEqual(targets, expected) { + t.Errorf("expected %v, got %v", expected, targets) + } + return nil + }, + } + + err := app.Run([]string{"app", "--target", "c", "--target", "d"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 3: Env var only (no CLI args) — should resolve to env values +func TestStringSliceFlag_EnvVarOnly(t *testing.T) { + os.Setenv("WHITELIST", "x.com,y.com") + defer os.Unsetenv("WHITELIST") + + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "whitelist", + EnvVars: []string{"WHITELIST"}, + }, + }, + Action: func(ctx *Context) error { + val := ctx.StringSlice("whitelist") + expected := []string{"x.com", "y.com"} + if !reflect.DeepEqual(val, expected) { + t.Errorf("expected %v, got %v", expected, val) + } + return nil + }, + } + + err := app.Run([]string{"app"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 4: CLI args only (no env var) — should resolve to CLI values +func TestStringSliceFlag_CLIOnly(t *testing.T) { + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "items", + }, + }, + Action: func(ctx *Context) error { + val := ctx.StringSlice("items") + expected := []string{"one", "two"} + if !reflect.DeepEqual(val, expected) { + t.Errorf("expected %v, got %v", expected, val) + } + return nil + }, + } + + err := app.Run([]string{"app", "--items", "one", "--items", "two"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 5: Default values used when neither env nor CLI provided +func TestStringSliceFlag_DefaultOnly(t *testing.T) { + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "mode", + Default: []string{"auto"}, + }, + }, + Action: func(ctx *Context) error { + val := ctx.StringSlice("mode") + expected := []string{"auto"} + if !reflect.DeepEqual(val, expected) { + t.Errorf("expected %v, got %v", expected, val) + } + return nil + }, + } + + err := app.Run([]string{"app"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 6: Env var overrides defaults (no CLI args present) +func TestStringSliceFlag_EnvVarOverridesDefault(t *testing.T) { + os.Setenv("LOG_LEVELS", "info,warn") + defer os.Unsetenv("LOG_LEVELS") + + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "levels", + EnvVars: []string{"LOG_LEVELS"}, + Default: []string{"error"}, + }, + }, + Action: func(ctx *Context) error { + levels := ctx.StringSlice("levels") + // Env should replace defaults, not append + expected := []string{"info", "warn"} + if !reflect.DeepEqual(levels, expected) { + t.Errorf("expected %v, got %v", expected, levels) + } + return nil + }, + } + + err := app.Run([]string{"app"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 7: CLI args override env vars and defaults +func TestStringSliceFlag_FullPrecedenceChain(t *testing.T) { + os.Setenv("LOG_LEVELS", "info,warn") + defer os.Unsetenv("LOG_LEVELS") + + app := &App{ + Flags: []Flag{ + &StringSliceFlag{ + Name: "levels", + EnvVars: []string{"LOG_LEVELS"}, + Default: []string{"error"}, + }, + }, + Action: func(ctx *Context) error { + levels := ctx.StringSlice("levels") + expected := []string{"debug", "trace"} + if !reflect.DeepEqual(levels, expected) { + t.Errorf("expected %v, got %v", expected, levels) + } + return nil + }, + } + + err := app.Run([]string{"app", "--levels", "debug", "--levels", "trace"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 7: IntSliceFlag also respects CLI-over-env precedence +func TestIntSliceFlag_CLIOverridesEnvVar(t *testing.T) { + os.Setenv("PORTS", "8080,9090") + defer os.Unsetenv("PORTS") + + app := &App{ + Flags: []Flag{ + &IntSliceFlag{ + Name: "port", + EnvVars: []string{"PORTS"}, + }, + }, + Action: func(ctx *Context) error { + ports := ctx.IntSlice("port") + expected := []int{3000} + if !reflect.DeepEqual(ports, expected) { + t.Errorf("expected %v, got %v", expected, ports) + } + return nil + }, + } + + err := app.Run([]string{"app", "--port", "3000"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 8: Float64SliceFlag also respects CLI-over-env precedence +func TestFloat64SliceFlag_CLIOverridesEnvVar(t *testing.T) { + os.Setenv("RATES", "1.5,2.5") + defer os.Unsetenv("RATES") + + app := &App{ + Flags: []Flag{ + &Float64SliceFlag{ + Name: "rate", + EnvVars: []string{"RATES"}, + }, + }, + Action: func(ctx *Context) error { + rates := ctx.Float64Slice("rate") + expected := []float64{3.0} + if !reflect.DeepEqual(rates, expected) { + t.Errorf("expected %v, got %v", expected, rates) + } + return nil + }, + } + + err := app.Run([]string{"app", "--rate", "3.0"}) + if err != nil { + t.Fatal(err) + } +} + +// Test 9: Env var only for IntSliceFlag +func TestIntSliceFlag_EnvVarOnly(t *testing.T) { + os.Setenv("IDS", "1,2,3") + defer os.Unsetenv("IDS") + + app := &App{ + Flags: []Flag{ + &IntSliceFlag{ + Name: "id", + EnvVars: []string{"IDS"}, + }, + }, + Action: func(ctx *Context) error { + ids := ctx.IntSlice("id") + expected := []int{1, 2, 3} + if !reflect.DeepEqual(ids, expected) { + t.Errorf("expected %v, got %v", expected, ids) + } + return nil + }, + } + + err := app.Run([]string{"app"}) + if err != nil { + t.Fatal(err) + } +}