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..11e4e38 100644 --- a/internal/pkg/cli/command/apiKey/delete.go +++ b/internal/pkg/cli/command/apiKey/delete.go @@ -1,24 +1,20 @@ 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" ) type DeleteApiKeyOptions struct { - apiKeyId string - skipConfirmation bool + apiKeyId string } func NewDeleteKeyCmd() *cobra.Command { @@ -30,7 +26,8 @@ func NewDeleteKeyCmd() *cobra.Command { GroupID: help.GROUP_API_KEYS.ID, Example: heredoc.Doc(` $ pc target -o "my-org" -p "my-project" - $ pc api-key delete -i "api-key-id" + $ pc api-key delete -i "api-key-id" + $ pc api-key delete -i "api-key-id" -y `), Run: func(cmd *cobra.Command, args []string) { ac := sdk.NewPineconeAdminClient() @@ -44,7 +41,9 @@ func NewDeleteKeyCmd() *cobra.Command { exit.Error(err) } - if !options.skipConfirmation { + // Check if -y flag is set + assumeYes, _ := cmd.Flags().GetBool("assume-yes") + if !assumeYes { confirmDeleteApiKey(keyToDelete.Name) } @@ -60,34 +59,20 @@ func NewDeleteKeyCmd() *cobra.Command { cmd.Flags().StringVarP(&options.apiKeyId, "id", "i", "", "The ID of the API key to delete") _ = cmd.MarkFlagRequired("id") - cmd.Flags().BoolVar(&options.skipConfirmation, "skip-confirmation", false, "Skip deletion confirmation prompt") return cmd } 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..564a54f 100644 --- a/internal/pkg/cli/command/index/configure.go +++ b/internal/pkg/cli/command/index/configure.go @@ -2,11 +2,14 @@ package index import ( "context" + "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/index" + indexpresenters "github.com/pinecone-io/cli/internal/pkg/utils/index/presenters" "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" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -19,34 +22,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 +59,21 @@ 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)) - presenters.PrintDescribeIndexTable(idx) + msg.SuccessMsg("Index %s configured successfully.", style.ResourceName(idx.Name)) + + indexpresenters.PrintDescribeIndexTable(idx) + + describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) + hint := fmt.Sprintf("Run %s at any time to check the status. \n\n", style.Code(describeCommand)) + pcio.Println(style.Hint(hint)) } 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..e4a2d5b 100644 --- a/internal/pkg/cli/command/index/create.go +++ b/internal/pkg/cli/command/index/create.go @@ -1,15 +1,19 @@ package index import ( - "context" + "errors" + "fmt" "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" + indexpresenters "github.com/pinecone-io/cli/internal/pkg/utils/index/presenters" + "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" - "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" @@ -17,214 +21,216 @@ import ( "github.com/spf13/cobra" ) -type indexType string - -const ( - indexTypeServerless indexType = "serverless" - indexTypeIntegrated indexType = "integrated" - indexTypePod indexType = "pod" -) - type createIndexOptions struct { - // required for all index types - name string - - // serverless only - vectorType string - - // serverless & integrated - cloud string - region string - - // serverless & pods - sourceCollection string - - // pods only - environment string - podType string - shards int32 - replicas int32 - metadataConfig []string - - // integrated only - model string - fieldMap map[string]string - readParameters map[string]string - writeParameters map[string]string - - // optional for all index types - dimension int32 - metric string - deletionProtection string - tags map[string]string - - json bool + CreateOptions index.CreateOptions + json bool } 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 - you can create depending on the configuration provided: + The %s command creates a new index with the specified configuration. There are different types of indexes + you can create: - Serverless (dense or sparse) - - Integrated - - Pod + - Pod (dense only) + + For serverless indexes, you can specify an embedding model to use via the %s flag: + + The CLI will try to automatically infer missing settings from those provided. For detailed documentation, see: %s - `, style.Code("pc index create"), style.URL(docslinks.DocsIndexCreate)), + `, style.Code("pc index create"), + style.Emphasis("--model"), + 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 + # create default index (serverless) + $ pc index create my-index + + # create serverless index + $ pc index create my-index --serverless + + # create pod index + $ pc index create my-index --pod + + # create a serverless index with explicit model + $ pc index create my-index --model llama-text-embed-v2 --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 + # create a serverless index with the default dense model + $ pc index create my-index --model dense --cloud aws --region us-east-1 + + # create a serverless index with the default sparse model + $ pc index create my-index --model sparse --cloud aws --region us-east-1 - # 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 `), + Args: index.ValidateIndexNameArgs, + SilenceUsage: true, Run: func(cmd *cobra.Command, args []string) { - runCreateIndexCmd(options) + options.CreateOptions.Name.Value = args[0] + runCreateIndexCmd(options, cmd, args) }, } - // Required flags - cmd.Flags().StringVarP(&options.name, "name", "n", "", "Name of index to create") - _ = cmd.MarkFlagRequired("name") + // index type flags + cmd.Flags().BoolVar(&options.CreateOptions.Serverless.Value, "serverless", false, "Create a serverless index (default)") + cmd.Flags().BoolVar(&options.CreateOptions.Pod.Value, "pod", false, "Create a pod index") // Serverless & Pods - cmd.Flags().StringVar(&options.sourceCollection, "source_collection", "", "When creating an index from a collection") + cmd.Flags().StringVar(&options.CreateOptions.SourceCollection.Value, "source_collection", "", "When creating an index from a collection") // Serverless & Integrated - cmd.Flags().StringVarP(&options.cloud, "cloud", "c", "", "Cloud provider where you would like to deploy your index") - cmd.Flags().StringVarP(&options.region, "region", "r", "", "Cloud region where you would like to deploy your index") + cmd.Flags().StringVarP(&options.CreateOptions.Cloud.Value, "cloud", "c", "", "Cloud provider where you would like to deploy your index") + cmd.Flags().StringVarP(&options.CreateOptions.Region.Value, "region", "r", "", "Cloud region where you would like to deploy your index") // Serverless flags - cmd.Flags().StringVarP(&options.vectorType, "vector_type", "v", "", "Vector type to use. One of: dense, sparse") + cmd.Flags().StringVarP(&options.CreateOptions.VectorType.Value, "vector_type", "v", "", "Vector type to use. One of: dense, sparse") // Pod flags - cmd.Flags().StringVar(&options.environment, "environment", "", "Environment of the index to create") - cmd.Flags().StringVar(&options.podType, "pod_type", "", "Type of pod to use") - cmd.Flags().Int32Var(&options.shards, "shards", 1, "Shards of the index to create") - cmd.Flags().Int32Var(&options.replicas, "replicas", 1, "Replicas of the index to create") - cmd.Flags().StringSliceVar(&options.metadataConfig, "metadata_config", []string{}, "Metadata configuration to limit the fields that are indexed for search") + cmd.Flags().StringVar(&options.CreateOptions.Environment.Value, "environment", "", "Environment of the index to create") + cmd.Flags().StringVar(&options.CreateOptions.PodType.Value, "pod_type", "", "Type of pod to use") + cmd.Flags().Int32Var(&options.CreateOptions.Shards.Value, "shards", 1, "Shards of the index to create") + cmd.Flags().Int32Var(&options.CreateOptions.Replicas.Value, "replicas", 1, "Replicas of the index to create") + cmd.Flags().StringSliceVar(&options.CreateOptions.MetadataConfig.Value, "metadata_config", []string{}, "Metadata configuration to limit the fields that are indexed for search") // Integrated flags - cmd.Flags().StringVar(&options.model, "model", "", "The name of the embedding model to use for the index") - cmd.Flags().StringToStringVar(&options.fieldMap, "field_map", map[string]string{}, "Identifies the name of the text field from your document model that will be embedded") - cmd.Flags().StringToStringVar(&options.readParameters, "read_parameters", map[string]string{}, "The read parameters for the embedding model") - cmd.Flags().StringToStringVar(&options.writeParameters, "write_parameters", map[string]string{}, "The write parameters for the embedding model") + cmd.Flags().StringVar(&options.CreateOptions.Model.Value, "model", "", fmt.Sprintf("Embedding model to use (e.g., llama-text-embed-v2, default, sparse). Use %s to see available models", style.Code("pc models"))) + cmd.Flags().StringToStringVar(&options.CreateOptions.FieldMap.Value, "field_map", map[string]string{}, "Identifies the name of the text field from your document model that will be embedded") + cmd.Flags().StringToStringVar(&options.CreateOptions.ReadParameters.Value, "read_parameters", map[string]string{}, "The read parameters for the embedding model") + cmd.Flags().StringToStringVar(&options.CreateOptions.WriteParameters.Value, "write_parameters", map[string]string{}, "The write parameters for the embedding model") // Optional flags - cmd.Flags().Int32VarP(&options.dimension, "dimension", "d", 0, "Dimension of the index to create") - cmd.Flags().StringVarP(&options.metric, "metric", "m", "cosine", "Metric to use. One of: cosine, euclidean, dotproduct") - cmd.Flags().StringVar(&options.deletionProtection, "deletion_protection", "", "Whether to enable deletion protection for the index. One of: enabled, disabled") - cmd.Flags().StringToStringVar(&options.tags, "tags", map[string]string{}, "Custom user tags to add to an index") + cmd.Flags().Int32VarP(&options.CreateOptions.Dimension.Value, "dimension", "d", 0, "Dimension of the index to create") + cmd.Flags().StringVarP(&options.CreateOptions.Metric.Value, "metric", "m", "", "Metric to use. One of: cosine, euclidean, dotproduct") + cmd.Flags().StringVar(&options.CreateOptions.DeletionProtection.Value, "deletion_protection", "", "Whether to enable deletion protection for the index. One of: enabled, disabled") + cmd.Flags().StringToStringVar(&options.CreateOptions.Tags.Value, "tags", map[string]string{}, "Custom user tags to add to an index") cmd.Flags().BoolVar(&options.json, "json", false, "Output as JSON") return cmd } -func runCreateIndexCmd(options createIndexOptions) { - ctx := context.Background() - pc := sdk.NewPineconeClient() +func runCreateIndexCmd(options createIndexOptions, cmd *cobra.Command, args []string) { + ctx := cmd.Context() - // validate and derive index type from arguments - err := options.validate() - if err != nil { - exit.Error(err) - return + // validationErrors := index.ValidateCreateOptions(options.CreateOptions) + // if len(validationErrors) > 0 { + // msg.FailMsgMultiLine(validationErrors...) + // exit.Error(errors.New(validationErrors[0])) // Use first error for exit code + // } + + inferredOptions := index.InferredCreateOptions(ctx, options.CreateOptions) + validationErrors := index.ValidateCreateOptions(inferredOptions) + if len(validationErrors) > 0 { + msg.FailMsgMultiLine(validationErrors...) + exit.Error(errors.New(validationErrors[0])) // Use first error for exit code } - idxType, err := options.deriveIndexType() - if err != nil { - exit.Error(err) - return + + // Print preview of what will be created + pcio.Println() + pcio.Printf("%s\n\n", + pcio.Sprintf("Creating %s index %s with the following configuration:", + style.Emphasis(string(inferredOptions.GetSpec())), + style.ResourceName(inferredOptions.Name.Value), + ), + ) + + indexpresenters.PrintIndexCreateConfigTable(&inferredOptions) + + // Ask for user confirmation unless -y flag is set + assumeYes, _ := cmd.Flags().GetBool("assume-yes") + if !assumeYes { + 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 { - tags := pinecone.IndexTags(options.tags) + if len(inferredOptions.Tags.Value) > 0 { + tags := pinecone.IndexTags(inferredOptions.Tags.Value) indexTags = &tags } // created index var idx *pinecone.Index + var err error + pc := sdk.NewPineconeClient() - switch idxType { - case indexTypeServerless: + switch inferredOptions.GetCreateFlow() { + case index.Serverless: // create serverless index - args := pinecone.CreateServerlessIndexRequest{ - Name: options.name, - Cloud: pinecone.Cloud(options.cloud), - Region: options.region, - Metric: pointerOrNil(pinecone.IndexMetric(options.metric)), - DeletionProtection: pointerOrNil(pinecone.DeletionProtection(options.deletionProtection)), - Dimension: pointerOrNil(options.dimension), - VectorType: pointerOrNil(options.vectorType), + req := pinecone.CreateServerlessIndexRequest{ + Name: inferredOptions.Name.Value, + Cloud: pinecone.Cloud(inferredOptions.Cloud.Value), + Region: inferredOptions.Region.Value, + Metric: pointerOrNil(pinecone.IndexMetric(inferredOptions.Metric.Value)), + DeletionProtection: pointerOrNil(pinecone.DeletionProtection(inferredOptions.DeletionProtection.Value)), + Dimension: pointerOrNil(inferredOptions.Dimension.Value), + VectorType: pointerOrNil(inferredOptions.VectorType.Value), Tags: indexTags, - SourceCollection: pointerOrNil(options.sourceCollection), + SourceCollection: pointerOrNil(inferredOptions.SourceCollection.Value), } - 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: + case index.Pod: // create pod index var metadataConfig *pinecone.PodSpecMetadataConfig - if len(options.metadataConfig) > 0 { + if len(inferredOptions.MetadataConfig.Value) > 0 { metadataConfig = &pinecone.PodSpecMetadataConfig{ - Indexed: &options.metadataConfig, + Indexed: &inferredOptions.MetadataConfig.Value, } } - args := pinecone.CreatePodIndexRequest{ - Name: options.name, - Dimension: options.dimension, - Environment: options.environment, - PodType: options.podType, - Shards: options.shards, - Replicas: options.replicas, - Metric: pointerOrNil(pinecone.IndexMetric(options.metric)), - DeletionProtection: pointerOrNil(pinecone.DeletionProtection(options.deletionProtection)), - SourceCollection: pointerOrNil(options.sourceCollection), + req := pinecone.CreatePodIndexRequest{ + Name: inferredOptions.Name.Value, + Dimension: inferredOptions.Dimension.Value, + Environment: inferredOptions.Environment.Value, + PodType: inferredOptions.PodType.Value, + Shards: inferredOptions.Shards.Value, + Replicas: inferredOptions.Replicas.Value, + Metric: pointerOrNil(pinecone.IndexMetric(inferredOptions.Metric.Value)), + DeletionProtection: pointerOrNil(pinecone.DeletionProtection(inferredOptions.DeletionProtection.Value)), + SourceCollection: pointerOrNil(inferredOptions.SourceCollection.Value), Tags: indexTags, 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: + case index.Integrated: // create integrated index - readParams := toInterfaceMap(options.readParameters) - writeParams := toInterfaceMap(options.writeParameters) - - args := pinecone.CreateIndexForModelRequest{ - Name: options.name, - Cloud: pinecone.Cloud(options.cloud), - Region: options.region, - DeletionProtection: pointerOrNil(pinecone.DeletionProtection(options.deletionProtection)), + readParams := toInterfaceMap(inferredOptions.ReadParameters.Value) + writeParams := toInterfaceMap(inferredOptions.WriteParameters.Value) + + req := pinecone.CreateIndexForModelRequest{ + Name: inferredOptions.Name.Value, + Cloud: pinecone.Cloud(inferredOptions.Cloud.Value), + Region: inferredOptions.Region.Value, + DeletionProtection: pointerOrNil(pinecone.DeletionProtection(inferredOptions.DeletionProtection.Value)), Embed: pinecone.CreateIndexForModelEmbed{ - Model: options.model, - FieldMap: toInterfaceMap(options.fieldMap), + Model: inferredOptions.Model.Value, + FieldMap: toInterfaceMap(inferredOptions.FieldMap.Value), ReadParameters: &readParams, WriteParameters: &writeParams, }, } - 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: @@ -233,53 +239,23 @@ func runCreateIndexCmd(options createIndexOptions) { exit.Error(err) } - renderSuccessOutput(idx, options) + renderSuccessOutput(idx, options.json) } -func renderSuccessOutput(idx *pinecone.Index, options createIndexOptions) { - if options.json { +func renderSuccessOutput(idx *pinecone.Index, jsonOutput bool) { + if jsonOutput { json := text.IndentJSON(idx) pcio.Println(json) 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)) - presenters.PrintDescribeIndexTable(idx) -} - -// validate specific input params -func (c *createIndexOptions) validate() error { - // name required for all index types - if c.name == "" { - err := pcio.Errorf("name is required") - log.Error().Err(err).Msg("Error creating index") - return err - } - - // environment and cloud/region cannot be provided together - if c.cloud != "" && c.region != "" && c.environment != "" { - err := pcio.Errorf("cloud, region, and environment cannot be provided together") - log.Error().Err(err).Msg("Error creating index") - return err - } + msg.SuccessMsg("Index %s created successfully.", style.ResourceName(idx.Name)) - return nil -} + indexpresenters.PrintDescribeIndexTable(idx) -// determine the type of index being created based on high level input params -func (c *createIndexOptions) deriveIndexType() (indexType, error) { - if c.cloud != "" && c.region != "" { - if c.model != "" { - return indexTypeIntegrated, nil - } else { - return indexTypeServerless, nil - } - } - if c.environment != "" { - return indexTypePod, nil - } - return "", pcio.Error("invalid index type. Please provide either environment, or cloud and region") + describeCommand := pcio.Sprintf("pc index describe %s", idx.Name) + hint := fmt.Sprintf("Run %s at any time to check the status. \n\n", style.Code(describeCommand)) + pcio.Println(style.Hint(hint)) } func pointerOrNil[T comparable](value T) *T { diff --git a/internal/pkg/cli/command/index/create_pod.go b/internal/pkg/cli/command/index/create_pod.go index a7a407b..26c079e 100644 --- a/internal/pkg/cli/command/index/create_pod.go +++ b/internal/pkg/cli/command/index/create_pod.go @@ -5,9 +5,9 @@ import ( "os" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + indexpresenters "github.com/pinecone-io/cli/internal/pkg/utils/index/presenters" "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" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -97,5 +97,5 @@ func runCreatePodCmd(options createPodOptions) { 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)) - presenters.PrintDescribeIndexTable(idx) + indexpresenters.PrintDescribeIndexTable(idx) } diff --git a/internal/pkg/cli/command/index/create_serverless.go b/internal/pkg/cli/command/index/create_serverless.go index 52e3f29..dbc2485 100644 --- a/internal/pkg/cli/command/index/create_serverless.go +++ b/internal/pkg/cli/command/index/create_serverless.go @@ -5,9 +5,9 @@ import ( "os" "github.com/pinecone-io/cli/internal/pkg/utils/exit" + indexpresenters "github.com/pinecone-io/cli/internal/pkg/utils/index/presenters" "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" "github.com/pinecone-io/cli/internal/pkg/utils/text" @@ -100,5 +100,5 @@ func runCreateServerlessCmd(options createServerlessOptions) { 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)) - presenters.PrintDescribeIndexTable(idx) + indexpresenters.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..1a0e0e7 --- /dev/null +++ b/internal/pkg/cli/command/index/create_test.go @@ -0,0 +1,52 @@ +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 business logic) + customTests := []testutils.CommandTestConfig{ + { + 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..d9363aa 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,40 @@ 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 unless -y flag is set + assumeYes, _ := cmd.Flags().GetBool("assume-yes") + if !assumeYes { + // 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..edbea79 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/presenters" + "github.com/pinecone-io/cli/internal/pkg/utils/index" + indexpresenters "github.com/pinecone-io/cli/internal/pkg/utils/index/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) + indexpresenters.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/index_test.go b/internal/pkg/cli/command/index/index_test.go deleted file mode 100644 index 2e79a0d..0000000 --- a/internal/pkg/cli/command/index/index_test.go +++ /dev/null @@ -1,171 +0,0 @@ -package index - -import ( - "strings" - "testing" -) - -func TestCreateIndexOptions_DeriveIndexType(t *testing.T) { - tests := []struct { - name string - options createIndexOptions - expected indexType - expectError bool - }{ - { - name: "serverless - cloud, region", - options: createIndexOptions{ - cloud: "aws", - region: "us-east-1", - }, - expected: indexTypeServerless, - }, - { - name: "integrated - cloud, region, model", - options: createIndexOptions{ - cloud: "aws", - region: "us-east-1", - model: "multilingual-e5-large", - }, - expected: indexTypeIntegrated, - }, - { - name: "pods - environment", - options: createIndexOptions{ - environment: "us-east-1-gcp", - }, - expected: indexTypePod, - }, - { - name: "serverless - cloud and region prioritized over environment", - options: createIndexOptions{ - cloud: "aws", - region: "us-east-1", - environment: "us-east-1-gcp", - }, - expected: indexTypeServerless, - }, - { - name: "error - no input", - options: createIndexOptions{}, - expectError: true, - }, - { - name: "error - cloud and model only", - options: createIndexOptions{ - cloud: "aws", - model: "multilingual-e5-large", - }, - expectError: true, - }, - { - name: "error - cloud only", - options: createIndexOptions{ - cloud: "aws", - }, - expectError: true, - }, - { - name: "error - model only", - options: createIndexOptions{ - model: "multilingual-e5-large", - }, - expectError: true, - }, - { - name: "error - region only", - options: createIndexOptions{ - region: "us-east-1", - }, - expectError: true, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got, err := tt.options.deriveIndexType() - if tt.expectError { - if err == nil { - t.Errorf("expected error, got nil") - } - } else { - if err != nil { - t.Errorf("unexpected error: %v", err) - } - if got != tt.expected { - t.Errorf("expected %v, got %v", tt.expected, got) - } - } - }) - } -} - -func TestCreateIndexOptions_Validate(t *testing.T) { - tests := []struct { - name string - options createIndexOptions - expectError bool - errorSubstr string - }{ - { - name: "serverless index with name and cloud, region", - options: createIndexOptions{ - name: "my-index", - cloud: "aws", - }, - expectError: false, - }, - { - name: "valid - integrated index with name and cloud, region, model", - options: createIndexOptions{ - name: "my-index", - cloud: "aws", - region: "us-east-1", - model: "multilingual-e5-large", - }, - }, - { - name: "valid - pod index with name and environment", - options: createIndexOptions{ - name: "my-index", - environment: "us-east-1-gcp", - }, - expectError: false, - }, - { - name: "error - missing name", - options: createIndexOptions{}, - expectError: true, - errorSubstr: "name is required", - }, - { - name: "error - name, cloud, region, environment all provided", - options: createIndexOptions{ - name: "my-index", - cloud: "aws", - region: "us-east-1", - environment: "us-east-1-gcp", - }, - expectError: true, - errorSubstr: "cloud, region, and environment cannot be provided together", - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - err := tt.options.validate() - - 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) - } - } - }) - } -} diff --git a/internal/pkg/cli/command/index/list.go b/internal/pkg/cli/command/index/list.go index 8c84a07..2a98ae7 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" + indexpresenters "github.com/pinecone-io/cli/internal/pkg/utils/index/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,17 @@ 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 + indexpresenters.PrintIndexTableWithIndexAttributesGroups(idxs, []indexpresenters.IndexAttributesGroup{ + indexpresenters.IndexAttributesGroupEssential, + // indexpresenters.IndexAttributesGroupInference, + indexpresenters.IndexAttributesGroupState, + }) } }, } @@ -55,28 +58,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/models/models.go b/internal/pkg/cli/command/models/models.go new file mode 100644 index 0000000..d1dec53 --- /dev/null +++ b/internal/pkg/cli/command/models/models.go @@ -0,0 +1,64 @@ +package models + +import ( + "context" + _ "embed" + "fmt" + "sort" + + 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/models" + "github.com/pinecone-io/cli/internal/pkg/utils/models/presenters" + "github.com/pinecone-io/cli/internal/pkg/utils/text" + "github.com/spf13/cobra" +) + +type ListModelsCmdOptions struct { + json bool + noCache bool +} + +func NewModelsCmd() *cobra.Command { + options := ListModelsCmdOptions{} + + cmd := &cobra.Command{ + Use: "models", + Short: "List the models hosted on Pinecone", + Run: func(cmd *cobra.Command, args []string) { + ctx := context.Background() + + // Use cache unless --no-cache flag is set + useCache := !options.noCache + models, err := models.GetModels(ctx, useCache) + if err != nil { + errorutil.HandleIndexAPIError(err, cmd, []string{}) + exit.Error(err) + } + + if len(models) == 0 { + fmt.Println("No models found.") + return + } + + // Sort results alphabetically by model name + sort.SliceStable(models, func(i, j int) bool { + return models[i].Model < models[j].Model + }) + + if options.json { + // Use fmt for data output - should not be suppressed by -q flag + json := text.IndentJSON(models) + fmt.Println(json) + } else { + // Show models in table format + presenters.PrintModelsTable(models) + } + }, + } + + cmd.Flags().BoolVar(&options.json, "json", false, "output as JSON") + cmd.Flags().BoolVar(&options.noCache, "no-cache", false, "skip cache and fetch fresh data from API") + + return cmd +} diff --git a/internal/pkg/cli/command/organization/delete.go b/internal/pkg/cli/command/organization/delete.go index 2fed04f..025cd11 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" @@ -18,9 +14,8 @@ import ( ) type DeleteOrganizationCmdOptions struct { - organizationID string - skipConfirmation bool - json bool + organizationID string + json bool } func NewDeleteOrganizationCmd() *cobra.Command { @@ -31,7 +26,7 @@ func NewDeleteOrganizationCmd() *cobra.Command { Short: "Delete an organization by ID", Example: heredoc.Doc(` $ pc organization delete -i - $ pc organization delete -i --skip-confirmation + $ pc organization delete -i -y `), GroupID: help.GROUP_ORGANIZATIONS.ID, Run: func(cmd *cobra.Command, args []string) { @@ -44,7 +39,9 @@ func NewDeleteOrganizationCmd() *cobra.Command { exit.Error(err) } - if !options.skipConfirmation { + // Check if -y flag is set + assumeYes, _ := cmd.Flags().GetBool("assume-yes") + if !assumeYes { confirmDelete(org.Name, org.Id) } @@ -70,33 +67,21 @@ func NewDeleteOrganizationCmd() *cobra.Command { _ = cmd.MarkFlagRequired("id") // optional flags - cmd.Flags().BoolVar(&options.skipConfirmation, "skip-confirmation", false, "Skip the deletion confirmation prompt") cmd.Flags().BoolVar(&options.json, "json", false, "Output as JSON") return cmd } 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..7c5e8b6 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" @@ -19,9 +16,8 @@ import ( ) type DeleteProjectCmdOptions struct { - projectId string - skipConfirmation bool - json bool + projectId string + json bool } func NewDeleteProjectCmd() *cobra.Command { @@ -58,7 +54,9 @@ func NewDeleteProjectCmd() *cobra.Command { verifyNoIndexes(projToDelete.Id, projToDelete.Name) verifyNoCollections(projToDelete.Id, projToDelete.Name) - if !options.skipConfirmation { + // Check if -y flag is set + assumeYes, _ := cmd.Flags().GetBool("assume-yes") + if !assumeYes { confirmDelete(projToDelete.Name) } @@ -81,37 +79,23 @@ func NewDeleteProjectCmd() *cobra.Command { // optional flags cmd.Flags().StringVarP(&options.projectId, "id", "i", "", "ID of the project to delete") - cmd.Flags().BoolVar(&options.skipConfirmation, "skip-confirmation", false, "Skip the deletion confirmation prompt") cmd.Flags().BoolVar(&options.json, "json", false, "Output as JSON") return cmd } 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..dc05569 100644 --- a/internal/pkg/cli/command/root/root.go +++ b/internal/pkg/cli/command/root/root.go @@ -10,6 +10,7 @@ import ( index "github.com/pinecone-io/cli/internal/pkg/cli/command/index" login "github.com/pinecone-io/cli/internal/pkg/cli/command/login" logout "github.com/pinecone-io/cli/internal/pkg/cli/command/logout" + "github.com/pinecone-io/cli/internal/pkg/cli/command/models" "github.com/pinecone-io/cli/internal/pkg/cli/command/organization" "github.com/pinecone-io/cli/internal/pkg/cli/command/project" target "github.com/pinecone-io/cli/internal/pkg/cli/command/target" @@ -23,7 +24,9 @@ import ( var rootCmd *cobra.Command type GlobalOptions struct { - quiet bool + quiet bool + verbose bool + assumeYes bool } func Execute() { @@ -54,6 +57,8 @@ Get started by logging in with `, style.CodeWithPrompt("pc login")), } + rootCmd.SetErrPrefix("\r") + rootCmd.SetUsageTemplate(help.HelpTemplate) // Auth group @@ -74,6 +79,7 @@ Get started by logging in with rootCmd.AddGroup(help.GROUP_VECTORDB) rootCmd.AddCommand(index.NewIndexCmd()) rootCmd.AddCommand(collection.NewCollectionCmd()) + rootCmd.AddCommand(models.NewModelsCmd()) // Misc group rootCmd.AddCommand(version.NewVersionCmd()) @@ -87,4 +93,6 @@ 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") + rootCmd.PersistentFlags().BoolVarP(&globalOptions.assumeYes, "assume-yes", "y", false, "assume yes to all confirmation requests") } diff --git a/internal/pkg/utils/cache/cache.go b/internal/pkg/utils/cache/cache.go new file mode 100644 index 0000000..8eb7b0b --- /dev/null +++ b/internal/pkg/utils/cache/cache.go @@ -0,0 +1,49 @@ +package cache + +import ( + "path/filepath" + "time" + + "github.com/pinecone-io/cli/internal/pkg/utils/configuration" +) + +var ( + // Initialize cache in the config directory + Cache = NewFileCache(filepath.Join(configuration.ConfigDirPath(), "cache")) +) + +// GetOrFetch is a helper function to get cached data or fetch from API +func GetOrFetch[T any](key string, ttl time.Duration, fetchFunc func() (*T, error)) (*T, error) { + var cached T + if found, err := Cache.Get(key, &cached); found && err == nil { + return &cached, nil + } + + // Fetch from API + data, err := fetchFunc() + if err != nil { + return nil, err + } + + // Cache the result + Cache.Set(key, data, ttl) + return data, nil +} + +// CacheWithTTL is a helper function to cache data with a specific TTL +func CacheWithTTL(key string, data interface{}, ttl time.Duration) error { + return Cache.Set(key, data, ttl) +} + +// GetCached is a helper function to get cached data +func GetCached[T any](key string) (*T, bool, error) { + var cached T + found, err := Cache.Get(key, &cached) + if err != nil { + return nil, false, err + } + if !found { + return nil, false, nil + } + return &cached, true, nil +} diff --git a/internal/pkg/utils/cache/file_cache.go b/internal/pkg/utils/cache/file_cache.go new file mode 100644 index 0000000..ed4531f --- /dev/null +++ b/internal/pkg/utils/cache/file_cache.go @@ -0,0 +1,93 @@ +package cache + +import ( + "encoding/json" + "os" + "path/filepath" + "time" +) + +type FileCache struct { + basePath string +} + +type CacheEntry struct { + Data json.RawMessage `json:"data"` + Timestamp time.Time `json:"timestamp"` + TTL time.Duration `json:"ttl"` +} + +func NewFileCache(basePath string) *FileCache { + return &FileCache{ + basePath: basePath, + } +} + +func (fc *FileCache) Get(key string, target interface{}) (bool, error) { + cacheFile := filepath.Join(fc.basePath, key+".json") + + // Check if file exists + if _, err := os.Stat(cacheFile); os.IsNotExist(err) { + return false, nil + } + + // Read and parse cache file + data, err := os.ReadFile(cacheFile) + if err != nil { + return false, err + } + + var entry CacheEntry + if err := json.Unmarshal(data, &entry); err != nil { + return false, err + } + + // Check if expired + if time.Since(entry.Timestamp) > entry.TTL { + os.Remove(cacheFile) // Clean up expired file + return false, nil + } + + // Unmarshal data directly into target + if err := json.Unmarshal(entry.Data, target); err != nil { + return false, err + } + + return true, nil +} + +func (fc *FileCache) Set(key string, data interface{}, ttl time.Duration) error { + // Ensure cache directory exists + if err := os.MkdirAll(fc.basePath, 0755); err != nil { + return err + } + + // Marshal the data to JSON first + dataBytes, err := json.Marshal(data) + if err != nil { + return err + } + + entry := CacheEntry{ + Data: json.RawMessage(dataBytes), + Timestamp: time.Now(), + TTL: ttl, + } + + entryBytes, err := json.Marshal(entry) + if err != nil { + return err + } + + cacheFile := filepath.Join(fc.basePath, key+".json") + return os.WriteFile(cacheFile, entryBytes, 0644) +} + +func (fc *FileCache) Delete(key string) error { + cacheFile := filepath.Join(fc.basePath, key+".json") + return os.Remove(cacheFile) +} + +func (fc *FileCache) Clear() error { + return os.RemoveAll(fc.basePath) +} 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/create_options.go b/internal/pkg/utils/index/create_options.go new file mode 100644 index 0000000..7c132b4 --- /dev/null +++ b/internal/pkg/utils/index/create_options.go @@ -0,0 +1,406 @@ +package index + +import ( + "context" + + "github.com/pinecone-io/cli/internal/pkg/utils/models" +) + +// ModelInfo is an alias for models.ModelInfo for convenience +type ModelInfo = models.ModelInfo + +// IndexSpec represents the type of index (serverless, pod) as per what the server recognizes +type IndexSpec string + +const ( + IndexSpecServerless IndexSpec = "serverless" + IndexSpecPod IndexSpec = "pod" +) + +// IndexCreateFlow represents the type of index for the creation flow +type IndexCreateFlow int + +const ( + Serverless IndexCreateFlow = iota + Pod + Integrated +) + +const ( + DefaultDense string = "llama-text-embed-v2" + DefaultSparse string = "pinecone-sparse-english-v0" +) + +// Option represents a configuration option with its value and whether it was inferred +type Option[T any] struct { + Value T + Inferred bool +} + +// CreateOptions represents the configuration for creating an index +type CreateOptions struct { + Name Option[string] + Serverless Option[bool] + Pod Option[bool] + VectorType Option[string] + Cloud Option[string] + Region Option[string] + SourceCollection Option[string] + Environment Option[string] + PodType Option[string] + Shards Option[int32] + Replicas Option[int32] + MetadataConfig Option[[]string] + Model Option[string] + FieldMap Option[map[string]string] + ReadParameters Option[map[string]string] + WriteParameters Option[map[string]string] + Dimension Option[int32] + Metric Option[string] + DeletionProtection Option[string] + Tags Option[map[string]string] +} + +// GetSpec determines the index specification type based on the flags +func (c *CreateOptions) GetSpec() IndexSpec { + if c.Pod.Value && !c.Serverless.Value { + return IndexSpecPod + } + + if c.Serverless.Value && !c.Pod.Value { + return IndexSpecServerless + } + return "" +} + +// GetSpecString returns the spec as a string for the presenter interface +func (c *CreateOptions) GetSpecString() (string, bool) { + spec := c.GetSpec() + return string(spec), c.Serverless.Inferred || c.Pod.Inferred +} + +func (c *CreateOptions) GetCreateFlow() IndexCreateFlow { + if c.GetSpec() == IndexSpecPod { + return Pod + } + + if c.GetSpec() == IndexSpecServerless && c.Model.Value != "" { + return Integrated + } + + return Serverless +} + +// InferredCreateOptions returns CreateOptions with inferred values applied based on the spec +func InferredCreateOptions(ctx context.Context, opts CreateOptions) CreateOptions { + // Get available models from API + availableModels, err := models.GetModels(ctx, true) // Use cache for performance + + if err == nil { + // Create a map of model names for quick lookup + modelMap := make(map[string]bool) + for _, model := range availableModels { + modelMap[model.Model] = true + } + + // Check if model exists in available models + modelExists := func(modelName string) bool { + return modelMap[modelName] + } + + // Handle default model mappings + if opts.Model.Value == "default" || opts.Model.Value == "dense" || opts.Model.Value == "default-dense" { + if modelExists(string(DefaultDense)) { + opts.Model = Option[string]{ + Value: string(DefaultDense), + Inferred: true, + } + } + } + + if opts.Model.Value == "sparse" || opts.Model.Value == "default-sparse" { + if modelExists(string(DefaultSparse)) { + opts.Model = Option[string]{ + Value: string(DefaultSparse), + Inferred: true, + } + } + } + + // Apply inference rules based on available models + if modelExists(opts.Model.Value) { + // Find the specific model data + var modelData *ModelInfo + for _, model := range availableModels { + if model.Model == opts.Model.Value { + modelData = &model + break + } + } + if modelData != nil { + applyModelInference(&opts, modelData) + } + } + } + + // set serverless to true if no spec is provided + if opts.GetSpec() == "" { + opts.Serverless = Option[bool]{ + Value: true, + Inferred: true, + } + } + + // Set vector type to dense unless already set + if opts.VectorType.Value == "" { + opts.VectorType = Option[string]{ + Value: "dense", + Inferred: true, + } + } + + // set cloud to aws if serverless and no cloud is provided + if opts.GetSpec() == IndexSpecServerless && opts.Cloud.Value == "" { + opts.Cloud = Option[string]{ + Value: "aws", + Inferred: true, + } + } + + // Infer default region based on cloud if region is not set + if opts.Cloud.Value != "" && opts.Region.Value == "" { + switch opts.Cloud.Value { + case "aws": + opts.Region = Option[string]{ + Value: "us-east-1", + Inferred: true, + } + case "gcp": + opts.Region = Option[string]{ + Value: "us-central1", + Inferred: true, + } + case "azure": + opts.Region = Option[string]{ + Value: "eastus2", + Inferred: true, + } + } + } + + if opts.GetSpec() == IndexSpecPod { + if opts.PodType.Value == "" { + opts.PodType = Option[string]{ + Value: "p1.x1", + Inferred: true, + } + } + if opts.Environment.Value == "" { + opts.Environment = Option[string]{ + Value: "us-east-1-aws", + Inferred: true, + } + } + if opts.Shards.Value == 0 { + opts.Shards = Option[int32]{ + Value: 1, + Inferred: true, + } + } + if opts.Replicas.Value == 0 { + opts.Replicas = Option[int32]{ + Value: 1, + Inferred: true, + } + } + } + + if opts.VectorType.Value == "dense" && opts.Dimension.Value == 0 { + opts.Dimension = Option[int32]{ + Value: 1024, + Inferred: true, + } + } + + // metric should be dotproduct when vector type is sparse + if opts.VectorType.Value == "sparse" && opts.Metric.Value == "" { + opts.Metric = Option[string]{ + Value: "dotproduct", + Inferred: true, + } + } + + if opts.Metric.Value == "" { + opts.Metric = Option[string]{ + Value: "cosine", + Inferred: true, + } + } + + if opts.DeletionProtection.Value == "" { + opts.DeletionProtection = Option[string]{ + Value: "disabled", + Inferred: true, + } + } + + return opts +} + +// applyModelInference applies model-specific inference rules based on model data +func applyModelInference(opts *CreateOptions, model *ModelInfo) { + // Set serverless to true for embedding models + if model.Type == "embed" { + opts.Serverless = Option[bool]{ + Value: true, + Inferred: true, + } + } + + // Set vector type from model data + if model.VectorType != nil { + opts.VectorType = Option[string]{ + Value: *model.VectorType, + Inferred: true, + } + } + + // Set dimension from model data if available + if model.DefaultDimension != nil && *model.DefaultDimension > 0 { + opts.Dimension = Option[int32]{ + Value: *model.DefaultDimension, + Inferred: true, + } + } + + // Set metric based on vector type + if model.VectorType != nil { + if *model.VectorType == "sparse" { + opts.Metric = Option[string]{ + Value: "dotproduct", + Inferred: true, + } + } else if *model.VectorType == "dense" { + opts.Metric = Option[string]{ + Value: "cosine", + Inferred: true, + } + } + } + + // Set field map for embedding models (common pattern) + if model.Type == "embed" { + opts.FieldMap = Option[map[string]string]{ + Value: map[string]string{"text": "text"}, + Inferred: true, + } + } + + // Set read/write parameters for embedding models + if model.Type == "embed" { + opts.ReadParameters = Option[map[string]string]{ + Value: map[string]string{"input_type": "query", "truncate": "END"}, + Inferred: true, + } + opts.WriteParameters = Option[map[string]string]{ + Value: map[string]string{"input_type": "passage", "truncate": "END"}, + Inferred: true, + } + } +} + +// inferredCreateOptionsFallback provides fallback behavior when models can't be fetched +// func inferredCreateOptionsFallback(opts CreateOptions) CreateOptions { +// // This is the original hardcoded logic as a fallback +// if EmbeddingModel(opts.Model.Value) == "default" || EmbeddingModel(opts.Model.Value) == "default-dense" { +// opts.Model = Option[string]{ +// Value: string(LlamaTextEmbedV2), +// Inferred: true, +// } +// } + +// if EmbeddingModel(opts.Model.Value) == "default-sparse" { +// opts.Model = Option[string]{ +// Value: string(PineconeSparseEnglishV0), +// Inferred: true, +// } +// } + +// // Apply the original inference logic using hardcoded model data +// // This is a fallback when API is not available +// applyModelInferenceFallback(&opts, opts.Model.Value) + +// // ... rest of the original logic +// return opts +// } + +// applyModelInferenceFallback provides hardcoded inference rules as fallback +// func applyModelInferenceFallback(opts *CreateOptions, modelName string) { +// switch EmbeddingModel(modelName) { +// case LlamaTextEmbedV2: +// opts.Serverless = Option[bool]{ +// Value: true, +// Inferred: true, +// } +// opts.FieldMap = Option[map[string]string]{ +// Value: map[string]string{"text": "text"}, +// Inferred: true, +// } +// opts.ReadParameters = Option[map[string]string]{ +// Value: map[string]string{"input_type": "query", "truncate": "END"}, +// Inferred: true, +// } +// opts.WriteParameters = Option[map[string]string]{ +// Value: map[string]string{"input_type": "passage", "truncate": "END"}, +// Inferred: true, +// } + +// case MultilingualE5Large: +// opts.Serverless = Option[bool]{ +// Value: true, +// Inferred: true, +// } +// opts.FieldMap = Option[map[string]string]{ +// Value: map[string]string{"text": "text"}, +// Inferred: true, +// } +// opts.ReadParameters = Option[map[string]string]{ +// Value: map[string]string{"input_type": "query", "truncate": "END"}, +// Inferred: true, +// } +// opts.WriteParameters = Option[map[string]string]{ +// Value: map[string]string{"input_type": "passage", "truncate": "END"}, +// Inferred: true, +// } + +// case PineconeSparseEnglishV0: +// opts.Serverless = Option[bool]{ +// Value: true, +// Inferred: true, +// } +// opts.FieldMap = Option[map[string]string]{ +// Value: map[string]string{"text": "text"}, +// Inferred: true, +// } +// opts.ReadParameters = Option[map[string]string]{ +// Value: map[string]string{"input_type": "query", "truncate": "END"}, +// Inferred: true, +// } +// opts.WriteParameters = Option[map[string]string]{ +// Value: map[string]string{"input_type": "passage", "truncate": "END"}, +// Inferred: true, +// } +// opts.Dimension = Option[int32]{ +// Value: 0, +// Inferred: true, +// } +// opts.VectorType = Option[string]{ +// Value: "sparse", +// Inferred: true, +// } +// opts.Metric = Option[string]{ +// Value: "dotproduct", +// Inferred: true, +// } +// } +// } diff --git a/internal/pkg/utils/index/presenters/columns.go b/internal/pkg/utils/index/presenters/columns.go new file mode 100644 index 0000000..46fce8b --- /dev/null +++ b/internal/pkg/utils/index/presenters/columns.go @@ -0,0 +1,394 @@ +package presenters + +import ( + "fmt" + "strings" + + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" + "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) []presenters.Column { + var columns []presenters.Column + for _, group := range groups { + switch group { + case IndexAttributesGroupEssential: + for _, col := range IndexColumnGroups.Essential.Columns { + columns = append(columns, presenters.Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupState: + for _, col := range IndexColumnGroups.State.Columns { + columns = append(columns, presenters.Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupPodSpec: + for _, col := range IndexColumnGroups.PodSpec.Columns { + columns = append(columns, presenters.Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupServerlessSpec: + for _, col := range IndexColumnGroups.ServerlessSpec.Columns { + columns = append(columns, presenters.Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupInference: + for _, col := range IndexColumnGroups.Inference.Columns { + columns = append(columns, presenters.Column{Title: col.ShortTitle, Width: col.Width}) + } + case IndexAttributesGroupOther: + for _, col := range IndexColumnGroups.Other.Columns { + columns = append(columns, presenters.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/index/presenters/table.go b/internal/pkg/utils/index/presenters/table.go new file mode 100644 index 0000000..7522a29 --- /dev/null +++ b/internal/pkg/utils/index/presenters/table.go @@ -0,0 +1,401 @@ +package presenters + +import ( + "fmt" + "slices" + "strings" + + "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/style" + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) + +// IndexDisplayData represents the unified display structure for index information +type IndexDisplayData struct { + // Essential information + Name string + Specification string + VectorType string + Metric string + Dimension string + + // State information (only for existing indexes) + Status string + Host string + DeletionProtection string + + // Pod-specific information + Environment string + PodType string + Replicas string + ShardCount string + PodCount string + + // Serverless-specific information + CloudProvider string + Region string + + // Inference information + Model string + EmbeddingDimension string + FieldMap string + ReadParameters string + WriteParameters string + + // Other information + Tags string +} + +// ConvertIndexToDisplayData converts a pinecone.Index to IndexDisplayData +func ConvertIndexToDisplayData(idx *pinecone.Index) *IndexDisplayData { + data := &IndexDisplayData{} + + // Essential information + data.Name = idx.Name + data.VectorType = string(idx.VectorType) + data.Metric = string(idx.Metric) + if idx.Dimension != nil && *idx.Dimension > 0 { + data.Dimension = fmt.Sprintf("%d", *idx.Dimension) + } + + // Determine specification + if idx.Spec.Serverless == nil { + data.Specification = "pod" + } else { + data.Specification = "serverless" + } + + // State information + if idx.Status != nil { + data.Status = string(idx.Status.State) + } + data.Host = idx.Host + if idx.DeletionProtection == pinecone.DeletionProtectionEnabled { + data.DeletionProtection = "enabled" + } else { + data.DeletionProtection = "disabled" + } + + // Pod-specific information + if idx.Spec.Pod != nil { + data.Environment = idx.Spec.Pod.Environment + data.PodType = idx.Spec.Pod.PodType + data.Replicas = fmt.Sprintf("%d", idx.Spec.Pod.Replicas) + data.ShardCount = fmt.Sprintf("%d", idx.Spec.Pod.ShardCount) + data.PodCount = fmt.Sprintf("%d", idx.Spec.Pod.PodCount) + } + + // Serverless-specific information + if idx.Spec.Serverless != nil { + data.CloudProvider = string(idx.Spec.Serverless.Cloud) + data.Region = idx.Spec.Serverless.Region + } + + // Inference information + if idx.Embed != nil { + data.Model = idx.Embed.Model + if idx.Embed.Dimension != nil && *idx.Embed.Dimension > 0 { + data.EmbeddingDimension = fmt.Sprintf("%d", *idx.Embed.Dimension) + } + + // Format field map + 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)) + } + slices.Sort(fieldMapPairs) + data.FieldMap = strings.Join(fieldMapPairs, ", ") + } + + // Format read parameters + 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)) + } + slices.Sort(readParamsPairs) + data.ReadParameters = strings.Join(readParamsPairs, ", ") + } + + // Format write parameters + 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)) + } + slices.Sort(writeParamsPairs) + data.WriteParameters = strings.Join(writeParamsPairs, ", ") + } + } + + // Tags + if idx.Tags != nil && len(*idx.Tags) > 0 { + var tagStrings []string + for key, value := range *idx.Tags { + tagStrings = append(tagStrings, fmt.Sprintf("%s=%s", key, value)) + } + slices.Sort(tagStrings) + data.Tags = strings.Join(tagStrings, ", ") + } + + return data +} + +// ConvertCreateOptionsToDisplayData converts index.CreateOptions to IndexDisplayData +func ConvertCreateOptionsToDisplayData(config *index.CreateOptions) *IndexDisplayData { + data := &IndexDisplayData{} + + // Essential information + data.Name = formatValueWithInferred(config.Name.Value, config.Name.Inferred) + data.VectorType = formatValueWithInferred(config.VectorType.Value, config.VectorType.Inferred) + data.Metric = formatValueWithInferred(config.Metric.Value, config.Metric.Inferred) + if config.Dimension.Value > 0 { + data.Dimension = formatValueWithInferred(fmt.Sprintf("%d", config.Dimension.Value), config.Dimension.Inferred) + } + data.Model = formatValueWithInferred(config.Model.Value, config.Model.Inferred) + + // Determine specification + spec, specInferred := config.GetSpecString() + data.Specification = formatValueWithInferred(spec, specInferred) + + // Pod-specific information + if config.GetSpec() == index.IndexSpecPod { + data.Environment = formatValueWithInferred(config.Environment.Value, config.Environment.Inferred) + data.PodType = formatValueWithInferred(config.PodType.Value, config.PodType.Inferred) + data.Replicas = formatValueWithInferred(fmt.Sprintf("%d", config.Replicas.Value), config.Replicas.Inferred) + data.ShardCount = formatValueWithInferred(fmt.Sprintf("%d", config.Shards.Value), config.Shards.Inferred) + // Pod count not available in create options + } + + // Serverless-specific information + if config.GetSpec() == index.IndexSpecServerless { + data.CloudProvider = formatValueWithInferred(config.Cloud.Value, config.Cloud.Inferred) + data.Region = formatValueWithInferred(config.Region.Value, config.Region.Inferred) + } + + // Format field map + if len(config.FieldMap.Value) > 0 { + var fieldMapPairs []string + for k, v := range config.FieldMap.Value { + fieldMapPairs = append(fieldMapPairs, fmt.Sprintf("%s=%v", k, v)) + } + data.FieldMap = formatValueWithInferred(strings.Join(fieldMapPairs, ", "), config.FieldMap.Inferred) + } + + // Format read parameters + if len(config.ReadParameters.Value) > 0 { + var readParamsPairs []string + for k, v := range config.ReadParameters.Value { + readParamsPairs = append(readParamsPairs, fmt.Sprintf("%s=%v", k, v)) + } + data.ReadParameters = formatValueWithInferred(strings.Join(readParamsPairs, ", "), config.ReadParameters.Inferred) + } + + // Format write parameters + if len(config.WriteParameters.Value) > 0 { + var writeParamsPairs []string + for k, v := range config.WriteParameters.Value { + writeParamsPairs = append(writeParamsPairs, fmt.Sprintf("%s=%v", k, v)) + } + data.WriteParameters = formatValueWithInferred(strings.Join(writeParamsPairs, ", "), config.WriteParameters.Inferred) + } + + // Deletion protection + deletionProtection := config.DeletionProtection.Value + if deletionProtection == "" { + deletionProtection = "disabled" + } + data.DeletionProtection = formatValueWithInferred(deletionProtection, config.DeletionProtection.Inferred) + + // Tags + if len(config.Tags.Value) > 0 { + var tagStrings []string + for key, value := range config.Tags.Value { + tagStrings = append(tagStrings, fmt.Sprintf("%s=%s", key, value)) + } + data.Tags = formatValueWithInferred(strings.Join(tagStrings, ", "), config.Tags.Inferred) + } + + return data +} + +// PrintIndexDisplayTable creates and renders a table for index display data +func PrintIndexDisplayTable(data *IndexDisplayData) { + // Build rows for the table + var rows []presenters.Row + + // Essential information + rows = append(rows, presenters.Row{"Name", data.Name}) + rows = append(rows, presenters.Row{"Specification", data.Specification}) + rows = append(rows, presenters.Row{"Vector Type", data.VectorType}) + rows = append(rows, presenters.Row{"Metric", data.Metric}) + rows = append(rows, presenters.Row{"Dimension", data.Dimension}) + + // Add spacing + rows = append(rows, presenters.Row{"", ""}) + + // State information (only show if we have status data) + if data.Status != "" { + rows = append(rows, presenters.Row{"Status", data.Status}) + rows = append(rows, presenters.Row{"Host URL", data.Host}) + rows = append(rows, presenters.Row{"Deletion Protection", data.DeletionProtection}) + rows = append(rows, presenters.Row{"", ""}) + } + + // Spec-specific information + if data.Specification == "serverless" { + rows = append(rows, presenters.Row{"Cloud Provider", data.CloudProvider}) + rows = append(rows, presenters.Row{"Region", data.Region}) + } else if data.Specification == "pod" { + rows = append(rows, presenters.Row{"Environment", data.Environment}) + rows = append(rows, presenters.Row{"Pod Type", data.PodType}) + rows = append(rows, presenters.Row{"Replicas", data.Replicas}) + rows = append(rows, presenters.Row{"Shard Count", data.ShardCount}) + if data.PodCount != "" { + rows = append(rows, presenters.Row{"Pod Count", data.PodCount}) + } + } + + // Add spacing + rows = append(rows, presenters.Row{"", ""}) + + // Inference information (only show if we have model data) + if data.Model != "" { + rows = append(rows, presenters.Row{"Model", data.Model}) + if data.EmbeddingDimension != "" { + rows = append(rows, presenters.Row{"Embedding Dimension", data.EmbeddingDimension}) + } + if data.FieldMap != "" { + rows = append(rows, presenters.Row{"Field Map", data.FieldMap}) + } + if data.ReadParameters != "" { + rows = append(rows, presenters.Row{"Read Parameters", data.ReadParameters}) + } + if data.WriteParameters != "" { + rows = append(rows, presenters.Row{"Write Parameters", data.WriteParameters}) + } + rows = append(rows, presenters.Row{"", ""}) + } + + // Other information + if data.DeletionProtection != "" && data.Status == "" { + rows = append(rows, presenters.Row{"Deletion Protection", data.DeletionProtection}) + } + + if data.Tags != "" { + rows = append(rows, presenters.Row{"Tags", data.Tags}) + } + + // 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() +} + +// 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 []presenters.Row + for _, idx := range indexes { + values := ExtractValuesForIndexAttributesGroups(idx, nonEmptyGroups) + rows = append(rows, presenters.Row(values)) + } + + // Use the table utility + presenters.PrintTable(presenters.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) { + // Print title + fmt.Println(style.Heading("Index Configuration")) + fmt.Println() + + // Convert to display data and print + data := ConvertIndexToDisplayData(idx) + PrintIndexDisplayTable(data) +} + +// PrintIndexCreateConfigTable creates and renders a table for index creation configuration +func PrintIndexCreateConfigTable(config *index.CreateOptions) { + fmt.Println(style.Heading("Index Configuration")) + fmt.Println() + + // Convert to display data and print with inferred values + data := ConvertCreateOptionsToDisplayData(config) + PrintIndexDisplayTable(data) +} + +// 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") +} + +// formatValueWithInferred formats a value with "(inferred)" indicator if the value was inferred +func formatValueWithInferred(value string, inferred bool) string { + if inferred { + return fmt.Sprintf("%s %s", value, style.SecondaryTextStyle().Render("(inferred)")) + } + return value +} diff --git a/internal/pkg/utils/index/validation.go b/internal/pkg/utils/index/validation.go new file mode 100644 index 0000000..aaf9332 --- /dev/null +++ b/internal/pkg/utils/index/validation.go @@ -0,0 +1,215 @@ +package index + +import ( + "context" + "errors" + "fmt" + "strings" + + "github.com/pinecone-io/cli/internal/pkg/utils/models" + "github.com/pinecone-io/cli/internal/pkg/utils/style" + "github.com/pinecone-io/cli/internal/pkg/utils/validation" + "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 +} + +// CreateOptionsRule creates a new validation rule from a function that takes *CreateOptions +func CreateOptionsRule(fn func(*CreateOptions) string) validation.Rule { + return func(value interface{}) string { + config, ok := value.(*CreateOptions) + if !ok { + return "" + } + return fn(config) + } +} + +// ValidateCreateOptions validates the index creation configuration using the validation framework +func ValidateCreateOptions(config CreateOptions) []string { + validator := validation.New() + + validator.AddRule(CreateOptionsRule(validateConfigIndexTypeFlags)) + validator.AddRule(CreateOptionsRule(validateConfigHasName)) + validator.AddRule(CreateOptionsRule(validateConfigNameLength)) + validator.AddRule(CreateOptionsRule(validateConfigNameStartsWithAlphanumeric)) + validator.AddRule(CreateOptionsRule(validateConfigNameEndsWithAlphanumeric)) + validator.AddRule(CreateOptionsRule(validateConfigNameCharacters)) + validator.AddRule(CreateOptionsRule(validateConfigServerlessCloud)) + validator.AddRule(CreateOptionsRule(validateConfigServerlessRegion)) + validator.AddRule(CreateOptionsRule(validateConfigPodEnvironment)) + validator.AddRule(CreateOptionsRule(validateConfigPodType)) + validator.AddRule(CreateOptionsRule(validateConfigPodSparseVector)) + validator.AddRule(CreateOptionsRule(validateConfigSparseVectorDimension)) + validator.AddRule(CreateOptionsRule(validateConfigSparseVectorMetric)) + validator.AddRule(CreateOptionsRule(validateConfigDenseVectorDimension)) + validator.AddRule(CreateOptionsRule(validateConfigModel)) + + return validator.Validate(&config) +} + +// validateConfigIndexTypeFlags checks that serverless and pod flags are not both set +func validateConfigIndexTypeFlags(config *CreateOptions) string { + if config.Serverless.Value && config.Pod.Value { + return fmt.Sprintf("%s and %s cannot be provided together", style.Code("serverless"), style.Code("pod")) + } + return "" +} + +// validateConfigHasName checks if the config has a non-empty name +func validateConfigHasName(config *CreateOptions) string { + if strings.TrimSpace(config.Name.Value) == "" { + return "index must have a name" + } + return "" +} + +// validateConfigNameLength checks if the config name is 1-45 characters long +func validateConfigNameLength(config *CreateOptions) string { + name := strings.TrimSpace(config.Name.Value) + if len(name) < 1 || len(name) > 45 { + return "index name must be 1-45 characters long" + } + return "" +} + +// validateConfigNameStartsWithAlphanumeric checks if the config name starts with an alphanumeric character +func validateConfigNameStartsWithAlphanumeric(config *CreateOptions) string { + name := strings.TrimSpace(config.Name.Value) + if len(name) > 0 { + first := name[0] + if !((first >= 'a' && first <= 'z') || (first >= '0' && first <= '9')) { + return "index name must start with an alphanumeric character" + } + } + return "" +} + +// validateConfigNameEndsWithAlphanumeric checks if the config name ends with an alphanumeric character +func validateConfigNameEndsWithAlphanumeric(config *CreateOptions) string { + name := strings.TrimSpace(config.Name.Value) + if len(name) > 0 { + last := name[len(name)-1] + if !((last >= 'a' && last <= 'z') || (last >= '0' && last <= '9')) { + return "index name must end with an alphanumeric character" + } + } + return "" +} + +// validateConfigNameCharacters checks if the config name consists only of lowercase alphanumeric characters or '-' +func validateConfigNameCharacters(config *CreateOptions) string { + name := strings.TrimSpace(config.Name.Value) + for _, char := range name { + if !((char >= 'a' && char <= 'z') || (char >= '0' && char <= '9') || char == '-') { + return "index name must consist only of lowercase alphanumeric characters or '-'" + } + } + return "" +} + +// validateConfigServerlessCloud checks that cloud is provided for serverless indexes +func validateConfigServerlessCloud(config *CreateOptions) string { + if config.GetSpec() == IndexSpecServerless && config.Cloud.Value == "" { + return fmt.Sprintf("%s is required for %s indexes", style.Code("cloud"), style.Code("serverless")) + } + return "" +} + +// validateConfigServerlessRegion checks that region is provided for serverless indexes +func validateConfigServerlessRegion(config *CreateOptions) string { + if config.GetSpec() == IndexSpecServerless && config.Region.Value == "" { + return fmt.Sprintf("%s is required for %s indexes", style.Code("region"), style.Code("serverless")) + } + return "" +} + +// validateConfigPodEnvironment checks that environment is provided for pod indexes +func validateConfigPodEnvironment(config *CreateOptions) string { + if config.GetSpec() == IndexSpecPod && config.Environment.Value == "" { + return fmt.Sprintf("%s is required for %s indexes", style.Code("environment"), style.Code("pod")) + } + return "" +} + +// validateConfigPodType checks that pod_type is provided for pod indexes +func validateConfigPodType(config *CreateOptions) string { + if config.GetSpec() == IndexSpecPod && config.PodType.Value == "" { + return fmt.Sprintf("%s is required for %s indexes", style.Code("pod_type"), style.Code("pod")) + } + return "" +} + +// validateConfigPodSparseVector checks that pod indexes cannot use sparse vector type +func validateConfigPodSparseVector(config *CreateOptions) string { + if config.GetSpec() == IndexSpecPod && config.VectorType.Value == "sparse" { + return fmt.Sprintf("%s vector type is not supported for %s indexes", style.Code("sparse"), style.Code("pod")) + } + return "" +} + +// validateConfigSparseVectorDimension checks that dimension should not be specified for sparse vector type +func validateConfigSparseVectorDimension(config *CreateOptions) string { + if config.VectorType.Value == "sparse" && config.Dimension.Value > 0 { + return fmt.Sprintf("%s should not be specified when vector type is %s", style.Code("dimension"), style.Code("sparse")) + } + return "" +} + +// validateConfigSparseVectorMetric checks that metric should be 'dotproduct' for sparse vector type +func validateConfigSparseVectorMetric(config *CreateOptions) string { + if config.VectorType.Value == "sparse" && config.Metric.Value != "" && config.Metric.Value != "dotproduct" { + return fmt.Sprintf("metric should be %s when vector type is %s", style.Code("dotproduct"), style.Code("sparse")) + } + return "" +} + +// validateConfigDenseVectorDimension checks that dimension is provided for dense vector indexes +func validateConfigDenseVectorDimension(config *CreateOptions) string { + // Check if it's a dense vector type (empty string means dense, or explicitly "dense") + if config.VectorType.Value == "dense" && config.Dimension.Value <= 0 { + return fmt.Sprintf("%s is required when vector type is %s", style.Code("dimension"), style.Code("dense")) + } + return "" +} + +// validateConfigModel checks that the model name is one of the supported models +func validateConfigModel(config *CreateOptions) string { + // Skip validation if no model is specified + if config.Model.Value == "" { + return "" + } + + // Get available models from API + ctx := context.Background() + availableModels, err := models.GetModels(ctx, true) // Use cache for performance + if err != nil { + // If we can't get models, skip validation (let the API call fail later) + return "" + } + + // Check if the model exists in available models + for _, model := range availableModels { + if model.Model == config.Model.Value { + return "" + } + } + + // Model not found + return fmt.Sprintf("model %s is not supported. Use %s to see available models", + style.Code(config.Model.Value), + style.Code("pc models")) +} diff --git a/internal/pkg/utils/interactive/confirmation.go b/internal/pkg/utils/interactive/confirmation.go new file mode 100644 index 0000000..e423dd4 --- /dev/null +++ b/internal/pkg/utils/interactive/confirmation.go @@ -0,0 +1,121 @@ +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/models/models.go b/internal/pkg/utils/models/models.go index 532cd23..15c6289 100644 --- a/internal/pkg/utils/models/models.go +++ b/internal/pkg/utils/models/models.go @@ -1,56 +1,95 @@ package models -type ChatCompletionRequest struct { - Stream bool `json:"stream"` - Messages []ChatCompletionMessage `json:"messages"` -} +import ( + "context" + "time" -type ChatCompletionModel struct { - Id string `json:"id"` - Choices []ChoiceModel `json:"choices"` - Model string `json:"model"` -} + "github.com/pinecone-io/cli/internal/pkg/utils/cache" + "github.com/pinecone-io/cli/internal/pkg/utils/sdk" + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) -type ChoiceModel struct { - FinishReason ChatFinishReason `json:"finish_reason"` - Index int32 `json:"index"` - Message ChatCompletionMessage `json:"message"` +// ModelInfo is our CLI's model representation +type ModelInfo struct { + Model string `json:"model"` + Type string `json:"type"` + VectorType *string `json:"vector_type"` + DefaultDimension *int32 `json:"default_dimension"` + ProviderName *string `json:"provider_name"` + ShortDescription string `json:"short_description"` + MaxBatchSize *int32 `json:"max_batch_size"` + MaxSequenceLength *int32 `json:"max_sequence_length"` + Modality *string `json:"modality"` + SupportedDimensions *[]int32 `json:"supported_dimensions"` + SupportedMetrics *[]pinecone.IndexMetric `json:"supported_metrics"` } -type ChatCompletionMessage struct { - Role string `json:"role"` - Content string `json:"content"` +// GetModels fetches models from API or cache +func GetModels(ctx context.Context, useCache bool) ([]ModelInfo, error) { + if useCache { + return getModelsWithCache(ctx) + } + + // When not using cache, fetch from API and update cache + models, err := getModelsFromAPI(ctx) + if err != nil { + return nil, err + } + + // Update cache with fresh data + cache.Cache.Set("models", models, 24*time.Hour) + return models, nil } -type ChatFinishReason string +// getModelsWithCache tries cache first, then API if not found +func getModelsWithCache(ctx context.Context) ([]ModelInfo, error) { + // Try to get from cache first + cached, found, err := cache.GetCached[[]ModelInfo]("models") + if found && err == nil { + return *cached, nil + } -const ( - Stop ChatFinishReason = "stop" - Length ChatFinishReason = "length" - ContentFilter ChatFinishReason = "content_filter" - FunctionCall ChatFinishReason = "function_call" -) + // Fetch from API if not in cache + models, err := getModelsFromAPI(ctx) + if err != nil { + return nil, err + } -type StreamChatCompletionModel struct { - Id string `json:"id"` - Choices []ChoiceChunkModel `json:"choices"` - Model string `json:"model"` + // Cache the models + cache.CacheWithTTL("models", models, 24*time.Hour) + return models, nil } -type StreamChunk struct { - Data StreamChatCompletionModel `json:"data"` -} +// getModelsFromAPI fetches models directly from the API +func getModelsFromAPI(ctx context.Context) ([]ModelInfo, error) { + pc := sdk.NewPineconeClient() + embed := "embed" + embedModels, err := pc.Inference.ListModels(ctx, &pinecone.ListModelsParams{Type: &embed}) + if err != nil { + return nil, err + } -type ChoiceChunkModel struct { - FinishReason ChatFinishReason `json:"finish_reason"` - Index int32 `json:"index"` - Delta ChatCompletionMessage `json:"delta"` -} + if embedModels == nil || embedModels.Models == nil { + return []ModelInfo{}, nil + } + + // Convert pinecone.ModelInfo to our ModelInfo + models := make([]ModelInfo, len(*embedModels.Models)) + for i, model := range *embedModels.Models { + models[i] = ModelInfo{ + Model: model.Model, + Type: model.Type, + VectorType: model.VectorType, + DefaultDimension: model.DefaultDimension, + ProviderName: model.ProviderName, + ShortDescription: model.ShortDescription, + MaxBatchSize: model.MaxBatchSize, + MaxSequenceLength: model.MaxSequenceLength, + Modality: model.Modality, + SupportedDimensions: model.SupportedDimensions, + SupportedMetrics: model.SupportedMetrics, + } + } -type ContextRefModel struct { - Id string `json:"id"` - Source string `json:"source"` - Text string `json:"text"` - Score float64 `json:"score"` - Path []string `json:"path"` + return models, nil } diff --git a/internal/pkg/utils/models/presenters/table.go b/internal/pkg/utils/models/presenters/table.go new file mode 100644 index 0000000..cb292e6 --- /dev/null +++ b/internal/pkg/utils/models/presenters/table.go @@ -0,0 +1,75 @@ +package presenters + +import ( + "fmt" + "strconv" + + "github.com/pinecone-io/cli/internal/pkg/utils/models" + "github.com/pinecone-io/cli/internal/pkg/utils/presenters" +) + +// PrintModelsTable creates and renders a table showing model information +func PrintModelsTable(models []models.ModelInfo) { + if len(models) == 0 { + fmt.Println("No models found.") + return + } + + // Define table columns + columns := []presenters.Column{ + {Title: "Model", Width: 25}, + {Title: "Type", Width: 8}, + {Title: "Vector Type", Width: 12}, + {Title: "Dimension", Width: 10}, + {Title: "Provider", Width: 15}, + {Title: "Description", Width: 40}, + } + + // Convert models to table rows + rows := make([]presenters.Row, len(models)) + for i, model := range models { + dimension := "-" + if model.DefaultDimension != nil { + dimension = strconv.Itoa(int(*model.DefaultDimension)) + } + + vectorType := "-" + if model.VectorType != nil { + vectorType = *model.VectorType + } + + provider := "-" + if model.ProviderName != nil { + provider = *model.ProviderName + } + + // Truncate description if too long + description := model.ShortDescription + if len(description) > 35 { + description = description[:32] + "..." + } + + rows[i] = presenters.Row{ + model.Model, + model.Type, + vectorType, + dimension, + provider, + description, + } + } + + // Print the table + presenters.PrintTable(presenters.TableOptions{ + Columns: columns, + Rows: rows, + }) +} + +// PrintModelsTableWithTitle creates and renders a models table with a title +func PrintModelsTableWithTitle(title string, models []models.ModelInfo) { + fmt.Println() + fmt.Printf("%s\n\n", title) + PrintModelsTable(models) + fmt.Println() +} diff --git a/internal/pkg/utils/msg/message.go b/internal/pkg/utils/msg/message.go index 4e204dc..95f9bdf 100644 --- a/internal/pkg/utils/msg/message.go +++ b/internal/pkg/utils/msg/message.go @@ -5,27 +5,93 @@ 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 FailMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + if len(messages) == 1 { + FailMsg(messages[0]) + return + } + + // Multi-line - use existing multi-line styling + formatted := style.FailMsgMultiLine(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 + } + + if len(messages) == 1 { + SuccessMsg(messages[0]) + return + } + + // Multi-line - use existing multi-line styling + formatted := style.SuccessMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// InfoMsgMultiLine displays multiple info messages in a single message box +func WarnMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + if len(messages) == 1 { + WarnMsg(messages[0]) + return + } + + // Multi-line - use existing multi-line styling + formatted := style.WarnMsgMultiLine(messages...) + pcio.Println("\n" + formatted + "\n") +} + +// FailMsgMultiLine displays multiple error messages in a single message box +func InfoMsgMultiLine(messages ...string) { + if len(messages) == 0 { + return + } + + if len(messages) == 1 { + InfoMsg(messages[0]) + return + } + + // Multi-line - use existing multi-line styling + formatted := style.InfoMsgMultiLine(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_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/models_table.go b/internal/pkg/utils/presenters/models_table.go new file mode 100644 index 0000000..a14f178 --- /dev/null +++ b/internal/pkg/utils/presenters/models_table.go @@ -0,0 +1,74 @@ +package presenters + +import ( + "fmt" + "strconv" + + "github.com/pinecone-io/go-pinecone/v4/pinecone" +) + +// PrintModelsTable creates and renders a table showing model information +func PrintModelsTable(models []pinecone.ModelInfo) { + if len(models) == 0 { + fmt.Println("No models found.") + return + } + + // Define table columns + columns := []Column{ + {Title: "Model", Width: 25}, + {Title: "Type", Width: 8}, + {Title: "Vector Type", Width: 12}, + {Title: "Dimension", Width: 10}, + {Title: "Provider", Width: 15}, + {Title: "Description", Width: 40}, + } + + // Convert models to table rows + rows := make([]Row, len(models)) + for i, model := range models { + dimension := "-" + if model.DefaultDimension != nil { + dimension = strconv.Itoa(int(*model.DefaultDimension)) + } + + vectorType := "-" + if model.VectorType != nil { + vectorType = *model.VectorType + } + + provider := "-" + if model.ProviderName != nil { + provider = *model.ProviderName + } + + // Truncate description if too long + description := model.ShortDescription + if len(description) > 35 { + description = description[:32] + "..." + } + + rows[i] = Row{ + model.Model, + model.Type, + vectorType, + dimension, + provider, + description, + } + } + + // Print the table + PrintTable(TableOptions{ + Columns: columns, + Rows: rows, + }) +} + +// PrintModelsTableWithTitle creates and renders a models table with a title +func PrintModelsTableWithTitle(title string, models []pinecone.ModelInfo) { + fmt.Println() + fmt.Printf("%s\n\n", title) + PrintModelsTable(models) + fmt.Println() +} diff --git a/internal/pkg/utils/presenters/table.go b/internal/pkg/utils/presenters/table.go new file mode 100644 index 0000000..b60e4d6 --- /dev/null +++ b/internal/pkg/utils/presenters/table.go @@ -0,0 +1,73 @@ +// 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/style" +) + +// 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() +} 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 + } +} diff --git a/internal/pkg/utils/validation/validator.go b/internal/pkg/utils/validation/validator.go new file mode 100644 index 0000000..db2dfbd --- /dev/null +++ b/internal/pkg/utils/validation/validator.go @@ -0,0 +1,34 @@ +package validation + +// Rule represents a validation rule function +type Rule func(value interface{}) string + +// Validator holds validation rules +type Validator struct { + rules []Rule +} + +// New creates a new validator +func New() *Validator { + return &Validator{ + rules: make([]Rule, 0), + } +} + +// AddRule adds a custom rule function to the validator +func (v *Validator) AddRule(rule Rule) { + v.rules = append(v.rules, rule) +} + +// Validate runs all rules against the value and returns an array of error messages +func (v *Validator) Validate(value interface{}) []string { + var errors []string + + for _, rule := range v.rules { + if errorMsg := rule(value); errorMsg != "" { + errors = append(errors, errorMsg) + } + } + + return errors +}