Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 24 additions & 16 deletions cmds/dutctl/dutctl.go
Original file line number Diff line number Diff line change
Expand Up @@ -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] <device>
dutctl [options] <device> <command> [args...]
Expand All @@ -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 <device> argument may be omitted
and dutctl will resolve it automatically. An explicit device argument always wins.

`

const (
Expand Down Expand Up @@ -147,43 +153,45 @@ 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)
}

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 len(app.args) == 0 {
return app.listRPC()
}

if app.args[0] == "list" {
if len(app.args) > 1 {
app.exit(errInvalidCmdline)
return errInvalidCmdline
}

err := app.listRPC()
app.exit(err)
return app.listRPC()
}

app.args = app.maybeResolveSingleDevice(app.args)

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]
command := app.args[1]
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
Expand Down
263 changes: 263 additions & 0 deletions cmds/dutctl/dutctl_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,263 @@
// 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: "no args defaults to list",
args: nil,
wantListHit: 1,
},
{
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"},
listDevices: []string{"mydevice"},
wantListHit: 1,
wantCmdHits: []string{"mydevice"},
},
{
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 {
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 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
}

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
}
31 changes: 31 additions & 0 deletions cmds/dutctl/rpc.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <command>` 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{})
Expand Down