Skip to content

Commit c8bc202

Browse files
committed
feat: default to list when dutctl is run without arguments
Extract the dispatch logic from start() into a small testable helper, cover it with baseline dispatch tests, and make a bare `dutctl` (no positional arguments) default to the list command. The usage text documents the new no-args form. Signed-off-by: Fabian Wienand <fabian.wienand@blindspot.software>
1 parent 18d6d9d commit c8bc202

2 files changed

Lines changed: 208 additions & 16 deletions

File tree

cmds/dutctl/dutctl.go

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const usageAbstract = `dutctl - The client application of the DUT Control system
2828
`
2929
const usageSynopsis = `
3030
SYNOPSIS:
31+
dutctl [options]
3132
dutctl [options] list
3233
dutctl [options] <device>
3334
dutctl [options] <device> <command> [args...]
@@ -42,9 +43,11 @@ The optional args are passed to the command.
4243
To list all available devices, use the list command. If only a device is provided,
4344
dutctl list all available commands for the device.
4445
45-
If a device, a command and the keyword help are provided, dutctl will show usage
46+
If a device, a command and the keyword help are provided, dutctl will show usage
4647
information for the command.
4748
49+
When dutctl is run without any positional arguments, it defaults to the list command.
50+
4851
`
4952

5053
const (
@@ -147,43 +150,43 @@ var errInvalidCmdline = fmt.Errorf("invalid command line")
147150
func (app *application) start() {
148151
log.SetOutput(app.stdout)
149152

150-
if len(app.args) == 0 {
151-
app.exit(errInvalidCmdline)
152-
}
153-
154-
if app.args[0] == "version" {
153+
if len(app.args) > 0 && app.args[0] == "version" {
155154
app.printVersion()
156155
app.exit(nil)
157156
}
158157

159158
app.setupRPCClient()
159+
app.exit(app.dispatch())
160+
}
161+
162+
// dispatch decides which RPC to call based on app.args.
163+
// It is split out from start so it can be unit tested without os.Exit.
164+
func (app *application) dispatch() error {
165+
if len(app.args) == 0 {
166+
return app.listRPC()
167+
}
160168

161169
if app.args[0] == "list" {
162170
if len(app.args) > 1 {
163-
app.exit(errInvalidCmdline)
171+
return errInvalidCmdline
164172
}
165173

166-
err := app.listRPC()
167-
app.exit(err)
174+
return app.listRPC()
168175
}
169176

170177
if len(app.args) == 1 {
171-
device := app.args[0]
172-
err := app.commandsRPC(device)
173-
app.exit(err)
178+
return app.commandsRPC(app.args[0])
174179
}
175180

176181
device := app.args[0]
177182
command := app.args[1]
178183
cmdArgs := app.args[2:]
179184

180185
if len(cmdArgs) > 0 && cmdArgs[0] == "help" {
181-
err := app.detailsRPC(device, command, "help")
182-
app.exit(err)
186+
return app.detailsRPC(device, command, "help")
183187
}
184188

185-
err := app.runRPC(device, command, cmdArgs)
186-
app.exit(err)
189+
return app.runRPC(device, command, cmdArgs)
187190
}
188191

189192
// exit terminates the application. If the provided error is not nil, it is printed to

cmds/dutctl/dutctl_test.go

Lines changed: 189 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,189 @@
1+
// Copyright 2025 Blindspot Software
2+
// Use of this source code is governed by a BSD-style
3+
// license that can be found in the LICENSE file.
4+
package main
5+
6+
import (
7+
"context"
8+
"errors"
9+
"io"
10+
"testing"
11+
12+
"connectrpc.com/connect"
13+
14+
"github.com/BlindspotSoftware/dutctl/internal/output"
15+
pb "github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1"
16+
"github.com/BlindspotSoftware/dutctl/protobuf/gen/dutctl/v1/dutctlv1connect"
17+
)
18+
19+
// fakeDeviceServiceClient is a hand-written test double for
20+
// dutctlv1connect.DeviceServiceClient. Only the unary RPCs are
21+
// implemented; Run returns nil because the streaming path is not
22+
// exercised in these tests.
23+
type fakeDeviceServiceClient struct {
24+
listDevices []string
25+
listErr error
26+
listCalls int
27+
28+
commandsCalls []string
29+
30+
detailsCalls []detailsCall
31+
}
32+
33+
type detailsCall struct {
34+
device, cmd, keyword string
35+
}
36+
37+
func (f *fakeDeviceServiceClient) List(
38+
_ context.Context, _ *connect.Request[pb.ListRequest],
39+
) (*connect.Response[pb.ListResponse], error) {
40+
f.listCalls++
41+
42+
if f.listErr != nil {
43+
return nil, f.listErr
44+
}
45+
46+
return connect.NewResponse(&pb.ListResponse{Devices: f.listDevices}), nil
47+
}
48+
49+
func (f *fakeDeviceServiceClient) Commands(
50+
_ context.Context, req *connect.Request[pb.CommandsRequest],
51+
) (*connect.Response[pb.CommandsResponse], error) {
52+
f.commandsCalls = append(f.commandsCalls, req.Msg.GetDevice())
53+
54+
return connect.NewResponse(&pb.CommandsResponse{}), nil
55+
}
56+
57+
func (f *fakeDeviceServiceClient) Details(
58+
_ context.Context, req *connect.Request[pb.DetailsRequest],
59+
) (*connect.Response[pb.DetailsResponse], error) {
60+
f.detailsCalls = append(f.detailsCalls, detailsCall{
61+
device: req.Msg.GetDevice(),
62+
cmd: req.Msg.GetCmd(),
63+
keyword: req.Msg.GetKeyword(),
64+
})
65+
66+
return connect.NewResponse(&pb.DetailsResponse{}), nil
67+
}
68+
69+
func (f *fakeDeviceServiceClient) Run(
70+
_ context.Context,
71+
) *connect.BidiStreamForClient[pb.RunRequest, pb.RunResponse] {
72+
return nil
73+
}
74+
75+
// Compile-time assertion that the fake satisfies the interface.
76+
var _ dutctlv1connect.DeviceServiceClient = (*fakeDeviceServiceClient)(nil)
77+
78+
// newTestApp builds an application with a fake RPC client and a
79+
// discarding formatter. Use it to drive dispatch in unit tests.
80+
func newTestApp(t *testing.T, fake *fakeDeviceServiceClient, args ...string) *application {
81+
t.Helper()
82+
83+
return &application{
84+
stdin: io.NopCloser(nil),
85+
stdout: io.Discard,
86+
stderr: io.Discard,
87+
exitFunc: func(int) {},
88+
args: args,
89+
rpcClient: fake,
90+
formatter: output.New(output.Config{Stdout: io.Discard, Stderr: io.Discard}),
91+
}
92+
}
93+
94+
func TestDispatch(t *testing.T) {
95+
tests := []struct {
96+
name string
97+
args []string
98+
listDevices []string
99+
wantErrIs error
100+
wantListHit int
101+
wantCmdHits []string
102+
wantDetailHi []detailsCall
103+
}{
104+
{
105+
name: "no args defaults to list",
106+
args: nil,
107+
wantListHit: 1,
108+
},
109+
{
110+
name: "explicit list",
111+
args: []string{"list"},
112+
wantListHit: 1,
113+
},
114+
{
115+
name: "explicit list with extra args is invalid",
116+
args: []string{"list", "extra"},
117+
wantErrIs: errInvalidCmdline,
118+
},
119+
{
120+
name: "single arg lists commands for that device",
121+
args: []string{"mydevice"},
122+
wantCmdHits: []string{"mydevice"},
123+
},
124+
{
125+
name: "device command help calls details",
126+
args: []string{"mydevice", "power", "help"},
127+
wantDetailHi: []detailsCall{
128+
{device: "mydevice", cmd: "power", keyword: "help"},
129+
},
130+
},
131+
}
132+
133+
for _, tt := range tests {
134+
t.Run(tt.name, func(t *testing.T) {
135+
fake := &fakeDeviceServiceClient{listDevices: tt.listDevices}
136+
app := newTestApp(t, fake, tt.args...)
137+
138+
err := app.dispatch()
139+
140+
if tt.wantErrIs != nil {
141+
if !errors.Is(err, tt.wantErrIs) {
142+
t.Fatalf("dispatch error: want %v, got %v", tt.wantErrIs, err)
143+
}
144+
} else if err != nil {
145+
t.Fatalf("dispatch: unexpected error: %v", err)
146+
}
147+
148+
if fake.listCalls != tt.wantListHit {
149+
t.Errorf("List calls: want %d, got %d", tt.wantListHit, fake.listCalls)
150+
}
151+
152+
if !equalStrings(fake.commandsCalls, tt.wantCmdHits) {
153+
t.Errorf("Commands calls: want %v, got %v", tt.wantCmdHits, fake.commandsCalls)
154+
}
155+
156+
if !equalDetails(fake.detailsCalls, tt.wantDetailHi) {
157+
t.Errorf("Details calls: want %v, got %v", tt.wantDetailHi, fake.detailsCalls)
158+
}
159+
})
160+
}
161+
}
162+
163+
func equalStrings(a, b []string) bool {
164+
if len(a) != len(b) {
165+
return false
166+
}
167+
168+
for i := range a {
169+
if a[i] != b[i] {
170+
return false
171+
}
172+
}
173+
174+
return true
175+
}
176+
177+
func equalDetails(a, b []detailsCall) bool {
178+
if len(a) != len(b) {
179+
return false
180+
}
181+
182+
for i := range a {
183+
if a[i] != b[i] {
184+
return false
185+
}
186+
}
187+
188+
return true
189+
}

0 commit comments

Comments
 (0)