From e60f31d669f4cfb47acabfe3525560f92fc1f423 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Wed, 13 May 2026 09:50:12 +0200 Subject: [PATCH 1/5] refactor: extract dispatch helper from dutctl start() Split the RPC selection logic out of start() into a new dispatch() method that returns an error. Behavior is unchanged: start() still calls app.exit() with the result. The split makes the dispatch decision tree testable without driving os.Exit. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 3d397d37..4e065d0b 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -157,20 +157,22 @@ func (app *application) start() { } app.setupRPCClient() + app.exit(app.dispatch()) +} +// dispatch decides which RPC to call based on app.args. +// It is split out from start so it can be unit tested without os.Exit. +func (app *application) dispatch() error { if app.args[0] == "list" { if len(app.args) > 1 { - app.exit(errInvalidCmdline) + return errInvalidCmdline } - err := app.listRPC() - app.exit(err) + return app.listRPC() } if len(app.args) == 1 { - device := app.args[0] - err := app.commandsRPC(device) - app.exit(err) + return app.commandsRPC(app.args[0]) } device := app.args[0] @@ -178,12 +180,10 @@ func (app *application) start() { cmdArgs := app.args[2:] if len(cmdArgs) > 0 && cmdArgs[0] == "help" { - err := app.detailsRPC(device, command, "help") - app.exit(err) + return app.detailsRPC(device, command, "help") } - err := app.runRPC(device, command, cmdArgs) - app.exit(err) + return app.runRPC(device, command, cmdArgs) } // exit terminates the application. If the provided error is not nil, it is printed to From 6117e055c9cf47d3c69b883fd25a903be41cf051 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Wed, 13 May 2026 09:50:12 +0200 Subject: [PATCH 2/5] test: add baseline tests for dutctl dispatch helper Hand-written fake DeviceServiceClient (Run is intentionally unimplemented since the streaming path is not exercised). Table-driven cases lock in current behavior for the list/commands/details paths before introducing the no-args and single-device auto-resolve features. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl_test.go | 184 +++++++++++++++++++++++++++++++++++++ 1 file changed, 184 insertions(+) create mode 100644 cmds/dutctl/dutctl_test.go diff --git a/cmds/dutctl/dutctl_test.go b/cmds/dutctl/dutctl_test.go new file mode 100644 index 00000000..08f6fb9d --- /dev/null +++ b/cmds/dutctl/dutctl_test.go @@ -0,0 +1,184 @@ +// Copyright 2025 Blindspot Software +// Use of this source code is governed by a BSD-style +// license that can be found in the LICENSE file. +package main + +import ( + "context" + "errors" + "io" + "testing" + + "connectrpc.com/connect" + + "github.com/BlindspotSoftware/dutctl/internal/output" + pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" + "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect" +) + +// fakeDeviceServiceClient is a hand-written test double for +// dutctlv1connect.DeviceServiceClient. Only the unary RPCs are +// implemented; Run returns nil because the streaming path is not +// exercised in these tests. +type fakeDeviceServiceClient struct { + listDevices []string + listErr error + listCalls int + + commandsCalls []string + + detailsCalls []detailsCall +} + +type detailsCall struct { + device, cmd, keyword string +} + +func (f *fakeDeviceServiceClient) List( + _ context.Context, _ *connect.Request[pb.ListRequest], +) (*connect.Response[pb.ListResponse], error) { + f.listCalls++ + + if f.listErr != nil { + return nil, f.listErr + } + + return connect.NewResponse(&pb.ListResponse{Devices: f.listDevices}), nil +} + +func (f *fakeDeviceServiceClient) Commands( + _ context.Context, req *connect.Request[pb.CommandsRequest], +) (*connect.Response[pb.CommandsResponse], error) { + f.commandsCalls = append(f.commandsCalls, req.Msg.GetDevice()) + + return connect.NewResponse(&pb.CommandsResponse{}), nil +} + +func (f *fakeDeviceServiceClient) Details( + _ context.Context, req *connect.Request[pb.DetailsRequest], +) (*connect.Response[pb.DetailsResponse], error) { + f.detailsCalls = append(f.detailsCalls, detailsCall{ + device: req.Msg.GetDevice(), + cmd: req.Msg.GetCmd(), + keyword: req.Msg.GetKeyword(), + }) + + return connect.NewResponse(&pb.DetailsResponse{}), nil +} + +func (f *fakeDeviceServiceClient) Run( + _ context.Context, +) *connect.BidiStreamForClient[pb.RunRequest, pb.RunResponse] { + return nil +} + +// Compile-time assertion that the fake satisfies the interface. +var _ dutctlv1connect.DeviceServiceClient = (*fakeDeviceServiceClient)(nil) + +// newTestApp builds an application with a fake RPC client and a +// discarding formatter. Use it to drive dispatch in unit tests. +func newTestApp(t *testing.T, fake *fakeDeviceServiceClient, args ...string) *application { + t.Helper() + + return &application{ + stdin: io.NopCloser(nil), + stdout: io.Discard, + stderr: io.Discard, + exitFunc: func(int) {}, + args: args, + rpcClient: fake, + formatter: output.New(output.Config{Stdout: io.Discard, Stderr: io.Discard}), + } +} + +func TestDispatch(t *testing.T) { + tests := []struct { + name string + args []string + listDevices []string + wantErrIs error + wantListHit int + wantCmdHits []string + wantDetailHi []detailsCall + }{ + { + name: "explicit list", + args: []string{"list"}, + wantListHit: 1, + }, + { + name: "explicit list with extra args is invalid", + args: []string{"list", "extra"}, + wantErrIs: errInvalidCmdline, + }, + { + name: "single arg lists commands for that device", + args: []string{"mydevice"}, + wantCmdHits: []string{"mydevice"}, + }, + { + name: "device command help calls details", + args: []string{"mydevice", "power", "help"}, + wantDetailHi: []detailsCall{ + {device: "mydevice", cmd: "power", keyword: "help"}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeDeviceServiceClient{listDevices: tt.listDevices} + app := newTestApp(t, fake, tt.args...) + + err := app.dispatch() + + if tt.wantErrIs != nil { + if !errors.Is(err, tt.wantErrIs) { + t.Fatalf("dispatch error: want %v, got %v", tt.wantErrIs, err) + } + } else if err != nil { + t.Fatalf("dispatch: unexpected error: %v", err) + } + + if fake.listCalls != tt.wantListHit { + t.Errorf("List calls: want %d, got %d", tt.wantListHit, fake.listCalls) + } + + if !equalStrings(fake.commandsCalls, tt.wantCmdHits) { + t.Errorf("Commands calls: want %v, got %v", tt.wantCmdHits, fake.commandsCalls) + } + + if !equalDetails(fake.detailsCalls, tt.wantDetailHi) { + t.Errorf("Details calls: want %v, got %v", tt.wantDetailHi, fake.detailsCalls) + } + }) + } +} + +func equalStrings(a, b []string) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} + +func equalDetails(a, b []detailsCall) bool { + if len(a) != len(b) { + return false + } + + for i := range a { + if a[i] != b[i] { + return false + } + } + + return true +} From 845f47b335c387e13e91b0bb6818c8ec3460d03a Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Wed, 13 May 2026 09:59:42 +0200 Subject: [PATCH 3/5] feat: default to list when dutctl is run without arguments MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Previously `dutctl -s host:port` errored with "invalid command line" and printed usage. The list RPC is the obvious default — show what devices the dutagent has — so dispatch to it when no positional args are given. Note: a no-args invocation against an unreachable dutagent now surfaces the connect.RPC error from List instead of the usage banner. That is more informative; the usage banner is still available via -h. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl.go | 10 +++++----- cmds/dutctl/dutctl_test.go | 5 +++++ 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 4e065d0b..5a8d3363 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -147,11 +147,7 @@ var errInvalidCmdline = fmt.Errorf("invalid command line") func (app *application) start() { log.SetOutput(app.stdout) - if len(app.args) == 0 { - app.exit(errInvalidCmdline) - } - - if app.args[0] == "version" { + if len(app.args) > 0 && app.args[0] == "version" { app.printVersion() app.exit(nil) } @@ -163,6 +159,10 @@ func (app *application) start() { // dispatch decides which RPC to call based on app.args. // It is split out from start so it can be unit tested without os.Exit. func (app *application) dispatch() error { + if len(app.args) == 0 { + return app.listRPC() + } + if app.args[0] == "list" { if len(app.args) > 1 { return errInvalidCmdline diff --git a/cmds/dutctl/dutctl_test.go b/cmds/dutctl/dutctl_test.go index 08f6fb9d..cce2ab69 100644 --- a/cmds/dutctl/dutctl_test.go +++ b/cmds/dutctl/dutctl_test.go @@ -101,6 +101,11 @@ func TestDispatch(t *testing.T) { wantCmdHits []string wantDetailHi []detailsCall }{ + { + name: "no args defaults to list", + args: nil, + wantListHit: 1, + }, { name: "explicit list", args: []string{"list"}, From 0638a8aca6922e5398c106506506b2b2f6a5f829 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Wed, 13 May 2026 10:05:01 +0200 Subject: [PATCH 4/5] feat: auto-resolve device when dutagent has exactly one Most dutagents host a single device, so requiring the user to repeat the device name on every invocation is friction with no benefit. Add a silent List RPC before dispatch: when the dutagent advertises exactly one device and the first arg is not that device, prepend the device name to the args. An explicit device argument always wins. Failure modes (List RPC fails, zero or multiple devices) leave args untouched so the subsequent RPC produces a meaningful user-facing error. Trade-off: one extra round trip per non-list, non-version invocation. Sub-ms on LAN, ~1xRTT on slow links. Acceptable for the UX gain; a future opt-out flag can be added if needed. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl.go | 2 + cmds/dutctl/dutctl_test.go | 78 +++++++++++++++++++++++++++++++++++++- cmds/dutctl/rpc.go | 31 +++++++++++++++ 3 files changed, 109 insertions(+), 2 deletions(-) diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index 5a8d3363..fd5dead0 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -171,6 +171,8 @@ func (app *application) dispatch() error { return app.listRPC() } + app.args = app.maybeResolveSingleDevice(app.args) + if len(app.args) == 1 { return app.commandsRPC(app.args[0]) } diff --git a/cmds/dutctl/dutctl_test.go b/cmds/dutctl/dutctl_test.go index cce2ab69..30b0651d 100644 --- a/cmds/dutctl/dutctl_test.go +++ b/cmds/dutctl/dutctl_test.go @@ -119,15 +119,28 @@ func TestDispatch(t *testing.T) { { name: "single arg lists commands for that device", args: []string{"mydevice"}, + listDevices: []string{"mydevice"}, + wantListHit: 1, wantCmdHits: []string{"mydevice"}, }, { - name: "device command help calls details", - args: []string{"mydevice", "power", "help"}, + name: "device command help calls details", + args: []string{"mydevice", "power", "help"}, + listDevices: []string{"mydevice"}, + wantListHit: 1, wantDetailHi: []detailsCall{ {device: "mydevice", cmd: "power", keyword: "help"}, }, }, + { + name: "single device auto-resolves help into details", + args: []string{"power", "help"}, + listDevices: []string{"onlydev"}, + wantListHit: 1, + wantDetailHi: []detailsCall{ + {device: "onlydev", cmd: "power", keyword: "help"}, + }, + }, } for _, tt := range tests { @@ -160,6 +173,67 @@ func TestDispatch(t *testing.T) { } } +func TestMaybeResolveSingleDevice(t *testing.T) { + errBoom := errors.New("boom") + + tests := []struct { + name string + args []string + listDevices []string + listErr error + want []string + }{ + { + name: "empty args returns empty", + args: nil, + want: nil, + }, + { + name: "single device matching first arg is unchanged", + args: []string{"only"}, + listDevices: []string{"only"}, + want: []string{"only"}, + }, + { + name: "single device differing from first arg is prepended", + args: []string{"power", "on"}, + listDevices: []string{"only"}, + want: []string{"only", "power", "on"}, + }, + { + name: "multiple devices: no rewrite", + args: []string{"power", "on"}, + listDevices: []string{"a", "b"}, + want: []string{"power", "on"}, + }, + { + name: "zero devices: no rewrite", + args: []string{"power"}, + listDevices: []string{}, + want: []string{"power"}, + }, + { + name: "list RPC failure: no rewrite", + args: []string{"power", "on"}, + listErr: errBoom, + want: []string{"power", "on"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fake := &fakeDeviceServiceClient{listDevices: tt.listDevices, listErr: tt.listErr} + app := newTestApp(t, fake) + + got := app.maybeResolveSingleDevice(tt.args) + + if !equalStrings(got, tt.want) { + t.Errorf("resolve: want %v, got %v", tt.want, got) + } + }) + } +} + func equalStrings(a, b []string) bool { if len(a) != len(b) { return false diff --git a/cmds/dutctl/rpc.go b/cmds/dutctl/rpc.go index 017b4ddc..21fad08a 100644 --- a/cmds/dutctl/rpc.go +++ b/cmds/dutctl/rpc.go @@ -21,6 +21,37 @@ import ( pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1" ) +// maybeResolveSingleDevice prepends the device name to args when the +// dutagent advertises exactly one device and the first arg is not that +// device. It allows `dutctl ` to work on single-device dutagents +// without requiring the user to repeat the device name on every call. +// +// On any RPC failure or ambiguity (zero or multiple devices) it returns +// args unchanged so the caller falls through to normal dispatch and lets +// the subsequent RPC produce a meaningful error. +func (app *application) maybeResolveSingleDevice(args []string) []string { + if len(args) == 0 { + return args + } + + res, err := app.rpcClient.List(context.Background(), connect.NewRequest(&pb.ListRequest{})) + if err != nil { + return args + } + + devs := res.Msg.GetDevices() + if len(devs) != 1 { + return args + } + + only := devs[0] + if args[0] == only { + return args + } + + return append([]string{only}, args...) +} + func (app *application) listRPC() error { ctx := context.Background() req := connect.NewRequest(&pb.ListRequest{}) From a3f6893224dc3c11741da5653da008b653a92532 Mon Sep 17 00:00:00 2001 From: Fabian Wienand Date: Wed, 13 May 2026 10:05:49 +0200 Subject: [PATCH 5/5] docs: document dutctl no-args list and single-device auto-resolve Add the bare `dutctl [options]` form to the synopsis and two sentences to the description covering the new default and auto-resolve behavior. Signed-off-by: Fabian Wienand --- cmds/dutctl/dutctl.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/cmds/dutctl/dutctl.go b/cmds/dutctl/dutctl.go index fd5dead0..25101d6d 100644 --- a/cmds/dutctl/dutctl.go +++ b/cmds/dutctl/dutctl.go @@ -28,6 +28,7 @@ const usageAbstract = `dutctl - The client application of the DUT Control system ` const usageSynopsis = ` SYNOPSIS: + dutctl [options] dutctl [options] list dutctl [options] dutctl [options] [args...] @@ -42,9 +43,14 @@ The optional args are passed to the command. To list all available devices, use the list command. If only a device is provided, dutctl list all available commands for the device. -If a device, a command and the keyword help are provided, dutctl will show usage +If a device, a command and the keyword help are provided, dutctl will show usage information for the command. +When dutctl is run without any positional arguments, it defaults to the list command. + +When the dutagent advertises exactly one device, the argument may be omitted +and dutctl will resolve it automatically. An explicit device argument always wins. + ` const (