Skip to content

Commit 8062cf7

Browse files
authored
refactor(internal/cli,librarian): move Run to cli package (#2122)
Move command execution logic from librarian.Run to a generic cli.Run method. Change the Command.Run field from: ``` Run func(ctx context.Context, cfg *config.Config) error ``` to ``` Action func(context.Context, *Command) error ``` This give the command handler access to the full Command context, rather than just the config. In a follow up PR, cli.Command will be refactored further to remove business logic from Config out of the internal/cli framework.
1 parent c12a89b commit 8062cf7

File tree

10 files changed

+222
-110
lines changed

10 files changed

+222
-110
lines changed

internal/cli/cli.go

Lines changed: 22 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ type Command struct {
3838
// Long is the full description of the command.
3939
Long string
4040

41-
// Run executes the command.
42-
Run func(ctx context.Context, cfg *config.Config) error
41+
// Action executes the command.
42+
Action func(context.Context, *Command) error
4343

4444
// Commands are the sub commands.
4545
Commands []*Command
@@ -52,10 +52,24 @@ type Command struct {
5252
Config *config.Config
5353
}
5454

55-
// Parse parses the provided command-line arguments using the command's flag
56-
// set.
57-
func (c *Command) Parse(args []string) error {
58-
return c.Flags.Parse(args)
55+
// Run executes the command with the provided arguments.
56+
func (c *Command) Run(ctx context.Context, args []string) error {
57+
cmd, remaining, err := lookupCommand(c, args)
58+
if err != nil {
59+
return err
60+
}
61+
if err := cmd.Flags.Parse(remaining); err != nil {
62+
return err
63+
}
64+
65+
if cmd.Action == nil {
66+
cmd.Flags.Usage()
67+
if len(cmd.Commands) > 0 {
68+
return nil
69+
}
70+
return fmt.Errorf("no action defined for command %q", cmd.Name())
71+
}
72+
return cmd.Action(ctx, cmd)
5973
}
6074

6175
// Name is the command name. Command.Short is always expected to begin with
@@ -111,10 +125,10 @@ func hasFlags(fs *flag.FlagSet) bool {
111125
return visited
112126
}
113127

114-
// LookupCommand looks up the command specified by the given arguments.
128+
// lookupCommand looks up the command specified by the given arguments.
115129
// It returns the command, the remaining arguments, and an error if the command
116130
// is not found.
117-
func LookupCommand(cmd *Command, args []string) (*Command, []string, error) {
131+
func lookupCommand(cmd *Command, args []string) (*Command, []string, error) {
118132
// If the first character of the argument is '-' assume it is a flag.
119133
for len(args) > 0 && args[0][0] != '-' {
120134
name := args[0]

internal/cli/cli_test.go

Lines changed: 80 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,6 @@ import (
2323

2424
"github.com/google/go-cmp/cmp"
2525
"github.com/google/go-cmp/cmp/cmpopts"
26-
"github.com/googleapis/librarian/internal/config"
2726
)
2827

2928
func TestParseAndSetFlags(t *testing.T) {
@@ -42,7 +41,7 @@ func TestParseAndSetFlags(t *testing.T) {
4241
cmd.Flags.IntVar(&intFlag, "count", 0, "count flag")
4342

4443
args := []string{"-name=foo", "-count=5"}
45-
if err := cmd.Parse(args); err != nil {
44+
if err := cmd.Flags.Parse(args); err != nil {
4645
t.Fatalf("Parse() failed: %v", err)
4746
}
4847

@@ -54,22 +53,21 @@ func TestParseAndSetFlags(t *testing.T) {
5453
}
5554
}
5655

57-
func TestRun(t *testing.T) {
56+
func TestAction(t *testing.T) {
5857
executed := false
5958
cmd := &Command{
6059
Short: "run runs the command",
61-
Run: func(ctx context.Context, cfg *config.Config) error {
60+
Action: func(ctx context.Context, cmd *Command) error {
6261
executed = true
6362
return nil
6463
},
6564
}
6665

67-
cfg := &config.Config{}
68-
if err := cmd.Run(t.Context(), cfg); err != nil {
66+
if err := cmd.Action(t.Context(), cmd); err != nil {
6967
t.Fatal(err)
7068
}
7169
if !executed {
72-
t.Errorf("cmd.Run was not executed")
70+
t.Errorf("cmd.Action was not executed")
7371
}
7472
}
7573

@@ -278,7 +276,7 @@ func TestLookupCommand(t *testing.T) {
278276
},
279277
} {
280278
t.Run(test.name, func(t *testing.T) {
281-
gotCmd, gotArgs, err := LookupCommand(test.cmd, test.args)
279+
gotCmd, gotArgs, err := lookupCommand(test.cmd, test.args)
282280
if (err != nil) != test.wantErr {
283281
t.Fatalf("error = %v, wantErr %v", err, test.wantErr)
284282
}
@@ -298,3 +296,77 @@ func TestLookupCommand(t *testing.T) {
298296
})
299297
}
300298
}
299+
300+
func TestRun(t *testing.T) {
301+
actionExecuted := false
302+
subcmd := &Command{
303+
Short: "bar is a subcommand",
304+
Long: "bar is a subcommand.",
305+
UsageLine: "bar",
306+
Action: func(ctx context.Context, cmd *Command) error {
307+
actionExecuted = true
308+
return nil
309+
},
310+
}
311+
subcmd.Init()
312+
313+
root := &Command{
314+
Short: "foo is the root command",
315+
Long: "foo is the root command.",
316+
UsageLine: "foo",
317+
Commands: []*Command{subcmd},
318+
}
319+
root.Init()
320+
321+
noaction := &Command{
322+
Short: "noaction has no action",
323+
Long: "noaction has no action.",
324+
UsageLine: "noaction",
325+
}
326+
noaction.Init()
327+
328+
for _, test := range []struct {
329+
name string
330+
cmd *Command
331+
args []string
332+
wantErr bool
333+
actionExecuted bool
334+
}{
335+
{
336+
name: "execute foo with subcommand bar",
337+
cmd: root,
338+
args: []string{"bar"},
339+
actionExecuted: true,
340+
},
341+
{
342+
name: "unknown subcommand",
343+
cmd: root,
344+
args: []string{"unknown"},
345+
wantErr: true,
346+
},
347+
{
348+
name: "flag parse error",
349+
cmd: subcmd,
350+
args: []string{"-unknown"},
351+
wantErr: true,
352+
},
353+
{
354+
name: "no action defined on command with no subcommands",
355+
cmd: noaction,
356+
wantErr: true,
357+
},
358+
} {
359+
t.Run(test.name, func(t *testing.T) {
360+
actionExecuted = false
361+
err := test.cmd.Run(t.Context(), test.args)
362+
if err != nil {
363+
if !test.wantErr {
364+
t.Errorf("error = %v, wantErr %v", err, test.wantErr)
365+
}
366+
}
367+
if actionExecuted != test.actionExecuted {
368+
t.Errorf("actionExecuted = %v, want %v", actionExecuted, test.actionExecuted)
369+
}
370+
})
371+
}
372+
}

internal/config/config.go

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,6 @@ const (
5555
// after release.
5656
ReleaseInitResponse = "release-init-response.json"
5757
pipelineStateFile = "state.yaml"
58-
versionCmdName = "version"
5958
)
6059

6160
// are variables so it can be replaced during testing.
@@ -258,9 +257,6 @@ func (c *Config) setupUser() error {
258257
}
259258

260259
func (c *Config) createWorkRoot() error {
261-
if c.CommandName == versionCmdName {
262-
return nil
263-
}
264260
if c.WorkRoot != "" {
265261
slog.Info("Using specified working directory", "dir", c.WorkRoot)
266262
return nil
@@ -286,9 +282,6 @@ func (c *Config) createWorkRoot() error {
286282
}
287283

288284
func (c *Config) deriveRepo() error {
289-
if c.CommandName == versionCmdName {
290-
return nil
291-
}
292285
if c.Repo != "" {
293286
slog.Debug("repo value provided by user", "repo", c.Repo)
294287
return nil
@@ -327,7 +320,7 @@ func (c *Config) IsValid() (bool, error) {
327320
return false, err
328321
}
329322

330-
if c.CommandName != versionCmdName && c.Repo == "" {
323+
if c.Repo == "" {
331324
return false, errors.New("language repository not specified or detected")
332325
}
333326

internal/librarian/action_test.go

Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
// Copyright 2025 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// https://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
package librarian
16+
17+
import (
18+
"strings"
19+
"testing"
20+
21+
"github.com/googleapis/librarian/internal/cli"
22+
"github.com/googleapis/librarian/internal/config"
23+
)
24+
25+
func TestGenerateAction(t *testing.T) {
26+
t.Parallel()
27+
testActionConfig(t, cmdGenerate)
28+
}
29+
30+
func TestInitAction(t *testing.T) {
31+
t.Parallel()
32+
testActionConfig(t, cmdInit)
33+
}
34+
35+
func TestTagAndReleaseAction(t *testing.T) {
36+
t.Parallel()
37+
testActionConfig(t, cmdTagAndRelease)
38+
}
39+
40+
// testActionConfig tests the execution flow for each Command.Action. The
41+
// functionality of the Config methods called inside these Actions are tested
42+
// separately in internal/config.
43+
func testActionConfig(t *testing.T, cmd *cli.Command) {
44+
t.Helper()
45+
for _, test := range []struct {
46+
cfg *config.Config
47+
wantErr string
48+
}{
49+
{
50+
cfg: &config.Config{
51+
WorkRoot: t.TempDir(),
52+
},
53+
wantErr: "repo flag not specified",
54+
},
55+
{
56+
cfg: &config.Config{
57+
WorkRoot: t.TempDir(),
58+
Repo: "myrepo",
59+
},
60+
wantErr: "repository does not exist",
61+
},
62+
{
63+
cfg: &config.Config{
64+
WorkRoot: t.TempDir(),
65+
Repo: "myrepo",
66+
LibraryVersion: "1.0.0",
67+
},
68+
wantErr: "specified library version without library id",
69+
},
70+
{
71+
cfg: &config.Config{
72+
WorkRoot: t.TempDir(),
73+
Repo: "https://github.com/googleapis/language-repo",
74+
},
75+
wantErr: "remote branch is required when cloning",
76+
},
77+
} {
78+
t.Run(test.wantErr, func(t *testing.T) {
79+
cmd.Config = test.cfg
80+
err := cmd.Action(t.Context(), cmd)
81+
if err == nil {
82+
t.Fatal("expected an error")
83+
}
84+
if !strings.Contains(err.Error(), test.wantErr) {
85+
t.Errorf("error mismatch, want: %q, got: %q", test.wantErr, err.Error())
86+
}
87+
})
88+
}
89+
}

internal/librarian/generate.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,14 @@ in '.librarian/state.yaml'.
8686
8787
Example with build and push:
8888
SDK_LIBRARIAN_GITHUB_TOKEN=xxx librarian generate --push --build`,
89-
Run: func(ctx context.Context, cfg *config.Config) error {
90-
runner, err := newGenerateRunner(cfg)
89+
Action: func(ctx context.Context, cmd *cli.Command) error {
90+
if err := cmd.Config.SetDefaults(); err != nil {
91+
return fmt.Errorf("failed to initialize config: %w", err)
92+
}
93+
if _, err := cmd.Config.IsValid(); err != nil {
94+
return fmt.Errorf("failed to validate config: %s", err)
95+
}
96+
runner, err := newGenerateRunner(cmd.Config)
9197
if err != nil {
9298
return err
9399
}

internal/librarian/librarian.go

Lines changed: 5 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,6 @@ package librarian
1616

1717
import (
1818
"context"
19-
"fmt"
2019
"log/slog"
2120

2221
"github.com/googleapis/librarian/internal/cli"
@@ -27,51 +26,16 @@ var CmdLibrarian = &cli.Command{
2726
Short: "librarian manages client libraries for Google APIs",
2827
UsageLine: "librarian <command> [arguments]",
2928
Long: "Librarian manages client libraries for Google APIs.",
30-
}
31-
32-
func init() {
33-
CmdLibrarian.Init()
34-
CmdLibrarian.Commands = append(CmdLibrarian.Commands,
29+
Commands: []*cli.Command{
3530
cmdGenerate,
3631
cmdRelease,
3732
cmdVersion,
38-
)
33+
},
3934
}
4035

41-
// Run executes the Librarian CLI with the given command line
42-
// arguments.
36+
// Run executes the Librarian CLI with the given command line arguments.
4337
func Run(ctx context.Context, arg ...string) error {
44-
if err := CmdLibrarian.Parse(arg); err != nil {
45-
return err
46-
}
47-
if len(arg) == 0 {
48-
CmdLibrarian.Flags.Usage()
49-
return nil
50-
}
51-
cmd, arg, err := cli.LookupCommand(CmdLibrarian, arg)
52-
if err != nil {
53-
return err
54-
}
55-
56-
// If a command is just a container for subcommands, it won't have a
57-
// Run function. In that case, display its usage instructions.
58-
if cmd.Run == nil {
59-
cmd.Flags.Usage()
60-
return fmt.Errorf("command %q requires a subcommand", cmd.Name())
61-
}
62-
63-
if err := cmd.Parse(arg); err != nil {
64-
// We expect that if cmd.Parse fails, it will already
65-
// have printed out a command-specific usage error,
66-
// so we don't need to display the general usage.
67-
return err
68-
}
38+
CmdLibrarian.Init()
6939
slog.Info("librarian", "arguments", arg)
70-
if err := cmd.Config.SetDefaults(); err != nil {
71-
return fmt.Errorf("failed to initialize config: %w", err)
72-
}
73-
if _, err := cmd.Config.IsValid(); err != nil {
74-
return fmt.Errorf("failed to validate config: %s", err)
75-
}
76-
return cmd.Run(ctx, cmd.Config)
40+
return CmdLibrarian.Run(ctx, arg)
7741
}

0 commit comments

Comments
 (0)