diff --git a/internal/cmd/manifest.go b/internal/cmd/manifest.go index 7b08632..40f959d 100644 --- a/internal/cmd/manifest.go +++ b/internal/cmd/manifest.go @@ -21,6 +21,7 @@ func GetManifestCmd() *cobra.Command { // Add subcommands manifestCmd.AddCommand(GetManifestAddCmd()) manifestCmd.AddCommand(GetManifestListCmd()) + manifestCmd.AddCommand(GetManifestDeleteCmd()) addStabilityInfo(manifestCmd) diff --git a/internal/cmd/manifest_delete.go b/internal/cmd/manifest_delete.go new file mode 100644 index 0000000..ca69e5f --- /dev/null +++ b/internal/cmd/manifest_delete.go @@ -0,0 +1,90 @@ +package cmd + +import ( + "fmt" + + "github.com/open-feature/cli/internal/config" + "github.com/open-feature/cli/internal/filesystem" + "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 GetManifestDeleteCmd() *cobra.Command { + manifestDeleteCmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a flag from the manifest", + Long: `Delete a flag from the manifest file by its key. + +Examples: + # Delete a flag named 'old-feature' + openfeature manifest delete old-feature + + # Delete a flag from a specific manifest file + openfeature manifest delete old-feature --manifest path/to/flags.json`, + Args: cobra.ExactArgs(1), + PreRunE: func(cmd *cobra.Command, args []string) error { + return initializeConfig(cmd, "manifest.delete") + }, + RunE: func(cmd *cobra.Command, args []string) error { + flagName := args[0] + manifestPath := config.GetManifestPath(cmd) + + // Check if manifest exists + exists, err := afero.Exists(filesystem.FileSystem(), manifestPath) + if err != nil { + return fmt.Errorf("failed to check manifest existence: %w", err) + } + + if !exists { + return fmt.Errorf("manifest file does not exist: %s", manifestPath) + } + + // Load existing manifest + fs, err := manifest.LoadFlagSet(manifestPath) + if err != nil { + return fmt.Errorf("failed to load manifest: %w", err) + } + + // Check if manifest has any flags + if len(fs.Flags) == 0 { + return fmt.Errorf("manifest contains no flags") + } + + // Check if flag exists + flagIndex := -1 + for i, flag := range fs.Flags { + if flag.Key == flagName { + flagIndex = i + break + } + } + + if flagIndex == -1 { + return fmt.Errorf("flag '%s' not found in manifest", flagName) + } + + // Remove the flag by creating a new slice without it + fs.Flags = append(fs.Flags[:flagIndex], fs.Flags[flagIndex+1:]...) + + // 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' deleted successfully from %s", flagName, manifestPath) + logger.Default.Debug(fmt.Sprintf("Deleted flag: name=%s, manifestPath=%s", flagName, manifestPath)) + + return nil + }, + } + + // Add command-specific flags + config.AddManifestDeleteFlags(manifestDeleteCmd) + addStabilityInfo(manifestDeleteCmd) + + return manifestDeleteCmd +} diff --git a/internal/cmd/manifest_delete_test.go b/internal/cmd/manifest_delete_test.go new file mode 100644 index 0000000..cc7bb03 --- /dev/null +++ b/internal/cmd/manifest_delete_test.go @@ -0,0 +1,463 @@ +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 TestManifestDeleteCmd(t *testing.T) { + tests := []struct { + name string + args []string + existingManifest string + expectedError string + validateResult func(t *testing.T, fs afero.Fs) + }{ + { + name: "delete existing flag from manifest with multiple flags", + args: []string{"delete", "flag-to-delete"}, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "flag-to-keep": { + "flagType": "boolean", + "defaultValue": true, + "description": "This flag should remain" + }, + "flag-to-delete": { + "flagType": "string", + "defaultValue": "remove me", + "description": "This flag should be deleted" + }, + "another-flag": { + "flagType": "integer", + "defaultValue": 42, + "description": "Another flag to keep" + } + } + }`, + 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, "Should have 2 flags remaining") + assert.Contains(t, flags, "flag-to-keep") + assert.Contains(t, flags, "another-flag") + assert.NotContains(t, flags, "flag-to-delete", "Deleted flag should not be present") + }, + }, + { + name: "delete the only flag in manifest", + args: []string{"delete", "only-flag"}, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "only-flag": { + "flagType": "boolean", + "defaultValue": false, + "description": "The only 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, 0, "Should have no flags remaining") + }, + }, + { + name: "error on non-existent flag", + args: []string{"delete", "non-existent-flag"}, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "existing-flag": { + "flagType": "boolean", + "defaultValue": true, + "description": "An existing flag" + } + } + }`, + expectedError: "flag 'non-existent-flag' not found in manifest", + }, + { + name: "error on empty manifest", + args: []string{"delete", "any-flag"}, + existingManifest: `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": {} + }`, + expectedError: "manifest contains no flags", + }, + { + name: "error on missing manifest file", + args: []string{"delete", "any-flag"}, + expectedError: "manifest file does not exist", + }, + } + + 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 TestManifestDeleteCmd_ManifestUnchangedOnError(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create manifest with one flag + originalManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "existing-flag": { + "flagType": "boolean", + "defaultValue": true, + "description": "An existing flag" + } + } + }` + err := afero.WriteFile(fs, "flags.json", []byte(originalManifest), 0644) + require.NoError(t, err) + + // Try to delete a non-existent flag + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + cmd.SetArgs([]string{"delete", "non-existent-flag", "-m", "flags.json"}) + + // Execute command - should fail + err = cmd.Execute() + assert.Error(t, err) + + // Verify manifest is unchanged + 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, 1, "Manifest should still have 1 flag") + assert.Contains(t, flags, "existing-flag", "Original flag should still exist") +} + +func TestManifestDeleteCmd_DisplaysSuccessMessage(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create manifest with two flags + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "flag-1": { + "flagType": "boolean", + "defaultValue": true, + "description": "First flag" + }, + "flag-2": { + "flagType": "string", + "defaultValue": "test", + "description": "Second 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{} + oldSuccess := pterm.Success.Writer + pterm.Success.Writer = buf + defer func() { + pterm.Success.Writer = oldSuccess + }() + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + cmd.SetArgs([]string{ + "delete", "flag-1", + "-m", "flags.json", + }) + + // Execute command + err = cmd.Execute() + require.NoError(t, err) + + // Validate the flag was actually deleted from the manifest + 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, 1, "Should have 1 flag remaining") + assert.Contains(t, flags, "flag-2", "Should still contain flag-2") + assert.NotContains(t, flags, "flag-1", "Should not contain deleted flag-1") + + // Validate success message is displayed + output := buf.String() + assert.Contains(t, output, "flag-1", "Output should contain the flag name") + assert.Contains(t, output, "deleted successfully", "Output should contain success message") + assert.Contains(t, output, "flags.json", "Output should contain the manifest path") +} + +func TestManifestDeleteCmd_WithCustomManifestPath(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create manifest in a custom location + customPath := "custom/path/manifest.json" + err := fs.MkdirAll("custom/path", 0755) + require.NoError(t, err) + + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "flag-to-delete": { + "flagType": "boolean", + "defaultValue": true, + "description": "Flag in custom location" + }, + "flag-to-keep": { + "flagType": "string", + "defaultValue": "keep", + "description": "Keep this one" + } + } + }` + err = afero.WriteFile(fs, customPath, []byte(existingManifest), 0644) + require.NoError(t, err) + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + cmd.SetArgs([]string{ + "delete", "flag-to-delete", + "-m", customPath, + }) + + // Execute command + err = cmd.Execute() + require.NoError(t, err) + + // Validate the flag was deleted from the custom location + content, err := afero.ReadFile(fs, customPath) + 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, 1, "Should have 1 flag remaining") + assert.Contains(t, flags, "flag-to-keep") + assert.NotContains(t, flags, "flag-to-delete") +} + +func TestManifestDeleteCmd_ValidatesArgs(t *testing.T) { + tests := []struct { + name string + args []string + expectedError string + }{ + { + name: "no flag name provided", + args: []string{"delete"}, + expectedError: "accepts 1 arg(s), received 0", + }, + { + name: "too many arguments", + args: []string{"delete", "flag1", "flag2"}, + expectedError: "accepts 1 arg(s), received 2", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create command + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + + // Set args + cmd.SetArgs(append(tt.args, "-m", "flags.json")) + + // Execute command - should fail + err := cmd.Execute() + assert.Error(t, err) + assert.Contains(t, err.Error(), tt.expectedError) + }) + } +} + +func TestManifestDeleteCmd_DeleteFirstFlag(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create manifest with flags in alphabetical order + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "aaa-first": { + "flagType": "boolean", + "defaultValue": true, + "description": "First flag" + }, + "bbb-second": { + "flagType": "string", + "defaultValue": "test", + "description": "Second flag" + }, + "ccc-third": { + "flagType": "integer", + "defaultValue": 42, + "description": "Third flag" + } + } + }` + err := afero.WriteFile(fs, "flags.json", []byte(existingManifest), 0644) + require.NoError(t, err) + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + cmd.SetArgs([]string{"delete", "aaa-first", "-m", "flags.json"}) + + // Execute command + err = cmd.Execute() + require.NoError(t, err) + + // Validate + 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, "Should have 2 flags remaining") + assert.NotContains(t, flags, "aaa-first") + assert.Contains(t, flags, "bbb-second") + assert.Contains(t, flags, "ccc-third") +} + +func TestManifestDeleteCmd_DeleteLastFlag(t *testing.T) { + // Setup + fs := afero.NewMemMapFs() + filesystem.SetFileSystem(fs) + + // Create manifest with flags + existingManifest := `{ + "$schema": "https://raw.githubusercontent.com/open-feature/cli/refs/heads/main/schema/v0/flag-manifest.json", + "flags": { + "aaa-first": { + "flagType": "boolean", + "defaultValue": true, + "description": "First flag" + }, + "bbb-second": { + "flagType": "string", + "defaultValue": "test", + "description": "Second flag" + }, + "zzz-last": { + "flagType": "integer", + "defaultValue": 42, + "description": "Last flag" + } + } + }` + err := afero.WriteFile(fs, "flags.json", []byte(existingManifest), 0644) + require.NoError(t, err) + + // Create command and execute + cmd := GetManifestCmd() + config.AddRootFlags(cmd) + cmd.SetArgs([]string{"delete", "zzz-last", "-m", "flags.json"}) + + // Execute command + err = cmd.Execute() + require.NoError(t, err) + + // Validate + 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, "Should have 2 flags remaining") + assert.Contains(t, flags, "aaa-first") + assert.Contains(t, flags, "bbb-second") + assert.NotContains(t, flags, "zzz-last") +} + diff --git a/internal/config/flags.go b/internal/config/flags.go index 2449422..83689cb 100644 --- a/internal/config/flags.go +++ b/internal/config/flags.go @@ -194,6 +194,11 @@ func AddManifestListFlags(cmd *cobra.Command) { // Currently no specific flags for list command, but function exists for consistency } +// AddManifestDeleteFlags adds the manifest delete command specific flags +func AddManifestDeleteFlags(cmd *cobra.Command) { + // Currently no specific flags for delete command, but function exists for consistency +} + // ShouldDisableInteractivePrompts returns true if interactive prompts should be disabled // This happens when: // - The --no-input flag is set, OR