diff --git a/docs/commands/openfeature.md b/docs/commands/openfeature.md index a76ca88..c8f21cc 100644 --- a/docs/commands/openfeature.md +++ b/docs/commands/openfeature.md @@ -26,6 +26,7 @@ openfeature [flags] * [openfeature compare](openfeature_compare.md) - Compare two feature flag manifests * [openfeature generate](openfeature_generate.md) - Generate typesafe OpenFeature accessors. * [openfeature init](openfeature_init.md) - Initialize a new project +* [openfeature manifest](openfeature_manifest.md) - Manage flag manifest files * [openfeature pull](openfeature_pull.md) - Pull a flag manifest from a remote source * [openfeature version](openfeature_version.md) - Print the version number of the OpenFeature CLI diff --git a/docs/commands/openfeature_manifest.md b/docs/commands/openfeature_manifest.md new file mode 100644 index 0000000..0efad49 --- /dev/null +++ b/docs/commands/openfeature_manifest.md @@ -0,0 +1,34 @@ + + +## openfeature manifest + +Manage flag manifest files + +### Synopsis + +Commands for managing OpenFeature flag manifest files. + +``` +openfeature manifest [flags] +``` + +### Options + +``` + -h, --help help for manifest +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts +``` + +### SEE ALSO + +* [openfeature](openfeature.md) - CLI for OpenFeature. +* [openfeature manifest add](openfeature_manifest_add.md) - Add a new flag to the manifest +* [openfeature manifest list](openfeature_manifest_list.md) - List all flags in the manifest + diff --git a/docs/commands/openfeature_manifest_add.md b/docs/commands/openfeature_manifest_add.md new file mode 100644 index 0000000..ffc04c0 --- /dev/null +++ b/docs/commands/openfeature_manifest_add.md @@ -0,0 +1,51 @@ + + +## openfeature manifest add + +Add a new flag to the manifest + +### Synopsis + +Add a new flag to the manifest file with the specified configuration. + +Examples: + # Add a boolean flag (default type) + openfeature manifest add new-feature --default-value false + + # Add a string flag with description + openfeature manifest add welcome-message --type string --default-value "Hello!" --description "Welcome message for users" + + # Add an integer flag + openfeature manifest add max-retries --type integer --default-value 3 + + # Add a float flag + openfeature manifest add discount-rate --type float --default-value 0.15 + + # Add an object flag + openfeature manifest add config --type object --default-value '{"key":"value"}' + +``` +openfeature manifest add [flag-name] [flags] +``` + +### Options + +``` + -d, --default-value string Default value for the flag (required) + --description string Description of the flag + -h, --help help for add + -t, --type string Type of the flag (boolean, string, integer, float, object) (default "boolean") +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts +``` + +### SEE ALSO + +* [openfeature manifest](openfeature_manifest.md) - Manage flag manifest files + diff --git a/docs/commands/openfeature_manifest_list.md b/docs/commands/openfeature_manifest_list.md new file mode 100644 index 0000000..462db5b --- /dev/null +++ b/docs/commands/openfeature_manifest_list.md @@ -0,0 +1,32 @@ + + +## openfeature manifest list + +List all flags in the manifest + +### Synopsis + +Display all flags defined in the manifest file with their configuration. + +``` +openfeature manifest list [flags] +``` + +### Options + +``` + -h, --help help for list +``` + +### Options inherited from parent commands + +``` + --debug Enable debug logging + -m, --manifest string Path to the flag manifest (default "flags.json") + --no-input Disable interactive prompts +``` + +### SEE ALSO + +* [openfeature manifest](openfeature_manifest.md) - Manage flag manifest files + diff --git a/internal/cmd/manifest.go b/internal/cmd/manifest.go new file mode 100644 index 0000000..7b08632 --- /dev/null +++ b/internal/cmd/manifest.go @@ -0,0 +1,28 @@ +package cmd + +import ( + "github.com/spf13/cobra" +) + +func GetManifestCmd() *cobra.Command { + manifestCmd := &cobra.Command{ + Use: "manifest", + Short: "Manage flag manifest files", + Long: `Commands for managing OpenFeature flag manifest files.`, + RunE: func(cmd *cobra.Command, args []string) error { + return cmd.Help() + }, + SilenceErrors: true, + SilenceUsage: true, + DisableSuggestions: false, + SuggestionsMinimumDistance: 2, + } + + // Add subcommands + manifestCmd.AddCommand(GetManifestAddCmd()) + manifestCmd.AddCommand(GetManifestListCmd()) + + addStabilityInfo(manifestCmd) + + return manifestCmd +} diff --git a/internal/cmd/manifest_add.go b/internal/cmd/manifest_add.go new file mode 100644 index 0000000..281a553 --- /dev/null +++ b/internal/cmd/manifest_add.go @@ -0,0 +1,186 @@ +package cmd + +import ( + "encoding/json" + "errors" + "fmt" + "strconv" + "strings" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/logger" + "github.com/open-feature/cli/internal/manifest" + "github.com/pterm/pterm" + "github.com/spf13/afero" + "github.com/spf13/cobra" +) + +func GetManifestAddCmd() *cobra.Command { + manifestAddCmd := &cobra.Command{ + Use: "add [flag-name]", + Short: "Add a new flag to the manifest", + Long: `Add a new flag to the manifest file with the specified configuration. + +Examples: + # Add a boolean flag (default type) + openfeature manifest add new-feature --default-value false + + # Add a string flag with description + openfeature manifest add welcome-message --type string --default-value "Hello!" --description "Welcome message for users" + + # Add an integer flag + openfeature manifest add max-retries --type integer --default-value 3 + + # Add a float flag + openfeature manifest add discount-rate --type float --default-value 0.15 + + # Add an object flag + openfeature manifest add config --type object --default-value '{"key":"value"}'`, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "manifest.add") + }, + RunE: func(cmd *cobra.Command, args []string) error { + flagName := args[0] + manifestPath := config.GetManifestPath(cmd) + + // Get flag configuration from command flags + flagType, _ := cmd.Flags().GetString("type") + defaultValueStr, _ := cmd.Flags().GetString("default-value") + description, _ := cmd.Flags().GetString("description") + + // Validate that default-value is provided + if !cmd.Flags().Changed("default-value") { + return errors.New("--default-value is required") + } + + // Parse flag type + parsedType, err := parseFlagTypeString(flagType) + if err != nil { + return fmt.Errorf("invalid flag type: %w", err) + } + + // Parse and validate default value + defaultValue, err := parseDefaultValue(defaultValueStr, parsedType) + if err != nil { + return fmt.Errorf("invalid default value for type %s: %w", flagType, err) + } + + // Load existing manifest + var fs *flagset.Flagset + exists, err := afero.Exists(filesystem.FileSystem(), manifestPath) + + if err != nil { + return fmt.Errorf("failed to check manifest existence: %w", err) + } + + if exists { + fs, err = manifest.LoadFlagSet(manifestPath) + if err != nil { + return fmt.Errorf("failed to load manifest: %w", err) + } + } else { + // If manifest doesn't exist, create a new one + fs = &flagset.Flagset{ + Flags: []flagset.Flag{}, + } + } + + // Check if flag already exists + for _, flag := range fs.Flags { + if flag.Key == flagName { + return fmt.Errorf("flag '%s' already exists in the manifest", flagName) + } + } + + // Add new flag + newFlag := flagset.Flag{ + Key: flagName, + Type: parsedType, + Description: description, + DefaultValue: defaultValue, + } + fs.Flags = append(fs.Flags, newFlag) + + // Write updated manifest + if err := manifest.Write(manifestPath, *fs); err != nil { + return fmt.Errorf("failed to write manifest: %w", err) + } + + // Success message + pterm.Success.Printfln("Flag '%s' added successfully to %s", flagName, manifestPath) + logger.Default.Debug(fmt.Sprintf("Added flag: name=%s, type=%s, defaultValue=%v, description=%s", + flagName, flagType, defaultValue, description)) + + // Display all current flags + displayFlagList(fs, manifestPath) + pterm.Println("Use the 'generate' command to update type-safe clients with the new flag.") + pterm.Println() + + return nil + }, + } + + // Add command-specific flags + config.AddManifestAddFlags(manifestAddCmd) + addStabilityInfo(manifestAddCmd) + + return manifestAddCmd +} + +// parseFlagTypeString converts a string flag type to FlagType enum +func parseFlagTypeString(typeStr string) (flagset.FlagType, error) { + switch strings.ToLower(typeStr) { + case "boolean", "bool": + return flagset.BoolType, nil + case "string": + return flagset.StringType, nil + case "integer", "int": + return flagset.IntType, nil + case "float", "number": + return flagset.FloatType, nil + case "object", "json": + return flagset.ObjectType, nil + default: + return flagset.UnknownFlagType, fmt.Errorf("unknown flag type: %s", typeStr) + } +} + +// parseDefaultValue parses and validates the default value based on flag type +func parseDefaultValue(value string, flagType flagset.FlagType) (interface{}, error) { + switch flagType { + case flagset.BoolType: + switch strings.ToLower(value) { + case "true": + return true, nil + case "false": + return false, nil + default: + return nil, fmt.Errorf("boolean value must be 'true' or 'false', got '%s'", value) + } + case flagset.StringType: + return value, nil + case flagset.IntType: + intVal, err := strconv.Atoi(value) + if err != nil { + return nil, fmt.Errorf("invalid integer value: %s", value) + } + return intVal, nil + case flagset.FloatType: + floatVal, err := strconv.ParseFloat(value, 64) + if err != nil { + return nil, fmt.Errorf("invalid float value: %s", value) + } + return floatVal, nil + case flagset.ObjectType: + var jsonObj interface{} + if err := json.Unmarshal([]byte(value), &jsonObj); err != nil { + return nil, fmt.Errorf("invalid JSON object: %s", err.Error()) + } + return jsonObj, nil + default: + return nil, fmt.Errorf("unsupported flag type: %v", flagType) + } +} diff --git a/internal/cmd/manifest_add_test.go b/internal/cmd/manifest_add_test.go new file mode 100644 index 0000000..498fd4b --- /dev/null +++ b/internal/cmd/manifest_add_test.go @@ -0,0 +1,429 @@ +package cmd + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + "github.com/pterm/pterm" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestAddCmd(t *testing.T) { + tests := []struct { + name string + args []string + existingManifest string + expectedError string + validateResult func(t *testing.T, fs afero.Fs) + }{ + { + name: "add boolean flag to empty manifest", + args: []string{ + "add", "new-feature", + "--default-value", "true", + "--description", "A new feature flag", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + assert.Contains(t, flags, "new-feature") + + flag := flags["new-feature"].(map[string]interface{}) + assert.Equal(t, "boolean", flag["flagType"]) + assert.Equal(t, true, flag["defaultValue"]) + assert.Equal(t, "A new feature flag", flag["description"]) + }, + }, + { + name: "add string flag", + args: []string{ + "add", "welcome-message", + "--type", "string", + "--default-value", "Hello World", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + flag := flags["welcome-message"].(map[string]interface{}) + assert.Equal(t, "string", flag["flagType"]) + assert.Equal(t, "Hello World", flag["defaultValue"]) + }, + }, + { + name: "add integer flag", + args: []string{ + "add", "max-retries", + "--type", "integer", + "--default-value", "5", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + flag := flags["max-retries"].(map[string]interface{}) + assert.Equal(t, "integer", flag["flagType"]) + // JSON unmarshaling converts numbers to float64 + assert.Equal(t, float64(5), flag["defaultValue"]) + }, + }, + { + name: "add float flag", + args: []string{ + "add", "discount-rate", + "--type", "float", + "--default-value", "0.15", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + flag := flags["discount-rate"].(map[string]interface{}) + assert.Equal(t, "float", flag["flagType"]) + assert.Equal(t, 0.15, flag["defaultValue"]) + }, + }, + { + name: "add object flag", + args: []string{ + "add", "config", + "--type", "object", + "--default-value", `{"key":"value","nested":{"prop":123}}`, + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + flag := flags["config"].(map[string]interface{}) + assert.Equal(t, "object", flag["flagType"]) + + defaultVal := flag["defaultValue"].(map[string]interface{}) + assert.Equal(t, "value", defaultVal["key"]) + nested := defaultVal["nested"].(map[string]interface{}) + assert.Equal(t, float64(123), nested["prop"]) + }, + }, + { + name: "error on duplicate flag name", + args: []string{ + "add", "existing-flag", + "--default-value", "true", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "existing-flag": { + "flagType": "boolean", + "defaultValue": false, + "description": "An existing flag" + } + } + }`, + expectedError: "flag 'existing-flag' already exists in the manifest", + }, + { + name: "error on missing default value", + args: []string{ + "add", "new-flag", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "--default-value is required", + }, + { + name: "error on invalid type", + args: []string{ + "add", "new-flag", + "--type", "invalid", + "--default-value", "test", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "invalid flag type: unknown flag type: invalid", + }, + { + name: "error on type mismatch - boolean", + args: []string{ + "add", "new-flag", + "--type", "boolean", + "--default-value", "not-a-bool", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "invalid default value for type boolean: boolean value must be 'true' or 'false', got 'not-a-bool'", + }, + { + name: "error on type mismatch - integer", + args: []string{ + "add", "new-flag", + "--type", "integer", + "--default-value", "not-an-int", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "invalid default value for type integer: invalid integer value: not-an-int", + }, + { + name: "error on type mismatch - float", + args: []string{ + "add", "new-flag", + "--type", "float", + "--default-value", "not-a-float", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "invalid default value for type float: invalid float value: not-a-float", + }, + { + name: "error on type mismatch - object", + args: []string{ + "add", "new-flag", + "--type", "object", + "--default-value", "not-valid-json", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "invalid default value for type object: invalid JSON object:", + }, + { + name: "add flag to existing manifest with flags", + args: []string{ + "add", "new-flag", + "--default-value", "false", + }, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "existing-flag": { + "flagType": "string", + "defaultValue": "test", + "description": "An existing flag" + } + } + }`, + validateResult: func(t *testing.T, fs afero.Fs) { + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + flags := manifest["flags"].(map[string]interface{}) + assert.Len(t, flags, 2) + assert.Contains(t, flags, "existing-flag") + assert.Contains(t, flags, "new-flag") + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create existing manifest if provided + if tt.existingManifest != "" { + err := afero.WriteFile(fs, "flags.json", []byte(tt.existingManifest), 0644) + require.NoError(t, err) + } + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + // Set args with manifest path + args := append(tt.args, "-m", "flags.json") + cmd.SetArgs(args) + + // Execute command + err := cmd.Execute() + + // Validate + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + if tt.validateResult != nil { + tt.validateResult(t, fs) + } + } + }) + } +} + +func TestManifestAddCmd_CreateNewManifest(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Don't create any existing manifest + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + cmd.SetArgs([]string{ + "add", "first-flag", + "--default-value", "true", + "--description", "The first flag in a new manifest", + "-m", "flags.json", + }) + + // Execute command + err := cmd.Execute() + require.NoError(t, err) + + // Validate the new manifest was created + exists, err := afero.Exists(fs, "flags.json") + require.NoError(t, err) + assert.True(t, exists) + + content, err := afero.ReadFile(fs, "flags.json") + require.NoError(t, err) + + var manifest map[string]interface{} + err = json.Unmarshal(content, &manifest) + require.NoError(t, err) + + // Check schema is present + assert.Contains(t, manifest, "$schema") + + // Check flag was added + flags := manifest["flags"].(map[string]interface{}) + assert.Contains(t, flags, "first-flag") + + flag := flags["first-flag"].(map[string]interface{}) + assert.Equal(t, "boolean", flag["flagType"]) + assert.Equal(t, true, flag["defaultValue"]) + assert.Equal(t, "The first flag in a new manifest", flag["description"]) +} + +func TestManifestAddCmd_DisplaysListAfterAdd(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create existing manifest with one flag + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "existing-flag": { + "flagType": "string", + "defaultValue": "test", + "description": "An existing flag" + } + } + }` + err := afero.WriteFile(fs, "flags.json", []byte(existingManifest), 0644) + require.NoError(t, err) + + // Enable pterm output and capture it + pterm.EnableOutput() + defer pterm.DisableOutput() + + buf := &bytes.Buffer{} + oldStdout := pterm.DefaultTable.Writer + oldSection := pterm.DefaultSection.Writer + oldInfo := pterm.Info.Writer + oldSuccess := pterm.Success.Writer + pterm.DefaultTable.Writer = buf + pterm.DefaultSection.Writer = buf + pterm.Info.Writer = buf + pterm.Success.Writer = buf + defer func() { + pterm.DefaultTable.Writer = oldStdout + pterm.DefaultSection.Writer = oldSection + pterm.Info.Writer = oldInfo + pterm.Success.Writer = oldSuccess + }() + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + cmd.SetArgs([]string{ + "add", "new-flag", + "--default-value", "true", + "--description", "A new flag", + "-m", "flags.json", + }) + + // Execute command + err = cmd.Execute() + require.NoError(t, err) + + // Validate output contains list of all flags + output := buf.String() + assert.Contains(t, output, "existing-flag", "Output should contain existing flag") + assert.Contains(t, output, "new-flag", "Output should contain newly added flag") + assert.Contains(t, output, "(2)", "Output should show total count of 2 flags") + assert.Contains(t, output, "string", "Output should show flag types") + assert.Contains(t, output, "boolean", "Output should show flag types") +} + diff --git a/internal/cmd/manifest_list.go b/internal/cmd/manifest_list.go new file mode 100644 index 0000000..c0d4504 --- /dev/null +++ b/internal/cmd/manifest_list.go @@ -0,0 +1,106 @@ +package cmd + +import ( + "encoding/json" + "fmt" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/flagset" + "github.com/open-feature/cli/internal/manifest" + "github.com/pterm/pterm" + "github.com/spf13/cobra" +) + +func GetManifestListCmd() *cobra.Command { + manifestListCmd := &cobra.Command{ + Use: "list", + Short: "List all flags in the manifest", + Long: `Display all flags defined in the manifest file with their configuration.`, + Args: cobra.NoArgs, + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "manifest.list") + }, + RunE: func(cmd *cobra.Command, args []string) error { + manifestPath := config.GetManifestPath(cmd) + + // Load existing manifest + fs, err := manifest.LoadFlagSet(manifestPath) + if err != nil { + return fmt.Errorf("failed to load manifest: %w", err) + } + + displayFlagList(fs, manifestPath) + return nil + }, + } + + // Add command-specific flags + config.AddManifestListFlags(manifestListCmd) + addStabilityInfo(manifestListCmd) + + return manifestListCmd +} + +// displayFlagList prints a formatted table of all flags in the flagset +func displayFlagList(fs *flagset.Flagset, manifestPath string) { + if len(fs.Flags) == 0 { + pterm.Info.Println("No flags found in manifest") + return + } + + // Print header + pterm.DefaultSection.Println(fmt.Sprintf("Flags in %s (%d)", manifestPath, len(fs.Flags))) + + // Create table data + tableData := pterm.TableData{ + {"Key", "Type", "Default Value", "Description"}, + } + + for _, flag := range fs.Flags { + // Format default value for display + defaultValueStr := formatValue(flag.DefaultValue) + + // Truncate description if too long + description := flag.Description + const maxDescriptionLength = 50 + + if len(description) > maxDescriptionLength { + description = description[:maxDescriptionLength-3] + "..." + } + + tableData = append(tableData, []string{ + flag.Key, + flag.Type.String(), + defaultValueStr, + description, + }) + } + + // Render table + _ = pterm.DefaultTable.WithHasHeader().WithData(tableData).Render() +} + +// formatValue converts a value to a string representation suitable for display +func formatValue(value interface{}) string { + switch v := value.(type) { + case string: + if len(v) > 30 { + return fmt.Sprintf("\"%s...\"", v[:27]) + } + return fmt.Sprintf("\"%s\"", v) + case bool, int, float64: + return fmt.Sprintf("%v", v) + case map[string]interface{}, []interface{}: + jsonBytes, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + jsonStr := string(jsonBytes) + if len(jsonStr) > 30 { + return jsonStr[:27] + "..." + } + return jsonStr + default: + return fmt.Sprintf("%v", v) + } +} diff --git a/internal/cmd/manifest_list_test.go b/internal/cmd/manifest_list_test.go new file mode 100644 index 0000000..27e6eee --- /dev/null +++ b/internal/cmd/manifest_list_test.go @@ -0,0 +1,358 @@ +package cmd + +import ( + "bytes" + "testing" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + "github.com/open-feature/cli/internal/flagset" + "github.com/pterm/pterm" + "github.com/spf13/afero" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestListCmd(t *testing.T) { + tests := []struct { + name string + manifestContent string + expectedError string + expectedInOutput []string + notInOutput []string + }{ + { + name: "list flags in manifest with multiple flags", + manifestContent: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "feature-a": { + "flagType": "boolean", + "defaultValue": true, + "description": "Feature A flag" + }, + "max-items": { + "flagType": "integer", + "defaultValue": 100, + "description": "Maximum items allowed" + }, + "welcome-msg": { + "flagType": "string", + "defaultValue": "Hello!", + "description": "Welcome message" + } + } + }`, + expectedInOutput: []string{ + "feature-a", + "max-items", + "welcome-msg", + "boolean", + "integer", + "string", + "(3)", + }, + }, + { + name: "list flags with empty manifest", + manifestContent: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedInOutput: []string{ + "No flags found in manifest", + }, + notInOutput: []string{ + "Total flags:", + }, + }, + { + name: "list flags with various types", + manifestContent: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "bool-flag": { + "flagType": "boolean", + "defaultValue": false, + "description": "A boolean flag" + }, + "string-flag": { + "flagType": "string", + "defaultValue": "test", + "description": "A string flag" + }, + "int-flag": { + "flagType": "integer", + "defaultValue": 42, + "description": "An integer flag" + }, + "float-flag": { + "flagType": "float", + "defaultValue": 3.14, + "description": "A float flag" + }, + "object-flag": { + "flagType": "object", + "defaultValue": {"key": "value"}, + "description": "An object flag" + } + } + }`, + expectedInOutput: []string{ + "bool-flag", + "string-flag", + "int-flag", + "float-flag", + "object-flag", + "boolean", + "string", + "integer", + "float", + "object", + "(5)", + }, + }, + { + name: "error on missing manifest file", + expectedError: "failed to load manifest", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create manifest if provided + if tt.manifestContent != "" { + err := afero.WriteFile(fs, "flags.json", []byte(tt.manifestContent), 0644) + require.NoError(t, err) + } + + // Enable pterm output and capture it + pterm.EnableOutput() + defer pterm.DisableOutput() + + buf := &bytes.Buffer{} + oldStdout := pterm.DefaultTable.Writer + oldSection := pterm.DefaultSection.Writer + oldInfo := pterm.Info.Writer + pterm.DefaultTable.Writer = buf + pterm.DefaultSection.Writer = buf + pterm.Info.Writer = buf + defer func() { + pterm.DefaultTable.Writer = oldStdout + pterm.DefaultSection.Writer = oldSection + pterm.Info.Writer = oldInfo + }() + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + cmd.SetArgs([]string{"list", "-m", "flags.json"}) + + // Execute command + err := cmd.Execute() + + // Validate + if tt.expectedError != "" { + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + } else { + assert.NoError(t, err) + + output := buf.String() + for _, expected := range tt.expectedInOutput { + assert.Contains(t, output, expected, "Output should contain: %s", expected) + } + for _, notExpected := range tt.notInOutput { + assert.NotContains(t, output, notExpected, "Output should not contain: %s", notExpected) + } + } + }) + } +} + +func TestDisplayFlagList(t *testing.T) { + tests := []struct { + name string + flagset *flagset.Flagset + manifestPath string + expectedInOutput []string + }{ + { + name: "display multiple flags", + flagset: &flagset.Flagset{ + Flags: []flagset.Flag{ + { + Key: "flag1", + Type: flagset.BoolType, + Description: "First flag", + DefaultValue: true, + }, + { + Key: "flag2", + Type: flagset.StringType, + Description: "Second flag", + DefaultValue: "test", + }, + }, + }, + manifestPath: "test.json", + expectedInOutput: []string{ + "flag1", + "flag2", + "boolean", + "string", + "First flag", + "Second flag", + "test.json", + }, + }, + { + name: "display empty flagset", + flagset: &flagset.Flagset{ + Flags: []flagset.Flag{}, + }, + manifestPath: "empty.json", + expectedInOutput: []string{ + "No flags found in manifest", + }, + }, + { + name: "truncate long description", + flagset: &flagset.Flagset{ + Flags: []flagset.Flag{ + { + Key: "long-desc", + Type: flagset.BoolType, + Description: "This is a very long description that should be truncated because it exceeds the maximum length", + DefaultValue: false, + }, + }, + }, + manifestPath: "test.json", + expectedInOutput: []string{ + "long-desc", + "...", + }, + }, + { + name: "truncate long string value", + flagset: &flagset.Flagset{ + Flags: []flagset.Flag{ + { + Key: "long-string", + Type: flagset.StringType, + Description: "Long string value", + DefaultValue: "This is a very long string value that should be truncated", + }, + }, + }, + manifestPath: "test.json", + expectedInOutput: []string{ + "long-string", + "...", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Enable pterm output and capture it + pterm.EnableOutput() + defer pterm.DisableOutput() + + buf := &bytes.Buffer{} + oldStdout := pterm.DefaultTable.Writer + oldSection := pterm.DefaultSection.Writer + oldInfo := pterm.Info.Writer + pterm.DefaultTable.Writer = buf + pterm.DefaultSection.Writer = buf + pterm.Info.Writer = buf + defer func() { + pterm.DefaultTable.Writer = oldStdout + pterm.DefaultSection.Writer = oldSection + pterm.Info.Writer = oldInfo + }() + + // Call the function + displayFlagList(tt.flagset, tt.manifestPath) + + // Validate output + output := buf.String() + for _, expected := range tt.expectedInOutput { + assert.Contains(t, output, expected, "Output should contain: %s", expected) + } + }) + } +} + +func TestFormatValue(t *testing.T) { + tests := []struct { + name string + value interface{} + expected string + }{ + { + name: "format short string", + value: "hello", + expected: `"hello"`, + }, + { + name: "format long string", + value: "this is a very long string that exceeds thirty characters", + expected: "...", + }, + { + name: "format boolean true", + value: true, + expected: "true", + }, + { + name: "format boolean false", + value: false, + expected: "false", + }, + { + name: "format integer", + value: 42, + expected: "42", + }, + { + name: "format float", + value: 3.14, + expected: "3.14", + }, + { + name: "format object", + value: map[string]interface{}{"key": "value"}, + expected: `{"key":"value"}`, + }, + { + name: "format large object", + value: map[string]interface{}{"key1": "value1", "key2": "value2", "key3": "value3"}, + expected: "...", + }, + { + name: "format array", + value: []interface{}{"a", "b", "c"}, + expected: `["a","b","c"]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := formatValue(tt.value) + assert.Contains(t, result, tt.expected) + }) + } +} + +func TestMain(m *testing.M) { + // Disable pterm output during tests by default + pterm.DisableOutput() + m.Run() +} diff --git a/internal/cmd/root.go b/internal/cmd/root.go index 1d28375..93daa5f 100644 --- a/internal/cmd/root.go +++ b/internal/cmd/root.go @@ -64,6 +64,7 @@ func GetRootCmd() *cobra.Command { rootCmd.AddCommand(GetGenerateCmd()) rootCmd.AddCommand(GetCompareCmd()) rootCmd.AddCommand(GetPullCmd()) + rootCmd.AddCommand(GetManifestCmd()) // Add a custom error handler after the command is created rootCmd.SetFlagErrorFunc(func(cmd *cobra.Command, err error) error { diff --git a/internal/config/flags.go b/internal/config/flags.go index 178318b..1edb13c 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -18,6 +18,9 @@ const ( FlagSourceUrlFlagName = "flag-source-url" AuthTokenFlagName = "auth-token" NoPromptFlagName = "no-prompt" + TypeFlagName = "type" + DefaultValueFlagName = "default-value" + DescriptionFlagName = "description" ) // Default values for flags @@ -139,3 +142,15 @@ func GetNoPrompt(cmd *cobra.Command) bool { noPrompt, _ := cmd.Flags().GetBool(NoPromptFlagName) return noPrompt } + +// AddManifestAddFlags adds the manifest add command specific flags +func AddManifestAddFlags(cmd *cobra.Command) { + cmd.Flags().StringP(TypeFlagName, "t", "boolean", "Type of the flag (boolean, string, integer, float, object)") + cmd.Flags().StringP(DefaultValueFlagName, "d", "", "Default value for the flag (required)") + cmd.Flags().String(DescriptionFlagName, "", "Description of the flag") +} + +// AddManifestListFlags adds the manifest list command specific flags +func AddManifestListFlags(cmd *cobra.Command) { + // Currently no specific flags for list command, but function exists for consistency +}