diff --git a/go.work.sum b/go.work.sum index 5d8ac06..f494c9d 100644 --- a/go.work.sum +++ b/go.work.sum @@ -1,3 +1,14 @@ +github.com/agnivade/levenshtein v1.2.1/go.mod h1:QVVI16kDrtSuwcpd0p1+xMC6Z/VfhtCyDIjcwga4/DU= +github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA= +github.com/bits-and-blooms/bitset v1.22.0/go.mod h1:7hO7Gc7Pp1vODcmWvKMRA9BNmbv6a/7QIWpPxHddWR8= +github.com/bmatcuk/doublestar/v4 v4.9.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= +github.com/charmbracelet/harmonica v0.2.0/go.mod h1:KSri/1RMQOZLbw7AHqgcBycp8pgJnQMYYT8QZRqZ1Ao= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/sahilm/fuzzy v0.1.1/go.mod h1:VFvziUEIMCrT6A6tw2RFIXPXXmzXbOsSHF0DOI8ZK9Y= github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA= +golang.org/x/crypto v0.44.0/go.mod h1:013i+Nw79BMiQiMsOPcVCB5ZIJbYkerPrGnOa00tvmc= +golang.org/x/mod v0.29.0/go.mod h1:NyhrlYXJ2H4eJiRy/WDBO6HMqZQ6q9nk4JzS3NuCK+w= +golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254= +golang.org/x/tools v0.38.0/go.mod h1:yEsQ/d/YK8cjh0L6rZlY8tgtlKiBNTL14pGDJPJpYQs= diff --git a/shared/prompt/confirm.go b/shared/prompt/confirm.go new file mode 100644 index 0000000..406865d --- /dev/null +++ b/shared/prompt/confirm.go @@ -0,0 +1,32 @@ +// Package prompt provides user interaction utilities. +package prompt + +import ( + "bufio" + "io" + "strings" +) + +// Confirm prompts the user for confirmation and returns true if they answer yes. +// It reads a single line from stdin and returns true if the answer is "y" or "Y". +func Confirm(stdin io.Reader) (bool, error) { + scanner := bufio.NewScanner(stdin) + if scanner.Scan() { + answer := strings.TrimSpace(scanner.Text()) + return answer == "y" || answer == "Y", nil + } + if err := scanner.Err(); err != nil { + return false, err + } + // EOF with no input means no confirmation + return false, nil +} + +// ConfirmOrForce returns true if force is true, or if the user confirms interactively. +// If force is true, it returns immediately without reading from stdin. +func ConfirmOrForce(force bool, stdin io.Reader) (bool, error) { + if force { + return true, nil + } + return Confirm(stdin) +} diff --git a/shared/prompt/confirm_test.go b/shared/prompt/confirm_test.go new file mode 100644 index 0000000..89a5f9a --- /dev/null +++ b/shared/prompt/confirm_test.go @@ -0,0 +1,113 @@ +package prompt + +import ( + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestConfirm(t *testing.T) { + tests := []struct { + name string + input string + want bool + wantErr bool + }{ + { + name: "lowercase y confirms", + input: "y\n", + want: true, + }, + { + name: "uppercase Y confirms", + input: "Y\n", + want: true, + }, + { + name: "yes does not confirm (only y)", + input: "yes\n", + want: false, + }, + { + name: "n does not confirm", + input: "n\n", + want: false, + }, + { + name: "empty input does not confirm", + input: "\n", + want: false, + }, + { + name: "whitespace around y confirms", + input: " y \n", + want: true, + }, + { + name: "EOF without input does not confirm", + input: "", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := Confirm(strings.NewReader(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestConfirmOrForce(t *testing.T) { + tests := []struct { + name string + force bool + input string + want bool + wantErr bool + }{ + { + name: "force bypasses confirmation", + force: true, + input: "", // Not read when force is true + want: true, + }, + { + name: "without force, y confirms", + force: false, + input: "y\n", + want: true, + }, + { + name: "without force, n does not confirm", + force: false, + input: "n\n", + want: false, + }, + { + name: "without force, empty does not confirm", + force: false, + input: "\n", + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ConfirmOrForce(tt.force, strings.NewReader(tt.input)) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} diff --git a/tools/cfl/internal/cmd/page/delete.go b/tools/cfl/internal/cmd/page/delete.go index 3bd5185..09d7936 100644 --- a/tools/cfl/internal/cmd/page/delete.go +++ b/tools/cfl/internal/cmd/page/delete.go @@ -1,12 +1,13 @@ package page import ( - "bufio" "context" "fmt" "github.com/spf13/cobra" + "github.com/open-cli-collective/atlassian-go/prompt" + "github.com/open-cli-collective/confluence-cli/internal/cmd/root" ) @@ -55,13 +56,11 @@ func runDelete(pageID string, opts *deleteOptions) error { fmt.Printf("About to delete page: %s (ID: %s)\n", page.Title, page.ID) fmt.Print("Are you sure? [y/N]: ") - scanner := bufio.NewScanner(opts.Stdin) - var confirm string - if scanner.Scan() { - confirm = scanner.Text() + confirmed, err := prompt.Confirm(opts.Stdin) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) } - - if confirm != "y" && confirm != "Y" { + if !confirmed { fmt.Println("Deletion cancelled.") return nil } diff --git a/tools/jtk/internal/cmd/issues/delete.go b/tools/jtk/internal/cmd/issues/delete.go index bd7ac53..70c5f07 100644 --- a/tools/jtk/internal/cmd/issues/delete.go +++ b/tools/jtk/internal/cmd/issues/delete.go @@ -5,6 +5,8 @@ import ( "github.com/spf13/cobra" + "github.com/open-cli-collective/atlassian-go/prompt" + "github.com/open-cli-collective/jira-ticket-cli/internal/cmd/root" ) @@ -35,9 +37,17 @@ func runDelete(opts *root.Options, issueKey string, force bool) error { v := opts.View() if !force { - v.Warning("This will permanently delete issue %s. This action cannot be undone.", issueKey) - v.Info("Use --force to skip this confirmation.") - return fmt.Errorf("deletion cancelled (use --force to confirm)") + fmt.Printf("This will permanently delete issue %s. This action cannot be undone.\n", issueKey) + fmt.Print("Are you sure? [y/N]: ") + + confirmed, err := prompt.Confirm(opts.Stdin) + if err != nil { + return fmt.Errorf("failed to read confirmation: %w", err) + } + if !confirmed { + v.Info("Deletion cancelled.") + return nil + } } client, err := opts.APIClient()