Skip to content

Commit eeb8795

Browse files
ceritiumclaude
andcommitted
Extract common patterns to centralized helper utilities
Create cmd/helpers package to consolidate repeated patterns across commands and reduce code duplication by ~200 lines while improving consistency. New helpers package includes: - error.go: Centralized error handling with ExitWithError() and ExitOnError() - config.go: Config/StackEntry types and LoadConfig() functions - fs.go: Filesystem utilities (WithWorkingDir, ResolveAbsPath, GetConfigDir) - env.go: Environment variable utilities (GetEnvRequired, GetEnvOrDefault) Updated commands to use helpers: - init.go: Move Config types to helpers, use WithWorkingDir, ExitOnError - check.go: Use LoadConfig, WithWorkingDir, ExitOnError, helpers.StackEntry - update.go: Use LoadConfig, WithWorkingDir, ExitOnError, helpers.StackEntry - push.go: Use LoadConfig, GetEnvRequired, ExitOnError, helpers.StackEntry - fetch_catalog.go: Use ExitOnError for error handling - autodetect.go: Use WithWorkingDir, ExitOnError - root.go: Use ExitWithError for error handling - check_test.go: Update tests to use helpers.StackEntry Benefits: - Consistent error handling with standardized exit codes - Single source of truth for config loading and validation - Cleaner directory context management - Better code reusability and maintainability - All tests passing, build successful 🤖 Generated with Claude Code Co-Authored-By: Claude Haiku 4.5 <[email protected]>
1 parent 37ada0c commit eeb8795

File tree

12 files changed

+287
-227
lines changed

12 files changed

+287
-227
lines changed

cmd/autodetect.go

Lines changed: 12 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,8 @@ package cmd
22

33
import (
44
"fmt"
5-
"os"
65

6+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
77
"github.com/spf13/cobra"
88
)
99

@@ -19,25 +19,18 @@ var autodetectCmd = &cobra.Command{
1919
targetDir = args[0]
2020
}
2121

22-
// Change to target directory for detection
23-
originalDir, err := os.Getwd()
24-
if err != nil {
25-
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
26-
os.Exit(1)
27-
}
28-
29-
if targetDir != "." {
30-
if err := os.Chdir(targetDir); err != nil {
31-
fmt.Fprintf(os.Stderr, "Error changing to directory %s: %v\n", targetDir, err)
32-
os.Exit(1)
33-
}
34-
defer os.Chdir(originalDir)
35-
}
36-
3722
fmt.Printf("Scanning directory: %s\n", targetDir)
3823

39-
// Detect project information
40-
info := DetectProjectInfo()
41-
PrintDetectedInfo(info)
24+
// Execute detection in target directory
25+
err := helpers.WithWorkingDir(targetDir, func() error {
26+
// Detect project information
27+
info := DetectProjectInfo()
28+
PrintDetectedInfo(info)
29+
return nil
30+
})
31+
32+
if err != nil {
33+
helpers.ExitOnError(err, "failed to scan directory")
34+
}
4235
},
4336
}

cmd/check.go

Lines changed: 31 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -4,10 +4,9 @@ import (
44
"encoding/json"
55
"fmt"
66
"os"
7-
"path/filepath"
87

8+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
99
"github.com/spf13/cobra"
10-
"gopkg.in/yaml.v3"
1110
)
1211

1312
type CheckResult struct {
@@ -45,57 +44,39 @@ var checkCmd = &cobra.Command{
4544
Short: "Check if detected versions match stacktodate.yml",
4645
Long: `Verify that the versions in stacktodate.yml match the currently detected versions in your project. Useful for CI/CD pipelines.`,
4746
Run: func(cmd *cobra.Command, args []string) {
48-
// Determine config file
49-
configPath := checkConfigFile
50-
if configPath == "" {
51-
configPath = "stacktodate.yml"
52-
}
53-
54-
// Resolve absolute path
55-
absConfigPath, err := filepath.Abs(configPath)
47+
// Load config without requiring UUID
48+
config, err := helpers.LoadConfig(checkConfigFile)
5649
if err != nil {
57-
fmt.Fprintf(os.Stderr, "Error resolving config path: %v\n", err)
58-
os.Exit(2)
50+
helpers.ExitWithError(2, "failed to load config: %v", err)
5951
}
6052

61-
// Read config file
62-
content, err := os.ReadFile(absConfigPath)
53+
// Resolve absolute path for directory management
54+
absConfigPath, err := helpers.ResolveAbsPath(checkConfigFile)
6355
if err != nil {
64-
fmt.Fprintf(os.Stderr, "Error reading config file %s: %v\n", configPath, err)
65-
os.Exit(2)
66-
}
67-
68-
// Parse YAML
69-
var config Config
70-
if err := yaml.Unmarshal(content, &config); err != nil {
71-
fmt.Fprintf(os.Stderr, "Error parsing %s: %v\n", configPath, err)
72-
os.Exit(2)
56+
if checkConfigFile == "" {
57+
absConfigPath, _ = helpers.ResolveAbsPath("stacktodate.yml")
58+
} else {
59+
helpers.ExitOnError(err, "failed to resolve config path")
60+
}
7361
}
7462

75-
// Change to config directory for detection
76-
originalDir, err := os.Getwd()
63+
// Get config directory
64+
configDir, err := helpers.GetConfigDir(absConfigPath)
7765
if err != nil {
78-
fmt.Fprintf(os.Stderr, "Error getting current directory: %v\n", err)
79-
os.Exit(2)
66+
helpers.ExitOnError(err, "failed to get config directory")
8067
}
8168

82-
configDir := filepath.Dir(absConfigPath)
83-
if configDir == "" {
84-
configDir = "."
85-
}
86-
87-
if configDir != "." {
88-
if err := os.Chdir(configDir); err != nil {
89-
fmt.Fprintf(os.Stderr, "Error changing to directory: %v\n", err)
90-
os.Exit(2)
91-
}
92-
defer os.Chdir(originalDir)
69+
// Detect current versions in config directory
70+
var detectedStack map[string]helpers.StackEntry
71+
err = helpers.WithWorkingDir(configDir, func() error {
72+
detectedInfo := DetectProjectInfo()
73+
detectedStack = normalizeDetectedToStack(detectedInfo)
74+
return nil
75+
})
76+
if err != nil {
77+
helpers.ExitOnError(err, "failed to detect versions")
9378
}
9479

95-
// Detect current versions
96-
detectedInfo := DetectProjectInfo()
97-
detectedStack := normalizeDetectedToStack(detectedInfo)
98-
9980
// Compare stacks
10081
result := compareStacks(config.Stack, detectedStack)
10182

@@ -113,39 +94,39 @@ var checkCmd = &cobra.Command{
11394
},
11495
}
11596

116-
func normalizeDetectedToStack(info DetectedInfo) map[string]StackEntry {
117-
normalized := make(map[string]StackEntry)
97+
func normalizeDetectedToStack(info DetectedInfo) map[string]helpers.StackEntry {
98+
normalized := make(map[string]helpers.StackEntry)
11899

119100
if len(info.Ruby) > 0 {
120-
normalized["ruby"] = StackEntry{
101+
normalized["ruby"] = helpers.StackEntry{
121102
Version: info.Ruby[0].Value,
122103
Source: info.Ruby[0].Source,
123104
}
124105
}
125106

126107
if len(info.Rails) > 0 {
127-
normalized["rails"] = StackEntry{
108+
normalized["rails"] = helpers.StackEntry{
128109
Version: info.Rails[0].Value,
129110
Source: info.Rails[0].Source,
130111
}
131112
}
132113

133114
if len(info.Node) > 0 {
134-
normalized["nodejs"] = StackEntry{
115+
normalized["nodejs"] = helpers.StackEntry{
135116
Version: info.Node[0].Value,
136117
Source: info.Node[0].Source,
137118
}
138119
}
139120

140121
if len(info.Go) > 0 {
141-
normalized["go"] = StackEntry{
122+
normalized["go"] = helpers.StackEntry{
142123
Version: info.Go[0].Value,
143124
Source: info.Go[0].Source,
144125
}
145126
}
146127

147128
if len(info.Python) > 0 {
148-
normalized["python"] = StackEntry{
129+
normalized["python"] = helpers.StackEntry{
149130
Version: info.Python[0].Value,
150131
Source: info.Python[0].Source,
151132
}
@@ -154,7 +135,7 @@ func normalizeDetectedToStack(info DetectedInfo) map[string]StackEntry {
154135
return normalized
155136
}
156137

157-
func compareStacks(configStack, detectedStack map[string]StackEntry) CheckResult {
138+
func compareStacks(configStack, detectedStack map[string]helpers.StackEntry) CheckResult {
158139
result := CheckResult{
159140
Results: CheckResults{
160141
Matched: []ComparisonEntry{},

cmd/check_test.go

Lines changed: 12 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@ package cmd
22

33
import (
44
"testing"
5+
6+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
57
)
68

79
func TestNormalizeDetectedToStack(t *testing.T) {
@@ -55,12 +57,12 @@ func TestNormalizeDetectedToStack(t *testing.T) {
5557
}
5658

5759
func TestCompareStacks_AllMatch(t *testing.T) {
58-
configStack := map[string]StackEntry{
60+
configStack := map[string]helpers.StackEntry{
5961
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
6062
"nodejs": {Version: "18.0.0", Source: ".nvmrc"},
6163
}
6264

63-
detectedStack := map[string]StackEntry{
65+
detectedStack := map[string]helpers.StackEntry{
6466
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
6567
"nodejs": {Version: "18.0.0", Source: ".nvmrc"},
6668
}
@@ -81,12 +83,12 @@ func TestCompareStacks_AllMatch(t *testing.T) {
8183
}
8284

8385
func TestCompareStacks_WithMismatch(t *testing.T) {
84-
configStack := map[string]StackEntry{
86+
configStack := map[string]helpers.StackEntry{
8587
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
8688
"rails": {Version: "7.1.0", Source: "Gemfile"},
8789
}
8890

89-
detectedStack := map[string]StackEntry{
91+
detectedStack := map[string]helpers.StackEntry{
9092
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
9193
"rails": {Version: "7.0.0", Source: "Gemfile"},
9294
}
@@ -107,12 +109,12 @@ func TestCompareStacks_WithMismatch(t *testing.T) {
107109
}
108110

109111
func TestCompareStacks_MissingConfig(t *testing.T) {
110-
configStack := map[string]StackEntry{
112+
configStack := map[string]helpers.StackEntry{
111113
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
112114
"nodejs": {Version: "18.0.0", Source: ".nvmrc"},
113115
}
114116

115-
detectedStack := map[string]StackEntry{
117+
detectedStack := map[string]helpers.StackEntry{
116118
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
117119
}
118120

@@ -132,14 +134,14 @@ func TestCompareStacks_MissingConfig(t *testing.T) {
132134
}
133135

134136
func TestCompareStacks_Complex(t *testing.T) {
135-
configStack := map[string]StackEntry{
137+
configStack := map[string]helpers.StackEntry{
136138
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
137139
"rails": {Version: "7.1.0", Source: "Gemfile"},
138140
"nodejs": {Version: "18.0.0", Source: ".nvmrc"},
139141
"python": {Version: "3.10", Source: ".python-version"},
140142
}
141143

142-
detectedStack := map[string]StackEntry{
144+
detectedStack := map[string]helpers.StackEntry{
143145
"ruby": {Version: "3.2.0", Source: ".ruby-version"},
144146
"rails": {Version: "7.0.0", Source: "Gemfile"},
145147
"nodejs": {Version: "18.0.0", Source: ".nvmrc"},
@@ -166,8 +168,8 @@ func TestCompareStacks_Complex(t *testing.T) {
166168
}
167169

168170
func TestCompareStacks_Empty(t *testing.T) {
169-
configStack := map[string]StackEntry{}
170-
detectedStack := map[string]StackEntry{}
171+
configStack := map[string]helpers.StackEntry{}
172+
detectedStack := map[string]helpers.StackEntry{}
171173

172174
result := compareStacks(configStack, detectedStack)
173175

cmd/fetch_catalog.go

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import (
44
"fmt"
55
"os"
66

7+
"github.com/stacktodate/stacktodate-cli/cmd/helpers"
78
"github.com/stacktodate/stacktodate-cli/cmd/lib/cache"
89
"github.com/spf13/cobra"
910
)
@@ -20,15 +21,13 @@ once every 24 hours. You can use this command to manually refresh the cache at a
2021
fmt.Fprintf(os.Stderr, "Fetching product catalog from stacktodate.club...\n")
2122

2223
if err := cache.FetchAndCache(); err != nil {
23-
fmt.Fprintf(os.Stderr, "✗ Error: %v\n", err)
24-
os.Exit(1)
24+
helpers.ExitOnError(err, "failed to fetch catalog")
2525
}
2626

2727
// Load and display info about cached products
2828
products, err := cache.LoadCache()
2929
if err != nil {
30-
fmt.Fprintf(os.Stderr, "✗ Error loading cached products: %v\n", err)
31-
os.Exit(1)
30+
helpers.ExitOnError(err, "failed to load cached products")
3231
}
3332

3433
cachePath, _ := cache.GetCachePath()

cmd/helpers/config.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
package helpers
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"gopkg.in/yaml.v3"
8+
)
9+
10+
// Config represents the stacktodate.yml structure
11+
type Config struct {
12+
UUID string `yaml:"uuid"`
13+
Name string `yaml:"name"`
14+
Stack map[string]StackEntry `yaml:"stack,omitempty"`
15+
}
16+
17+
// StackEntry represents a single technology entry in the stack
18+
type StackEntry struct {
19+
Version string `yaml:"version"`
20+
Source string `yaml:"source"`
21+
}
22+
23+
// LoadConfig reads and parses a config file from the given path
24+
// If path is empty, uses "stacktodate.yml" as default
25+
func LoadConfig(configPath string) (*Config, error) {
26+
if configPath == "" {
27+
configPath = "stacktodate.yml"
28+
}
29+
30+
content, err := os.ReadFile(configPath)
31+
if err != nil {
32+
return nil, fmt.Errorf("reading config file %s: %w", configPath, err)
33+
}
34+
35+
var config Config
36+
if err := yaml.Unmarshal(content, &config); err != nil {
37+
return nil, fmt.Errorf("parsing config file %s: %w", configPath, err)
38+
}
39+
40+
return &config, nil
41+
}
42+
43+
// LoadConfigWithDefaults loads a config file with optional UUID validation
44+
func LoadConfigWithDefaults(configPath string, requireUUID bool) (*Config, error) {
45+
config, err := LoadConfig(configPath)
46+
if err != nil {
47+
return nil, err
48+
}
49+
50+
if requireUUID && config.UUID == "" {
51+
return nil, fmt.Errorf("uuid not found in config file")
52+
}
53+
54+
return config, nil
55+
}

cmd/helpers/env.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package helpers
2+
3+
import (
4+
"fmt"
5+
"os"
6+
)
7+
8+
// GetEnvRequired retrieves an environment variable or returns an error if not set
9+
func GetEnvRequired(key string) (string, error) {
10+
value := os.Getenv(key)
11+
if value == "" {
12+
return "", fmt.Errorf("environment variable %s not set", key)
13+
}
14+
return value, nil
15+
}
16+
17+
// GetEnvOrDefault retrieves an environment variable or returns the default value if not set
18+
func GetEnvOrDefault(key, defaultValue string) string {
19+
if value := os.Getenv(key); value != "" {
20+
return value
21+
}
22+
return defaultValue
23+
}

0 commit comments

Comments
 (0)