Skip to content

Commit fb6262e

Browse files
authored
refactor: replace package-level globals with CLI struct dependency injection (#219)
* refactor: replace package-level globals with CLI struct dependency injection ## What Introduce a CLI struct that holds logger, writer, build info, and rootCmd. Convert all command files from init() with package-level vars to methods on CLI. Delete execute.go and replace with cli.go. Update main.go to use cmd.NewCLI(...).Execute(). ## Why Package-level mutable globals made tests non-parallelizable and created implicit coupling between command files. The list command needed a runtime wrapper hack because writer was nil at init() time. Dependency injection via a struct makes dependencies explicit and testable. ## Notes - The exported cmd.Execute() function is replaced by cmd.NewCLI().Execute() — any external callers would break, but main.go is the only caller - The list.go runtime wrapper indirection is eliminated — c.writer is available when commands run via PersistentPreRun - discoverPluginNames now accepts a logger interface parameter instead of using the package global, allowing nil in tests - The exported Run() function is now unexported as c.run() — it was only called internally - Tests use a newTestCLI helper that constructs isolated CLI instances with a buffer-backed writer, enabling future parallel test execution Signed-off-by: jmeridth <jmeridth@gmail.com> * fix: route all version output through c.writer and add NewCLI wiring test ## What Use c.writer for non-verbose version output instead of fmt.Println to stdout. Add a test that verifies NewCLI registers all expected subcommands. ## Why Non-verbose version output bypassed c.writer, making it uncapturable in tests and inconsistent with verbose mode and all other commands. The NewCLI constructor wires all subcommands but had no test to catch regressions in that wiring. ## Notes - The non-verbose version test now asserts the version string appears in the buffer and that verbose-only fields (Commit:) are absent, rather than asserting an empty buffer Signed-off-by: jmeridth <jmeridth@gmail.com> --------- Signed-off-by: jmeridth <jmeridth@gmail.com>
1 parent 01a2188 commit fb6262e

File tree

11 files changed

+247
-242
lines changed

11 files changed

+247
-242
lines changed

cmd/cli.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
// Package cmd provides the command-line interface for pvtr.
2+
// It defines the root command and all subcommands, handles configuration,
3+
// and manages the execution flow of the application.
4+
package cmd
5+
6+
import (
7+
"log"
8+
"os"
9+
"path/filepath"
10+
"text/tabwriter"
11+
12+
hclog "github.com/hashicorp/go-hclog"
13+
"github.com/spf13/cobra"
14+
"github.com/spf13/viper"
15+
16+
"github.com/privateerproj/privateer-sdk/command"
17+
"github.com/privateerproj/privateer-sdk/config"
18+
)
19+
20+
// CLI holds the shared dependencies for all pvtr commands.
21+
type CLI struct {
22+
buildVersion string
23+
buildGitCommitHash string
24+
buildTime string
25+
26+
logger hclog.Logger
27+
writer *tabwriter.Writer
28+
rootCmd *cobra.Command
29+
}
30+
31+
// NewCLI creates a CLI instance with the given build metadata
32+
// and registers all subcommands.
33+
func NewCLI(version, commitHash, builtAt string) *CLI {
34+
c := &CLI{
35+
buildVersion: version,
36+
buildGitCommitHash: commitHash,
37+
buildTime: builtAt,
38+
}
39+
40+
c.rootCmd = &cobra.Command{
41+
Use: "pvtr",
42+
Short: "pvtr root command",
43+
PersistentPreRun: c.persistentPreRun,
44+
}
45+
46+
command.SetBase(c.rootCmd)
47+
c.rootCmd.PersistentFlags().StringP("binaries-path", "b", defaultBinariesPath(), "Path to the directory where plugins are installed")
48+
_ = viper.BindPFlag("binaries-path", c.rootCmd.PersistentFlags().Lookup("binaries-path"))
49+
50+
c.addRunCmd()
51+
c.addEnvCmd()
52+
c.addVersionCmd()
53+
c.addListCmd()
54+
c.addGenPluginCmd()
55+
56+
return c
57+
}
58+
59+
// Execute runs the root command. This is called by main.main().
60+
func (c *CLI) Execute() {
61+
err := c.rootCmd.Execute()
62+
if err != nil {
63+
os.Exit(1)
64+
}
65+
}
66+
67+
// persistentPreRun initializes the logger and writer for use by all commands.
68+
func (c *CLI) persistentPreRun(cmd *cobra.Command, args []string) {
69+
cfg := config.NewConfig(nil)
70+
c.logger = cfg.Logger
71+
72+
c.writer = tabwriter.NewWriter(os.Stdout, 1, 1, 1, ' ', 0)
73+
command.ReadConfig()
74+
}
75+
76+
// defaultBinariesPath returns the default path where plugins are installed.
77+
// It constructs a path in the user's home directory under .privateer/bin.
78+
// If the home directory cannot be determined, it falls back to a relative
79+
// path (./.privateer/bin) and logs a warning.
80+
func defaultBinariesPath() string {
81+
home, err := os.UserHomeDir()
82+
if err != nil {
83+
log.Printf("Warning: could not determine home directory: %v", err)
84+
return filepath.Join(".", ".privateer", "bin")
85+
}
86+
return filepath.Join(home, ".privateer", "bin")
87+
}

cmd/cli_test.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
package cmd
2+
3+
import (
4+
"testing"
5+
)
6+
7+
func TestNewCLI_RegistersAllSubcommands(t *testing.T) {
8+
c := NewCLI("1.0.0", "abc123", "2026-01-01T00:00:00Z")
9+
10+
expectedCommands := []string{
11+
"run",
12+
"env",
13+
"version",
14+
"list",
15+
"generate-plugin",
16+
}
17+
18+
registered := make(map[string]bool)
19+
for _, cmd := range c.rootCmd.Commands() {
20+
registered[cmd.Name()] = true
21+
}
22+
23+
for _, name := range expectedCommands {
24+
if !registered[name] {
25+
t.Errorf("expected subcommand %q to be registered, but it was not", name)
26+
}
27+
}
28+
}

cmd/env.go

Lines changed: 39 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -13,49 +13,51 @@ import (
1313
"github.com/spf13/viper"
1414
)
1515

16-
// envCmd represents the env command, which displays runtime environment details.
17-
var envCmd = &cobra.Command{
18-
Use: "env",
19-
Aliases: []string{"info"},
20-
Short: "Display runtime environment details.",
21-
Long: `Display the binary path, config location, plugins directory, installed plugins, version, and build information.`,
22-
Run: func(cmd *cobra.Command, args []string) {
23-
binaryPath, err := os.Executable()
24-
if err != nil {
25-
binaryPath = "unknown"
26-
}
16+
func (c *CLI) addEnvCmd() {
17+
envCmd := &cobra.Command{
18+
Use: "env",
19+
Aliases: []string{"info"},
20+
Short: "Display runtime environment details.",
21+
Long: `Display the binary path, config location, plugins directory, installed plugins, version, and build information.`,
22+
Run: func(cmd *cobra.Command, args []string) {
23+
binaryPath, err := os.Executable()
24+
if err != nil {
25+
binaryPath = "unknown"
26+
}
2727

28-
configFile := viper.ConfigFileUsed()
29-
var configStatus string
30-
if configFile == "" {
31-
configStatus = "none"
32-
} else if _, err := os.Stat(configFile); err == nil {
33-
configStatus = fmt.Sprintf("%s (found)", configFile)
34-
} else {
35-
configStatus = fmt.Sprintf("%s (not found)", configFile)
36-
}
28+
configFile := viper.ConfigFileUsed()
29+
var configStatus string
30+
if configFile == "" {
31+
configStatus = "none"
32+
} else if _, err := os.Stat(configFile); err == nil {
33+
configStatus = fmt.Sprintf("%s (found)", configFile)
34+
} else {
35+
configStatus = fmt.Sprintf("%s (not found)", configFile)
36+
}
3737

38-
pluginsDir := viper.GetString("binaries-path")
39-
pluginNames := discoverPluginNames(pluginsDir)
38+
pluginsDir := viper.GetString("binaries-path")
39+
pluginNames := discoverPluginNames(c.logger, pluginsDir)
4040

41-
_, _ = fmt.Fprintf(writer, "Binary:\t%s\n", binaryPath)
42-
_, _ = fmt.Fprintf(writer, "Config:\t%s\n", configStatus)
43-
_, _ = fmt.Fprintf(writer, "Plugins Dir:\t%s\n", pluginsDir)
44-
_, _ = fmt.Fprintf(writer, "Plugins:\t%s\n", pluginNames)
45-
_, _ = fmt.Fprintf(writer, "Version:\t%s\n", buildVersion)
46-
_, _ = fmt.Fprintf(writer, "Commit:\t%s\n", buildGitCommitHash)
47-
_, _ = fmt.Fprintf(writer, "Build Time:\t%s\n", buildTime)
48-
_, _ = fmt.Fprintf(writer, "Go Version:\t%s\n", runtime.Version())
49-
_, _ = fmt.Fprintf(writer, "OS/Arch:\t%s/%s\n", runtime.GOOS, runtime.GOARCH)
50-
if err := writer.Flush(); err != nil {
51-
log.Printf("Error flushing writer: %v", err)
52-
}
53-
},
41+
_, _ = fmt.Fprintf(c.writer, "Binary:\t%s\n", binaryPath)
42+
_, _ = fmt.Fprintf(c.writer, "Config:\t%s\n", configStatus)
43+
_, _ = fmt.Fprintf(c.writer, "Plugins Dir:\t%s\n", pluginsDir)
44+
_, _ = fmt.Fprintf(c.writer, "Plugins:\t%s\n", pluginNames)
45+
_, _ = fmt.Fprintf(c.writer, "Version:\t%s\n", c.buildVersion)
46+
_, _ = fmt.Fprintf(c.writer, "Commit:\t%s\n", c.buildGitCommitHash)
47+
_, _ = fmt.Fprintf(c.writer, "Build Time:\t%s\n", c.buildTime)
48+
_, _ = fmt.Fprintf(c.writer, "Go Version:\t%s\n", runtime.Version())
49+
_, _ = fmt.Fprintf(c.writer, "OS/Arch:\t%s/%s\n", runtime.GOOS, runtime.GOARCH)
50+
if err := c.writer.Flush(); err != nil {
51+
log.Printf("Error flushing writer: %v", err)
52+
}
53+
},
54+
}
55+
c.rootCmd.AddCommand(envCmd)
5456
}
5557

56-
func discoverPluginNames(pluginsDir string) string {
58+
func discoverPluginNames(logger interface{ Error(string, ...interface{}) }, pluginsDir string) string {
5759
pluginPaths, err := hcplugin.Discover("*", pluginsDir)
58-
if err != nil {
60+
if err != nil && logger != nil {
5961
logger.Error("error discovering plugins", "dir", pluginsDir, "error", err)
6062
}
6163
var names []string
@@ -71,7 +73,3 @@ func discoverPluginNames(pluginsDir string) string {
7173
}
7274
return strings.Join(names, ", ")
7375
}
74-
75-
func init() {
76-
rootCmd.AddCommand(envCmd)
77-
}

cmd/env_test.go

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,16 +8,27 @@ import (
88
"strings"
99
"testing"
1010
"text/tabwriter"
11+
12+
"github.com/spf13/cobra"
1113
)
1214

15+
func newTestCLI(buf *bytes.Buffer, version, commit, buildTime string) *CLI {
16+
c := &CLI{
17+
buildVersion: version,
18+
buildGitCommitHash: commit,
19+
buildTime: buildTime,
20+
writer: tabwriter.NewWriter(buf, 1, 1, 1, ' ', 0),
21+
}
22+
c.rootCmd = &cobra.Command{Use: "pvtr"}
23+
return c
24+
}
25+
1326
func TestEnvCmd_ContainsExpectedFields(t *testing.T) {
1427
var buf bytes.Buffer
15-
writer = tabwriter.NewWriter(&buf, 1, 1, 1, ' ', 0)
16-
17-
buildVersion = "1.2.3"
18-
buildGitCommitHash = "a1b2c3d"
19-
buildTime = "2026-01-01T00:00:00Z"
28+
c := newTestCLI(&buf, "1.2.3", "a1b2c3d", "2026-01-01T00:00:00Z")
29+
c.addEnvCmd()
2030

31+
envCmd, _, _ := c.rootCmd.Find([]string{"env"})
2132
envCmd.Run(envCmd, []string{})
2233

2334
output := buf.String()
@@ -42,12 +53,10 @@ func TestEnvCmd_ContainsExpectedFields(t *testing.T) {
4253

4354
func TestEnvCmd_DisplaysBuildInfo(t *testing.T) {
4455
var buf bytes.Buffer
45-
writer = tabwriter.NewWriter(&buf, 1, 1, 1, ' ', 0)
46-
47-
buildVersion = "test-version"
48-
buildGitCommitHash = "e4f5a6b"
49-
buildTime = "2026-02-15T00:00:00Z"
56+
c := newTestCLI(&buf, "test-version", "e4f5a6b", "2026-02-15T00:00:00Z")
57+
c.addEnvCmd()
5058

59+
envCmd, _, _ := c.rootCmd.Find([]string{"env"})
5160
envCmd.Run(envCmd, []string{})
5261

5362
output := buf.String()
@@ -72,8 +81,10 @@ func TestEnvCmd_DisplaysBuildInfo(t *testing.T) {
7281

7382
func TestEnvCmd_ShowsBinaryPath(t *testing.T) {
7483
var buf bytes.Buffer
75-
writer = tabwriter.NewWriter(&buf, 1, 1, 1, ' ', 0)
84+
c := newTestCLI(&buf, "", "", "")
85+
c.addEnvCmd()
7686

87+
envCmd, _, _ := c.rootCmd.Find([]string{"env"})
7788
envCmd.Run(envCmd, []string{})
7889

7990
output := buf.String()
@@ -91,14 +102,14 @@ func TestDiscoverPluginNames_EmptyDir(t *testing.T) {
91102
}
92103
defer func() { _ = os.RemoveAll(tmpDir) }()
93104

94-
result := discoverPluginNames(tmpDir)
105+
result := discoverPluginNames(nil, tmpDir)
95106
if result != "none" {
96107
t.Errorf("expected 'none' for empty dir, got: %s", result)
97108
}
98109
}
99110

100111
func TestDiscoverPluginNames_NonexistentDir(t *testing.T) {
101-
result := discoverPluginNames("/nonexistent/path")
112+
result := discoverPluginNames(nil, "/nonexistent/path")
102113
if result != "none" {
103114
t.Errorf("expected 'none' for nonexistent dir, got: %s", result)
104115
}
@@ -118,11 +129,10 @@ func TestDiscoverPluginNames_FiltersPvtrAndPrivateer(t *testing.T) {
118129
}
119130
}
120131

121-
result := discoverPluginNames(tmpDir)
132+
result := discoverPluginNames(nil, tmpDir)
122133

123134
// Exact-name binaries should be filtered out
124135
for _, filtered := range []string{"pvtr", "privateer"} {
125-
// Check the result doesn't contain the exact name as a standalone entry
126136
for _, entry := range strings.Split(result, ", ") {
127137
if entry == filtered {
128138
t.Errorf("expected %q to be filtered out, got: %s", filtered, result)

cmd/execute.go

Lines changed: 0 additions & 89 deletions
This file was deleted.

0 commit comments

Comments
 (0)