diff --git a/.goreleaser.yaml b/.goreleaser.yaml index 8cfb152..00ae01e 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -30,7 +30,7 @@ universal_binaries: - replace: true archives: - - format: tar.gz + - formats: ["tar.gz"] # this name template makes the OS and Arch compatible with the results of `uname`. name_template: >- {{ .ProjectName }}_ @@ -42,7 +42,7 @@ archives: # use zip for windows archives format_overrides: - goos: windows - format: zip + formats: ["zip"] report_sizes: true diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 73ece76..e141e19 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,7 @@ 2. Install goreleaser ``` -brew install goreleaser/tap/goreleaser +brew install --cask goreleaser/tap/goreleaser ``` 3. Build the CLI @@ -68,6 +68,163 @@ Some facts that could be useful: - You can enable debug output with the `PINECONE_LOG_LEVEL=DEBUG` env var - Are you pointed at the correct environment? The current value of the environment setting (i.e. prod or staging) is controlled through `pc config set-environment staging` is not clearly surfaced through the printed output. If things aren't working as you expect, you might be pointed in the wrong place. See `cat ~/.config/pinecone/config.yaml` to confirm. +## Development Practices & Tools + +This project follows several established patterns and provides utilities to ensure consistency across the codebase. + +### Output Functions & Quiet Mode + +The CLI supports a `-q` (quiet) flag that suppresses non-essential output while preserving essential data. Follow these guidelines: + +**Use `pcio` functions for:** + +- User-facing messages (success, error, warning, info) +- Progress indicators and status updates +- Interactive prompts and confirmations +- Help text and documentation +- Any output that should be suppressed with `-q` flag + +**Use `fmt` functions for:** + +- Data output from informational commands (list, describe) +- JSON output that should always be displayed +- Table rendering and structured data display +- Any output that should NOT be suppressed with `-q` flag + +```go +// āœ… Correct usage +pcio.Println("Creating index...") // User message - suppressed with -q +msg.SuccessMsg("Index created!") // User message - suppressed with -q +fmt.Println(jsonData) // Data output - always displayed + +// āŒ Incorrect usage +pcio.Println(jsonData) // Wrong! Data would be suppressed +fmt.Println("Creating index...") // Wrong! Ignores quiet mode +``` + +### Error Handling + +Use the centralized error handling utilities: + +```go +// For API errors with structured responses +errorutil.HandleIndexAPIError(err, cmd, args) + +// For program termination +exit.Error(err) // Logs error and exits with code 1 +exit.ErrorMsg("msg") // Logs message and exits with code 1 +exit.Success() // Logs success and exits with code 0 +``` + +### User Messages & Styling + +Use the `msg` package for consistent user messaging: + +```go +msg.SuccessMsg("Operation completed successfully!") +msg.FailMsg("Operation failed: %s", err) +msg.WarnMsg("This will delete the resource") +msg.InfoMsg("Processing...") +msg.HintMsg("Use --help for more options") + +// Multi-line messages +msg.WarnMsgMultiLine("Warning 1", "Warning 2", "Warning 3") +``` + +Use the `style` package for consistent text formatting: + +```go +style.Heading("Section Title") +style.Emphasis("important text") +style.Code("command-name") +style.URL("https://example.com") +``` + +### Interactive Components + +For user confirmations, use the interactive package: + +```go +result := interactive.AskForConfirmation("Delete this resource?") +switch result { +case interactive.ConfirmationYes: + // Proceed with deletion +case interactive.ConfirmationNo: + // Cancel operation +case interactive.ConfirmationQuit: + // Exit program +} +``` + +### Table Rendering + +Use the `presenters` package for consistent table output: + +```go +// For data tables (always displayed, not suppressed by -q) +presenters.PrintTable(presenters.TableOptions{ + Columns: []presenters.Column{{Title: "Name", Width: 20}}, + Rows: []presenters.Row{{"example"}}, +}) + +// For index-specific tables +presenters.PrintIndexTableWithIndexAttributesGroups(indexes, groups) +``` + +### Testing Utilities + +Use the `testutils` package for consistent command testing: + +```go +// Test command arguments and flags +tests := []testutils.CommandTestConfig{ + { + Name: "valid arguments", + Args: []string{"my-arg"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + ExpectedArgs: []string{"my-arg"}, + }, +} +testutils.TestCommandArgsAndFlags(t, cmd, tests) + +// Test JSON flag configuration +testutils.AssertJSONFlag(t, cmd) +``` + +### Validation Utilities + +Use centralized validation functions: + +```go +// For index name validation +index.ValidateIndexNameArgs(cmd, args) + +// For other validations, check the respective utility packages +``` + +### Logging + +Use structured logging with the `log` package: + +```go +log.Debug().Str("index", name).Msg("Creating index") +log.Error().Err(err).Msg("Failed to create index") +log.Info().Msg("Operation completed") +``` + +### Configuration Management + +Use the configuration utilities for consistent config handling: + +```go +// Get current state +org := state.TargetOrg.Get() +proj := state.TargetProj.Get() + +// Configuration files are managed through the config package +``` + ## Making a Pull Request Please fork this repo and make a PR with your changes. Run `gofmt` and `goimports` on all proposed diff --git a/go.mod b/go.mod index d871e2b..8069eac 100644 --- a/go.mod +++ b/go.mod @@ -4,7 +4,6 @@ go 1.23.0 require ( github.com/MakeNowJust/heredoc v1.0.0 - github.com/briandowns/spinner v1.23.0 github.com/charmbracelet/bubbles v0.18.0 github.com/charmbracelet/bubbletea v0.25.0 github.com/charmbracelet/lipgloss v0.10.0 @@ -13,6 +12,7 @@ require ( github.com/pinecone-io/go-pinecone/v4 v4.1.4 github.com/rs/zerolog v1.32.0 github.com/spf13/cobra v1.8.0 + github.com/spf13/pflag v1.0.5 github.com/spf13/viper v1.18.2 golang.org/x/oauth2 v0.30.0 golang.org/x/term v0.33.0 @@ -49,7 +49,6 @@ require ( github.com/sourcegraph/conc v0.3.0 // indirect github.com/spf13/afero v1.11.0 // indirect github.com/spf13/cast v1.6.0 // indirect - github.com/spf13/pflag v1.0.5 // indirect github.com/stretchr/testify v1.10.0 // indirect github.com/subosito/gotenv v1.6.0 // indirect go.uber.org/atomic v1.9.0 // indirect diff --git a/go.sum b/go.sum index d4706db..9459458 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,6 @@ github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= github.com/bmatcuk/doublestar v1.1.1/go.mod h1:UD6OnuiIn0yFxxA2le/rnRU1G4RaI4UvFv1sNto9p6w= -github.com/briandowns/spinner v1.23.0 h1:alDF2guRWqa/FOZZYWjlMIx2L6H0wyewPxo/CH4Pt2A= -github.com/briandowns/spinner v1.23.0/go.mod h1:rPG4gmXeN3wQV/TsAY4w8lPdIM6RX3yqeBQJSrbXjuE= github.com/charmbracelet/bubbles v0.18.0 h1:PYv1A036luoBGroX6VWjQIE9Syf2Wby2oOl/39KLfy0= github.com/charmbracelet/bubbles v0.18.0/go.mod h1:08qhZhtIwzgrtBjAcJnij1t1H0ZRjwHyGsy6AL11PSw= github.com/charmbracelet/bubbletea v0.25.0 h1:bAfwk7jRz7FKFl9RzlIULPkStffg5k6pNt5dywy4TcM= diff --git a/internal/pkg/cli/command/apiKey/delete.go b/internal/pkg/cli/command/apiKey/delete.go index 7ccf29d..988c38e 100644 --- a/internal/pkg/cli/command/apiKey/delete.go +++ b/internal/pkg/cli/command/apiKey/delete.go @@ -1,16 +1,13 @@ package apiKey import ( - "bufio" - "fmt" - "os" - "strings" - "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" @@ -65,29 +62,16 @@ func NewDeleteKeyCmd() *cobra.Command { } func confirmDeleteApiKey(apiKeyName string) { - msg.WarnMsg("This operation will delete API Key %s from project %s.", style.Emphasis(apiKeyName), style.Emphasis(state.TargetProj.Get().Name)) - msg.WarnMsg("Any integrations you have that auth with this API Key will stop working.") - msg.WarnMsg("This action cannot be undone.") - - // Prompt the user - fmt.Print("Do you want to continue? (y/N): ") - - // Read the user's input - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - fmt.Println("Error reading input:", err) - return - } - - // Trim any whitespace from the input and convert to lowercase - input = strings.TrimSpace(strings.ToLower(input)) + msg.WarnMsgMultiLine( + pcio.Sprintf("This operation will delete API Key %s from project %s.", style.Emphasis(apiKeyName), style.Emphasis(state.TargetProj.Get().Name)), + "Any integrations you have that auth with this API Key will stop working.", + "This action cannot be undone.", + ) - // Check if the user entered "y" or "yes" - if input == "y" || input == "yes" { - msg.InfoMsg("You chose to continue delete.") - } else { + question := "Are you sure you want to proceed with deleting this API key?" + if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() } + msg.InfoMsg("You chose to continue delete.") } diff --git a/internal/pkg/cli/command/apiKey/list.go b/internal/pkg/cli/command/apiKey/list.go index 1698c8a..0430745 100644 --- a/internal/pkg/cli/command/apiKey/list.go +++ b/internal/pkg/cli/command/apiKey/list.go @@ -1,6 +1,7 @@ package apiKey import ( + "fmt" "sort" "strings" @@ -9,7 +10,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" @@ -61,7 +61,7 @@ func NewListKeysCmd() *cobra.Command { if options.json { json := text.IndentJSON(sortedKeys) - pcio.Println(json) + fmt.Println(json) } else { printTable(sortedKeys) } @@ -74,17 +74,17 @@ func NewListKeysCmd() *cobra.Command { } func printTable(keys []*pinecone.APIKey) { - pcio.Printf("Organization: %s (ID: %s)\n", style.Emphasis(state.TargetOrg.Get().Name), style.Emphasis(state.TargetOrg.Get().Id)) - pcio.Printf("Project: %s (ID: %s)\n", style.Emphasis(state.TargetProj.Get().Name), style.Emphasis(state.TargetProj.Get().Id)) - pcio.Println() - pcio.Println(style.Heading("API Keys")) - pcio.Println() + fmt.Printf("Organization: %s (ID: %s)\n", style.Emphasis(state.TargetOrg.Get().Name), style.Emphasis(state.TargetOrg.Get().Id)) + fmt.Printf("Project: %s (ID: %s)\n", style.Emphasis(state.TargetProj.Get().Name), style.Emphasis(state.TargetProj.Get().Id)) + fmt.Println() + fmt.Println(style.Heading("API Keys")) + fmt.Println() writer := presenters.NewTabWriter() columns := []string{"NAME", "ID", "PROJECT ID", "ROLES"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, key := range keys { values := []string{ @@ -93,7 +93,7 @@ func printTable(keys []*pinecone.APIKey) { key.ProjectId, strings.Join(key.Roles, ", "), } - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() diff --git a/internal/pkg/cli/command/collection/describe.go b/internal/pkg/cli/command/collection/describe.go index 35d24d8..7b84f9b 100644 --- a/internal/pkg/cli/command/collection/describe.go +++ b/internal/pkg/cli/command/collection/describe.go @@ -2,10 +2,10 @@ package collection import ( "context" + "fmt" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -35,7 +35,7 @@ func NewDescribeCollectionCmd() *cobra.Command { if options.json { json := text.IndentJSON(collection) - pcio.Println(json) + fmt.Println(json) } else { presenters.PrintDescribeCollectionTable(collection) } diff --git a/internal/pkg/cli/command/collection/list.go b/internal/pkg/cli/command/collection/list.go index d693758..9f7878f 100644 --- a/internal/pkg/cli/command/collection/list.go +++ b/internal/pkg/cli/command/collection/list.go @@ -2,6 +2,7 @@ package collection import ( "context" + "fmt" "os" "sort" "strconv" @@ -10,7 +11,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/spf13/cobra" @@ -45,7 +45,7 @@ func NewListCollectionsCmd() *cobra.Command { if options.json { json := text.IndentJSON(collections) - pcio.Println(json) + fmt.Println(json) } else { printTable(collections) } @@ -63,11 +63,11 @@ func printTable(collections []*pinecone.Collection) { columns := []string{"NAME", "DIMENSION", "SIZE", "STATUS", "VECTORS", "ENVIRONMENT"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, coll := range collections { values := []string{coll.Name, string(coll.Dimension), strconv.FormatInt(coll.Size, 10), string(coll.Status), string(coll.VectorCount), coll.Environment} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() } diff --git a/internal/pkg/cli/command/config/cmd.go b/internal/pkg/cli/command/config/cmd.go index b435531..029058d 100644 --- a/internal/pkg/cli/command/config/cmd.go +++ b/internal/pkg/cli/command/config/cmd.go @@ -18,9 +18,11 @@ func NewConfigCmd() *cobra.Command { } cmd.AddCommand(NewSetColorCmd()) + cmd.AddCommand(NewSetColorSchemeCmd()) cmd.AddCommand(NewSetApiKeyCmd()) cmd.AddCommand(NewGetApiKeyCmd()) cmd.AddCommand(NewSetEnvCmd()) + cmd.AddCommand(NewShowColorSchemeCmd()) return cmd } diff --git a/internal/pkg/cli/command/config/set_color_scheme.go b/internal/pkg/cli/command/config/set_color_scheme.go new file mode 100644 index 0000000..ed4136d --- /dev/null +++ b/internal/pkg/cli/command/config/set_color_scheme.go @@ -0,0 +1,66 @@ +package config + +import ( + "strings" + + conf "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" + "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/spf13/cobra" +) + +func NewSetColorSchemeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "set-color-scheme", + Short: "Configure the color scheme for the Pinecone CLI", + Long: `Set the color scheme used by the Pinecone CLI. + +Available color schemes: + pc-default-dark - Dark theme optimized for dark terminal backgrounds + pc-default-light - Light theme optimized for light terminal backgrounds + +The color scheme affects all colored output in the CLI, including tables, messages, and the color scheme display.`, + Example: help.Examples([]string{ + "pc config set-color-scheme pc-default-dark", + "pc config set-color-scheme pc-default-light", + }), + Run: func(cmd *cobra.Command, args []string) { + if len(args) == 0 { + msg.FailMsg("Please provide a color scheme name") + msg.InfoMsg("Available color schemes: %s", strings.Join(getAvailableColorSchemes(), ", ")) + exit.ErrorMsg("No color scheme provided") + } + + schemeName := args[0] + + // Validate the color scheme + if !isValidColorScheme(schemeName) { + msg.FailMsg("Invalid color scheme: %s", schemeName) + msg.InfoMsg("Available color schemes: %s", strings.Join(getAvailableColorSchemes(), ", ")) + exit.ErrorMsg("Invalid color scheme") + } + + conf.ColorScheme.Set(schemeName) + msg.SuccessMsg("Color scheme updated to %s", style.Emphasis(schemeName)) + }, + } + + return cmd +} + +// getAvailableColorSchemes returns a list of available color scheme names +func getAvailableColorSchemes() []string { + schemes := make([]string, 0, len(style.AvailableColorSchemes)) + for name := range style.AvailableColorSchemes { + schemes = append(schemes, name) + } + return schemes +} + +// isValidColorScheme checks if the given scheme name is valid +func isValidColorScheme(schemeName string) bool { + _, exists := style.AvailableColorSchemes[schemeName] + return exists +} diff --git a/internal/pkg/cli/command/config/show_color_scheme.go b/internal/pkg/cli/command/config/show_color_scheme.go new file mode 100644 index 0000000..2991ae7 --- /dev/null +++ b/internal/pkg/cli/command/config/show_color_scheme.go @@ -0,0 +1,93 @@ +package config + +import ( + "fmt" + + conf "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" + "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/spf13/cobra" +) + +func NewShowColorSchemeCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "show-color-scheme", + Short: "Display the Pinecone CLI color scheme for development reference", + Long: `Display all available colors in the Pinecone CLI color scheme. +This command is useful for developers to see available colors and choose appropriate ones for their components. + +Use 'pc config set-color-scheme' to change the color scheme.`, + Example: help.Examples([]string{ + "pc config show-color-scheme", + }), + Run: func(cmd *cobra.Command, args []string) { + showSimpleColorScheme() + }, + } + + return cmd +} + +// showSimpleColorScheme displays colors in a simple text format +func showSimpleColorScheme() { + colorsEnabled := conf.Color.Get() + + fmt.Println("šŸŽØ Pinecone CLI Color Scheme") + fmt.Println("============================") + fmt.Printf("Colors Enabled: %t\n", colorsEnabled) + + // Show which color scheme is being used + currentScheme := conf.ColorScheme.Get() + fmt.Printf("Color Scheme: %s\n", currentScheme) + fmt.Println() + + if colorsEnabled { + // Primary colors + fmt.Println("Primary Colors:") + fmt.Printf(" Primary Blue: %s\n", style.PrimaryStyle().Render("This is primary blue text")) + fmt.Printf(" Success Green: %s\n", style.SuccessStyle().Render("This is success green text")) + fmt.Printf(" Warning Yellow: %s\n", style.WarningStyle().Render("This is warning yellow text")) + fmt.Printf(" Error Red: %s\n", style.ErrorStyle().Render("This is error red text")) + fmt.Printf(" Info Blue: %s\n", style.InfoStyle().Render("This is info blue text")) + fmt.Println() + + // Text colors + fmt.Println("Text Colors:") + fmt.Printf(" Primary Text: %s\n", style.PrimaryTextStyle().Render("This is primary text")) + fmt.Printf(" Secondary Text: %s\n", style.SecondaryTextStyle().Render("This is secondary text")) + fmt.Printf(" Muted Text: %s\n", style.MutedTextStyle().Render("This is muted text")) + fmt.Println() + + // Background colors + fmt.Println("Background Colors:") + fmt.Printf(" Background: %s\n", style.BackgroundStyle().Render("This is background color")) + fmt.Printf(" Surface: %s\n", style.SurfaceStyle().Render("This is surface color")) + fmt.Println() + + // Border colors + fmt.Println("Border Colors:") + fmt.Printf(" Border: %s\n", style.BorderStyle().Render("This is border color")) + fmt.Printf(" Border Muted: %s\n", style.BorderMutedStyle().Render("This is border muted color")) + fmt.Println() + + // Usage examples with actual CLI function calls + fmt.Println("Status Messages Examples:") + fmt.Printf(" %s\n", style.SuccessMsg("Operation completed successfully")) + fmt.Printf(" %s\n", style.FailMsg("Operation failed")) + fmt.Printf(" %s\n", style.WarnMsg("This is a warning message")) + fmt.Printf(" %s\n", style.InfoMsg("This is an info message")) + fmt.Println() + + // Typography examples + fmt.Println("Typography Examples:") + fmt.Printf(" %s\n", style.Emphasis("This text is emphasized")) + fmt.Printf(" %s\n", style.HeavyEmphasis("This text is heavily emphasized")) + fmt.Printf(" %s\n", style.Heading("This is a heading")) + fmt.Printf(" %s\n", style.Underline("This text is underlined")) + fmt.Printf(" %s\n", style.Hint("This is a hint message")) + fmt.Printf(" This is code/command: %s\n", style.Code("pc login")) + fmt.Printf(" This is URL: %s\n", style.URL("https://pinecone.io")) + } else { + fmt.Println("Colors are disabled. Enable colors to see the color scheme.") + } +} diff --git a/internal/pkg/cli/command/index/configure.go b/internal/pkg/cli/command/index/configure.go index d148486..d246164 100644 --- a/internal/pkg/cli/command/index/configure.go +++ b/internal/pkg/cli/command/index/configure.go @@ -3,7 +3,9 @@ package index import ( "context" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" @@ -19,34 +21,34 @@ type configureIndexOptions struct { podType string replicas int32 deletionProtection string - - json bool + json bool } func NewConfigureIndexCmd() *cobra.Command { options := configureIndexOptions{} cmd := &cobra.Command{ - Use: "configure", - Short: "Configure an existing index with the specified configuration", - Example: "", + Use: "configure ", + Short: "Configure an existing index with the specified configuration", + Example: "", + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { - runConfigureIndexCmd(options) + options.name = args[0] + runConfigureIndexCmd(options, cmd, args) }, } - // Required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "name of index to configure") - // Optional flags cmd.Flags().StringVarP(&options.podType, "pod_type", "t", "", "type of pod to use, can only upgrade when configuring") cmd.Flags().Int32VarP(&options.replicas, "replicas", "r", 0, "replicas of the index to configure") cmd.Flags().StringVarP(&options.deletionProtection, "deletion_protection", "p", "", "enable or disable deletion protection for the index") + cmd.Flags().BoolVar(&options.json, "json", false, "output as JSON") return cmd } -func runConfigureIndexCmd(options configureIndexOptions) { +func runConfigureIndexCmd(options configureIndexOptions, cmd *cobra.Command, args []string) { ctx := context.Background() pc := sdk.NewPineconeClient() @@ -56,16 +58,17 @@ func runConfigureIndexCmd(options configureIndexOptions) { DeletionProtection: pinecone.DeletionProtection(options.deletionProtection), }) if err != nil { - msg.FailMsg("Failed to configure index %s: %+v\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } + if options.json { json := text.IndentJSON(idx) pcio.Println(json) return } - describeCommand := pcio.Sprintf("pc index describe --name %s", idx.Name) - msg.SuccessMsg("Index %s configured successfully. Run %s to check status. \n\n", style.Emphasis(idx.Name), style.Code(describeCommand)) + describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) + msg.SuccessMsg("Index %s configured successfully. Run %s to check status. \n\n", style.ResourceName(idx.Name), style.Code(describeCommand)) presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/configure_test.go b/internal/pkg/cli/command/index/configure_test.go new file mode 100644 index 0000000..31e05f4 --- /dev/null +++ b/internal/pkg/cli/command/index/configure_test.go @@ -0,0 +1,82 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestConfigureCmd_ArgsValidation(t *testing.T) { + cmd := NewConfigureIndexCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this command (configure-specific flags) + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - positional arg with --json flag", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --json=false", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "false"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --pod_type flag", + Args: []string{"my-index"}, + Flags: map[string]string{"pod_type": "p1.x1"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --replicas flag", + Args: []string{"my-index"}, + Flags: map[string]string{"replicas": "2"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --deletion_protection flag", + Args: []string{"my-index"}, + Flags: map[string]string{"deletion_protection": "enabled"}, + ExpectError: false, + }, + { + Name: "error - no arguments but with --json flag", + Args: []string{}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments with --json flag", + Args: []string{"index1", "index2"}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} + +func TestConfigureCmd_Flags(t *testing.T) { + cmd := NewConfigureIndexCmd() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} + +func TestConfigureCmd_Usage(t *testing.T) { + cmd := NewConfigureIndexCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "configure ", "index") +} diff --git a/internal/pkg/cli/command/index/create.go b/internal/pkg/cli/command/index/create.go index 1f4d67b..20f2f93 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -5,7 +5,10 @@ import ( "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/docslinks" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/log" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" @@ -65,7 +68,7 @@ func NewCreateIndexCmd() *cobra.Command { options := createIndexOptions{} cmd := &cobra.Command{ - Use: "create", + Use: "create ", Short: "Create a new index with the specified configuration", Long: heredoc.Docf(` The %s command creates a new index with the specified configuration. There are several different types of indexes @@ -80,23 +83,22 @@ func NewCreateIndexCmd() *cobra.Command { `, style.Code("pc index create"), style.URL(docslinks.DocsIndexCreate)), Example: heredoc.Doc(` # create a serverless index - $ pc index create --name my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 + $ pc index create my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 # create a pod index - $ pc index create --name my-index --dimension 1536 --metric cosine --environment us-east-1-aws --pod-type p1.x1 --shards 2 --replicas 2 + $ pc index create my-index --dimension 1536 --metric cosine --environment us-east-1-aws --pod-type p1.x1 --shards 2 --replicas 2 # create an integrated index - $ pc index create --name my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 --model multilingual-e5-large --field_map text=chunk_text + $ pc index create my-index --dimension 1536 --metric cosine --cloud aws --region us-east-1 --model multilingual-e5-large --field_map text=chunk_text `), + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { - runCreateIndexCmd(options) + options.name = args[0] + runCreateIndexCmd(options, cmd, args) }, } - // Required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "Name of index to create") - _ = cmd.MarkFlagRequired("name") - // Serverless & Pods cmd.Flags().StringVar(&options.sourceCollection, "source_collection", "", "When creating an index from a collection") @@ -131,22 +133,34 @@ func NewCreateIndexCmd() *cobra.Command { return cmd } -func runCreateIndexCmd(options createIndexOptions) { +func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []string) { ctx := context.Background() pc := sdk.NewPineconeClient() // validate and derive index type from arguments err := options.validate() if err != nil { + msg.FailMsg("%s\n", err.Error()) exit.Error(err) return } idxType, err := options.deriveIndexType() if err != nil { + msg.FailMsg("%s\n", err.Error()) exit.Error(err) return } + // Print preview of what will be created + printCreatePreview(options, idxType) + + // Ask for user confirmation + question := "Is this configuration correct? Do you want to proceed with creating the index?" + if !interactive.GetConfirmation(question) { + pcio.Println(style.InfoMsg("Index creation cancelled.")) + return + } + // index tags var indexTags *pinecone.IndexTags if len(options.tags) > 0 { @@ -160,7 +174,7 @@ func runCreateIndexCmd(options createIndexOptions) { switch idxType { case indexTypeServerless: // create serverless index - args := pinecone.CreateServerlessIndexRequest{ + req := pinecone.CreateServerlessIndexRequest{ Name: options.name, Cloud: pinecone.Cloud(options.cloud), Region: options.region, @@ -172,9 +186,9 @@ func runCreateIndexCmd(options createIndexOptions) { SourceCollection: pointerOrNil(options.sourceCollection), } - idx, err = pc.CreateServerlessIndex(ctx, &args) + idx, err = pc.CreateServerlessIndex(ctx, &req) if err != nil { - msg.FailMsg("Failed to create serverless index %s: %s\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } case indexTypePod: @@ -185,7 +199,7 @@ func runCreateIndexCmd(options createIndexOptions) { Indexed: &options.metadataConfig, } } - args := pinecone.CreatePodIndexRequest{ + req := pinecone.CreatePodIndexRequest{ Name: options.name, Dimension: options.dimension, Environment: options.environment, @@ -199,9 +213,9 @@ func runCreateIndexCmd(options createIndexOptions) { MetadataConfig: metadataConfig, } - idx, err = pc.CreatePodIndex(ctx, &args) + idx, err = pc.CreatePodIndex(ctx, &req) if err != nil { - msg.FailMsg("Failed to create pod index %s: %s\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } case indexTypeIntegrated: @@ -209,7 +223,7 @@ func runCreateIndexCmd(options createIndexOptions) { readParams := toInterfaceMap(options.readParameters) writeParams := toInterfaceMap(options.writeParameters) - args := pinecone.CreateIndexForModelRequest{ + req := pinecone.CreateIndexForModelRequest{ Name: options.name, Cloud: pinecone.Cloud(options.cloud), Region: options.region, @@ -222,9 +236,9 @@ func runCreateIndexCmd(options createIndexOptions) { }, } - idx, err = pc.CreateIndexForModel(ctx, &args) + idx, err = pc.CreateIndexForModel(ctx, &req) if err != nil { - msg.FailMsg("Failed to create integrated index %s: %s\n", style.Emphasis(options.name), err) + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } default: @@ -236,6 +250,55 @@ func runCreateIndexCmd(options createIndexOptions) { renderSuccessOutput(idx, options) } +// printCreatePreview prints a preview of the index configuration that will be created +func printCreatePreview(options createIndexOptions, idxType indexType) { + log.Debug().Str("name", options.name).Msg("Printing index creation preview") + + // Create a mock pinecone.Index for preview display + mockIndex := &pinecone.Index{ + Name: options.name, + Metric: pinecone.IndexMetric(options.metric), + Dimension: &options.dimension, + DeletionProtection: pinecone.DeletionProtection(options.deletionProtection), + Status: &pinecone.IndexStatus{ + State: "Creating", + }, + } + + // Set spec based on index type + if idxType == "serverless" { + mockIndex.Spec = &pinecone.IndexSpec{ + Serverless: &pinecone.ServerlessSpec{ + Cloud: pinecone.Cloud(options.cloud), + Region: options.region, + }, + } + mockIndex.VectorType = options.vectorType + } else { + mockIndex.Spec = &pinecone.IndexSpec{ + Pod: &pinecone.PodSpec{ + Environment: options.environment, + PodType: options.podType, + Replicas: options.replicas, + ShardCount: options.shards, + PodCount: 0, //?!?!?!?! + }, + } + } + + // Print title + pcio.Println() + pcio.Printf("%s\n\n", + pcio.Sprintf("Creating %s index %s with the following configuration:", + style.Emphasis(string(idxType)), + style.ResourceName(options.name), + ), + ) + + // Use the specialized index table without status info (second column set) + presenters.PrintDescribeIndexTable(mockIndex) +} + func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { if options.json { json := text.IndentJSON(idx) @@ -243,8 +306,8 @@ func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { return } - describeCommand := pcio.Sprintf("pc index describe --name %s", idx.Name) - msg.SuccessMsg("Index %s created successfully. Run %s to check status. \n\n", style.Emphasis(idx.Name), style.Code(describeCommand)) + describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) + msg.SuccessMsg("Index %s created successfully. Run %s to check status. \n\n", style.ResourceName(idx.Name), style.Code(describeCommand)) presenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/create_test.go b/internal/pkg/cli/command/index/create_test.go new file mode 100644 index 0000000..7b45a14 --- /dev/null +++ b/internal/pkg/cli/command/index/create_test.go @@ -0,0 +1,100 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestCreateCmd_ArgsValidation(t *testing.T) { + cmd := NewCreateIndexCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this command (create-specific flags) + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - positional arg with --json flag", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --json=false", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "false"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --dimension flag", + Args: []string{"my-index"}, + Flags: map[string]string{"dimension": "1536"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --metric flag", + Args: []string{"my-index"}, + Flags: map[string]string{"metric": "cosine"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --cloud and --region flags", + Args: []string{"my-index"}, + Flags: map[string]string{"cloud": "aws", "region": "us-east-1"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --environment flag", + Args: []string{"my-index"}, + Flags: map[string]string{"environment": "us-east-1-aws"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --pod_type flag", + Args: []string{"my-index"}, + Flags: map[string]string{"pod_type": "p1.x1"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --model flag", + Args: []string{"my-index"}, + Flags: map[string]string{"model": "multilingual-e5-large"}, + ExpectError: false, + }, + { + Name: "error - no arguments but with --json flag", + Args: []string{}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments with --json flag", + Args: []string{"index1", "index2"}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} + +func TestCreateCmd_Flags(t *testing.T) { + cmd := NewCreateIndexCmd() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} + +func TestCreateCmd_Usage(t *testing.T) { + cmd := NewCreateIndexCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "create ", "index") +} diff --git a/internal/pkg/cli/command/index/delete.go b/internal/pkg/cli/command/index/delete.go index 65910fe..05b044b 100644 --- a/internal/pkg/cli/command/index/delete.go +++ b/internal/pkg/cli/command/index/delete.go @@ -2,10 +2,13 @@ package index import ( "context" - "strings" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + "github.com/pinecone-io/cli/internal/pkg/utils/index" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/spf13/cobra" @@ -19,29 +22,36 @@ func NewDeleteCmd() *cobra.Command { options := DeleteCmdOptions{} cmd := &cobra.Command{ - Use: "delete", - Short: "Delete an index", + Use: "delete ", + Short: "Delete an index", + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { + options.name = args[0] + + // Ask for user confirmation + msg.WarnMsgMultiLine( + pcio.Sprintf("This will delete the index %s and all its data.", style.ResourceName(options.name)), + "This action cannot be undone.", + ) + question := "Are you sure you want to proceed with the deletion?" + if !interactive.GetConfirmation(question) { + pcio.Println(style.InfoMsg("Index deletion cancelled.")) + return + } + ctx := context.Background() pc := sdk.NewPineconeClient() err := pc.DeleteIndex(ctx, options.name) if err != nil { - if strings.Contains(err.Error(), "not found") { - msg.FailMsg("The index %s does not exist\n", style.Emphasis(options.name)) - } else { - msg.FailMsg("Failed to delete index %s: %s\n", style.Emphasis(options.name), err) - } + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } - msg.SuccessMsg("Index %s deleted.\n", style.Emphasis(options.name)) + msg.SuccessMsg("Index %s deleted.\n", style.ResourceName(options.name)) }, } - // required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "name of index to delete") - _ = cmd.MarkFlagRequired("name") - return cmd } diff --git a/internal/pkg/cli/command/index/delete_test.go b/internal/pkg/cli/command/index/delete_test.go new file mode 100644 index 0000000..9eedecb --- /dev/null +++ b/internal/pkg/cli/command/index/delete_test.go @@ -0,0 +1,24 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestDeleteCmd_ArgsValidation(t *testing.T) { + cmd := NewDeleteCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} + +func TestDeleteCmd_Usage(t *testing.T) { + cmd := NewDeleteCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "delete ", "index") +} diff --git a/internal/pkg/cli/command/index/describe.go b/internal/pkg/cli/command/index/describe.go index 8278b17..d55d0d8 100644 --- a/internal/pkg/cli/command/index/describe.go +++ b/internal/pkg/cli/command/index/describe.go @@ -1,14 +1,13 @@ package index import ( - "strings" + "fmt" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" - "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" + "github.com/pinecone-io/cli/internal/pkg/utils/index" "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" - "github.com/pinecone-io/cli/internal/pkg/utils/style" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/spf13/cobra" ) @@ -22,34 +21,29 @@ func NewDescribeCmd() *cobra.Command { options := DescribeCmdOptions{} cmd := &cobra.Command{ - Use: "describe", - Short: "Get configuration and status information for an index", + Use: "describe ", + Short: "Get configuration and status information for an index", + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { + options.name = args[0] pc := sdk.NewPineconeClient() idx, err := pc.DescribeIndex(cmd.Context(), options.name) if err != nil { - if strings.Contains(err.Error(), "not found") { - msg.FailMsg("The index %s does not exist\n", style.Emphasis(options.name)) - } else { - msg.FailMsg("Failed to describe index %s: %s\n", style.Emphasis(options.name), err) - } + errorutil.HandleIndexAPIError(err, cmd, args) exit.Error(err) } if options.json { json := text.IndentJSON(idx) - pcio.Println(json) + fmt.Println(json) } else { presenters.PrintDescribeIndexTable(idx) } }, } - // required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "name of index to describe") - _ = cmd.MarkFlagRequired("name") - // optional flags cmd.Flags().BoolVar(&options.json, "json", false, "output as JSON") diff --git a/internal/pkg/cli/command/index/describe_test.go b/internal/pkg/cli/command/index/describe_test.go new file mode 100644 index 0000000..1388ab0 --- /dev/null +++ b/internal/pkg/cli/command/index/describe_test.go @@ -0,0 +1,64 @@ +package index + +import ( + "testing" + + "github.com/pinecone-io/cli/internal/pkg/utils/testutils" +) + +func TestDescribeCmd_ArgsValidation(t *testing.T) { + cmd := NewDescribeCmd() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this command (e.g., with --json flag) + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - positional arg with --json flag", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + }, + { + Name: "valid - positional arg with --json=false", + Args: []string{"my-index"}, + Flags: map[string]string{"json": "false"}, + ExpectError: false, + }, + { + Name: "error - no arguments but with --json flag", + Args: []string{}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments with --json flag", + Args: []string{"index1", "index2"}, + Flags: map[string]string{"json": "true"}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} + +func TestDescribeCmd_Flags(t *testing.T) { + cmd := NewDescribeCmd() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} + +func TestDescribeCmd_Usage(t *testing.T) { + cmd := NewDescribeCmd() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "describe ", "index") +} diff --git a/internal/pkg/cli/command/index/list.go b/internal/pkg/cli/command/index/list.go index 8c84a07..3560ee4 100644 --- a/internal/pkg/cli/command/index/list.go +++ b/internal/pkg/cli/command/index/list.go @@ -2,19 +2,15 @@ package index import ( "context" - "os" + "fmt" "sort" - "strings" - "text/tabwriter" + errorutil "github.com/pinecone-io/cli/internal/pkg/utils/error" "github.com/pinecone-io/cli/internal/pkg/utils/exit" - "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/spf13/cobra" - - "github.com/pinecone-io/go-pinecone/v4/pinecone" ) type ListIndexCmdOptions struct { @@ -33,7 +29,7 @@ func NewListCmd() *cobra.Command { idxs, err := pc.ListIndexes(ctx) if err != nil { - msg.FailMsg("Failed to list indexes: %s\n", err) + errorutil.HandleIndexAPIError(err, cmd, []string{}) exit.Error(err) } @@ -43,10 +39,16 @@ func NewListCmd() *cobra.Command { }) if options.json { + // Use fmt for data output - should not be suppressed by -q flag json := text.IndentJSON(idxs) - pcio.Println(json) + fmt.Println(json) } else { - printTable(idxs) + // Show essential and state information + // Note: presenters functions now use fmt internally for data output + presenters.PrintIndexTableWithIndexAttributesGroups(idxs, []presenters.IndexAttributesGroup{ + presenters.IndexAttributesGroupEssential, + presenters.IndexAttributesGroupState, + }) } }, } @@ -55,28 +57,3 @@ func NewListCmd() *cobra.Command { return cmd } - -func printTable(idxs []*pinecone.Index) { - writer := tabwriter.NewWriter(os.Stdout, 10, 1, 3, ' ', 0) - - columns := []string{"NAME", "STATUS", "HOST", "DIMENSION", "METRIC", "SPEC"} - header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) - - for _, idx := range idxs { - dimension := "nil" - if idx.Dimension != nil { - dimension = pcio.Sprintf("%d", *idx.Dimension) - } - if idx.Spec.Serverless == nil { - // Pod index - values := []string{idx.Name, string(idx.Status.State), idx.Host, dimension, string(idx.Metric), "pod"} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") - } else { - // Serverless index - values := []string{idx.Name, string(idx.Status.State), idx.Host, dimension, string(idx.Metric), "serverless"} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") - } - } - writer.Flush() -} diff --git a/internal/pkg/cli/command/login/whoami.go b/internal/pkg/cli/command/login/whoami.go index 02b4d3a..b5afc3a 100644 --- a/internal/pkg/cli/command/login/whoami.go +++ b/internal/pkg/cli/command/login/whoami.go @@ -32,7 +32,7 @@ func NewWhoAmICmd() *cobra.Command { exit.Error(pcio.Errorf("error parsing claims from access token: %s", err)) return } - msg.InfoMsg("Logged in as " + style.Emphasis(claims.Email)) + msg.InfoMsg("Logged in as " + style.ResourceName(claims.Email)) }, } diff --git a/internal/pkg/cli/command/organization/delete.go b/internal/pkg/cli/command/organization/delete.go index 2fed04f..1a2aa65 100644 --- a/internal/pkg/cli/command/organization/delete.go +++ b/internal/pkg/cli/command/organization/delete.go @@ -1,15 +1,11 @@ package organization import ( - "bufio" - "fmt" - "os" - "strings" - "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" @@ -77,26 +73,15 @@ func NewDeleteOrganizationCmd() *cobra.Command { } func confirmDelete(organizationName string, organizationID string) { - msg.WarnMsg("This will delete the organization %s (ID: %s).", style.Emphasis(organizationName), style.Emphasis(organizationID)) - msg.WarnMsg("This action cannot be undone.") - - // Prompt the user - fmt.Print("Do you want to continue? (y/N): ") - - // Read the user's input - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - pcio.Println(fmt.Errorf("Error reading input: %w", err)) - return - } - - input = strings.TrimSpace(strings.ToLower(input)) + msg.WarnMsgMultiLine( + pcio.Sprintf("This will delete the organization %s (ID: %s).", style.Emphasis(organizationName), style.Emphasis(organizationID)), + "This action cannot be undone.", + ) - if input == "y" || input == "yes" { - msg.InfoMsg("You chose to continue delete.") - } else { + question := "Are you sure you want to proceed with deleting this organization?" + if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() } + msg.InfoMsg("You chose to continue delete.") } diff --git a/internal/pkg/cli/command/organization/list.go b/internal/pkg/cli/command/organization/list.go index 0c8f445..0fceba5 100644 --- a/internal/pkg/cli/command/organization/list.go +++ b/internal/pkg/cli/command/organization/list.go @@ -1,6 +1,7 @@ package organization import ( + "fmt" "os" "strings" "text/tabwriter" @@ -11,7 +12,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" "github.com/pinecone-io/cli/internal/pkg/utils/msg" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/pinecone-io/go-pinecone/v4/pinecone" @@ -42,7 +42,7 @@ func NewListOrganizationsCmd() *cobra.Command { if options.json { json := text.IndentJSON(orgs) - pcio.Println(json) + fmt.Println(json) return } @@ -60,7 +60,7 @@ func printTable(orgs []*pinecone.Organization) { columns := []string{"NAME", "ID", "CREATED AT", "PAYMENT STATUS", "PLAN", "SUPPORT TIER"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, org := range orgs { values := []string{ @@ -71,7 +71,7 @@ func printTable(orgs []*pinecone.Organization) { org.Plan, org.SupportTier, } - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() } diff --git a/internal/pkg/cli/command/project/delete.go b/internal/pkg/cli/command/project/delete.go index 3687671..c255f37 100644 --- a/internal/pkg/cli/command/project/delete.go +++ b/internal/pkg/cli/command/project/delete.go @@ -1,16 +1,13 @@ package project import ( - "bufio" "context" - "fmt" - "os" - "strings" "github.com/MakeNowJust/heredoc" "github.com/pinecone-io/cli/internal/pkg/utils/configuration/state" "github.com/pinecone-io/cli/internal/pkg/utils/exit" "github.com/pinecone-io/cli/internal/pkg/utils/help" + "github.com/pinecone-io/cli/internal/pkg/utils/interactive" "github.com/pinecone-io/cli/internal/pkg/utils/msg" "github.com/pinecone-io/cli/internal/pkg/utils/pcio" "github.com/pinecone-io/cli/internal/pkg/utils/sdk" @@ -88,30 +85,17 @@ func NewDeleteProjectCmd() *cobra.Command { } func confirmDelete(projectName string) { - msg.WarnMsg("This will delete the project %s in organization %s.", style.Emphasis(projectName), style.Emphasis(state.TargetOrg.Get().Name)) - msg.WarnMsg("This action cannot be undone.") + msg.WarnMsgMultiLine( + pcio.Sprintf("This will delete the project %s in organization %s.", style.Emphasis(projectName), style.Emphasis(state.TargetOrg.Get().Name)), + "This action cannot be undone.", + ) - // Prompt the user - fmt.Print("Do you want to continue? (y/N): ") - - // Read the user's input - reader := bufio.NewReader(os.Stdin) - input, err := reader.ReadString('\n') - if err != nil { - pcio.Println(fmt.Errorf("Error reading input: %w", err)) - return - } - - // Trim any whitespace from the input and convert to lowercase - input = strings.TrimSpace(strings.ToLower(input)) - - // Check if the user entered "y" or "yes" - if input == "y" || input == "yes" { - msg.InfoMsg("You chose to continue delete.") - } else { + question := "Are you sure you want to proceed with deleting this project?" + if !interactive.GetConfirmation(question) { msg.InfoMsg("Operation canceled.") exit.Success() } + msg.InfoMsg("You chose to continue delete.") } func verifyNoIndexes(projectId string, projectName string) { diff --git a/internal/pkg/cli/command/project/list.go b/internal/pkg/cli/command/project/list.go index 7128ad8..535c36a 100644 --- a/internal/pkg/cli/command/project/list.go +++ b/internal/pkg/cli/command/project/list.go @@ -2,6 +2,7 @@ package project import ( "context" + "fmt" "os" "strconv" "strings" @@ -15,8 +16,6 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/text" "github.com/pinecone-io/go-pinecone/v4/pinecone" "github.com/spf13/cobra" - - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" ) type ListProjectCmdOptions struct { @@ -45,7 +44,7 @@ func NewListProjectsCmd() *cobra.Command { if options.json { json := text.IndentJSON(projects) - pcio.Println(json) + fmt.Println(json) } else { printTable(projects) } @@ -62,7 +61,7 @@ func printTable(projects []*pinecone.Project) { columns := []string{"NAME", "ID", "ORGANIZATION ID", "CREATED AT", "FORCE ENCRYPTION", "MAX PODS"} header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) + fmt.Fprint(writer, header) for _, proj := range projects { values := []string{ @@ -72,7 +71,7 @@ func printTable(projects []*pinecone.Project) { proj.CreatedAt.String(), strconv.FormatBool(proj.ForceEncryptionWithCmek), strconv.Itoa(proj.MaxPods)} - pcio.Fprintf(writer, strings.Join(values, "\t")+"\n") + fmt.Fprintf(writer, strings.Join(values, "\t")+"\n") } writer.Flush() } diff --git a/internal/pkg/cli/command/root/root.go b/internal/pkg/cli/command/root/root.go index 44c3677..cd0422c 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -23,7 +23,8 @@ import ( var rootCmd *cobra.Command type GlobalOptions struct { - quiet bool + quiet bool + verbose bool } func Execute() { @@ -54,6 +55,8 @@ Get started by logging in with `, style.CodeWithPrompt("pc login")), } + rootCmd.SetErrPrefix("\r") + rootCmd.SetUsageTemplate(help.HelpTemplate) // Auth group @@ -87,4 +90,5 @@ Get started by logging in with // Global flags rootCmd.PersistentFlags().BoolVarP(&globalOptions.quiet, "quiet", "q", false, "suppress output") + rootCmd.PersistentFlags().BoolVarP(&globalOptions.verbose, "verbose", "V", false, "show detailed error information") } diff --git a/internal/pkg/utils/configuration/config/config.go b/internal/pkg/utils/configuration/config/config.go index 9629f33..7d82877 100644 --- a/internal/pkg/utils/configuration/config/config.go +++ b/internal/pkg/utils/configuration/config/config.go @@ -22,10 +22,16 @@ var ( ViperStore: ConfigViper, DefaultValue: "production", } + ColorScheme = configuration.ConfigProperty[string]{ + KeyName: "color_scheme", + ViperStore: ConfigViper, + DefaultValue: "pc-default-dark", + } ) var properties = []configuration.Property{ Color, Environment, + ColorScheme, } var configFile = configuration.ConfigFile{ diff --git a/internal/pkg/utils/error/error.go b/internal/pkg/utils/error/error.go new file mode 100644 index 0000000..1d15d06 --- /dev/null +++ b/internal/pkg/utils/error/error.go @@ -0,0 +1,85 @@ +package error + +import ( + "encoding/json" + "strings" + + "github.com/pinecone-io/cli/internal/pkg/utils/msg" + "github.com/spf13/cobra" +) + +// APIError represents a structured API error response +type APIError struct { + StatusCode int `json:"status_code"` + Body string `json:"body"` + ErrorCode string `json:"error_code"` + Message string `json:"message"` +} + +// HandleIndexAPIError is a convenience function specifically for index operations +// It extracts the operation from the command context and uses the first argument as index name +func HandleIndexAPIError(err error, cmd *cobra.Command, args []string) { + if err == nil { + return + } + + verbose, _ := cmd.Flags().GetBool("verbose") + + // Try to extract JSON error from the error message + errorMsg := err.Error() + + // Look for JSON-like content in the error message + var apiErr APIError + if jsonStart := strings.Index(errorMsg, "{"); jsonStart != -1 { + jsonContent := errorMsg[jsonStart:] + if jsonEnd := strings.LastIndex(jsonContent, "}"); jsonEnd != -1 { + jsonContent = jsonContent[:jsonEnd+1] + if json.Unmarshal([]byte(jsonContent), &apiErr) == nil && apiErr.Message != "" { + displayStructuredError(apiErr, verbose) + return + } + } + } + + // If no structured error found, show the raw error message + if verbose { + msg.FailMsg("%s\nFull error: %s\n", + errorMsg, errorMsg) + } else { + msg.FailMsg("%s\n", errorMsg) + } +} + +// displayStructuredError handles structured API error responses +func displayStructuredError(apiErr APIError, verbose bool) { + // Try to get the message from the body field first (actual API response) + userMessage := apiErr.Message // fallback to outer message + + // Parse the body field which contains the actual API response + if apiErr.Body != "" { + var bodyResponse struct { + Error struct { + Code string `json:"code"` + Message string `json:"message"` + } `json:"error"` + Status int `json:"status"` + } + + if json.Unmarshal([]byte(apiErr.Body), &bodyResponse) == nil && bodyResponse.Error.Message != "" { + userMessage = bodyResponse.Error.Message + } + } + + if userMessage == "" { + userMessage = "Unknown error occurred" + } + + if verbose { + // Show full JSON error in verbose mode - nicely formatted + jsonBytes, _ := json.MarshalIndent(apiErr, "", " ") + msg.FailMsg("%s\n\nFull error response:\n%s\n", + userMessage, string(jsonBytes)) + } else { + msg.FailMsg("%s\n", userMessage) + } +} diff --git a/internal/pkg/utils/error/error_test.go b/internal/pkg/utils/error/error_test.go new file mode 100644 index 0000000..e806d73 --- /dev/null +++ b/internal/pkg/utils/error/error_test.go @@ -0,0 +1,62 @@ +package error + +import ( + "fmt" + "testing" + + "github.com/spf13/cobra" +) + +func TestHandleIndexAPIErrorWithCommand(t *testing.T) { + tests := []struct { + name string + err error + indexName string + commandName string + verbose bool + expectedOutput string + }{ + { + name: "JSON error with message field", + err: &mockError{message: `{"message": "Index not found", "code": 404}`}, + indexName: "test-index", + commandName: "describe ", + verbose: false, + expectedOutput: "Index not found", + }, + { + name: "Verbose mode shows full JSON", + err: &mockError{message: `{"message": "Rate limit exceeded", "code": 429}`}, + indexName: "my-index", + commandName: "create ", + verbose: true, + expectedOutput: "Rate limit exceeded", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a mock command with verbose flag and set the command name + cmd := &cobra.Command{} + cmd.Flags().Bool("verbose", false, "verbose output") + cmd.Use = tt.commandName + + // Set the verbose flag on the command + cmd.Flags().Set("verbose", fmt.Sprintf("%t", tt.verbose)) + + // This is a basic test to ensure the function doesn't panic + // In a real test environment, we would capture stdout/stderr + // and verify the exact output + HandleIndexAPIError(tt.err, cmd, []string{tt.indexName}) + }) + } +} + +// mockError is a simple error implementation for testing +type mockError struct { + message string +} + +func (e *mockError) Error() string { + return e.message +} diff --git a/internal/pkg/utils/index/validation.go b/internal/pkg/utils/index/validation.go new file mode 100644 index 0000000..b3e2cfa --- /dev/null +++ b/internal/pkg/utils/index/validation.go @@ -0,0 +1,24 @@ +package index + +import ( + "errors" + "strings" + + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/spf13/cobra" +) + +// ValidateIndexNameArgs validates that exactly one non-empty index name is provided as a positional argument. +// This is the standard validation used across all index commands (create, describe, delete, configure). +func ValidateIndexNameArgs(cmd *cobra.Command, args []string) error { + if len(args) == 0 { + return errors.New("\b" + style.FailMsg("please provide an index name")) + } + if len(args) > 1 { + return errors.New("\b" + style.FailMsg("please provide only one index name")) + } + if strings.TrimSpace(args[0]) == "" { + return errors.New("\b" + style.FailMsg("index name cannot be empty")) + } + return nil +} diff --git a/internal/pkg/utils/interactive/confirmation.go b/internal/pkg/utils/interactive/confirmation.go new file mode 100644 index 0000000..f423558 --- /dev/null +++ b/internal/pkg/utils/interactive/confirmation.go @@ -0,0 +1,119 @@ +package interactive + +import ( + "fmt" + + tea "github.com/charmbracelet/bubbletea" + "github.com/pinecone-io/cli/internal/pkg/utils/log" + "github.com/pinecone-io/cli/internal/pkg/utils/style" +) + +// ConfirmationResult represents the result of a confirmation dialog +type ConfirmationResult int + +const ( + ConfirmationYes ConfirmationResult = iota + ConfirmationNo + ConfirmationQuit +) + +// ConfirmationModel handles the user confirmation dialog +type ConfirmationModel struct { + question string + choice ConfirmationResult + quitting bool +} + +// NewConfirmationModel creates a new confirmation dialog model +func NewConfirmationModel(question string) ConfirmationModel { + return ConfirmationModel{ + question: question, + choice: -1, // Invalid state until user makes a choice + } +} + +func (m ConfirmationModel) Init() tea.Cmd { + return nil +} + +func (m ConfirmationModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.KeyMsg: + switch keypress := msg.String(); keypress { + case "y", "Y": + m.choice = ConfirmationYes + return m, tea.Quit + case "n", "N": + m.choice = ConfirmationNo + return m, tea.Quit + } + } + return m, nil +} + +func (m ConfirmationModel) View() string { + if m.quitting { + return "" + } + if m.choice != -1 { + return "" + } + + // Use centralized color scheme + questionStyle, promptStyle, keyStyle, _ := style.GetBrandedConfirmationStyles() + + // Create the confirmation prompt with styled keys + keys := fmt.Sprintf("%s to confirm, %s to cancel", + keyStyle.Render("'y'"), + keyStyle.Render("'n'")) + + return fmt.Sprintf("%s\n%s %s", + questionStyle.Render(m.question), + promptStyle.Render("Press"), + keys) +} + +// GetConfirmation prompts the user to confirm an action +// Returns true if the user confirmed with 'y', false if they declined with 'n' +func GetConfirmation(question string) bool { + m := NewConfirmationModel(question) + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + log.Error().Err(err).Msg("Error running confirmation program") + return false + } + + // Get the final model state + confModel, ok := finalModel.(ConfirmationModel) + if !ok { + log.Error().Msg("Failed to cast final model to ConfirmationModel") + return false + } + + return confModel.choice == ConfirmationYes +} + +// GetConfirmationResult prompts the user to confirm an action and returns the detailed result +// This allows callers to distinguish between "no" and "quit" responses (though both 'n' and 'q' now map to ConfirmationNo) +// Note: Ctrl+C will kill the entire CLI process and is not handled gracefully +func GetConfirmationResult(question string) ConfirmationResult { + m := NewConfirmationModel(question) + + p := tea.NewProgram(m) + finalModel, err := p.Run() + if err != nil { + log.Error().Err(err).Msg("Error running confirmation program") + return ConfirmationNo + } + + // Get the final model state + confModel, ok := finalModel.(ConfirmationModel) + if !ok { + log.Error().Msg("Failed to cast final model to ConfirmationModel") + return ConfirmationNo + } + + return confModel.choice +} diff --git a/internal/pkg/utils/msg/message.go b/internal/pkg/utils/msg/message.go index 4e204dc..6821240 100644 --- a/internal/pkg/utils/msg/message.go +++ b/internal/pkg/utils/msg/message.go @@ -5,27 +5,73 @@ import ( "github.com/pinecone-io/cli/internal/pkg/utils/style" ) +// FailMsg displays an error message to the user. +// Uses pcio functions so the message is suppressed with -q flag. func FailMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.FailMsg(formatted)) + pcio.Println("\n" + style.FailMsg(formatted) + "\n") } func SuccessMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.SuccessMsg(formatted)) + pcio.Println("\n" + style.SuccessMsg(formatted) + "\n") } func WarnMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.WarnMsg(formatted)) + pcio.Println("\n" + style.WarnMsg(formatted) + "\n") } func InfoMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) - pcio.Println(style.InfoMsg(formatted)) + pcio.Println("\n" + style.InfoMsg(formatted) + "\n") } func HintMsg(format string, a ...any) { formatted := pcio.Sprintf(format, a...) pcio.Println(style.Hint(formatted)) } + +// WarnMsgMultiLine displays multiple warning messages in a single message box +func WarnMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line warning box + formatted := style.WarnMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// SuccessMsgMultiLine displays multiple success messages in a single message box +func SuccessMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line success box + formatted := style.SuccessMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// InfoMsgMultiLine displays multiple info messages in a single message box +func InfoMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line info box + formatted := style.InfoMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// FailMsgMultiLine displays multiple error messages in a single message box +func FailMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + // Create a proper multi-line error box + formatted := style.FailMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} diff --git a/internal/pkg/utils/pcio/print.go b/internal/pkg/utils/pcio/print.go index 043e401..3c3739f 100644 --- a/internal/pkg/utils/pcio/print.go +++ b/internal/pkg/utils/pcio/print.go @@ -5,6 +5,24 @@ import ( "io" ) +// Package pcio provides output functions that respect the global quiet mode. +// +// USAGE GUIDELINES: +// +// Use pcio functions for: +// - User-facing messages (success, error, warning, info) +// - Progress indicators and status updates +// - Interactive prompts and confirmations +// - Help text and documentation +// - Any output that should be suppressed with -q flag +// +// Use fmt functions for: +// - Data output from informational commands (list, describe) +// - JSON output that should always be displayed +// - Table rendering and structured data display +// - String formatting (Sprintf, Errorf, Error) +// - Any output that should NOT be suppressed with -q flag +// // The purpose of this package is to stub out the fmt package so that // the -q quiet mode can be implemented in a consistent way across all // commands. @@ -57,6 +75,12 @@ func Fprint(w io.Writer, a ...any) { } } +// NOTE: The following three functions are aliases to `fmt` functions and do not check the quiet flag. +// This creates inconsistency with the guidelines to use `fmt` directly (not `pcio`) for non-quiet output. +// These wrappers are kept for now because: +// 1) They don't break quiet mode behavior (they're just aliases) +// 2) A mass refactoring would require updating 100+ usages across the codebase + // alias Sprintf to fmt.Sprintf func Sprintf(format string, a ...any) string { return fmt.Sprintf(format, a...) @@ -69,5 +93,5 @@ func Errorf(format string, a ...any) error { // alias Error to fmt.Errorf func Error(a ...any) error { - return fmt.Errorf(fmt.Sprint(a...)) + return fmt.Errorf("%s", fmt.Sprint(a...)) } diff --git a/internal/pkg/utils/presenters/collection_description.go b/internal/pkg/utils/presenters/collection_description.go index 06ce679..4a35294 100644 --- a/internal/pkg/utils/presenters/collection_description.go +++ b/internal/pkg/utils/presenters/collection_description.go @@ -30,9 +30,9 @@ func PrintDescribeCollectionTable(coll *pinecone.Collection) { func ColorizeCollectionStatus(state pinecone.CollectionStatus) string { switch state { case pinecone.CollectionStatusReady: - return style.StatusGreen(string(state)) + return style.SuccessStyle().Render(string(state)) case pinecone.CollectionStatusInitializing, pinecone.CollectionStatusTerminating: - return style.StatusYellow(string(state)) + return style.WarningStyle().Render(string(state)) } return string(state) diff --git a/internal/pkg/utils/presenters/index_columns.go b/internal/pkg/utils/presenters/index_columns.go new file mode 100644 index 0000000..34e3904 --- /dev/null +++ b/internal/pkg/utils/presenters/index_columns.go @@ -0,0 +1,393 @@ +package presenters + +import ( + "fmt" + "strings" + + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) + +// IndexAttributesGroup represents the available attribute groups for index display +type IndexAttributesGroup string + +const ( + IndexAttributesGroupEssential IndexAttributesGroup = "essential" + IndexAttributesGroupState IndexAttributesGroup = "state" + IndexAttributesGroupPodSpec IndexAttributesGroup = "pod_spec" + IndexAttributesGroupServerlessSpec IndexAttributesGroup = "serverless_spec" + IndexAttributesGroupInference IndexAttributesGroup = "inference" + IndexAttributesGroupOther IndexAttributesGroup = "other" +) + +// AllIndexAttributesGroups returns all available index attribute groups +func AllIndexAttributesGroups() []IndexAttributesGroup { + return []IndexAttributesGroup{ + IndexAttributesGroupEssential, + IndexAttributesGroupState, + IndexAttributesGroupPodSpec, + IndexAttributesGroupServerlessSpec, + IndexAttributesGroupInference, + IndexAttributesGroupOther, + } +} + +// IndexColumn represents a table column with both short and full names +type IndexColumn struct { + ShortTitle string + FullTitle string + Width int +} + +// ColumnGroup represents a group of columns with both short and full names +type ColumnGroup struct { + Name string + Columns []IndexColumn +} + +// IndexColumnGroups defines the available column groups for index tables +// Each group represents a logical set of related index properties that can be displayed together +var IndexColumnGroups = struct { + Essential ColumnGroup // Basic index information (name, spec, type, metric, dimension) + State ColumnGroup // Runtime state information (status, host, protection) + PodSpec ColumnGroup // Pod-specific configuration (environment, pod type, replicas, etc.) + ServerlessSpec ColumnGroup // Serverless-specific configuration (cloud, region) + Inference ColumnGroup // Inference/embedding model information + Other ColumnGroup // Other information (tags, custom fields, etc.) +}{ + Essential: ColumnGroup{ + Name: "essential", + Columns: []IndexColumn{ + {ShortTitle: "NAME", FullTitle: "Name", Width: 20}, + {ShortTitle: "SPEC", FullTitle: "Specification", Width: 12}, + {ShortTitle: "TYPE", FullTitle: "Vector Type", Width: 8}, + {ShortTitle: "METRIC", FullTitle: "Metric", Width: 8}, + {ShortTitle: "DIM", FullTitle: "Dimension", Width: 8}, + }, + }, + State: ColumnGroup{ + Name: "state", + Columns: []IndexColumn{ + {ShortTitle: "STATUS", FullTitle: "Status", Width: 10}, + {ShortTitle: "HOST", FullTitle: "Host URL", Width: 60}, + {ShortTitle: "PROT", FullTitle: "Deletion Protection", Width: 8}, + }, + }, + PodSpec: ColumnGroup{ + Name: "pod_spec", + Columns: []IndexColumn{ + {ShortTitle: "ENV", FullTitle: "Environment", Width: 12}, + {ShortTitle: "POD_TYPE", FullTitle: "Pod Type", Width: 12}, + {ShortTitle: "REPLICAS", FullTitle: "Replicas", Width: 8}, + {ShortTitle: "SHARDS", FullTitle: "Shard Count", Width: 8}, + {ShortTitle: "PODS", FullTitle: "Pod Count", Width: 8}, + }, + }, + ServerlessSpec: ColumnGroup{ + Name: "serverless_spec", + Columns: []IndexColumn{ + {ShortTitle: "CLOUD", FullTitle: "Cloud Provider", Width: 12}, + {ShortTitle: "REGION", FullTitle: "Region", Width: 15}, + }, + }, + Inference: ColumnGroup{ + Name: "inference", + Columns: []IndexColumn{ + {ShortTitle: "MODEL", FullTitle: "Model", Width: 25}, + {ShortTitle: "EMBED DIM", FullTitle: "Embedding Dimension", Width: 10}, + {ShortTitle: "FIELD MAP", FullTitle: "Field Map", Width: 20}, + {ShortTitle: "READ PARAMS", FullTitle: "Read Parameters", Width: 20}, + {ShortTitle: "WRITE PARAMS", FullTitle: "Write Parameters", Width: 20}, + }, + }, + Other: ColumnGroup{ + Name: "other", + Columns: []IndexColumn{ + {ShortTitle: "TAGS", FullTitle: "Tags", Width: 30}, + }, + }, +} + +// GetColumnsForIndexAttributesGroups returns columns for the specified index attribute groups (using short names for horizontal tables) +func GetColumnsForIndexAttributesGroups(groups []IndexAttributesGroup) []Column { + var columns []Column + for _, group := range groups { + switch group { + case IndexAttributesGroupEssential: + for _, col := range IndexColumnGroups.Essential.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupState: + for _, col := range IndexColumnGroups.State.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupPodSpec: + for _, col := range IndexColumnGroups.PodSpec.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupServerlessSpec: + for _, col := range IndexColumnGroups.ServerlessSpec.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupInference: + for _, col := range IndexColumnGroups.Inference.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupOther: + for _, col := range IndexColumnGroups.Other.Columns { + columns = append(columns, Column{Title: col.ShortTitle, Width: col.Width}) + } + } + } + return columns +} + +// ExtractEssentialValues extracts essential values from an index +func ExtractEssentialValues(idx *pinecone.Index) []string { + // Determine spec + var spec string + if idx.Spec.Serverless == nil { + spec = "pod" + } else { + spec = "serverless" + } + + // Determine type (for serverless indexes) + var indexType string + if idx.VectorType != "" { + indexType = string(idx.VectorType) + } else { + indexType = "dense" // Default for pod indexes + } + + // Get dimension + dimension := "" + if idx.Dimension != nil && *idx.Dimension > 0 { + dimension = fmt.Sprintf("%d", *idx.Dimension) + } + + return []string{ + idx.Name, + spec, + indexType, + string(idx.Metric), + dimension, + } +} + +// ExtractStateValues extracts state-related values from an index +func ExtractStateValues(idx *pinecone.Index) []string { + // Check if protected + protected := "no" + if idx.DeletionProtection == pinecone.DeletionProtectionEnabled { + protected = "yes" + } + + status := "" + if idx.Status != nil { + status = string(idx.Status.State) + } + + return []string{ + status, + idx.Host, + protected, + } +} + +// ExtractPodSpecValues extracts pod specification values from an index +func ExtractPodSpecValues(idx *pinecone.Index) []string { + if idx.Spec.Pod == nil { + return []string{"", "", "", "", ""} + } + + return []string{ + idx.Spec.Pod.Environment, + idx.Spec.Pod.PodType, + fmt.Sprintf("%d", idx.Spec.Pod.Replicas), + fmt.Sprintf("%d", idx.Spec.Pod.ShardCount), + fmt.Sprintf("%d", idx.Spec.Pod.PodCount), + } +} + +// ExtractServerlessSpecValues extracts serverless specification values from an index +func ExtractServerlessSpecValues(idx *pinecone.Index) []string { + if idx.Spec.Serverless == nil { + return []string{"", ""} + } + + return []string{ + string(idx.Spec.Serverless.Cloud), + idx.Spec.Serverless.Region, + } +} + +// ExtractInferenceValues extracts inference-related values from an index +func ExtractInferenceValues(idx *pinecone.Index) []string { + if idx.Embed == nil { + return []string{"", "", "", "", ""} + } + + embedDim := "" + if idx.Embed.Dimension != nil && *idx.Embed.Dimension > 0 { + embedDim = fmt.Sprintf("%d", *idx.Embed.Dimension) + } + + // Format field map + fieldMapStr := "" + if idx.Embed.FieldMap != nil && len(*idx.Embed.FieldMap) > 0 { + var fieldMapPairs []string + for k, v := range *idx.Embed.FieldMap { + fieldMapPairs = append(fieldMapPairs, fmt.Sprintf("%s=%v", k, v)) + } + fieldMapStr = strings.Join(fieldMapPairs, ", ") + } + + // Format read parameters + readParamsStr := "" + if idx.Embed.ReadParameters != nil && len(*idx.Embed.ReadParameters) > 0 { + var readParamsPairs []string + for k, v := range *idx.Embed.ReadParameters { + readParamsPairs = append(readParamsPairs, fmt.Sprintf("%s=%v", k, v)) + } + readParamsStr = strings.Join(readParamsPairs, ", ") + } + + // Format write parameters + writeParamsStr := "" + if idx.Embed.WriteParameters != nil && len(*idx.Embed.WriteParameters) > 0 { + var writeParamsPairs []string + for k, v := range *idx.Embed.WriteParameters { + writeParamsPairs = append(writeParamsPairs, fmt.Sprintf("%s=%v", k, v)) + } + writeParamsStr = strings.Join(writeParamsPairs, ", ") + } + + return []string{ + idx.Embed.Model, + embedDim, + fieldMapStr, + readParamsStr, + writeParamsStr, + } +} + +// ExtractOtherValues extracts other values from an index (tags, custom fields, etc.) +func ExtractOtherValues(idx *pinecone.Index) []string { + if idx.Tags == nil || len(*idx.Tags) == 0 { + return []string{""} + } + + // Convert tags to a string representation showing key-value pairs + var tagStrings []string + for key, value := range *idx.Tags { + tagStrings = append(tagStrings, fmt.Sprintf("%s=%s", key, value)) + } + return []string{fmt.Sprint(strings.Join(tagStrings, ", "))} +} + +// ExtractValuesForIndexAttributesGroups extracts values for the specified index attribute groups from an index +func ExtractValuesForIndexAttributesGroups(idx *pinecone.Index, groups []IndexAttributesGroup) []string { + var values []string + for _, group := range groups { + switch group { + case IndexAttributesGroupEssential: + values = append(values, ExtractEssentialValues(idx)...) + case IndexAttributesGroupState: + values = append(values, ExtractStateValues(idx)...) + case IndexAttributesGroupPodSpec: + values = append(values, ExtractPodSpecValues(idx)...) + case IndexAttributesGroupServerlessSpec: + values = append(values, ExtractServerlessSpecValues(idx)...) + case IndexAttributesGroupInference: + values = append(values, ExtractInferenceValues(idx)...) + case IndexAttributesGroupOther: + values = append(values, ExtractOtherValues(idx)...) + } + } + return values +} + +// getColumnsWithNamesForIndexAttributesGroup returns columns with both short and full names for a specific index attribute group +func getColumnsWithNamesForIndexAttributesGroup(group IndexAttributesGroup) []IndexColumn { + switch group { + case IndexAttributesGroupEssential: + return IndexColumnGroups.Essential.Columns + case IndexAttributesGroupState: + return IndexColumnGroups.State.Columns + case IndexAttributesGroupPodSpec: + return IndexColumnGroups.PodSpec.Columns + case IndexAttributesGroupServerlessSpec: + return IndexColumnGroups.ServerlessSpec.Columns + case IndexAttributesGroupInference: + return IndexColumnGroups.Inference.Columns + case IndexAttributesGroupOther: + return IndexColumnGroups.Other.Columns + default: + return []IndexColumn{} + } +} + +// getValuesForIndexAttributesGroup returns values for a specific index attribute group +func getValuesForIndexAttributesGroup(idx *pinecone.Index, group IndexAttributesGroup) []string { + switch group { + case IndexAttributesGroupEssential: + return ExtractEssentialValues(idx) + case IndexAttributesGroupState: + return ExtractStateValues(idx) + case IndexAttributesGroupPodSpec: + return ExtractPodSpecValues(idx) + case IndexAttributesGroupServerlessSpec: + return ExtractServerlessSpecValues(idx) + case IndexAttributesGroupInference: + return ExtractInferenceValues(idx) + case IndexAttributesGroupOther: + return ExtractOtherValues(idx) + default: + return []string{} + } +} + +// hasNonEmptyValues checks if a group has any meaningful (non-empty) values +func hasNonEmptyValues(values []string) bool { + for _, value := range values { + if value != "" && value != "nil" { + return true + } + } + return false +} + +// filterNonEmptyIndexAttributesGroups filters out index attribute groups that have no meaningful data across all indexes +func filterNonEmptyIndexAttributesGroups(indexes []*pinecone.Index, groups []IndexAttributesGroup) []IndexAttributesGroup { + var nonEmptyGroups []IndexAttributesGroup + + for _, group := range groups { + hasData := false + for _, idx := range indexes { + values := getValuesForIndexAttributesGroup(idx, group) + if hasNonEmptyValues(values) { + hasData = true + break + } + } + if hasData { + nonEmptyGroups = append(nonEmptyGroups, group) + } + } + + return nonEmptyGroups +} + +// filterNonEmptyIndexAttributesGroupsForIndex filters out index attribute groups that have no meaningful data for a specific index +func filterNonEmptyIndexAttributesGroupsForIndex(idx *pinecone.Index, groups []IndexAttributesGroup) []IndexAttributesGroup { + var nonEmptyGroups []IndexAttributesGroup + + for _, group := range groups { + values := getValuesForIndexAttributesGroup(idx, group) + if hasNonEmptyValues(values) { + nonEmptyGroups = append(nonEmptyGroups, group) + } + } + + return nonEmptyGroups +} diff --git a/internal/pkg/utils/presenters/index_description.go b/internal/pkg/utils/presenters/index_description.go deleted file mode 100644 index 3364c15..0000000 --- a/internal/pkg/utils/presenters/index_description.go +++ /dev/null @@ -1,85 +0,0 @@ -package presenters - -import ( - "strings" - - "github.com/pinecone-io/cli/internal/pkg/utils/log" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" - "github.com/pinecone-io/cli/internal/pkg/utils/style" - "github.com/pinecone-io/cli/internal/pkg/utils/text" - "github.com/pinecone-io/go-pinecone/v4/pinecone" -) - -func ColorizeState(state pinecone.IndexStatusState) string { - switch state { - case pinecone.Ready: - return style.StatusGreen(string(state)) - case pinecone.Initializing, pinecone.Terminating, pinecone.ScalingDown, pinecone.ScalingDownPodSize, pinecone.ScalingUp, pinecone.ScalingUpPodSize: - return style.StatusYellow(string(state)) - case pinecone.InitializationFailed: - return style.StatusRed(string(state)) - default: - return string(state) - } -} - -func ColorizeDeletionProtection(deletionProtection pinecone.DeletionProtection) string { - if deletionProtection == pinecone.DeletionProtectionEnabled { - return style.StatusGreen("enabled") - } - return style.StatusRed("disabled") -} - -func PrintDescribeIndexTable(idx *pinecone.Index) { - writer := NewTabWriter() - log.Debug().Str("name", idx.Name).Msg("Printing index description") - - columns := []string{"ATTRIBUTE", "VALUE"} - header := strings.Join(columns, "\t") + "\n" - pcio.Fprint(writer, header) - - pcio.Fprintf(writer, "Name\t%s\n", idx.Name) - pcio.Fprintf(writer, "Dimension\t%v\n", DisplayOrNone(idx.Dimension)) - pcio.Fprintf(writer, "Metric\t%s\n", string(idx.Metric)) - pcio.Fprintf(writer, "Deletion Protection\t%s\n", ColorizeDeletionProtection(idx.DeletionProtection)) - pcio.Fprintf(writer, "Vector Type\t%s\n", DisplayOrNone(idx.VectorType)) - pcio.Fprintf(writer, "\t\n") - pcio.Fprintf(writer, "State\t%s\n", ColorizeState(idx.Status.State)) - pcio.Fprintf(writer, "Ready\t%s\n", ColorizeBool(idx.Status.Ready)) - pcio.Fprintf(writer, "Host\t%s\n", style.Emphasis(idx.Host)) - pcio.Fprintf(writer, "Private Host\t%s\n", DisplayOrNone(idx.PrivateHost)) - pcio.Fprintf(writer, "\t\n") - - var specType string - if idx.Spec.Serverless == nil { - specType = "pod" - pcio.Fprintf(writer, "Spec\t%s\n", specType) - pcio.Fprintf(writer, "Environment\t%s\n", idx.Spec.Pod.Environment) - pcio.Fprintf(writer, "PodType\t%s\n", idx.Spec.Pod.PodType) - pcio.Fprintf(writer, "Replicas\t%d\n", idx.Spec.Pod.Replicas) - pcio.Fprintf(writer, "ShardCount\t%d\n", idx.Spec.Pod.ShardCount) - pcio.Fprintf(writer, "PodCount\t%d\n", idx.Spec.Pod.PodCount) - pcio.Fprintf(writer, "MetadataConfig\t%s\n", text.InlineJSON(idx.Spec.Pod.MetadataConfig)) - pcio.Fprintf(writer, "Source Collection\t%s\n", DisplayOrNone(idx.Spec.Pod.SourceCollection)) - } else { - specType = "serverless" - pcio.Fprintf(writer, "Spec\t%s\n", specType) - pcio.Fprintf(writer, "Cloud\t%s\n", idx.Spec.Serverless.Cloud) - pcio.Fprintf(writer, "Region\t%s\n", idx.Spec.Serverless.Region) - pcio.Fprintf(writer, "Source Collection\t%s\n", DisplayOrNone(idx.Spec.Serverless.SourceCollection)) - } - pcio.Fprintf(writer, "\t\n") - - if idx.Embed != nil { - pcio.Fprintf(writer, "Model\t%s\n", idx.Embed.Model) - pcio.Fprintf(writer, "Field Map\t%s\n", text.InlineJSON(idx.Embed.FieldMap)) - pcio.Fprintf(writer, "Read Parameters\t%s\n", text.InlineJSON(idx.Embed.ReadParameters)) - pcio.Fprintf(writer, "Write Parameters\t%s\n", text.InlineJSON(idx.Embed.WriteParameters)) - } - - if idx.Tags != nil { - pcio.Fprintf(writer, "Tags\t%s\n", text.InlineJSON(idx.Tags)) - } - - writer.Flush() -} diff --git a/internal/pkg/utils/presenters/table.go b/internal/pkg/utils/presenters/table.go new file mode 100644 index 0000000..18bd461 --- /dev/null +++ b/internal/pkg/utils/presenters/table.go @@ -0,0 +1,198 @@ +// Package presenters provides table rendering functions for data display. +// +// NOTE: This package uses fmt functions directly (not pcio) because: +// - Data output should NOT be suppressed by the -q flag +// - Informational commands (list, describe) need to display data even in quiet mode +// - Only user-facing messages (progress, confirmations) should respect quiet mode +package presenters + +import ( + "fmt" + + "github.com/charmbracelet/bubbles/table" + "github.com/pinecone-io/cli/internal/pkg/utils/log" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) + +// Column represents a table column with title and width +type Column struct { + Title string + Width int +} + +// Row represents a table row as a slice of strings +type Row []string + +// TableOptions contains configuration options for creating a table +type TableOptions struct { + Columns []Column + Rows []Row +} + +// PrintTable creates and renders a bubbles table with the given options +func PrintTable(options TableOptions) { + // Convert abstract types to bubbles table types + bubblesColumns := make([]table.Column, len(options.Columns)) + for i, col := range options.Columns { + bubblesColumns[i] = table.Column{ + Title: col.Title, + Width: col.Width, + } + } + + bubblesRows := make([]table.Row, len(options.Rows)) + for i, row := range options.Rows { + bubblesRows[i] = table.Row(row) + } + + // Create and configure the table + t := table.New( + table.WithColumns(bubblesColumns), + table.WithRows(bubblesRows), + table.WithFocused(false), // Always disable focus to prevent row selection + table.WithHeight(len(options.Rows)), + ) + + // Use centralized color scheme for table styling (no selection version) + s, _ := style.GetBrandedTableNoSelectionStyles() + t.SetStyles(s) + + // Always ensure no row is selected/highlighted + // This must be done after setting styles + t.SetCursor(-1) + + // Render the table directly + fmt.Println(t.View()) +} + +// PrintTableWithTitle creates and renders a bubbles table with a title +func PrintTableWithTitle(title string, options TableOptions) { + fmt.Println() + fmt.Printf("%s\n\n", style.Heading(title)) + PrintTable(options) + fmt.Println() +} + +// PrintIndexTableWithIndexAttributesGroups creates and renders a table for index information with custom index attribute groups +func PrintIndexTableWithIndexAttributesGroups(indexes []*pinecone.Index, groups []IndexAttributesGroup) { + // Filter out groups that have no meaningful data + nonEmptyGroups := filterNonEmptyIndexAttributesGroups(indexes, groups) + if len(nonEmptyGroups) == 0 { + return + } + + // Get columns for the non-empty groups + columns := GetColumnsForIndexAttributesGroups(nonEmptyGroups) + + // Build table rows + var rows []Row + for _, idx := range indexes { + values := ExtractValuesForIndexAttributesGroups(idx, nonEmptyGroups) + rows = append(rows, Row(values)) + } + + // Use the table utility + PrintTable(TableOptions{ + Columns: columns, + Rows: rows, + }) + + fmt.Println() + + // Add a note about full URLs if state info is shown + hasStateGroup := false + for _, group := range nonEmptyGroups { + if group == IndexAttributesGroupState { + hasStateGroup = true + break + } + } + if hasStateGroup && len(indexes) > 0 { + hint := fmt.Sprintf("Use %s to see index details", style.Code("pc index describe ")) + fmt.Println(style.Hint(hint)) + } +} + +// PrintDescribeIndexTable creates and renders a table for index description with right-aligned first column and secondary text styling +func PrintDescribeIndexTable(idx *pinecone.Index) { + log.Debug().Str("name", idx.Name).Msg("Printing index description") + + // Print title + fmt.Println(style.Heading("Index Configuration")) + fmt.Println() + + // Print all groups with their information + PrintDescribeIndexTableWithIndexAttributesGroups(idx, AllIndexAttributesGroups()) +} + +// PrintDescribeIndexTableWithIndexAttributesGroups creates and renders a table for index description with specified index attribute groups +func PrintDescribeIndexTableWithIndexAttributesGroups(idx *pinecone.Index, groups []IndexAttributesGroup) { + // Filter out groups that have no meaningful data for this specific index + nonEmptyGroups := filterNonEmptyIndexAttributesGroupsForIndex(idx, groups) + if len(nonEmptyGroups) == 0 { + return + } + + // Build rows for the table using the same order as the table view + var rows []Row + for i, group := range nonEmptyGroups { + // Get the columns with full names for this specific group + groupColumns := getColumnsWithNamesForIndexAttributesGroup(group) + groupValues := getValuesForIndexAttributesGroup(idx, group) + + // Add spacing before each group (except the first) + if i > 0 { + rows = append(rows, Row{"", ""}) + } + + // Add rows for this group using full names + for j, col := range groupColumns { + if j < len(groupValues) { + rows = append(rows, Row{col.FullTitle, groupValues[j]}) + } + } + } + + // Print each row with right-aligned first column and secondary text styling + for _, row := range rows { + if len(row) >= 2 { + // Right align the first column content + rightAlignedFirstCol := fmt.Sprintf("%20s", row[0]) + + // Apply secondary text styling to the first column + styledFirstCol := style.SecondaryTextStyle().Render(rightAlignedFirstCol) + + // Print the row + rowText := fmt.Sprintf("%s %s", styledFirstCol, row[1]) + fmt.Println(rowText) + } else if len(row) == 1 && row[0] == "" { + // Empty row for spacing + fmt.Println() + } + } + // Add spacing after the last row + fmt.Println() +} + +// ColorizeState applies appropriate styling to index state +func ColorizeState(state pinecone.IndexStatusState) string { + switch state { + case pinecone.Ready: + return style.SuccessStyle().Render(string(state)) + case pinecone.Initializing, pinecone.Terminating, pinecone.ScalingDown, pinecone.ScalingDownPodSize, pinecone.ScalingUp, pinecone.ScalingUpPodSize: + return style.WarningStyle().Render(string(state)) + case pinecone.InitializationFailed: + return style.ErrorStyle().Render(string(state)) + default: + return string(state) + } +} + +// ColorizeDeletionProtection applies appropriate styling to deletion protection status +func ColorizeDeletionProtection(deletionProtection pinecone.DeletionProtection) string { + if deletionProtection == pinecone.DeletionProtectionEnabled { + return style.SuccessStyle().Render("enabled") + } + return style.ErrorStyle().Render("disabled") +} diff --git a/internal/pkg/utils/presenters/target_context.go b/internal/pkg/utils/presenters/target_context.go index ae26635..3f929de 100644 --- a/internal/pkg/utils/presenters/target_context.go +++ b/internal/pkg/utils/presenters/target_context.go @@ -11,7 +11,7 @@ import ( func labelUnsetIfEmpty(value string) string { if value == "" { - return style.StatusRed("UNSET") + return style.ErrorStyle().Render("UNSET") } return value } diff --git a/internal/pkg/utils/presenters/text.go b/internal/pkg/utils/presenters/text.go index 6e6a867..7da7312 100644 --- a/internal/pkg/utils/presenters/text.go +++ b/internal/pkg/utils/presenters/text.go @@ -8,9 +8,9 @@ import ( func ColorizeBool(b bool) string { if b { - return style.StatusGreen("true") + return style.SuccessStyle().Render("true") } - return style.StatusRed("false") + return style.ErrorStyle().Render("false") } func DisplayOrNone(val any) any { diff --git a/internal/pkg/utils/style/color.go b/internal/pkg/utils/style/color.go deleted file mode 100644 index 9f5486d..0000000 --- a/internal/pkg/utils/style/color.go +++ /dev/null @@ -1,34 +0,0 @@ -package style - -import ( - "github.com/fatih/color" - "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" -) - -func applyColor(s string, c *color.Color) string { - color.NoColor = !config.Color.Get() - colored := c.SprintFunc() - return colored(s) -} - -func applyStyle(s string, c color.Attribute) string { - color.NoColor = !config.Color.Get() - colored := color.New(c).SprintFunc() - return colored(s) -} - -func CodeWithPrompt(s string) string { - return (applyStyle("$ ", color.Faint) + applyColor(s, color.New(color.FgMagenta, color.Bold))) -} - -func StatusGreen(s string) string { - return applyStyle(s, color.FgGreen) -} - -func StatusYellow(s string) string { - return applyStyle(s, color.FgYellow) -} - -func StatusRed(s string) string { - return applyStyle(s, color.FgRed) -} diff --git a/internal/pkg/utils/style/colors.go b/internal/pkg/utils/style/colors.go new file mode 100644 index 0000000..0aff2df --- /dev/null +++ b/internal/pkg/utils/style/colors.go @@ -0,0 +1,148 @@ +package style + +import ( + "github.com/charmbracelet/lipgloss" + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" +) + +// ColorScheme defines the centralized color palette for the Pinecone CLI +// Based on Pinecone's official website CSS variables for consistent branding +type ColorScheme struct { + // Primary brand colors + PrimaryBlue string // Pinecone blue - main brand color + SuccessGreen string // Success states + WarningYellow string // Warning states + ErrorRed string // Error states + InfoBlue string // Info states + + // Text colors + PrimaryText string // Main text color + SecondaryText string // Secondary/muted text + MutedText string // Very muted text + + // Background colors + Background string // Main background + Surface string // Surface/card backgrounds + + // Border colors + Border string // Default borders + BorderMuted string // Muted borders +} + +// Available color schemes +var AvailableColorSchemes = map[string]ColorScheme{ + "pc-default-dark": DarkColorScheme(), + "pc-default-light": LightColorScheme(), +} + +// LightColorScheme returns colors optimized for light terminal backgrounds +// Uses Pinecone's official colors for text/backgrounds, but vibrant colors for status messages +func LightColorScheme() ColorScheme { + return ColorScheme{ + // Primary brand colors (using vibrant colors that work well in both themes) + PrimaryBlue: "#002bff", // --primary-main (Pinecone brand) + SuccessGreen: "#28a745", // More vibrant green for better visibility + WarningYellow: "#ffc107", // More vibrant amber for better visibility + ErrorRed: "#dc3545", // More vibrant red for better visibility + InfoBlue: "#17a2b8", // More vibrant info blue for better visibility + + // Text colors (from Pinecone's light theme) + PrimaryText: "#1c1917", // --text-primary + SecondaryText: "#57534e", // --text-secondary + MutedText: "#a8a29e", // --text-tertiary + + // Background colors (from Pinecone's light theme) + Background: "#fbfbfc", // --background + Surface: "#f2f3f6", // --surface + + // Border colors (from Pinecone's light theme) + Border: "#e7e5e4", // --border + BorderMuted: "#d8dddf", // --divider + } +} + +// DarkColorScheme returns colors optimized for dark terminal backgrounds +// Uses Pinecone's official colors for text/backgrounds, but more vibrant colors for status messages +func DarkColorScheme() ColorScheme { + return ColorScheme{ + // Primary brand colors (optimized for dark terminals) + PrimaryBlue: "#1e86ee", // --primary-main + SuccessGreen: "#28a745", // More vibrant green for dark terminals + WarningYellow: "#ffc107", // More vibrant amber for dark terminals + ErrorRed: "#dc3545", // More vibrant red for dark terminals + InfoBlue: "#17a2b8", // More vibrant info blue for dark terminals + + // Text colors (from Pinecone's dark theme) + PrimaryText: "#fff", // --text-primary + SecondaryText: "#a3a3a3", // --text-secondary + MutedText: "#525252", // --text-tertiary + + // Background colors (from Pinecone's dark theme) + Background: "#171717", // --background + Surface: "#252525", // --surface + + // Border colors (from Pinecone's dark theme) + Border: "#404040", // --border + BorderMuted: "#2a2a2a", // --divider + } +} + +// DefaultColorScheme returns the configured color scheme +func DefaultColorScheme() ColorScheme { + schemeName := config.ColorScheme.Get() + if scheme, exists := AvailableColorSchemes[schemeName]; exists { + return scheme + } + // Fallback to dark theme if configured scheme doesn't exist + return DarkColorScheme() +} + +// GetColorScheme returns the current color scheme +// This can be extended in the future to support themes +func GetColorScheme() ColorScheme { + return DefaultColorScheme() +} + +// LipglossColorScheme provides lipgloss-compatible color styles +type LipglossColorScheme struct { + PrimaryBlue lipgloss.Color + SuccessGreen lipgloss.Color + WarningYellow lipgloss.Color + ErrorRed lipgloss.Color + InfoBlue lipgloss.Color + PrimaryText lipgloss.Color + SecondaryText lipgloss.Color + MutedText lipgloss.Color + Background lipgloss.Color + Surface lipgloss.Color + Border lipgloss.Color + BorderMuted lipgloss.Color +} + +// GetLipglossColorScheme returns lipgloss-compatible colors +func GetLipglossColorScheme() LipglossColorScheme { + scheme := GetColorScheme() + return LipglossColorScheme{ + PrimaryBlue: lipgloss.Color(scheme.PrimaryBlue), + SuccessGreen: lipgloss.Color(scheme.SuccessGreen), + WarningYellow: lipgloss.Color(scheme.WarningYellow), + ErrorRed: lipgloss.Color(scheme.ErrorRed), + InfoBlue: lipgloss.Color(scheme.InfoBlue), + PrimaryText: lipgloss.Color(scheme.PrimaryText), + SecondaryText: lipgloss.Color(scheme.SecondaryText), + MutedText: lipgloss.Color(scheme.MutedText), + Background: lipgloss.Color(scheme.Background), + Surface: lipgloss.Color(scheme.Surface), + Border: lipgloss.Color(scheme.Border), + BorderMuted: lipgloss.Color(scheme.BorderMuted), + } +} + +// GetAvailableColorSchemeNames returns a list of available color scheme names +func GetAvailableColorSchemeNames() []string { + names := make([]string, 0, len(AvailableColorSchemes)) + for name := range AvailableColorSchemes { + names = append(names, name) + } + return names +} diff --git a/internal/pkg/utils/style/functions.go b/internal/pkg/utils/style/functions.go new file mode 100644 index 0000000..a76e97c --- /dev/null +++ b/internal/pkg/utils/style/functions.go @@ -0,0 +1,235 @@ +package style + +import ( + "fmt" + + "github.com/charmbracelet/lipgloss" + "github.com/fatih/color" + "github.com/pinecone-io/cli/internal/pkg/utils/pcio" +) + +// Typography functions using predefined styles + +func Emphasis(s string) string { + return EmphasisStyle().Render(s) +} + +func HeavyEmphasis(s string) string { + return HeavyEmphasisStyle().Render(s) +} + +func Heading(s string) string { + return HeadingStyle().Render(s) +} + +func Underline(s string) string { + return UnderlineStyle().Render(s) +} + +func Hint(s string) string { + return HintStyle().Render("Hint: ") + s +} + +func CodeHint(templateString string, codeString string) string { + return HintStyle().Render("Hint: ") + pcio.Sprintf(templateString, Code(codeString)) +} + +func Code(s string) string { + if color.NoColor { + // Add backticks for code formatting if color is disabled + return "`" + s + "`" + } + return CodeStyle().Render(s) +} + +func ResourceName(s string) string { + if color.NoColor { + // Add backticks for code formatting if color is disabled + return "`" + s + "`" + } + return HeavyEmphasisStyle().Render(s) +} + +func URL(s string) string { + return URLStyle().Render(s) +} + +// Message functions using predefined box styles + +func SuccessMsg(s string) string { + if color.NoColor { + return fmt.Sprintf("🟩 [SUCCESS] %s", s) + } + icon := "\r🟩" + box := SuccessBoxStyle().Render(icon + " SUCCESS") + return fmt.Sprintf("%s %s", box, s) +} + +func WarnMsg(s string) string { + if color.NoColor { + return fmt.Sprintf("🟧 [WARNING] %s", s) + } + icon := "\r🟧" + box := WarningBoxStyle().Render(icon + " WARNING") + return fmt.Sprintf("%s %s", box, s) +} + +func InfoMsg(s string) string { + if color.NoColor { + return fmt.Sprintf("🟦 [INFO] %s", s) + } + icon := "\r🟦" + box := InfoBoxStyle().Render(icon + " INFO") + return fmt.Sprintf("%s %s", box, s) +} + +func FailMsg(s string, a ...any) string { + message := fmt.Sprintf(s, a...) + if color.NoColor { + return fmt.Sprintf("🟄 [ERROR] %s", message) + } + icon := "\r🟄" + box := ErrorBoxStyle().Render(icon + " ERROR") + return fmt.Sprintf("%s %s", box, message) +} + +// repeat creates a string by repeating a character n times +func repeat(char string, n int) string { + result := "" + for i := 0; i < n; i++ { + result += char + } + return result +} + +// WarnMsgMultiLine creates a multi-line warning message with proper alignment +func WarnMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟧 [WARNING] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the warning label + icon := "\r🟧" + label := " WARNING" + boxStyle := WarningBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// SuccessMsgMultiLine creates a multi-line success message with proper alignment +func SuccessMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟩 [SUCCESS] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the success label + icon := "\r🟩" + label := " SUCCESS" + boxStyle := SuccessBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// InfoMsgMultiLine creates a multi-line info message with proper alignment +func InfoMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟦 [INFO] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the info label + icon := "\r🟦" + label := " INFO" + boxStyle := InfoBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// FailMsgMultiLine creates a multi-line error message with proper alignment +func FailMsgMultiLine(messages ...string) string { + if len(messages) == 0 { + return "" + } + + if color.NoColor { + // Simple text format for no-color mode + result := "🟄 [ERROR] " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n " + messages[i] + } + return result + } + + // Create the first line with the error label + icon := "\r🟄" + label := " ERROR" + boxStyle := ErrorBoxStyle() + labelBox := boxStyle.Render(icon + label) + + // Build the result + result := labelBox + " " + messages[0] + for i := 1; i < len(messages); i++ { + result += "\n" + repeat(" ", MessageBoxFixedWidth) + messages[i] + } + + return result +} + +// Legacy functions using fatih/color (kept for backward compatibility) + +func CodeWithPrompt(s string) string { + if color.NoColor { + return "$ " + s + } + colors := GetLipglossColorScheme() + promptStyle := lipgloss.NewStyle().Foreground(colors.SecondaryText) + commandStyle := lipgloss.NewStyle().Foreground(colors.InfoBlue).Bold(true) + return promptStyle.Render("$ ") + commandStyle.Render(s) +} diff --git a/internal/pkg/utils/style/spinner.go b/internal/pkg/utils/style/spinner.go deleted file mode 100644 index f2aa527..0000000 --- a/internal/pkg/utils/style/spinner.go +++ /dev/null @@ -1,55 +0,0 @@ -package style - -import ( - "time" - - "github.com/briandowns/spinner" - - "github.com/pinecone-io/cli/internal/pkg/utils/exit" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" -) - -var ( - spinnerTextEllipsis = "..." - spinnerTextDone = StatusGreen("done") - spinnerTextFailed = StatusRed("failed") - - spinnerColor = "blue" -) - -func Waiting(fn func() error) error { - return loading("", "", "", fn) -} - -func Spinner(text string, fn func() error) error { - initialMsg := text + "... " - doneMsg := initialMsg + spinnerTextDone + "\n" - failMsg := initialMsg + spinnerTextFailed + "\n" - - return loading(initialMsg, doneMsg, failMsg, fn) -} - -func loading(initialMsg, doneMsg, failMsg string, fn func() error) error { - s := spinner.New(spinner.CharSets[11], 100*time.Millisecond) - s.Prefix = initialMsg - s.FinalMSG = doneMsg - s.HideCursor = true - s.Writer = pcio.Messages - - if err := s.Color(spinnerColor); err != nil { - exit.Error(err) - } - - s.Start() - err := fn() - if err != nil { - s.FinalMSG = failMsg - } - s.Stop() - - if err != nil { - return err - } - - return nil -} diff --git a/internal/pkg/utils/style/styles.go b/internal/pkg/utils/style/styles.go new file mode 100644 index 0000000..ee34734 --- /dev/null +++ b/internal/pkg/utils/style/styles.go @@ -0,0 +1,248 @@ +package style + +import ( + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/lipgloss" + "github.com/pinecone-io/cli/internal/pkg/utils/configuration/config" +) + +const MessageBoxFixedWidth = 14 + +// Predefined styles for common use cases +var ( + // Status styles + SuccessStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.SuccessGreen) + } + + WarningStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.WarningYellow) + } + + ErrorStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.ErrorRed) + } + + InfoStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.InfoBlue) + } + + PrimaryStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryBlue) + } + + // Text styles + PrimaryTextStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryText) + } + + SecondaryTextStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.SecondaryText) + } + + MutedTextStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.MutedText) + } + + // Background styles + BackgroundStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Background(colors.Background).Foreground(colors.PrimaryText) + } + + SurfaceStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Background(colors.Surface).Foreground(colors.PrimaryText) + } + + // Border styles + BorderStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.Border) + } + + BorderMutedStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.BorderMuted) + } + + // Typography styles + EmphasisStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryBlue) + } + + HeavyEmphasisStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryBlue).Bold(true) + } + + HeadingStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryText).Bold(true) + } + + UnderlineStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.PrimaryText).Underline(true) + } + + HintStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.SecondaryText) + } + + CodeStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.InfoBlue).Bold(true) + } + + URLStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle().Foreground(colors.InfoBlue).Italic(true) + } + + // Message box styles with icon|label layout + SuccessBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.SuccessGreen). + Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast + Bold(true). + Padding(0, 1). + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment + } + + WarningBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.WarningYellow). + Foreground(lipgloss.Color("#000000")). // Always black text for good contrast on yellow + Bold(true). + Padding(0, 1). + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment + } + + ErrorBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.ErrorRed). + Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast + Bold(true). + Padding(0, 1). + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment + } + + InfoBoxStyle = func() lipgloss.Style { + colors := GetLipglossColorScheme() + return lipgloss.NewStyle(). + Background(colors.InfoBlue). + Foreground(lipgloss.Color("#FFFFFF")). // Always white text for good contrast + Bold(true). + Padding(0, 1). + Width(MessageBoxFixedWidth) // Fixed width for consistent alignment + } +) + +// GetBrandedTableStyles returns table styles using the centralized color scheme +func GetBrandedTableStyles() (table.Styles, bool) { + colors := GetLipglossColorScheme() + colorsEnabled := config.Color.Get() + + s := table.DefaultStyles() + + if colorsEnabled { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colors.PrimaryBlue). + Foreground(colors.PrimaryBlue). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + // Ensure selected row style doesn't interfere + s.Selected = s.Selected. + Foreground(colors.PrimaryText). + Background(colors.Background). + Bold(false) + } else { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + s.Selected = s.Selected. + Foreground(lipgloss.Color("")). + Background(lipgloss.Color("")). + Bold(false) + } + + return s, colorsEnabled +} + +// GetBrandedTableNoSelectionStyles returns table styles for read-only tables without row selection +func GetBrandedTableNoSelectionStyles() (table.Styles, bool) { + colors := GetLipglossColorScheme() + colorsEnabled := config.Color.Get() + + s := table.DefaultStyles() + + if colorsEnabled { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(colors.PrimaryBlue). + Foreground(colors.PrimaryBlue). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + // Empty selected style since cell style is already applied to each cell + // and we don't want any additional styling for selected rows + s.Selected = lipgloss.NewStyle() + } else { + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderBottom(true). + Bold(true) + s.Cell = s.Cell.Padding(0, 1) + // Empty selected style since cell style is already applied to each cell + // and we don't want any additional styling for selected rows + s.Selected = lipgloss.NewStyle() + } + + return s, colorsEnabled +} + +// GetBrandedConfirmationStyles returns confirmation dialog styles using the centralized color scheme +func GetBrandedConfirmationStyles() (lipgloss.Style, lipgloss.Style, lipgloss.Style, bool) { + colors := GetLipglossColorScheme() + colorsEnabled := config.Color.Get() + + var questionStyle, promptStyle, keyStyle lipgloss.Style + + if colorsEnabled { + questionStyle = HeadingStyle() + promptStyle = SecondaryTextStyle().MarginBottom(1) + keyStyle = lipgloss.NewStyle(). + Foreground(colors.InfoBlue). + Bold(true) + } else { + questionStyle = lipgloss.NewStyle(). + Bold(true). + MarginBottom(1) + + promptStyle = lipgloss.NewStyle(). + MarginBottom(1) + + keyStyle = lipgloss.NewStyle(). + Bold(true) + } + + return questionStyle, promptStyle, keyStyle, colorsEnabled +} diff --git a/internal/pkg/utils/style/typography.go b/internal/pkg/utils/style/typography.go deleted file mode 100644 index 632d10d..0000000 --- a/internal/pkg/utils/style/typography.go +++ /dev/null @@ -1,61 +0,0 @@ -package style - -import ( - "fmt" - - "github.com/fatih/color" - "github.com/pinecone-io/cli/internal/pkg/utils/pcio" -) - -func Emphasis(s string) string { - return applyStyle(s, color.FgCyan) -} - -func HeavyEmphasis(s string) string { - return applyColor(s, color.New(color.FgCyan, color.Bold)) -} - -func Heading(s string) string { - return applyStyle(s, color.Bold) -} - -func Underline(s string) string { - return applyStyle(s, color.Underline) -} - -func Hint(s string) string { - return applyStyle("Hint: ", color.Faint) + s -} - -func CodeHint(templateString string, codeString string) string { - return applyStyle("Hint: ", color.Faint) + pcio.Sprintf(templateString, Code(codeString)) -} - -func SuccessMsg(s string) string { - return applyStyle("[SUCCESS] ", color.FgGreen) + s -} - -func WarnMsg(s string) string { - return applyStyle("[WARN] ", color.FgYellow) + s -} - -func InfoMsg(s string) string { - return applyStyle("[INFO] ", color.FgHiWhite) + s -} - -func FailMsg(s string, a ...any) string { - return applyStyle("[ERROR] ", color.FgRed) + fmt.Sprintf(s, a...) -} - -func Code(s string) string { - formatted := applyColor(s, color.New(color.FgMagenta, color.Bold)) - if color.NoColor { - // Add backticks for code formatting if color is disabled - return "`" + formatted + "`" - } - return formatted -} - -func URL(s string) string { - return applyStyle(applyStyle(s, color.FgBlue), color.Italic) -} diff --git a/internal/pkg/utils/testutils/README.md b/internal/pkg/utils/testutils/README.md new file mode 100644 index 0000000..049ffba --- /dev/null +++ b/internal/pkg/utils/testutils/README.md @@ -0,0 +1,228 @@ +# Test Utilities + +This package provides reusable test utilities for testing CLI commands, particularly for common patterns like the `--json` flag and argument validation. + +## File Organization + +- `testutils.go` - Complex testing utilities (`TestCommandArgsAndFlags`) +- `assertions.go` - Simple assertion utilities (`AssertCommandUsage`, `AssertJSONFlag`) +- `index_validation.go` - Index name validation utilities (`GetIndexNameValidationTests`) + +## Generic Command Testing + +The most powerful utility is `TestCommandArgsAndFlags` which provides a generic way to test any command's argument validation and flag handling: + +```go +func TestMyCommand_ArgsValidation(t *testing.T) { + cmd := NewMyCommand() + + tests := []testutils.CommandTestConfig{ + { + Name: "valid - single argument with flag", + Args: []string{"my-arg"}, + Flags: map[string]string{"json": "true"}, + ExpectError: false, + ExpectedArgs: []string{"my-arg"}, + ExpectedFlags: map[string]interface{}{ + "json": true, + }, + }, + { + Name: "error - no arguments", + Args: []string{}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide an argument", + }, + } + + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} +``` + +## JSON Flag Testing + +The `--json` flag is used across many commands in the CLI. The JSON utility answers one simple question: **"Does my command have a properly configured `--json` flag?"** + +### JSON Flag Test + +```go +func TestMyCommand_Flags(t *testing.T) { + cmd := NewMyCommand() + + // Test that the command has a properly configured --json flag + testutils.AssertJSONFlag(t, cmd) +} +``` + +This single function verifies that the `--json` flag is: + +- Properly defined +- Boolean type +- Optional (not required) +- Has a description mentioning "json" +- Can be set to true/false + +## Command Usage Testing + +The `AssertCommandUsage` utility tests that a command has proper usage metadata: + +```go +func TestMyCommand_Usage(t *testing.T) { + cmd := NewMyCommand() + + // Test that the command has proper usage metadata + testutils.AssertCommandUsage(t, cmd, "my-command ", "domain") +} +``` + +This function verifies that the command has: + +- Correct usage string format +- Non-empty short description +- Description mentions the expected domain + +## Index Name Validation + +For commands that take an index name as a positional argument (like `describe`, `delete`, etc.), there are specialized utilities: + +### Index Name Validator + +**Basic approach (preset tests only):** + +```go +func TestMyIndexCommand_ArgsValidation(t *testing.T) { + cmd := NewMyIndexCommand() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} +``` + +**Advanced approach (preset + custom tests):** + +```go +func TestMyIndexCommand_ArgsValidation(t *testing.T) { + cmd := NewMyIndexCommand() + + // Get preset index name validation tests + tests := testutils.GetIndexNameValidationTests() + + // Add custom tests for this specific command + customTests := []testutils.CommandTestConfig{ + { + Name: "valid - with custom flag", + Args: []string{"my-index"}, + Flags: map[string]string{"custom-flag": "value"}, + ExpectError: false, + }, + } + + // Combine preset tests with custom tests + allTests := append(tests, customTests...) + + // Use the generic test utility + testutils.TestCommandArgsAndFlags(t, cmd, allTests) +} +``` + +### Testing Flags Separately + +**For commands with --json flag:** + +```go +func TestMyIndexCommand_Flags(t *testing.T) { + cmd := NewMyIndexCommand() + + // Test the --json flag specifically + testutils.AssertJSONFlag(t, cmd) +} +``` + +**For commands with custom flags:** + +```go +func TestMyIndexCommand_Flags(t *testing.T) { + cmd := NewMyIndexCommand() + + // Test custom flags using the generic utility + tests := []testutils.CommandTestConfig{ + { + Name: "valid - with custom flag", + Args: []string{"my-index"}, + Flags: map[string]string{"custom-flag": "value"}, + ExpectError: false, + ExpectedArgs: []string{"my-index"}, + ExpectedFlags: map[string]interface{}{ + "custom-flag": "value", + }, + }, + } + + testutils.TestCommandArgsAndFlags(t, cmd, tests) +} +``` + +### Index Name Validator Function + +```go +func NewMyIndexCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "my-command ", + Short: "Description of my command", + Args: testutils.CreateIndexNameValidator(), // Reusable validator + Run: func(cmd *cobra.Command, args []string) { + // Command logic here + }, + } + return cmd +} +``` + +The index name validator handles: + +- Empty string validation +- Whitespace-only validation +- Multiple argument validation +- No argument validation + +## Available Functions + +### Generic Command Testing + +- `TestCommandArgsAndFlags(t, cmd, tests)` - Generic utility to test any command's argument validation and flag handling +- `CommandTestConfig` - Configuration struct for defining test cases +- `AssertCommandUsage(t, cmd, expectedUsage, expectedDomain)` - Tests that a command has proper usage metadata + +### Index Name Validation + +- `GetIndexNameValidationTests()` - Returns preset test cases for index name validation + +### JSON Flag Specific + +- `AssertJSONFlag(t, cmd)` - Verifies that the command has a properly configured `--json` flag (definition, type, optional, description, functionality) + +## Supported Flag Types + +The generic utility supports all common flag types: + +- `bool` - Boolean flags +- `string` - String flags +- `int`, `int64` - Integer flags +- `float64` - Float flags +- `stringSlice`, `intSlice` - Slice flags + +## Usage in Commands + +Any command that follows the standard cobra pattern can use these utilities. The generic utilities are particularly useful for commands with: + +- Positional arguments +- Multiple flags of different types +- Custom argument validation logic + +## Example + +See `internal/pkg/cli/command/index/describe_test.go` for a complete example of how to use these utilities. diff --git a/internal/pkg/utils/testutils/assertions.go b/internal/pkg/utils/testutils/assertions.go new file mode 100644 index 0000000..104dc98 --- /dev/null +++ b/internal/pkg/utils/testutils/assertions.go @@ -0,0 +1,91 @@ +package testutils + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" +) + +// AssertCommandUsage tests that a command has proper usage metadata +// This function can be reused by any command to test its usage information +func AssertCommandUsage(t *testing.T, cmd *cobra.Command, expectedUsage string, expectedDomain string) { + t.Helper() + + // Test usage string + if cmd.Use != expectedUsage { + t.Errorf("expected Use to be %q, got %q", expectedUsage, cmd.Use) + } + + // Test short description exists + if cmd.Short == "" { + t.Error("expected command to have a short description") + } + + // Test description mentions domain + if !strings.Contains(strings.ToLower(cmd.Short), expectedDomain) { + t.Errorf("expected short description to mention %q, got %q", expectedDomain, cmd.Short) + } +} + +// AssertJSONFlag tests the common --json flag pattern used across commands +// This function comprehensively tests flag definition, properties, and functionality +// This function can be reused by any command that has a --json flag +func AssertJSONFlag(t *testing.T, cmd *cobra.Command) { + t.Helper() + + // Test that the json flag is properly defined + jsonFlag := cmd.Flags().Lookup("json") + if jsonFlag == nil { + t.Error("expected --json flag to be defined") + return + } + + // Test that it's a boolean flag + if jsonFlag.Value.Type() != "bool" { + t.Errorf("expected --json flag to be bool type, got %s", jsonFlag.Value.Type()) + } + + // Test that the flag is optional (not required) + if jsonFlag.Annotations[cobra.BashCompOneRequiredFlag] != nil { + t.Error("expected --json flag to be optional") + } + + // Test that the flag has a description + if jsonFlag.Usage == "" { + t.Error("expected --json flag to have a usage description") + } + + // Test that the description mentions JSON + if !strings.Contains(strings.ToLower(jsonFlag.Usage), "json") { + t.Errorf("expected --json flag description to mention 'json', got %q", jsonFlag.Usage) + } + + // Test setting json flag to true + err := cmd.Flags().Set("json", "true") + if err != nil { + t.Errorf("failed to set --json flag to true: %v", err) + } + + jsonValue, err := cmd.Flags().GetBool("json") + if err != nil { + t.Errorf("failed to get --json flag value: %v", err) + } + if !jsonValue { + t.Error("expected --json flag to be true after setting it") + } + + // Test setting json flag to false + err = cmd.Flags().Set("json", "false") + if err != nil { + t.Errorf("failed to set --json flag to false: %v", err) + } + + jsonValue, err = cmd.Flags().GetBool("json") + if err != nil { + t.Errorf("failed to get --json flag value: %v", err) + } + if jsonValue { + t.Error("expected --json flag to be false after setting it") + } +} diff --git a/internal/pkg/utils/testutils/index_validation.go b/internal/pkg/utils/testutils/index_validation.go new file mode 100644 index 0000000..038c305 --- /dev/null +++ b/internal/pkg/utils/testutils/index_validation.go @@ -0,0 +1,62 @@ +package testutils + +// GetIndexNameValidationTests returns standard index name validation test cases +// These tests focus ONLY on index name validation, without any flag assumptions +// Flags should be tested separately using the generic TestCommandArgsAndFlags utility +func GetIndexNameValidationTests() []CommandTestConfig { + return []CommandTestConfig{ + { + Name: "valid - single index name", + Args: []string{"my-index"}, + Flags: map[string]string{}, + ExpectError: false, + }, + { + Name: "valid - index name with special characters", + Args: []string{"my-index-123"}, + Flags: map[string]string{}, + ExpectError: false, + }, + { + Name: "valid - index name with underscores", + Args: []string{"my_index_123"}, + Flags: map[string]string{}, + ExpectError: false, + }, + { + Name: "error - no arguments", + Args: []string{}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide an index name", + }, + { + Name: "error - multiple arguments", + Args: []string{"index1", "index2"}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + { + Name: "error - three arguments", + Args: []string{"index1", "index2", "index3"}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "please provide only one index name", + }, + { + Name: "error - empty string argument", + Args: []string{""}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "index name cannot be empty", + }, + { + Name: "error - whitespace only argument", + Args: []string{" "}, + Flags: map[string]string{}, + ExpectError: true, + ErrorSubstr: "index name cannot be empty", + }, + } +} diff --git a/internal/pkg/utils/testutils/testutils.go b/internal/pkg/utils/testutils/testutils.go new file mode 100644 index 0000000..591c089 --- /dev/null +++ b/internal/pkg/utils/testutils/testutils.go @@ -0,0 +1,114 @@ +package testutils + +import ( + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// CommandTestConfig represents the configuration for testing a command's arguments and flags +type CommandTestConfig struct { + Name string + Args []string + Flags map[string]string + ExpectError bool + ErrorSubstr string + ExpectedArgs []string // Expected positional arguments after processing + ExpectedFlags map[string]interface{} // Expected flag values after processing +} + +// TestCommandArgsAndFlags provides a generic way to test any command's argument validation and flag handling +// This can be used for any command that follows the standard cobra pattern +func TestCommandArgsAndFlags(t *testing.T, cmd *cobra.Command, tests []CommandTestConfig) { + t.Helper() + + for _, tt := range tests { + t.Run(tt.Name, func(t *testing.T) { + // Create a fresh command instance for each test + cmdCopy := *cmd + cmdCopy.Flags().SortFlags = false + + // Reset all flags to their default values + cmdCopy.Flags().VisitAll(func(flag *pflag.Flag) { + flag.Value.Set(flag.DefValue) + }) + + // Set flags if provided + for flagName, flagValue := range tt.Flags { + err := cmdCopy.Flags().Set(flagName, flagValue) + if err != nil { + t.Fatalf("failed to set flag %s=%s: %v", flagName, flagValue, err) + } + } + + // Test the Args validation function + err := cmdCopy.Args(&cmdCopy, tt.Args) + + if tt.ExpectError { + if err == nil { + t.Errorf("expected error but got nil") + } else if tt.ErrorSubstr != "" && !strings.Contains(err.Error(), tt.ErrorSubstr) { + t.Errorf("expected error to contain %q, got %q", tt.ErrorSubstr, err.Error()) + } + } else { + if err != nil { + t.Errorf("unexpected error: %v", err) + } + + // If validation passed, test that the command would be configured correctly + if len(tt.Args) > 0 && len(tt.ExpectedArgs) > 0 { + // Verify positional arguments + for i, expectedArg := range tt.ExpectedArgs { + if i < len(tt.Args) && tt.Args[i] != expectedArg { + t.Errorf("expected arg[%d] to be %q, got %q", i, expectedArg, tt.Args[i]) + } + } + } + + // Verify flag values + for flagName, expectedValue := range tt.ExpectedFlags { + flag := cmdCopy.Flags().Lookup(flagName) + if flag == nil { + t.Errorf("expected flag %s to exist", flagName) + continue + } + + // Get the actual flag value based on its type + actualValue, err := getFlagValue(&cmdCopy, flagName, flag.Value.Type()) + if err != nil { + t.Errorf("failed to get flag %s value: %v", flagName, err) + continue + } + + if actualValue != expectedValue { + t.Errorf("expected flag %s to be %v, got %v", flagName, expectedValue, actualValue) + } + } + } + }) + } +} + +// getFlagValue retrieves the value of a flag based on its type +func getFlagValue(cmd *cobra.Command, flagName, flagType string) (interface{}, error) { + switch flagType { + case "bool": + return cmd.Flags().GetBool(flagName) + case "string": + return cmd.Flags().GetString(flagName) + case "int": + return cmd.Flags().GetInt(flagName) + case "int64": + return cmd.Flags().GetInt64(flagName) + case "float64": + return cmd.Flags().GetFloat64(flagName) + case "stringSlice": + return cmd.Flags().GetStringSlice(flagName) + case "intSlice": + return cmd.Flags().GetIntSlice(flagName) + default: + return cmd.Flags().GetString(flagName) // fallback to string + } +}