Skip to content

Commit 6ad5e2c

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 1c601b4 commit 6ad5e2c

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
@@ -24,9 +24,10 @@ import (
2424

2525
const usageAbstract = `dutctl - The client application of the DUT Control system.
2626
`
27+
2728
const usageSynopsis = `
2829
SYNOPSIS:
29-
dutctl [options] list
30+
dutctl [options] [list]
3031
dutctl [options] <device>
3132
dutctl [options] <device> <command> [args...]
3233
dutctl [options] <device> <command> help
@@ -35,6 +36,7 @@ SYNOPSIS:
3536
dutctl version
3637
3738
`
39+
3840
const usageDescription = `
3941
If a device and a command are provided, dutctl will execute the command on the device.
4042
The optional args are passed to the command.
@@ -49,6 +51,7 @@ The lock command reserves a device for the current user; the optional duration
4951
(e.g. 30m, 2h) defaults to 30m. The unlock command releases it; pass the -force
5052
option to release a lock held by another user.
5153
54+
When dutctl is run without any positional arguments, it defaults to the list command.
5255
`
5356

5457
// Usage strings for the command-line flags, shown in the OPTIONS section of `dutctl -h`.
@@ -178,30 +181,32 @@ const exitInterrupted = 130
178181

179182
// start is the entry point of the application.
180183
func (app *application) start() {
181-
if len(app.args) == 0 {
182-
app.exit(errInvalidCmdline)
183-
}
184-
185-
if app.args[0] == "version" {
184+
if len(app.args) > 0 && app.args[0] == "version" {
186185
app.printVersion()
187186
app.exit(nil)
188187
}
189188

190189
app.setupRPCClient()
190+
app.exit(app.dispatch())
191+
}
192+
193+
// dispatch decides which RPC to call based on app.args.
194+
// It is split out from start so it can be unit tested without os.Exit.
195+
func (app *application) dispatch() error {
196+
if len(app.args) == 0 {
197+
return app.listRPC()
198+
}
191199

192200
if app.args[0] == "list" {
193201
if len(app.args) > 1 {
194-
app.exit(errInvalidCmdline)
202+
return errInvalidCmdline
195203
}
196204

197-
err := app.listRPC()
198-
app.exit(err)
205+
return app.listRPC()
199206
}
200207

201208
if len(app.args) == 1 {
202-
device := app.args[0]
203-
err := app.commandsRPC(device)
204-
app.exit(err)
209+
return app.commandsRPC(app.args[0])
205210
}
206211

207212
device := app.args[0]
@@ -216,12 +221,10 @@ func (app *application) start() {
216221
}
217222

218223
if len(cmdArgs) > 0 && cmdArgs[0] == "help" {
219-
err := app.detailsRPC(device, command, "help")
220-
app.exit(err)
224+
return app.detailsRPC(device, command, "help")
221225
}
222226

223-
err := app.runRPC(device, command, cmdArgs)
224-
app.exit(err)
227+
return app.runRPC(device, command, cmdArgs)
225228
}
226229

227230
// exit terminates the application. Buffered diagnostics (the warning summary)

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)