Skip to content

Commit 8ebd8d7

Browse files
committed
refactor: enhance CLI commands with UI formatting and improved prompting
This commit refines the webhook-cli commands by integrating UI formatting for output messages, enhancing user experience. It replaces plain text outputs with formatted messages for actions like clearing the cache, cleaning templates, and downloading templates. Additionally, it introduces a custom prompter for user confirmations, ensuring a more interactive command-line experience. Several test cases are added to validate these changes, improving overall code quality and reliability.
1 parent e64f8e4 commit 8ebd8d7

11 files changed

Lines changed: 347 additions & 213 deletions

apps/webhook-cli/internal/cli/templates/cache_command.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import (
88
"github.com/spf13/cobra"
99

1010
"github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/runtime"
11+
"github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/ui"
1112
)
1213

1314
func newCacheCommand(deps Dependencies) *cobra.Command {
@@ -44,7 +45,7 @@ func newCacheClearCommand(deps Dependencies) *cobra.Command {
4445
if err := service.ClearCache(ctx); err != nil {
4546
return mapTemplateCommandError(err, "")
4647
}
47-
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Template cache cleared.")
48+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.FormatSuccess("Template cache cleared."))
4849
return nil
4950
},
5051
}
Lines changed: 10 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,14 @@
11
package templates
22

33
import (
4-
"bufio"
54
"context"
65
"errors"
76
"fmt"
8-
"io"
9-
"strings"
107

118
"github.com/spf13/cobra"
129

1310
"github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/runtime"
11+
"github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/ui"
1412
)
1513

1614
func newCleanCommand(deps Dependencies) *cobra.Command {
@@ -39,25 +37,30 @@ func newCleanCommand(deps Dependencies) *cobra.Command {
3937
if err != nil {
4038
return mapTemplateCommandError(err, "")
4139
}
40+
prompter := deps.Prompter
41+
if prompter == nil {
42+
prompter = ui.DefaultPrompter
43+
}
4244
if len(items) == 0 {
43-
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "No local templates to remove.")
45+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.FormatInfo("No local templates to remove."))
4446
return nil
4547
}
4648
if !cleanArgs.Force {
47-
confirmed, confirmErr := promptTemplateCleanConfirm(cmd, fmt.Sprintf("Delete all %d template(s)? [y/N]: ", len(items)))
49+
prompt := fmt.Sprintf("Delete all %d template(s)?", len(items))
50+
confirmed, confirmErr := prompter.Confirm(prompt, cmd.InOrStdin(), cmd.OutOrStdout())
4851
if confirmErr != nil {
4952
return confirmErr
5053
}
5154
if !confirmed {
52-
_, _ = fmt.Fprintln(cmd.OutOrStdout(), "Cancelled.")
55+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.FormatCancelled())
5356
return nil
5457
}
5558
}
5659
deletedCount, err := service.CleanLocal(ctx)
5760
if err != nil {
5861
return mapTemplateCommandError(err, "")
5962
}
60-
_, _ = fmt.Fprintf(cmd.OutOrStdout(), "Removed %d template(s)\n", deletedCount)
63+
_, _ = fmt.Fprintln(cmd.OutOrStdout(), ui.FormatSuccess(fmt.Sprintf("Removed %d template(s)", deletedCount)))
6164
return nil
6265
},
6366
}
@@ -66,21 +69,3 @@ func newCleanCommand(deps Dependencies) *cobra.Command {
6669
cmd.Flags().String("templates-dir", "", "Directory where templates are stored")
6770
return cmd
6871
}
69-
70-
func promptTemplateCleanConfirm(cmd *cobra.Command, prompt string) (bool, error) {
71-
_, _ = fmt.Fprint(cmd.OutOrStdout(), prompt)
72-
reader := bufio.NewReader(cmd.InOrStdin())
73-
line, err := reader.ReadString('\n')
74-
if err != nil {
75-
if errors.Is(err, io.EOF) {
76-
normalized := strings.TrimSpace(strings.ToLower(line))
77-
if normalized == "" {
78-
return false, nil
79-
}
80-
return normalized == "y" || normalized == "yes", nil
81-
}
82-
return false, err
83-
}
84-
normalized := strings.TrimSpace(strings.ToLower(line))
85-
return normalized == "y" || normalized == "yes", nil
86-
}

apps/webhook-cli/internal/cli/templates/clean_command_test.go

Lines changed: 196 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -2,42 +2,218 @@ package templates
22

33
import (
44
"bytes"
5+
"context"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"io"
10+
"path/filepath"
11+
"strings"
512
"testing"
13+
"time"
614

715
"github.com/spf13/cobra"
16+
17+
templatestore "github.com/endalk200/better-webhook/apps/webhook-cli/internal/adapters/storage/template"
18+
apptemplates "github.com/endalk200/better-webhook/apps/webhook-cli/internal/app/templates"
19+
domain "github.com/endalk200/better-webhook/apps/webhook-cli/internal/domain/template"
20+
"github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/runtime"
21+
platformtime "github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/time"
822
)
923

10-
func TestPromptTemplateCleanConfirmHandlesEOFWithoutTrailingNewline(t *testing.T) {
11-
command := &cobra.Command{Use: "test"}
12-
command.SetIn(bytes.NewBufferString("y"))
13-
command.SetOut(&bytes.Buffer{})
24+
func TestCleanCommandRejectsUnexpectedArgs(t *testing.T) {
25+
cmd := newCleanCommand(Dependencies{})
26+
if err := cmd.Args(cmd, []string{"unexpected"}); err == nil {
27+
t.Fatalf("expected clean command to reject positional args")
28+
}
29+
}
30+
31+
type fakeTemplatePrompter struct {
32+
confirmed bool
33+
err error
34+
called int
35+
}
36+
37+
func (p *fakeTemplatePrompter) Confirm(_ string, _ io.Reader, _ io.Writer) (bool, error) {
38+
p.called++
39+
if p.err != nil {
40+
return false, p.err
41+
}
42+
return p.confirmed, nil
43+
}
44+
45+
func TestCleanCommandCancelledByPromptKeepsTemplates(t *testing.T) {
46+
templatesDir := t.TempDir()
47+
seedLocalTemplateForCleanTest(t, templatesDir, "github-push")
48+
prompter := &fakeTemplatePrompter{confirmed: false}
49+
50+
cmd := newCleanCommand(Dependencies{
51+
ServiceFactory: testTemplateServiceFactory(t),
52+
Prompter: prompter,
53+
})
54+
var output bytes.Buffer
55+
cmd.SetOut(&output)
56+
cmd.SetErr(&output)
57+
cmd.SetIn(strings.NewReader("unused\n"))
58+
cmd.SetArgs([]string{"--templates-dir", templatesDir})
59+
initializeTemplatesRuntimeConfig(t, cmd, templatesDir)
1460

15-
confirmed, err := promptTemplateCleanConfirm(command, "Delete? ")
61+
if err := cmd.Execute(); err != nil {
62+
t.Fatalf("execute clean command: %v", err)
63+
}
64+
if prompter.called != 1 {
65+
t.Fatalf("expected prompter to be called once, got %d", prompter.called)
66+
}
67+
if !strings.Contains(output.String(), "Cancelled.") {
68+
t.Fatalf("expected cancellation output, got %q", output.String())
69+
}
70+
71+
store, err := templatestore.NewStore(templatesDir)
1672
if err != nil {
17-
t.Fatalf("prompt confirm returned error: %v", err)
73+
t.Fatalf("create local template store: %v", err)
1874
}
19-
if !confirmed {
20-
t.Fatalf("expected EOF input without newline to be treated as confirmation")
75+
items, err := store.List(context.Background())
76+
if err != nil {
77+
t.Fatalf("list local templates: %v", err)
78+
}
79+
if len(items) != 1 {
80+
t.Fatalf("expected template to remain after cancellation, got %d", len(items))
2181
}
2282
}
2383

24-
func TestPromptTemplateCleanConfirmTreatsEmptyEOFAsCancel(t *testing.T) {
25-
command := &cobra.Command{Use: "test"}
26-
command.SetIn(bytes.NewBuffer(nil))
27-
command.SetOut(&bytes.Buffer{})
84+
func TestCleanCommandForceSkipsPromptAndDeletesTemplates(t *testing.T) {
85+
templatesDir := t.TempDir()
86+
seedLocalTemplateForCleanTest(t, templatesDir, "github-push")
87+
prompter := &fakeTemplatePrompter{err: errors.New("prompter should not be called")}
88+
89+
cmd := newCleanCommand(Dependencies{
90+
ServiceFactory: testTemplateServiceFactory(t),
91+
Prompter: prompter,
92+
})
93+
var output bytes.Buffer
94+
cmd.SetOut(&output)
95+
cmd.SetErr(&output)
96+
cmd.SetArgs([]string{"--templates-dir", templatesDir, "--force"})
97+
initializeTemplatesRuntimeConfig(t, cmd, templatesDir)
2898

29-
confirmed, err := promptTemplateCleanConfirm(command, "Delete? ")
99+
if err := cmd.Execute(); err != nil {
100+
t.Fatalf("execute clean command: %v", err)
101+
}
102+
if prompter.called != 0 {
103+
t.Fatalf("expected --force to skip prompt, got %d calls", prompter.called)
104+
}
105+
if !strings.Contains(output.String(), "Removed 1 template(s)") {
106+
t.Fatalf("expected clean success output, got %q", output.String())
107+
}
108+
109+
store, err := templatestore.NewStore(templatesDir)
30110
if err != nil {
31-
t.Fatalf("prompt confirm returned error: %v", err)
111+
t.Fatalf("create local template store: %v", err)
32112
}
33-
if confirmed {
34-
t.Fatalf("expected empty EOF input to be treated as cancel")
113+
items, err := store.List(context.Background())
114+
if err != nil {
115+
t.Fatalf("list local templates: %v", err)
116+
}
117+
if len(items) != 0 {
118+
t.Fatalf("expected templates to be deleted, got %d", len(items))
35119
}
36120
}
37121

38-
func TestCleanCommandRejectsUnexpectedArgs(t *testing.T) {
39-
cmd := newCleanCommand(Dependencies{})
40-
if err := cmd.Args(cmd, []string{"unexpected"}); err == nil {
41-
t.Fatalf("expected clean command to reject positional args")
122+
func TestCleanCommandReturnsPromptError(t *testing.T) {
123+
templatesDir := t.TempDir()
124+
seedLocalTemplateForCleanTest(t, templatesDir, "github-push")
125+
prompter := &fakeTemplatePrompter{err: errors.New("prompt failed")}
126+
127+
cmd := newCleanCommand(Dependencies{
128+
ServiceFactory: testTemplateServiceFactory(t),
129+
Prompter: prompter,
130+
})
131+
cmd.SetOut(&bytes.Buffer{})
132+
cmd.SetErr(&bytes.Buffer{})
133+
cmd.SetArgs([]string{"--templates-dir", templatesDir})
134+
initializeTemplatesRuntimeConfig(t, cmd, templatesDir)
135+
136+
err := cmd.Execute()
137+
if err == nil {
138+
t.Fatalf("expected clean command to return prompt error")
139+
}
140+
if !strings.Contains(err.Error(), "prompt failed") {
141+
t.Fatalf("expected prompt error to be returned, got %v", err)
142+
}
143+
}
144+
145+
type noOpRemoteTemplateSource struct{}
146+
147+
func (noOpRemoteTemplateSource) FetchIndex(context.Context) (domain.TemplatesIndex, error) {
148+
return domain.TemplatesIndex{}, errors.New("not implemented")
149+
}
150+
151+
func (noOpRemoteTemplateSource) FetchTemplate(context.Context, string) (domain.WebhookTemplate, error) {
152+
return domain.WebhookTemplate{}, errors.New("not implemented")
153+
}
154+
155+
func testTemplateServiceFactory(t *testing.T) ServiceFactory {
156+
t.Helper()
157+
return func(templatesDir string) (*apptemplates.Service, error) {
158+
localStore, err := templatestore.NewStore(templatesDir)
159+
if err != nil {
160+
return nil, err
161+
}
162+
cacheStore, err := templatestore.NewCache(filepath.Join(templatesDir, ".index-cache.json"))
163+
if err != nil {
164+
return nil, err
165+
}
166+
return apptemplates.NewService(
167+
localStore,
168+
noOpRemoteTemplateSource{},
169+
cacheStore,
170+
platformtime.SystemClock{},
171+
), nil
172+
}
173+
}
174+
175+
func seedLocalTemplateForCleanTest(t *testing.T, templatesDir string, id string) {
176+
t.Helper()
177+
store, err := templatestore.NewStore(templatesDir)
178+
if err != nil {
179+
t.Fatalf("create local template store: %v", err)
180+
}
181+
182+
downloadedAt := time.Date(2026, time.February, 24, 12, 0, 0, 0, time.UTC).Format(time.RFC3339Nano)
183+
templateFile := fmt.Sprintf("github/%s.jsonc", id)
184+
if _, err := store.Save(context.Background(), domain.TemplateMetadata{
185+
ID: id,
186+
Name: id,
187+
Provider: "github",
188+
Event: "push",
189+
File: templateFile,
190+
}, domain.WebhookTemplate{
191+
Method: "POST",
192+
Provider: "github",
193+
Event: "push",
194+
Body: json.RawMessage(`{"ok":true}`),
195+
}, downloadedAt); err != nil {
196+
t.Fatalf("seed local template: %v", err)
197+
}
198+
}
199+
200+
type staticTemplateConfigLoader struct {
201+
config runtime.AppConfig
202+
}
203+
204+
func (l staticTemplateConfigLoader) Load(_ string) (runtime.AppConfig, error) {
205+
return l.config, nil
206+
}
207+
208+
func initializeTemplatesRuntimeConfig(t *testing.T, cmd *cobra.Command, templatesDir string) {
209+
t.Helper()
210+
if err := runtime.InitializeConfig(cmd, staticTemplateConfigLoader{
211+
config: runtime.AppConfig{
212+
CapturesDir: t.TempDir(),
213+
TemplatesDir: templatesDir,
214+
LogLevel: runtime.LogLevelInfo,
215+
},
216+
}); err != nil {
217+
t.Fatalf("initialize runtime config: %v", err)
42218
}
43219
}

apps/webhook-cli/internal/cli/templates/command.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,12 +11,14 @@ import (
1111
apptemplates "github.com/endalk200/better-webhook/apps/webhook-cli/internal/app/templates"
1212
domain "github.com/endalk200/better-webhook/apps/webhook-cli/internal/domain/template"
1313
platformplaceholders "github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/placeholders"
14+
"github.com/endalk200/better-webhook/apps/webhook-cli/internal/platform/ui"
1415
)
1516

1617
type ServiceFactory func(templatesDir string) (*apptemplates.Service, error)
1718

1819
type Dependencies struct {
1920
ServiceFactory ServiceFactory
21+
Prompter ui.Prompter
2022
}
2123

2224
func NewCommand(deps Dependencies) *cobra.Command {

0 commit comments

Comments
 (0)