Skip to content

Commit 352a464

Browse files
RiSKeDjenstopp
authored andcommitted
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 dadd3ae commit 352a464

2 files changed

Lines changed: 225 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: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
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+
devices := make([]*pb.DeviceInfo, 0, len(f.listDevices))
47+
for _, name := range f.listDevices {
48+
devices = append(devices, &pb.DeviceInfo{Name: name})
49+
}
50+
51+
return connect.NewResponse(&pb.ListResponse{Devices: devices}), nil
52+
}
53+
54+
func (f *fakeDeviceServiceClient) Commands(
55+
_ context.Context, req *connect.Request[pb.CommandsRequest],
56+
) (*connect.Response[pb.CommandsResponse], error) {
57+
f.commandsCalls = append(f.commandsCalls, req.Msg.GetDevice())
58+
59+
return connect.NewResponse(&pb.CommandsResponse{}), nil
60+
}
61+
62+
func (f *fakeDeviceServiceClient) Details(
63+
_ context.Context, req *connect.Request[pb.DetailsRequest],
64+
) (*connect.Response[pb.DetailsResponse], error) {
65+
f.detailsCalls = append(f.detailsCalls, detailsCall{
66+
device: req.Msg.GetDevice(),
67+
cmd: req.Msg.GetCmd(),
68+
keyword: req.Msg.GetKeyword(),
69+
})
70+
71+
return connect.NewResponse(&pb.DetailsResponse{}), nil
72+
}
73+
74+
func (f *fakeDeviceServiceClient) Run(
75+
_ context.Context,
76+
) *connect.BidiStreamForClient[pb.RunRequest, pb.RunResponse] {
77+
return nil
78+
}
79+
80+
func (f *fakeDeviceServiceClient) Lock(
81+
_ context.Context, _ *connect.Request[pb.LockRequest],
82+
) (*connect.Response[pb.LockResponse], error) {
83+
return connect.NewResponse(&pb.LockResponse{}), nil
84+
}
85+
86+
func (f *fakeDeviceServiceClient) Unlock(
87+
_ context.Context, _ *connect.Request[pb.UnlockRequest],
88+
) (*connect.Response[pb.UnlockResponse], error) {
89+
return connect.NewResponse(&pb.UnlockResponse{}), nil
90+
}
91+
92+
// Compile-time assertion that the fake satisfies the interface.
93+
var _ dutctlv1connect.DeviceServiceClient = (*fakeDeviceServiceClient)(nil)
94+
95+
// newTestApp builds an application with a fake RPC client and a
96+
// discarding formatter. Use it to drive dispatch in unit tests.
97+
func newTestApp(t *testing.T, fake *fakeDeviceServiceClient, args ...string) *application {
98+
t.Helper()
99+
100+
return &application{
101+
stdin: io.NopCloser(nil),
102+
stdout: io.Discard,
103+
stderr: io.Discard,
104+
exitFunc: func(int) {},
105+
args: args,
106+
rpcClient: fake,
107+
formatter: output.New(output.Config{Stdout: io.Discard, Stderr: io.Discard}),
108+
}
109+
}
110+
111+
func TestDispatch(t *testing.T) {
112+
tests := []struct {
113+
name string
114+
args []string
115+
listDevices []string
116+
wantErrIs error
117+
wantListHit int
118+
wantCmdHits []string
119+
wantDetailHi []detailsCall
120+
}{
121+
{
122+
name: "no args defaults to list",
123+
args: nil,
124+
wantListHit: 1,
125+
},
126+
{
127+
name: "explicit list",
128+
args: []string{"list"},
129+
wantListHit: 1,
130+
},
131+
{
132+
name: "explicit list with extra args is invalid",
133+
args: []string{"list", "extra"},
134+
wantErrIs: errInvalidCmdline,
135+
},
136+
{
137+
name: "single arg lists commands for that device",
138+
args: []string{"mydevice"},
139+
wantCmdHits: []string{"mydevice"},
140+
},
141+
{
142+
name: "device command help calls details",
143+
args: []string{"mydevice", "power", "help"},
144+
wantDetailHi: []detailsCall{
145+
{device: "mydevice", cmd: "power", keyword: "help"},
146+
},
147+
},
148+
}
149+
150+
for _, tt := range tests {
151+
t.Run(tt.name, func(t *testing.T) {
152+
fake := &fakeDeviceServiceClient{listDevices: tt.listDevices}
153+
app := newTestApp(t, fake, tt.args...)
154+
155+
err := app.dispatch()
156+
157+
if tt.wantErrIs != nil {
158+
if !errors.Is(err, tt.wantErrIs) {
159+
t.Fatalf("dispatch error: want %v, got %v", tt.wantErrIs, err)
160+
}
161+
} else if err != nil {
162+
t.Fatalf("dispatch: unexpected error: %v", err)
163+
}
164+
165+
if fake.listCalls != tt.wantListHit {
166+
t.Errorf("List calls: want %d, got %d", tt.wantListHit, fake.listCalls)
167+
}
168+
169+
if !equalStrings(fake.commandsCalls, tt.wantCmdHits) {
170+
t.Errorf("Commands calls: want %v, got %v", tt.wantCmdHits, fake.commandsCalls)
171+
}
172+
173+
if !equalDetails(fake.detailsCalls, tt.wantDetailHi) {
174+
t.Errorf("Details calls: want %v, got %v", tt.wantDetailHi, fake.detailsCalls)
175+
}
176+
})
177+
}
178+
}
179+
180+
func equalStrings(a, b []string) bool {
181+
if len(a) != len(b) {
182+
return false
183+
}
184+
185+
for i := range a {
186+
if a[i] != b[i] {
187+
return false
188+
}
189+
}
190+
191+
return true
192+
}
193+
194+
func equalDetails(a, b []detailsCall) bool {
195+
if len(a) != len(b) {
196+
return false
197+
}
198+
199+
for i := range a {
200+
if a[i] != b[i] {
201+
return false
202+
}
203+
}
204+
205+
return true
206+
}

0 commit comments

Comments
 (0)