diff --git a/AGENTS.md b/AGENTS.md index 974e047..cc32420 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -205,11 +205,13 @@ Skills are reusable capabilities that can be discovered and executed by AI agent 2. In the `NewCmd()` function: - Create and configure the `cobra.Command` - **IMPORTANT**: Always define the `Args` field to specify argument validation + - **IMPORTANT**: Always add an `Example` field with usage examples - Set up any flags or subcommands - Return the configured command 3. Register the command in `pkg/cmd/root.go` by adding `rootCmd.AddCommand(NewCmd())` in the `NewRootCmd()` function 4. Create corresponding test file `pkg/cmd/_test.go` 5. In tests, create command instances using `NewRootCmd()` or `NewCmd()` as needed +6. **IMPORTANT**: Add a `TestCmd_Examples` function to validate the examples **Command Argument Validation:** @@ -227,6 +229,8 @@ func NewExampleCmd() *cobra.Command { return &cobra.Command{ Use: "example", Short: "An example command", + Example: `# Run the example command +kortex-cli example`, Args: cobra.NoArgs, // Always declare Args field Run: func(cmd *cobra.Command, args []string) { // Command logic here @@ -238,6 +242,142 @@ func NewExampleCmd() *cobra.Command { rootCmd.AddCommand(NewExampleCmd()) ``` +**Command Examples:** + +All commands **MUST** include an `Example` field with usage examples. Examples improve help documentation and are automatically validated to ensure they stay accurate as the code evolves. + +**Example Format:** +```go +func NewExampleCmd() *cobra.Command { + return &cobra.Command{ + Use: "example [arg]", + Short: "An example command", + Example: `# Basic usage with comment +kortex-cli example + +# With argument +kortex-cli example value + +# With flag +kortex-cli example --flag value`, + Args: cobra.MaximumNArgs(1), + Run: func(cmd *cobra.Command, args []string) { + // Command logic here + }, + } +} +``` + +**Example Guidelines:** +- Use comments (lines starting with `#`) to describe what each example does +- Show the most common use cases (typically 3-5 examples) +- Include examples for all important flags +- Examples must use the actual binary name (`kortex-cli`) +- All commands and flags in examples must exist +- Keep examples concise and realistic + +**Validating Examples:** + +Every command with an `Example` field **MUST** have a corresponding validation test: + +```go +func TestCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the command + cmd := NewCmd() + + // Verify Example field is not empty + if cmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(cmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 3 // Adjust based on your examples + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, cmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} +``` + +**What the validator checks:** +- Binary name is `kortex-cli` +- All commands exist in the command tree +- All flags (both long and short) are valid for the command +- No invalid subcommands are used + +**Reference:** See `pkg/cmd/init.go` and `pkg/cmd/init_test.go` for complete examples. + +**Alias Commands:** + +Alias commands are shortcuts that delegate to existing commands (e.g., `list` as an alias for `workspace list`). For alias commands: + +1. **Inherit the Example field** from the original command +2. **Adapt the examples** to show the alias syntax instead of the full command +3. **Do NOT create validation tests** for aliases (they use the same validation as the original command) + +Use the `AdaptExampleForAlias()` helper function (from `pkg/cmd/helpers.go`) to automatically replace the command name in examples while preserving comments: + +```go +func NewListCmd() *cobra.Command { + // Create the workspace list command + workspaceListCmd := NewWorkspaceListCmd() + + // Create an alias command that delegates to workspace list + cmd := &cobra.Command{ + Use: "list", + Short: workspaceListCmd.Short, + Long: workspaceListCmd.Long, + Example: AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), + Args: workspaceListCmd.Args, + PreRunE: workspaceListCmd.PreRunE, + RunE: workspaceListCmd.RunE, + } + + // Copy flags from workspace list command + cmd.Flags().AddFlagSet(workspaceListCmd.Flags()) + + return cmd +} +``` + +The `AdaptExampleForAlias()` function: +- Replaces the original command with the alias **only in command lines** (lines starting with `kortex-cli`) +- **Preserves comments unchanged** (lines starting with `#`) +- Maintains formatting and indentation + +**Example transformation:** +```bash +# Original (from workspace list): +# List all workspaces +kortex-cli workspace list + +# List in JSON format +kortex-cli workspace list --output json + +# After AdaptExampleForAlias(..., "workspace list", "list"): +# List all workspaces +kortex-cli list + +# List in JSON format +kortex-cli list --output json +``` + +**Reference:** See `pkg/cmd/list.go` and `pkg/cmd/remove.go` for complete alias examples. + ### Command Implementation Pattern Commands should follow a consistent structure for maintainability and testability: diff --git a/pkg/cmd/helpers.go b/pkg/cmd/helpers.go new file mode 100644 index 0000000..2f131b1 --- /dev/null +++ b/pkg/cmd/helpers.go @@ -0,0 +1,53 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import "strings" + +// AdaptExampleForAlias replaces the original command with the alias command +// in the example string, but only in command lines (not in comments). +// This is useful for alias commands that want to inherit examples from +// their original commands while showing the alias syntax. +// +// Example: +// +// original := `# List all workspaces +// kortex-cli workspace list +// +// # List in JSON format +// kortex-cli workspace list --output json` +// +// adapted := AdaptExampleForAlias(original, "workspace list", "list") +// // Result: +// // `# List all workspaces +// // kortex-cli list +// // +// // # List in JSON format +// // kortex-cli list --output json` +func AdaptExampleForAlias(example, originalCmd, aliasCmd string) string { + lines := strings.Split(example, "\n") + var result []string + + for _, line := range lines { + trimmed := strings.TrimSpace(line) + // Only replace in command lines (starting with kortex-cli), not in comments + if strings.HasPrefix(trimmed, "kortex-cli ") { + line = strings.Replace(line, originalCmd, aliasCmd, 1) + } + result = append(result, line) + } + + return strings.Join(result, "\n") +} diff --git a/pkg/cmd/helpers_test.go b/pkg/cmd/helpers_test.go new file mode 100644 index 0000000..72dc004 --- /dev/null +++ b/pkg/cmd/helpers_test.go @@ -0,0 +1,120 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package cmd + +import "testing" + +func TestAdaptExampleForAlias(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + example string + originalCmd string + aliasCmd string + want string + }{ + { + name: "replaces command in simple example", + example: `# List all workspaces +kortex-cli workspace list`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list`, + }, + { + name: "replaces command with flags", + example: `# List workspaces in JSON format +kortex-cli workspace list --output json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List workspaces in JSON format +kortex-cli list --output json`, + }, + { + name: "replaces multiple occurrences", + example: `# List all workspaces +kortex-cli workspace list + +# List in JSON format +kortex-cli workspace list --output json + +# List using short flag +kortex-cli workspace list -o json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list + +# List in JSON format +kortex-cli list --output json + +# List using short flag +kortex-cli list -o json`, + }, + { + name: "does not replace in comments", + example: `# Use workspace list to see all workspaces +kortex-cli workspace list`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# Use workspace list to see all workspaces +kortex-cli list`, + }, + { + name: "replaces remove command", + example: `# Remove workspace by ID +kortex-cli workspace remove abc123`, + originalCmd: "workspace remove", + aliasCmd: "remove", + want: `# Remove workspace by ID +kortex-cli remove abc123`, + }, + { + name: "handles empty example", + example: ``, + originalCmd: "workspace list", + aliasCmd: "list", + want: ``, + }, + { + name: "preserves indentation", + example: `# List all workspaces +kortex-cli workspace list + +# Another example + kortex-cli workspace list --output json`, + originalCmd: "workspace list", + aliasCmd: "list", + want: `# List all workspaces +kortex-cli list + +# Another example + kortex-cli list --output json`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := AdaptExampleForAlias(tt.example, tt.originalCmd, tt.aliasCmd) + if got != tt.want { + t.Errorf("AdaptExampleForAlias() mismatch:\nGot:\n%s\n\nWant:\n%s", got, tt.want) + } + }) + } +} diff --git a/pkg/cmd/init.go b/pkg/cmd/init.go index 47dbba7..b19513d 100644 --- a/pkg/cmd/init.go +++ b/pkg/cmd/init.go @@ -139,6 +139,17 @@ func NewInitCmd() *cobra.Command { The sources directory defaults to the current directory (.) if not specified. The workspace configuration directory defaults to .kortex/ inside the sources directory if not specified.`, + Example: `# Register current directory as workspace +kortex-cli init + +# Register specific directory as workspace +kortex-cli init /path/to/project + +# Register with custom workspace name +kortex-cli init --name my-project + +# Show detailed output +kortex-cli init --verbose`, Args: cobra.MaximumNArgs(1), PreRunE: c.preRun, RunE: c.run, diff --git a/pkg/cmd/init_test.go b/pkg/cmd/init_test.go index 5a325d0..8632e6d 100644 --- a/pkg/cmd/init_test.go +++ b/pkg/cmd/init_test.go @@ -25,6 +25,7 @@ import ( "strings" "testing" + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" "github.com/spf13/cobra" ) @@ -1003,3 +1004,34 @@ func TestInitCmd_E2E(t *testing.T) { } }) } + +func TestInitCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the init command + initCmd := NewInitCmd() + + // Verify Example field is not empty + if initCmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(initCmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 4 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, initCmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +} diff --git a/pkg/cmd/list.go b/pkg/cmd/list.go index ac89459..784bf7c 100644 --- a/pkg/cmd/list.go +++ b/pkg/cmd/list.go @@ -31,6 +31,7 @@ func NewListCmd() *cobra.Command { Use: "list", Short: workspaceListCmd.Short, Long: workspaceListCmd.Long, + Example: AdaptExampleForAlias(workspaceListCmd.Example, "workspace list", "list"), Args: workspaceListCmd.Args, PreRunE: workspaceListCmd.PreRunE, RunE: workspaceListCmd.RunE, diff --git a/pkg/cmd/remove.go b/pkg/cmd/remove.go index 68bbaa9..e6e29de 100644 --- a/pkg/cmd/remove.go +++ b/pkg/cmd/remove.go @@ -31,6 +31,7 @@ func NewRemoveCmd() *cobra.Command { Use: "remove ID", Short: workspaceRemoveCmd.Short, Long: workspaceRemoveCmd.Long, + Example: AdaptExampleForAlias(workspaceRemoveCmd.Example, "workspace remove", "remove"), Args: workspaceRemoveCmd.Args, PreRunE: workspaceRemoveCmd.PreRunE, RunE: workspaceRemoveCmd.RunE, diff --git a/pkg/cmd/testutil/example_validator.go b/pkg/cmd/testutil/example_validator.go new file mode 100644 index 0000000..fb8340c --- /dev/null +++ b/pkg/cmd/testutil/example_validator.go @@ -0,0 +1,285 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "fmt" + "strings" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// ExampleCommand represents a parsed command from an Example string +type ExampleCommand struct { + Raw string // Original command line + Binary string // Binary name (should be "kortex-cli") + Args []string // Subcommands and positional arguments + FlagPresent map[string]bool // Flags that were present in the command + FlagValues map[string]string // Values for flags (empty string if no value provided) + Flags map[string]string // Deprecated: use FlagPresent and FlagValues instead +} + +// ParseExampleCommands extracts kortex-cli commands from Example string +// - Ignores empty lines and comment lines (starting with #) +// - Returns error if non-comment, non-kortex-cli lines exist +func ParseExampleCommands(example string) ([]ExampleCommand, error) { + var commands []ExampleCommand + lines := strings.Split(example, "\n") + + for i, line := range lines { + line = strings.TrimSpace(line) + + // Skip empty lines + if line == "" { + continue + } + + // Skip comment lines + if strings.HasPrefix(line, "#") { + continue + } + + // Parse command line + cmd, err := parseCommandLine(line) + if err != nil { + return nil, fmt.Errorf("line %d: %w", i+1, err) + } + + commands = append(commands, cmd) + } + + return commands, nil +} + +// parseCommandLine parses a single command line into ExampleCommand +func parseCommandLine(line string) (ExampleCommand, error) { + // Split by whitespace, respecting quotes (simple parsing) + parts := splitCommandLine(line) + if len(parts) == 0 { + return ExampleCommand{}, fmt.Errorf("empty command line") + } + + // First part should be kortex-cli + if parts[0] != "kortex-cli" { + return ExampleCommand{}, fmt.Errorf("command must start with 'kortex-cli', got '%s'", parts[0]) + } + + cmd := ExampleCommand{ + Raw: line, + Binary: parts[0], + Args: []string{}, + FlagPresent: make(map[string]bool), + FlagValues: make(map[string]string), + Flags: make(map[string]string), + } + + // Parse remaining parts as args and flags + for i := 1; i < len(parts); i++ { + part := parts[i] + + if strings.HasPrefix(part, "--") { + // Long flag + flagName, flagValue, hasValue := parseLongFlag(part, parts, &i) + cmd.FlagPresent[flagName] = true + cmd.FlagValues[flagName] = flagValue + // Maintain backward compatibility with deprecated Flags field + cmd.Flags[flagName] = flagValue + _ = hasValue // Will be used in validation + } else if strings.HasPrefix(part, "-") && len(part) > 1 { + // Short flag + flagName, flagValue, hasValue := parseShortFlag(part, parts, &i) + cmd.FlagPresent[flagName] = true + cmd.FlagValues[flagName] = flagValue + // Maintain backward compatibility with deprecated Flags field + cmd.Flags[flagName] = flagValue + _ = hasValue // Will be used in validation + } else { + // Positional argument + cmd.Args = append(cmd.Args, part) + } + } + + return cmd, nil +} + +// parseLongFlag parses a long flag (--flag or --flag=value or --flag value) +// Returns: flagName, flagValue, hasValue +func parseLongFlag(part string, parts []string, i *int) (string, string, bool) { + // Remove -- prefix + flagPart := strings.TrimPrefix(part, "--") + + // Check for --flag=value format + if idx := strings.Index(flagPart, "="); idx != -1 { + return flagPart[:idx], flagPart[idx+1:], true + } + + // Check for --flag value format + if *i+1 < len(parts) && !strings.HasPrefix(parts[*i+1], "-") { + *i++ + return flagPart, parts[*i], true + } + + // Flag with no value (--flag) + return flagPart, "", false +} + +// parseShortFlag parses a short flag (-f or -f value) +// Returns: flagName, flagValue, hasValue +func parseShortFlag(part string, parts []string, i *int) (string, string, bool) { + // Remove - prefix + flagPart := strings.TrimPrefix(part, "-") + + // Check for -f value format + if *i+1 < len(parts) && !strings.HasPrefix(parts[*i+1], "-") { + *i++ + return flagPart, parts[*i], true + } + + // Flag with no value (-f) + return flagPart, "", false +} + +// splitCommandLine splits a command line by whitespace +func splitCommandLine(line string) []string { + var parts []string + var current strings.Builder + inQuote := false + quoteChar := rune(0) + + for _, char := range line { + switch { + case (char == '"' || char == '\'') && !inQuote: + inQuote = true + quoteChar = char + case char == quoteChar && inQuote: + inQuote = false + quoteChar = 0 + case char == ' ' && !inQuote: + if current.Len() > 0 { + parts = append(parts, current.String()) + current.Reset() + } + default: + current.WriteRune(char) + } + } + + if current.Len() > 0 { + parts = append(parts, current.String()) + } + + return parts +} + +// ValidateExampleCommand verifies command exists and flags are valid +// - Uses Cobra's Find() to locate command in tree +// - Checks both command-specific and persistent flags +func ValidateExampleCommand(rootCmd *cobra.Command, exampleCmd ExampleCommand) error { + // Find the command in the tree + cmd, remainingArgs, err := rootCmd.Find(exampleCmd.Args) + if err != nil { + return fmt.Errorf("command not found: %s: %w", strings.Join(exampleCmd.Args, " "), err) + } + + // Validate remaining arguments using the command's Args validator + // This respects cobra.NoArgs, cobra.ExactArgs, etc. + if cmd.Args != nil { + if err := cmd.Args(cmd, remainingArgs); err != nil { + // Provide a clearer error message for unknown subcommands + if len(remainingArgs) > 0 && cmd.HasSubCommands() { + return fmt.Errorf("unknown command %q for %q", strings.Join(remainingArgs, " "), cmd.CommandPath()) + } + return fmt.Errorf("invalid arguments for %q: %w", cmd.CommandPath(), err) + } + } + + // Validate each flag + for flagName := range exampleCmd.FlagPresent { + var flag *pflag.Flag + + // Determine if this is a short flag (single character) + isShortFlag := len(flagName) == 1 + + if isShortFlag { + // For short flags, use ShorthandLookup + // Check local flags + flag = cmd.Flags().ShorthandLookup(flagName) + if flag == nil { + // Check persistent flags + flag = cmd.PersistentFlags().ShorthandLookup(flagName) + } + if flag == nil { + // Check inherited flags from parent commands + flag = cmd.InheritedFlags().ShorthandLookup(flagName) + } + } else { + // For long flags, use regular Lookup + // Check local flags + flag = cmd.Flags().Lookup(flagName) + if flag == nil { + // Check persistent flags + flag = cmd.PersistentFlags().Lookup(flagName) + } + if flag == nil { + // Check inherited flags from parent commands + flag = cmd.InheritedFlags().Lookup(flagName) + } + } + + if flag == nil { + cmdPath := cmd.CommandPath() + prefix := "--" + if isShortFlag { + prefix = "-" + } + return fmt.Errorf("flag %s%s not found in command: %s", prefix, flagName, cmdPath) + } + + // Validate that flags requiring values have values + // A flag requires a value if: + // - It's present in the command line + // - It has no value (empty string) + // - NoOptDefVal is empty (meaning the flag doesn't accept being used without a value) + // - It's not a boolean flag + flagValue := exampleCmd.FlagValues[flagName] + if flagValue == "" && flag.NoOptDefVal == "" && flag.Value.Type() != "bool" { + cmdPath := cmd.CommandPath() + prefix := "--" + if isShortFlag { + prefix = "-" + } + return fmt.Errorf("flag %s%s requires a value in command: %s", prefix, flagName, cmdPath) + } + } + + return nil +} + +// ValidateCommandExamples combines parsing and validation +func ValidateCommandExamples(rootCmd *cobra.Command, example string) error { + commands, err := ParseExampleCommands(example) + if err != nil { + return fmt.Errorf("failed to parse examples: %w", err) + } + + for _, cmd := range commands { + if err := ValidateExampleCommand(rootCmd, cmd); err != nil { + return fmt.Errorf("invalid example '%s': %w", cmd.Raw, err) + } + } + + return nil +} diff --git a/pkg/cmd/testutil/example_validator_test.go b/pkg/cmd/testutil/example_validator_test.go new file mode 100644 index 0000000..722942d --- /dev/null +++ b/pkg/cmd/testutil/example_validator_test.go @@ -0,0 +1,469 @@ +// Copyright 2026 Red Hat, Inc. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package testutil + +import ( + "testing" + + "github.com/spf13/cobra" +) + +func TestParseExampleCommands(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + example string + wantCount int + wantErr bool + checkCommands func(t *testing.T, commands []ExampleCommand) + }{ + { + name: "single command", + example: `kortex-cli init`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Binary != "kortex-cli" { + t.Errorf("Expected binary 'kortex-cli', got '%s'", commands[0].Binary) + } + if len(commands[0].Args) != 1 || commands[0].Args[0] != "init" { + t.Errorf("Expected args [init], got %v", commands[0].Args) + } + }, + }, + { + name: "multiple commands with comments", + example: `# Initialize workspace +kortex-cli init + +# List workspaces +kortex-cli workspace list`, + wantCount: 2, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if len(commands[0].Args) != 1 || commands[0].Args[0] != "init" { + t.Errorf("Expected first command args [init], got %v", commands[0].Args) + } + if len(commands[1].Args) != 2 || commands[1].Args[0] != "workspace" || commands[1].Args[1] != "list" { + t.Errorf("Expected second command args [workspace list], got %v", commands[1].Args) + } + }, + }, + { + name: "command with long flag using equals", + example: `kortex-cli workspace list --output=json`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["output"] != "json" { + t.Errorf("Expected flag output=json, got %v", commands[0].Flags) + } + }, + }, + { + name: "command with long flag using space", + example: `kortex-cli workspace list --output json`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["output"] != "json" { + t.Errorf("Expected flag output=json, got %v", commands[0].Flags) + } + }, + }, + { + name: "command with short flag", + example: `kortex-cli workspace list -o json`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["o"] != "json" { + t.Errorf("Expected flag o=json, got %v", commands[0].Flags) + } + }, + }, + { + name: "command with positional argument", + example: `kortex-cli workspace remove abc123`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if len(commands[0].Args) != 3 { + t.Errorf("Expected 3 args, got %d", len(commands[0].Args)) + } + if commands[0].Args[2] != "abc123" { + t.Errorf("Expected arg 'abc123', got '%s'", commands[0].Args[2]) + } + }, + }, + { + name: "command with path argument", + example: `kortex-cli init /path/to/project`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if len(commands[0].Args) != 2 { + t.Errorf("Expected 2 args, got %d", len(commands[0].Args)) + } + if commands[0].Args[1] != "/path/to/project" { + t.Errorf("Expected arg '/path/to/project', got '%s'", commands[0].Args[1]) + } + }, + }, + { + name: "command with boolean flag", + example: `kortex-cli init --verbose`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if _, exists := commands[0].Flags["verbose"]; !exists { + t.Errorf("Expected flag 'verbose' to exist") + } + }, + }, + { + name: "empty example", + example: ``, + wantCount: 0, + }, + { + name: "only comments and empty lines", + example: `# This is a comment + +# Another comment +`, + wantCount: 0, + }, + { + name: "invalid command - wrong binary", + example: `other-cli init`, + wantErr: true, + }, + { + name: "command with multiple flags", + example: `kortex-cli init --name my-project --verbose`, + wantCount: 1, + checkCommands: func(t *testing.T, commands []ExampleCommand) { + if commands[0].Flags["name"] != "my-project" { + t.Errorf("Expected flag name=my-project, got %v", commands[0].Flags) + } + if _, exists := commands[0].Flags["verbose"]; !exists { + t.Errorf("Expected flag 'verbose' to exist") + } + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + commands, err := ParseExampleCommands(tt.example) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + return + } + + if err != nil { + t.Errorf("Unexpected error: %v", err) + return + } + + if len(commands) != tt.wantCount { + t.Errorf("Expected %d commands, got %d", tt.wantCount, len(commands)) + return + } + + if tt.checkCommands != nil { + tt.checkCommands(t, commands) + } + }) + } +} + +func TestValidateExampleCommand(t *testing.T) { + t.Parallel() + + // Helper function to create a fresh command tree for each test + createTestCommandTree := func() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "kortex-cli", + } + + workspaceCmd := &cobra.Command{ + Use: "workspace", + } + + listCmd := &cobra.Command{ + Use: "list", + Args: cobra.NoArgs, + } + listCmd.Flags().String("output", "text", "output format") + listCmd.Flags().StringP("format", "f", "text", "alias for output") + + removeCmd := &cobra.Command{ + Use: "remove", + } + + initCmd := &cobra.Command{ + Use: "init", + } + initCmd.Flags().String("name", "", "workspace name") + initCmd.Flags().Bool("verbose", false, "verbose output") + + workspaceCmd.AddCommand(listCmd) + workspaceCmd.AddCommand(removeCmd) + rootCmd.AddCommand(workspaceCmd) + rootCmd.AddCommand(initCmd) + + // Add a global flag + rootCmd.PersistentFlags().String("storage", "", "storage directory") + + return rootCmd + } + + tests := []struct { + name string + exampleCmd ExampleCommand + wantErr bool + }{ + { + name: "valid command - init", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, + }, + wantErr: false, + }, + { + name: "valid command - workspace list", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, + }, + wantErr: false, + }, + { + name: "valid command with flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list --output json", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + FlagPresent: map[string]bool{"output": true}, + FlagValues: map[string]string{"output": "json"}, + }, + wantErr: false, + }, + { + name: "valid command with short flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list -f json", + Binary: "kortex-cli", + Args: []string{"workspace", "list"}, + FlagPresent: map[string]bool{"f": true}, + FlagValues: map[string]string{"f": "json"}, + }, + wantErr: false, + }, + { + name: "valid command with persistent flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --storage /tmp/storage", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"storage": true}, + FlagValues: map[string]string{"storage": "/tmp/storage"}, + }, + wantErr: false, + }, + { + name: "invalid command - non-existent", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli nonexistent", + Binary: "kortex-cli", + Args: []string{"nonexistent"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, + }, + wantErr: true, + }, + { + name: "invalid flag", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --nonexistent-flag value", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"nonexistent-flag": true}, + FlagValues: map[string]string{"nonexistent-flag": "value"}, + }, + wantErr: true, + }, + { + name: "valid command with multiple flags", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --name test --verbose", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"name": true, "verbose": true}, + FlagValues: map[string]string{"name": "test", "verbose": ""}, + }, + wantErr: false, + }, + { + name: "string flag missing value", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --name", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"name": true}, + FlagValues: map[string]string{"name": ""}, + }, + wantErr: true, + }, + { + name: "boolean flag without value is valid", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli init --verbose", + Binary: "kortex-cli", + Args: []string{"init"}, + FlagPresent: map[string]bool{"verbose": true}, + FlagValues: map[string]string{"verbose": ""}, + }, + wantErr: false, + }, + { + name: "extra positional to leaf command", + exampleCmd: ExampleCommand{ + Raw: "kortex-cli workspace list extra", + Binary: "kortex-cli", + Args: []string{"workspace", "list", "extra"}, + FlagPresent: map[string]bool{}, + FlagValues: map[string]string{}, + }, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a fresh command tree for this test to avoid data races + rootCmd := createTestCommandTree() + + err := ValidateExampleCommand(rootCmd, tt.exampleCmd) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} + +func TestValidateCommandExamples(t *testing.T) { + t.Parallel() + + // Helper function to create a fresh command tree for each test + createTestCommandTree := func() *cobra.Command { + rootCmd := &cobra.Command{ + Use: "kortex-cli", + } + + initCmd := &cobra.Command{ + Use: "init", + } + initCmd.Flags().String("name", "", "workspace name") + initCmd.Flags().Bool("verbose", false, "verbose output") + + rootCmd.AddCommand(initCmd) + rootCmd.PersistentFlags().String("storage", "", "storage directory") + + return rootCmd + } + + tests := []struct { + name string + example string + wantErr bool + }{ + { + name: "valid examples", + example: `# Initialize workspace +kortex-cli init + +# Initialize with name +kortex-cli init --name my-project + +# Verbose output +kortex-cli init --verbose`, + wantErr: false, + }, + { + name: "invalid command in examples", + example: `# Valid command +kortex-cli init + +# Invalid command +kortex-cli nonexistent`, + wantErr: true, + }, + { + name: "invalid flag in examples", + example: `# Invalid flag +kortex-cli init --invalid-flag value`, + wantErr: true, + }, + { + name: "empty examples", + example: ``, + wantErr: false, + }, + { + name: "only comments", + example: `# This is just a comment +# Another comment`, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + // Create a fresh command tree for this test to avoid data races + rootCmd := createTestCommandTree() + + err := ValidateCommandExamples(rootCmd, tt.example) + + if tt.wantErr { + if err == nil { + t.Errorf("Expected error, got nil") + } + } else { + if err != nil { + t.Errorf("Unexpected error: %v", err) + } + } + }) + } +} diff --git a/pkg/cmd/workspace_list.go b/pkg/cmd/workspace_list.go index 301756b..d01867b 100644 --- a/pkg/cmd/workspace_list.go +++ b/pkg/cmd/workspace_list.go @@ -136,9 +136,17 @@ func NewWorkspaceListCmd() *cobra.Command { c := &workspaceListCmd{} cmd := &cobra.Command{ - Use: "list", - Short: "List all registered workspaces", - Long: "List all workspaces registered with kortex-cli init", + Use: "list", + Short: "List all registered workspaces", + Long: "List all workspaces registered with kortex-cli init", + Example: `# List all workspaces +kortex-cli workspace list + +# List workspaces in JSON format +kortex-cli workspace list --output json + +# List using short flag +kortex-cli workspace list -o json`, Args: cobra.NoArgs, PreRunE: c.preRun, RunE: c.run, diff --git a/pkg/cmd/workspace_list_test.go b/pkg/cmd/workspace_list_test.go index c5f7f46..2f8dbb2 100644 --- a/pkg/cmd/workspace_list_test.go +++ b/pkg/cmd/workspace_list_test.go @@ -26,6 +26,7 @@ import ( "testing" api "github.com/kortex-hub/kortex-cli-api/cli/go" + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" "github.com/spf13/cobra" ) @@ -479,5 +480,35 @@ func TestWorkspaceListCmd_E2E(t *testing.T) { t.Errorf("Expected Configuration %s, got %s", addedInstance.GetConfigDir(), workspace.Paths.Configuration) } }) +} + +func TestWorkspaceListCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the workspace list command + listCmd := NewWorkspaceListCmd() + + // Verify Example field is not empty + if listCmd.Example == "" { + t.Fatal("Example field should not be empty") + } + // Parse the examples + commands, err := testutil.ParseExampleCommands(listCmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 3 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, listCmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } } diff --git a/pkg/cmd/workspace_remove.go b/pkg/cmd/workspace_remove.go index e91db5e..75cb38e 100644 --- a/pkg/cmd/workspace_remove.go +++ b/pkg/cmd/workspace_remove.go @@ -79,9 +79,11 @@ func NewWorkspaceRemoveCmd() *cobra.Command { c := &workspaceRemoveCmd{} cmd := &cobra.Command{ - Use: "remove ID", - Short: "Remove a workspace", - Long: "Remove a workspace by its ID", + Use: "remove ID", + Short: "Remove a workspace", + Long: "Remove a workspace by its ID", + Example: `# Remove workspace by ID +kortex-cli workspace remove abc123`, Args: cobra.ExactArgs(1), PreRunE: c.preRun, RunE: c.run, diff --git a/pkg/cmd/workspace_remove_test.go b/pkg/cmd/workspace_remove_test.go index de4edc3..9a8224b 100644 --- a/pkg/cmd/workspace_remove_test.go +++ b/pkg/cmd/workspace_remove_test.go @@ -24,6 +24,7 @@ import ( "strings" "testing" + "github.com/kortex-hub/kortex-cli/pkg/cmd/testutil" "github.com/kortex-hub/kortex-cli/pkg/instances" "github.com/spf13/cobra" ) @@ -327,3 +328,34 @@ func TestWorkspaceRemoveCmd_E2E(t *testing.T) { } }) } + +func TestWorkspaceRemoveCmd_Examples(t *testing.T) { + t.Parallel() + + // Get the workspace remove command + removeCmd := NewWorkspaceRemoveCmd() + + // Verify Example field is not empty + if removeCmd.Example == "" { + t.Fatal("Example field should not be empty") + } + + // Parse the examples + commands, err := testutil.ParseExampleCommands(removeCmd.Example) + if err != nil { + t.Fatalf("Failed to parse examples: %v", err) + } + + // Verify we have the expected number of examples + expectedCount := 1 + if len(commands) != expectedCount { + t.Errorf("Expected %d example commands, got %d", expectedCount, len(commands)) + } + + // Validate all examples against the root command + rootCmd := NewRootCmd() + err = testutil.ValidateCommandExamples(rootCmd, removeCmd.Example) + if err != nil { + t.Errorf("Example validation failed: %v", err) + } +}