summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorMarc Pervaz Boocha <mboocha@sudomsg.com>2025-10-04 20:24:23 +0530
committerMarc Pervaz Boocha <mboocha@sudomsg.com>2025-10-04 20:24:23 +0530
commit30aec0042088cb5b89624f5dcb026bfc5848cf9c (patch)
treeea1dacfa58d914a25df15c4f0c2d4331bafc6118
parentConveric generic to test package (diff)
downloadkit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.tar
kit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.tar.gz
kit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.tar.bz2
kit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.tar.lz
kit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.tar.xz
kit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.tar.zst
kit-30aec0042088cb5b89624f5dcb026bfc5848cf9c.zip
Added subcommand supportv0.3.0main
Diffstat (limited to '')
-rw-r--r--go.mod2
-rw-r--r--go.sum4
-rw-r--r--runner/run.go145
-rw-r--r--runner/run_test.go163
4 files changed, 285 insertions, 29 deletions
diff --git a/go.mod b/go.mod
index c97de5f..2c6e5d5 100644
--- a/go.mod
+++ b/go.mod
@@ -2,4 +2,4 @@ module go.sudomsg.com/kit
go 1.25.0
-require golang.org/x/sync v0.16.0
+require golang.org/x/sync v0.17.0
diff --git a/go.sum b/go.sum
index 878acfe..c02bc3e 100644
--- a/go.sum
+++ b/go.sum
@@ -1,2 +1,2 @@
-golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
-golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
+golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
+golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
diff --git a/runner/run.go b/runner/run.go
index 04f1907..893ee7d 100644
--- a/runner/run.go
+++ b/runner/run.go
@@ -2,7 +2,9 @@ package runner
import (
"context"
+ "errors"
"flag"
+ "fmt"
"log/slog"
"os"
"os/signal"
@@ -11,49 +13,140 @@ import (
"go.sudomsg.com/kit/logging"
)
-// LoadWithArgs initializes context, signal handling, flag parsing, and runs the given application callback.
-//
-// - args: Command-line arguments (excluding/exact os.Args).
-// - run: Callback function (your main app logic). Receives a context (with signal cancellation),
-// a FlagSet (ready for use but not yet parsed), and trailing arguments.
-//
-// Handles panics and logs via logging.RecoverAndLog.
-// Logs and exits on non-nil errors from the run callback.
-func LoadWithArgs(args []string, run func(ctx context.Context, fs *flag.FlagSet, args []string) error) {
+const (
+ ExitSuccess = 0
+ ExitFailure = 1
+ ExitPanic = 2
+ ExitUsage = 3
+)
+
+type UsageError struct {
+ Err error
+}
+
+func (e UsageError) Error() string {
+ return e.Err.Error()
+}
+
+func (e UsageError) Unwrap() error {
+ return e.Err
+}
+
+func RunWithArgs(args []string, cmd Command) {
ctx := context.Background()
- ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
+ ctx, stop := signal.NotifyContext(ctx, os.Interrupt, syscall.SIGTERM)
defer stop()
defer func() {
if err := recover(); err != nil {
logging.RecoverAndLog(ctx, "Panicked", err)
- os.Exit(1)
+ os.Exit(ExitPanic)
}
}()
- name := "app"
+ if err := runCmd(ctx, cmd, args); err != nil {
+ var ue UsageError
+ if errors.As(err, &ue) {
+ os.Exit(ExitUsage)
+ }
+ slog.Log(ctx, slog.LevelError, "Program Terminated", "error", err)
+ os.Exit(ExitFailure)
+ }
+}
+
+func Run(cmd Command) {
+ RunWithArgs(os.Args, cmd)
+}
+
+type Command struct {
+ Name string
+ Description string
+ Run func(ctx context.Context, cs *CommandSet, args []string) error
+}
+
+func runCmd(ctx context.Context, cmd Command, args []string) error {
+ name := "command"
if len(args) > 0 && args[0] != "" {
name = args[0]
args = args[1:]
}
+ if cmd.Name != "" {
+ name = cmd.Name
+ }
+
+ fs := flag.NewFlagSet(name, flag.ContinueOnError)
+
+ c := &CommandSet{
+ FlagSet: fs,
+ description: cmd.Description,
+ }
+
+ c.Usage = func() {
+ fmt.Fprintf(fs.Output(), "Usage of %s:\n", fs.Name())
+ c.PrintDefaults()
+ }
+
+ c.AddSubcommand("help", "Print out the help", func(ctx context.Context, cs *CommandSet, args []string) error {
+ c.Usage()
+ return nil
+ })
+
+ return cmd.Run(ctx, c, args)
+}
+
+type CommandSet struct {
+ *flag.FlagSet
+ cmds []Command
+ description string
+}
- fs := flag.NewFlagSet(name, flag.ExitOnError)
+var ErrInvalidSubcommand = errors.New("invalid subcommand")
- if err := run(ctx, fs, args); err != nil {
- slog.ErrorContext(ctx, "Program Terminated", "error", err)
- os.Exit(1)
+func (c *CommandSet) Run(ctx context.Context, args []string) error {
+ if err := c.Parse(args); err != nil {
+ return err
+ }
+
+ args = c.Args()
+ if len(args) == 0 {
+ fmt.Fprintf(c.Output(), "Missing Subcommand")
+ c.Usage()
+ return UsageError{Err: ErrInvalidSubcommand}
+ }
+
+ for _, sc := range c.cmds {
+ if sc.Name == args[0] {
+ return runCmd(ctx, sc, args)
+ }
+ }
+
+ fmt.Fprintf(c.Output(), "Invalid Subcommand: %s", args[0])
+ c.Usage()
+ return UsageError{Err: fmt.Errorf("invalid subcommand: %s", args[0])}
+
+}
+
+func (c *CommandSet) AddSubcommand(name string, usage string, run func(ctx context.Context, cs *CommandSet, args []string) error) {
+ c.cmds = append(c.cmds, Command{Name: name, Description: usage, Run: run})
+}
+
+func (c *CommandSet) PrintDefaults() {
+ c.FlagSet.PrintDefaults()
+ if c.description != "" {
+ fmt.Fprintf(c.Output(), "\n%s\n", c.description)
+ }
+ if len(c.cmds) > 0 {
+ fmt.Fprintf(c.Output(), "\nSubcommands:\n")
+ for _, sc := range c.cmds {
+ fmt.Fprintf(c.Output(), " %s\t%s\n", sc.Name, sc.Description)
+ }
}
}
-// LoadWithArgs initializes context, signal handling, flag parsing, and runs the given application callback.
-//
-// - args: Command-line arguments (excluding/exact os.Args).
-// - run: Callback function (your main app logic). Receives a context (with signal cancellation),
-// a FlagSet (ready for use but not yet parsed), and trailing arguments.
-//
-// Handles panics and logs via logging.RecoverAndLog.
-// Logs and exits on non-nil errors from the run callback.
-func Load(run func(ctx context.Context, fs *flag.FlagSet, args []string) error) {
- LoadWithArgs(os.Args, run)
+func (c *CommandSet) Parse(args []string) error {
+ if err := c.FlagSet.Parse(args); err != nil {
+ return UsageError{Err: err}
+ }
+ return nil
}
diff --git a/runner/run_test.go b/runner/run_test.go
new file mode 100644
index 0000000..d6c1500
--- /dev/null
+++ b/runner/run_test.go
@@ -0,0 +1,163 @@
+package runner_test
+
+import (
+ "bytes"
+ "context"
+ "errors"
+ "flag"
+ "io"
+ "reflect"
+ "strings"
+ "testing"
+
+ "go.sudomsg.com/kit/runner"
+)
+
+func TestCommandSet_Run(t *testing.T) {
+ t.Run("Dispatch", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+
+ var called bool
+ cs := &runner.CommandSet{
+ FlagSet: flag.NewFlagSet("test", flag.ContinueOnError),
+ }
+ cs.SetOutput(io.Discard)
+
+ cs.AddSubcommand("foo", "foo command", func(ctx context.Context, cs *runner.CommandSet, args []string) error {
+ called = true
+ if !reflect.DeepEqual(args, []string{"bar", "baz"}) {
+ t.Fatalf("callback got args %v, want [bar baz]", args)
+ }
+ if gotName := cs.Name(); gotName != "foo" {
+ t.Fatalf("sub FlagSet name = %q, want %q", gotName, "foo")
+ }
+
+ return nil
+ })
+
+ err := cs.Run(ctx, []string{"foo", "bar", "baz"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !called {
+ t.Fatal("expected foo subcommand to be called")
+ }
+ })
+
+ t.Run("Recursive Dispatch", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+
+ var called bool
+ cs := &runner.CommandSet{
+ FlagSet: flag.NewFlagSet("test", flag.ContinueOnError),
+ }
+ cs.SetOutput(io.Discard)
+
+ cs.AddSubcommand("foo", "foo command", func(ctx context.Context, cs *runner.CommandSet, args []string) error {
+ cs.AddSubcommand("bar", "bar command", func(ctx context.Context, cs *runner.CommandSet, args []string) error {
+ called = true
+ if !reflect.DeepEqual(args, []string{"baz"}) {
+ t.Fatalf("callback got args %v, want [bar baz]", args)
+ }
+ if gotName := cs.Name(); gotName != "bar" {
+ t.Fatalf("sub FlagSet name = %q, want %q", gotName, "foo")
+ }
+ return nil
+ })
+ return cs.Run(ctx, args)
+ })
+
+ err := cs.Run(ctx, []string{"foo", "bar", "baz"})
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !called {
+ t.Fatal("expected foo subcommand to be called")
+ }
+
+ })
+
+ t.Run("No Subcommand", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ var buf bytes.Buffer
+
+ cs := &runner.CommandSet{
+ FlagSet: flag.NewFlagSet("test", flag.ContinueOnError),
+ }
+ cs.SetOutput(&buf)
+
+ err := cs.Run(ctx, []string{})
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+ var ue runner.UsageError
+ if !errors.As(err, &ue) {
+ t.Fatalf("expected UsageError, got %T", err)
+ }
+ if !strings.Contains(buf.String(), "Missing Subcommand") {
+ t.Errorf("expected help output to mention missing subcommand, got %q", buf.String())
+ }
+ })
+
+ t.Run("Invalid Subcommand", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ var buf bytes.Buffer
+
+ cs := &runner.CommandSet{
+ FlagSet: flag.NewFlagSet("test", flag.ContinueOnError),
+ }
+ cs.SetOutput(&buf)
+
+ cs.AddSubcommand("foo", "bar", func(ctx context.Context, cs *runner.CommandSet, args []string) error {
+ t.Fatalf("foo command called")
+ return nil
+ })
+
+ err := cs.Run(ctx, []string{"nope"})
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ var ue runner.UsageError
+ if !errors.As(err, &ue) {
+ t.Fatalf("expected UsageError, got %T", err)
+ }
+ if !strings.Contains(buf.String(), "Invalid Subcommand") {
+ t.Errorf("expected help output to mention invalid subcommand, got %q", buf.String())
+ }
+ })
+
+ t.Run("Invalid Flags", func(t *testing.T) {
+ t.Parallel()
+
+ ctx := t.Context()
+ var buf bytes.Buffer
+
+ cs := &runner.CommandSet{
+ FlagSet: flag.NewFlagSet("test", flag.ContinueOnError),
+ }
+ cs.SetOutput(&buf)
+
+ cs.AddSubcommand("foo", "foo command", func(ctx context.Context, cs *runner.CommandSet, args []string) error {
+ return cs.Run(ctx, args)
+ })
+
+ err := cs.Run(ctx, []string{"foo", "-bar"})
+ if err == nil {
+ t.Fatal("expected error, got nil")
+ }
+
+ var ue runner.UsageError
+ if !errors.As(err, &ue) {
+ t.Fatalf("expected UsageError, got %T", err)
+ }
+ })
+}