diff --git a/cmd/config/plugins/get.go b/cmd/config/plugins/get.go index 31b37a2..9b7a5b8 100644 --- a/cmd/config/plugins/get.go +++ b/cmd/config/plugins/get.go @@ -5,21 +5,143 @@ import ( "github.com/spf13/cobra" - "github.com/mozilla-ai/mcpd/v2/internal/cmd" - "github.com/mozilla-ai/mcpd/v2/internal/cmd/options" + internalcmd "github.com/mozilla-ai/mcpd/v2/internal/cmd" + cmdopts "github.com/mozilla-ai/mcpd/v2/internal/cmd/options" + "github.com/mozilla-ai/mcpd/v2/internal/cmd/output" + "github.com/mozilla-ai/mcpd/v2/internal/config" + "github.com/mozilla-ai/mcpd/v2/internal/printer" ) +const ( + flagCategory = "category" + flagName = "name" + flagFormat = "format" +) + +// GetCmd represents the get command. +// NOTE: Use NewGetCmd to create a GetCmd. +type GetCmd struct { + *internalcmd.BaseCmd + + // cfgLoader is used to load the configuration. + cfgLoader config.Loader + + // pluginConfigPrinter is used to output top-level plugin configuration. + pluginConfigPrinter output.Printer[printer.PluginConfigResult] + + // pluginEntryPrinter is used to output specific plugin entries. + pluginEntryPrinter output.Printer[printer.PluginEntryResult] + + // format stores the format flag when specified. + format internalcmd.OutputFormat + + // category is the (optional) category name to look in for the plugin. + category config.Category + + // name is the (optional) name of the plugin to return config for. + name string +} + // NewGetCmd creates the get command for plugins. -// TODO: Implement in a future PR. -func NewGetCmd(baseCmd *cmd.BaseCmd, opt ...options.CmdOption) (*cobra.Command, error) { +func NewGetCmd(baseCmd *internalcmd.BaseCmd, opt ...cmdopts.CmdOption) (*cobra.Command, error) { + opts, err := cmdopts.NewOptions(opt...) + if err != nil { + return nil, err + } + + c := &GetCmd{ + BaseCmd: baseCmd, + cfgLoader: opts.ConfigLoader, + pluginConfigPrinter: &printer.PluginConfigPrinter{}, + pluginEntryPrinter: &printer.PluginEntryPrinter{}, + format: internalcmd.FormatText, // Default to plain text + } + cobraCmd := &cobra.Command{ Use: "get", - Short: "Get plugin-level config or specific plugin entry", - Long: "Get plugin-level configuration (when no flags provided) or specific plugin entry (when --category and --name provided)", - RunE: func(cmd *cobra.Command, args []string) error { - return fmt.Errorf("not yet implemented") - }, + Short: "Get top level configuration for the plugin subsystem or for a specific plugin entry in a category", + Long: `Get top level configuration for the plugin subsystem or for a specific plugin entry in a category. + +When called without flags, shows plugin-level configuration (e.g. plugin directory). +When called with --category and --name, shows the specific plugin entry. +Both --category and --name must be provided together.`, + Example: ` # Get plugin-level configuration + mcpd config plugins get + + # Get specific plugin entry + mcpd config plugins get --category authentication --name jwt-auth`, + RunE: c.run, + Args: cobra.NoArgs, } + allowedCategories := config.OrderedCategories() + cobraCmd.Flags().Var( + &c.category, + flagCategory, + fmt.Sprintf("Specify the category (one of: %s)", allowedCategories.String()), + ) + + cobraCmd.Flags().StringVar( + &c.name, + flagName, + "", + "Plugin name", + ) + + allowedOutputFormats := internalcmd.AllowedOutputFormats() + cobraCmd.Flags().Var( + &c.format, + flagFormat, + fmt.Sprintf("Specify the output format (one of: %s)", allowedOutputFormats.String()), + ) + return cobraCmd, nil } + +func (c *GetCmd) run(cmd *cobra.Command, _ []string) error { + // Validate optional flags. + if err := c.RequireTogether(cmd, flagCategory, flagName); err != nil { + return err + } + + cfg, err := c.LoadConfig(c.cfgLoader) + if err != nil { + return err + } + + // Show top-level plugin config settings. + if !cmd.Flags().Changed(flagCategory) { + var dir string + if cfg.Plugins != nil { + dir = cfg.Plugins.Dir + } + + result := printer.PluginConfigResult{ + Dir: dir, + } + + handler, err := internalcmd.FormatHandler(cmd.OutOrStdout(), c.format, c.pluginConfigPrinter) + if err != nil { + return err + } + + return handler.HandleResult(result) + } + + // Show a single plugin item for category/name if we can find it. + if entry, found := cfg.Plugin(c.category, c.name); found { + result := printer.PluginEntryResult{ + PluginEntry: entry, + Category: c.category, + } + + handler, err := internalcmd.FormatHandler(cmd.OutOrStdout(), c.format, c.pluginEntryPrinter) + if err != nil { + return err + } + + return handler.HandleResult(result) + } + + return fmt.Errorf("plugin '%s' not found in category '%s'", c.name, c.category) +} diff --git a/cmd/config/plugins/get_test.go b/cmd/config/plugins/get_test.go new file mode 100644 index 0000000..2cad728 --- /dev/null +++ b/cmd/config/plugins/get_test.go @@ -0,0 +1,490 @@ +package plugins + +import ( + "bytes" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + "gopkg.in/yaml.v3" + + "github.com/mozilla-ai/mcpd/v2/internal/cmd" + cmdopts "github.com/mozilla-ai/mcpd/v2/internal/cmd/options" + "github.com/mozilla-ai/mcpd/v2/internal/config" + "github.com/mozilla-ai/mcpd/v2/internal/printer" +) + +func TestGetCmd_FlagConstants(t *testing.T) { + t.Parallel() + + require.Equal(t, "category", flagCategory) + require.Equal(t, "name", flagName) +} + +func TestNewGetCmd(t *testing.T) { + t.Parallel() + + base := &cmd.BaseCmd{} + c, err := NewGetCmd(base) + require.NoError(t, err) + require.NotNil(t, c) + + require.Equal(t, "get", c.Use) + require.Contains(t, c.Short, "Get top level configuration for the plugin subsystem") + require.NotNil(t, c.RunE) +} + +func TestGetCmd_PluginLevelConfig(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + setupConfig func(*config.Config) + expectedOutput string + expectError bool + errorContains string + }{ + { + name: "configured directory", + setupConfig: func(cfg *config.Config) { + cfg.Plugins = &config.PluginConfig{ + Dir: "/path/to/plugins", + } + }, + expectedOutput: "Plugin Configuration:\n Directory: /path/to/plugins\n", + }, + { + name: "no plugin config", + setupConfig: func(cfg *config.Config) { + cfg.Plugins = nil + }, + expectError: false, + expectedOutput: "Plugin Configuration:\n Directory: (not configured)\n", + }, + { + name: "empty directory", + setupConfig: func(cfg *config.Config) { + cfg.Plugins = &config.PluginConfig{ + Dir: "", + } + }, + expectedOutput: "Plugin Configuration:\n Directory: (not configured)\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := &config.Config{} + tc.setupConfig(cfg) + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + + if tc.expectError { + require.Error(t, err) + require.ErrorContains(t, err, tc.errorContains) + } else { + require.NoError(t, err) + require.Empty(t, stderr.String()) + require.Equal(t, tc.expectedOutput, stdout.String()) + } + }) + } +} + +func TestGetCmd_PluginLevelConfig_JSON(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Dir: "/path/to/plugins", + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set("format", "json") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + var wrapper struct { + Result printer.PluginConfigResult `json:"result"` + } + err = json.Unmarshal(stdout.Bytes(), &wrapper) + require.NoError(t, err) + + require.Equal(t, "/path/to/plugins", wrapper.Result.Dir) +} + +func TestGetCmd_PluginLevelConfig_YAML(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Dir: "/path/to/plugins", + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set("format", "yaml") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + var wrapper struct { + Result printer.PluginConfigResult `yaml:"result"` + } + err = yaml.Unmarshal(stdout.Bytes(), &wrapper) + require.NoError(t, err) + + require.Equal(t, "/path/to/plugins", wrapper.Result.Dir) +} + +func TestGetCmd_SpecificPluginEntry(t *testing.T) { + t.Parallel() + + requiredTrue := true + commitHash := "abc123" + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Authentication: []config.PluginEntry{ + { + Name: "jwt-auth", + Flows: []config.Flow{config.FlowRequest, config.FlowResponse}, + Required: &requiredTrue, + CommitHash: &commitHash, + }, + }, + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = getCmd.Flags().Set(flagName, "jwt-auth") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + expectedOutput := "Plugin 'jwt-auth' in category 'authentication':\n" + + " Flows: request, response\n" + + " Required: true\n" + + " Commit Hash: abc123\n" + + require.Equal(t, expectedOutput, stdout.String()) +} + +func TestGetCmd_SpecificPluginEntry_JSON(t *testing.T) { + t.Parallel() + + requiredFalse := false + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Observability: []config.PluginEntry{ + { + Name: "logger", + Flows: []config.Flow{config.FlowRequest}, + Required: &requiredFalse, + }, + }, + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "observability") + require.NoError(t, err) + err = getCmd.Flags().Set(flagName, "logger") + require.NoError(t, err) + err = getCmd.Flags().Set("format", "json") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + var wrapper struct { + Result printer.PluginEntryResult `json:"result"` + } + err = json.Unmarshal(stdout.Bytes(), &wrapper) + require.NoError(t, err) + + require.Equal(t, "logger", wrapper.Result.Name) + require.Equal(t, config.CategoryObservability, wrapper.Result.Category) + require.Equal(t, []config.Flow{config.FlowRequest}, wrapper.Result.Flows) + require.NotNil(t, wrapper.Result.Required) + require.False(t, *wrapper.Result.Required) + require.Nil(t, wrapper.Result.CommitHash) +} + +func TestGetCmd_SpecificPluginEntry_YAML(t *testing.T) { + t.Parallel() + + requiredTrue := true + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Audit: []config.PluginEntry{ + { + Name: "compliance", + Flows: []config.Flow{config.FlowResponse}, + Required: &requiredTrue, + }, + }, + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "audit") + require.NoError(t, err) + err = getCmd.Flags().Set(flagName, "compliance") + require.NoError(t, err) + err = getCmd.Flags().Set("format", "yaml") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + var wrapper struct { + Result printer.PluginEntryResult `yaml:"result"` + } + err = yaml.Unmarshal(stdout.Bytes(), &wrapper) + require.NoError(t, err) + + require.Equal(t, "compliance", wrapper.Result.Name) + require.Equal(t, config.CategoryAudit, wrapper.Result.Category) + require.Equal(t, []config.Flow{config.FlowResponse}, wrapper.Result.Flows) + require.NotNil(t, wrapper.Result.Required) + require.True(t, *wrapper.Result.Required) +} + +func TestGetCmd_FlagValidation(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + category string + pluginName string + expectedError string + }{ + { + name: "only category provided", + category: "authentication", + pluginName: "", + expectedError: "must be provided together or not at all", + }, + { + name: "only name provided", + category: "", + pluginName: "jwt-auth", + expectedError: "must be provided together or not at all", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cfg := &config.Config{} + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + if tc.category != "" { + err = getCmd.Flags().Set(flagCategory, tc.category) + require.NoError(t, err) + } + if tc.pluginName != "" { + err = getCmd.Flags().Set(flagName, tc.pluginName) + require.NoError(t, err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.Error(t, err) + require.ErrorContains(t, err, tc.expectedError) + }) + } +} + +func TestGetCmd_InvalidCategory(t *testing.T) { + t.Parallel() + + cfg := &config.Config{} + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "invalid-category") + require.Error(t, err) + require.ErrorContains(t, err, "invalid category 'invalid-category'") +} + +func TestGetCmd_PluginNotFound(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + Authentication: []config.PluginEntry{ + { + Name: "other-plugin", + Flows: []config.Flow{config.FlowRequest}, + }, + }, + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = getCmd.Flags().Set(flagName, "jwt-auth") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.Error(t, err) + require.ErrorContains(t, err, "plugin 'jwt-auth' not found in category 'authentication'") +} + +func TestGetCmd_PluginNotFound_NilPluginConfig(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Plugins: nil, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "authentication") + require.NoError(t, err) + err = getCmd.Flags().Set(flagName, "jwt-auth") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.Error(t, err) + require.EqualError(t, err, "plugin 'jwt-auth' not found in category 'authentication'") +} + +func TestGetCmd_CaseInsensitiveCategory(t *testing.T) { + t.Parallel() + + cfg := &config.Config{ + Plugins: &config.PluginConfig{ + RateLimiting: []config.PluginEntry{ + { + Name: "rate-limiter", + Flows: []config.Flow{config.FlowRequest}, + }, + }, + }, + } + + mockLoader := &mockLoader{cfg: cfg} + base := &cmd.BaseCmd{} + getCmd, err := NewGetCmd(base, cmdopts.WithConfigLoader(mockLoader)) + require.NoError(t, err) + + err = getCmd.Flags().Set(flagCategory, "RATE_LIMITING") + require.NoError(t, err) + err = getCmd.Flags().Set(flagName, "rate-limiter") + require.NoError(t, err) + + var stdout bytes.Buffer + var stderr bytes.Buffer + getCmd.SetOut(&stdout) + getCmd.SetErr(&stderr) + + err = getCmd.RunE(getCmd, []string{}) + require.NoError(t, err) + require.Empty(t, stderr.String()) + + require.Contains(t, stdout.String(), "Plugin 'rate-limiter' in category 'rate_limiting'") +} diff --git a/cmd/config/plugins/helpers_test.go b/cmd/config/plugins/helpers_test.go new file mode 100644 index 0000000..e05451d --- /dev/null +++ b/cmd/config/plugins/helpers_test.go @@ -0,0 +1,18 @@ +package plugins + +import ( + "github.com/mozilla-ai/mcpd/v2/internal/config" +) + +// mockLoader is a mock config.Loader for testing. +type mockLoader struct { + cfg *config.Config + err error +} + +func (m *mockLoader) Load(_ string) (config.Modifier, error) { + if m.err != nil { + return nil, m.err + } + return m.cfg, nil +} diff --git a/cmd/config/plugins/list_test.go b/cmd/config/plugins/list_test.go index 76ffb28..fc9280c 100644 --- a/cmd/config/plugins/list_test.go +++ b/cmd/config/plugins/list_test.go @@ -16,19 +16,6 @@ import ( "github.com/mozilla-ai/mcpd/v2/internal/printer" ) -// Mock loader for testing. -type mockLoaderForList struct { - cfg *config.Config - err error -} - -func (m *mockLoaderForList) Load(_ string) (config.Modifier, error) { - if m.err != nil { - return nil, m.err - } - return m.cfg, nil -} - // newTestConfig creates a config.Config with the given plugin map. func newTestConfig(t *testing.T, plugins map[config.Category][]config.PluginEntry) *config.Config { t.Helper() @@ -161,7 +148,7 @@ func TestListCmd_SingleCategory(t *testing.T) { t.Parallel() cfg := newTestConfig(t, tc.plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -262,7 +249,7 @@ func TestListCmd_AllCategories(t *testing.T) { t.Parallel() cfg := newTestConfig(t, tc.plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -346,7 +333,7 @@ func TestListCmd_JSONOutput(t *testing.T) { t.Parallel() cfg := newTestConfig(t, tc.plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -442,7 +429,7 @@ func TestListCmd_YAMLOutput(t *testing.T) { t.Parallel() cfg := newTestConfig(t, tc.plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -501,7 +488,7 @@ func TestListCmd_DistinctPluginCount(t *testing.T) { } cfg := newTestConfig(t, plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -555,7 +542,7 @@ func TestListCmd_DistinctPluginCount(t *testing.T) { } cfg := newTestConfig(t, plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -579,7 +566,7 @@ func TestListCmd_DistinctPluginCount(t *testing.T) { func TestListCmd_ConfigLoadError(t *testing.T) { t.Parallel() - loader := &mockLoaderForList{ + loader := &mockLoader{ err: fmt.Errorf("failed to load config"), } @@ -618,7 +605,7 @@ func TestListCmd_AllValidCategories(t *testing.T) { t.Parallel() cfg := newTestConfig(t, map[config.Category][]config.PluginEntry{}) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) @@ -644,7 +631,7 @@ func TestListCmd_AllValidCategories(t *testing.T) { t.Run("category_case_insensitive", func(t *testing.T) { t.Parallel() cfg := newTestConfig(t, map[config.Category][]config.PluginEntry{}) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) require.NoError(t, err) @@ -694,7 +681,7 @@ func TestListCmd_CategoryExecutionOrder(t *testing.T) { } cfg := newTestConfig(t, plugins) - loader := &mockLoaderForList{cfg: cfg} + loader := &mockLoader{cfg: cfg} base := &cmd.BaseCmd{} listCmd, err := NewListCmd(base, cmdopts.WithConfigLoader(loader)) diff --git a/internal/cmd/basecmd.go b/internal/cmd/basecmd.go index 86d0aae..bdbe0d6 100644 --- a/internal/cmd/basecmd.go +++ b/internal/cmd/basecmd.go @@ -4,8 +4,11 @@ import ( "fmt" "io" "os" + "slices" + "strings" "github.com/hashicorp/go-hclog" + "github.com/spf13/cobra" "github.com/mozilla-ai/mcpd/v2/internal/cache" "github.com/mozilla-ai/mcpd/v2/internal/cmd/output" @@ -196,3 +199,21 @@ func (c *BaseCmd) LoadConfig(loader config.Loader) (*config.Config, error) { } return cfg, nil } + +// RequireTogether validates that a set of flags are either all provided or all omitted. +// Returns an error if only some of the flags are provided. +func (c *BaseCmd) RequireTogether(cmd *cobra.Command, names ...string) error { + count := 0 + for _, name := range names { + if cmd.Flags().Changed(name) { + count++ + } + } + + if count > 0 && count < len(names) { + slices.Sort(names) + return fmt.Errorf("flags (%s) must be provided together or not at all", strings.Join(names, ", ")) + } + + return nil +} diff --git a/internal/cmd/basecmd_test.go b/internal/cmd/basecmd_test.go new file mode 100644 index 0000000..311e738 --- /dev/null +++ b/internal/cmd/basecmd_test.go @@ -0,0 +1,122 @@ +package cmd + +import ( + "fmt" + "slices" + "strings" + "testing" + + "github.com/spf13/cobra" + "github.com/stretchr/testify/require" +) + +func TestBaseCmd_RequireTogether(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + flagNames []string + setFlags []string + expectError bool + }{ + { + name: "all flags provided", + flagNames: []string{"flag1", "flag2"}, + setFlags: []string{"flag1", "flag2"}, + expectError: false, + }, + { + name: "no flags provided", + flagNames: []string{"flag1", "flag2"}, + setFlags: []string{}, + expectError: false, + }, + { + name: "only first flag provided", + flagNames: []string{"flag1", "flag2"}, + setFlags: []string{"flag1"}, + expectError: true, + }, + { + name: "only second flag provided", + flagNames: []string{"flag1", "flag2"}, + setFlags: []string{"flag2"}, + expectError: true, + }, + { + name: "three flags all provided", + flagNames: []string{"flag1", "flag2", "flag3"}, + setFlags: []string{"flag1", "flag2", "flag3"}, + expectError: false, + }, + { + name: "three flags none provided", + flagNames: []string{"flag1", "flag2", "flag3"}, + setFlags: []string{}, + expectError: false, + }, + { + name: "three flags only one provided", + flagNames: []string{"flag1", "flag2", "flag3"}, + setFlags: []string{"flag1"}, + expectError: true, + }, + { + name: "three flags only two provided", + flagNames: []string{"flag1", "flag2", "flag3"}, + setFlags: []string{"flag1", "flag3"}, + expectError: true, + }, + { + name: "three flags only two provided - test sorting", + flagNames: []string{"flag1", "flag2", "flag3"}, + setFlags: []string{"flag3", "flag1"}, + expectError: true, + }, + { + name: "single flag not provided", + flagNames: []string{"flag1"}, + setFlags: []string{}, + expectError: false, + }, + { + name: "single flag provided", + flagNames: []string{"flag1"}, + setFlags: []string{"flag1"}, + expectError: false, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + cmd := &cobra.Command{ + Use: "test", + } + + for _, flagName := range tc.flagNames { + cmd.Flags().String(flagName, "", "test flag") + } + + for _, flagName := range tc.setFlags { + err := cmd.Flags().Set(flagName, "value") + require.NoError(t, err) + } + + baseCmd := &BaseCmd{} + err := baseCmd.RequireTogether(cmd, tc.flagNames...) + + if tc.expectError { + require.Error(t, err) + require.ErrorContains(t, err, "must be provided together or not at all") + names := slices.Clone(tc.flagNames) + slices.Sort(names) + sortedNames := strings.Join(names, ", ") + require.ErrorContains(t, err, fmt.Sprintf("(%s)", sortedNames)) + } else { + require.NoError(t, err) + } + }) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 4e8a87a..b342137 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -134,6 +134,7 @@ func (c *Config) Plugin(category Category, name string) (PluginEntry, bool) { if c.Plugins == nil { return PluginEntry{}, false } + return c.Plugins.plugin(category, name) } diff --git a/internal/printer/plugin_config_printer.go b/internal/printer/plugin_config_printer.go new file mode 100644 index 0000000..0e37185 --- /dev/null +++ b/internal/printer/plugin_config_printer.go @@ -0,0 +1,58 @@ +package printer + +import ( + "fmt" + "io" + + "github.com/mozilla-ai/mcpd/v2/internal/cmd/output" +) + +var _ output.Printer[PluginConfigResult] = (*PluginConfigPrinter)(nil) + +// PluginConfigResult represents plugin-level configuration. +type PluginConfigResult struct { + Dir string `json:"dir,omitempty" yaml:"dir,omitempty"` +} + +// PluginConfigPrinter handles text output for plugin-level config. +type PluginConfigPrinter struct { + headerFunc output.WriteFunc[PluginConfigResult] + footerFunc output.WriteFunc[PluginConfigResult] +} + +// Header writes a custom header if one has been configured via SetHeader. +func (p *PluginConfigPrinter) Header(w io.Writer, count int) { + if p.headerFunc != nil { + p.headerFunc(w, count) + } +} + +// SetHeader configures a custom header function for the printer. +func (p *PluginConfigPrinter) SetHeader(fn output.WriteFunc[PluginConfigResult]) { + p.headerFunc = fn +} + +// Item writes formatted plugin-level configuration to the output. +func (p *PluginConfigPrinter) Item(w io.Writer, result PluginConfigResult) error { + _, _ = fmt.Fprintln(w, "Plugin Configuration:") + + dir := result.Dir + if dir == "" { + dir = "(not configured)" + } + _, _ = fmt.Fprintf(w, " Directory: %s\n", dir) + + return nil +} + +// Footer writes a custom footer if one has been configured via SetFooter. +func (p *PluginConfigPrinter) Footer(w io.Writer, count int) { + if p.footerFunc != nil { + p.footerFunc(w, count) + } +} + +// SetFooter configures a custom footer function for the printer. +func (p *PluginConfigPrinter) SetFooter(fn output.WriteFunc[PluginConfigResult]) { + p.footerFunc = fn +} diff --git a/internal/printer/plugin_config_printer_test.go b/internal/printer/plugin_config_printer_test.go new file mode 100644 index 0000000..64cde73 --- /dev/null +++ b/internal/printer/plugin_config_printer_test.go @@ -0,0 +1,102 @@ +package printer + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestPluginConfigPrinter_Item(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result PluginConfigResult + expected string + }{ + { + name: "configured directory", + result: PluginConfigResult{ + Dir: "/path/to/plugins", + }, + expected: "Plugin Configuration:\n" + + " Directory: /path/to/plugins\n", + }, + { + name: "empty directory shows not configured", + result: PluginConfigResult{ + Dir: "", + }, + expected: "Plugin Configuration:\n" + + " Directory: (not configured)\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginConfigPrinter{} + + err := printer.Item(&buf, tc.result) + require.NoError(t, err) + + require.Equal(t, tc.expected, buf.String()) + }) + } +} + +func TestPluginConfigPrinter_HeaderFooter(t *testing.T) { + t.Parallel() + + t.Run("custom header", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginConfigPrinter{} + + printer.SetHeader(func(w io.Writer, count int) { + _, _ = w.Write([]byte("=== HEADER ===\n")) + }) + + printer.Header(&buf, 1) + require.Equal(t, "=== HEADER ===\n", buf.String()) + }) + + t.Run("custom footer", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginConfigPrinter{} + + printer.SetFooter(func(w io.Writer, count int) { + _, _ = w.Write([]byte("=== FOOTER ===\n")) + }) + + printer.Footer(&buf, 1) + require.Equal(t, "=== FOOTER ===\n", buf.String()) + }) + + t.Run("no header when not set", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginConfigPrinter{} + + printer.Header(&buf, 1) + require.Empty(t, buf.String()) + }) + + t.Run("no footer when not set", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginConfigPrinter{} + + printer.Footer(&buf, 1) + require.Empty(t, buf.String()) + }) +} diff --git a/internal/printer/plugin_entry_printer.go b/internal/printer/plugin_entry_printer.go new file mode 100644 index 0000000..1c77d89 --- /dev/null +++ b/internal/printer/plugin_entry_printer.go @@ -0,0 +1,60 @@ +package printer + +import ( + "fmt" + "io" + + "github.com/mozilla-ai/mcpd/v2/internal/cmd/output" + "github.com/mozilla-ai/mcpd/v2/internal/config" +) + +var _ output.Printer[PluginEntryResult] = (*PluginEntryPrinter)(nil) + +// PluginEntryResult represents a single plugin entry output structure. +type PluginEntryResult struct { + config.PluginEntry + + Category config.Category `json:"category" yaml:"category"` +} + +// PluginEntryPrinter handles text output for plugin entries. +type PluginEntryPrinter struct { + headerFunc output.WriteFunc[PluginEntryResult] + footerFunc output.WriteFunc[PluginEntryResult] +} + +// Header writes a custom header if one has been configured via SetHeader. +func (p *PluginEntryPrinter) Header(w io.Writer, count int) { + if p.headerFunc != nil { + p.headerFunc(w, count) + } +} + +// SetHeader configures a custom header function for the printer. +func (p *PluginEntryPrinter) SetHeader(fn output.WriteFunc[PluginEntryResult]) { + p.headerFunc = fn +} + +// Item writes a formatted plugin entry to the output. +func (p *PluginEntryPrinter) Item(w io.Writer, result PluginEntryResult) error { + _, _ = fmt.Fprintf(w, "Plugin '%s' in category '%s':\n", result.Name, result.Category) + _, _ = fmt.Fprintf(w, " Flows: %s\n", formatFlows(result.Flows)) + _, _ = fmt.Fprintf(w, " Required: %s\n", formatRequired(result.Required)) + if result.CommitHash != nil { + _, _ = fmt.Fprintf(w, " Commit Hash: %s\n", *result.CommitHash) + } + + return nil +} + +// Footer writes a custom footer if one has been configured via SetFooter. +func (p *PluginEntryPrinter) Footer(w io.Writer, count int) { + if p.footerFunc != nil { + p.footerFunc(w, count) + } +} + +// SetFooter configures a custom footer function for the printer. +func (p *PluginEntryPrinter) SetFooter(fn output.WriteFunc[PluginEntryResult]) { + p.footerFunc = fn +} diff --git a/internal/printer/plugin_entry_printer_test.go b/internal/printer/plugin_entry_printer_test.go new file mode 100644 index 0000000..e546d61 --- /dev/null +++ b/internal/printer/plugin_entry_printer_test.go @@ -0,0 +1,146 @@ +package printer + +import ( + "bytes" + "io" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/mozilla-ai/mcpd/v2/internal/config" +) + +func TestPluginEntryPrinter_Item(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + result PluginEntryResult + expected string + }{ + { + name: "plugin with all fields", + result: PluginEntryResult{ + PluginEntry: config.PluginEntry{ + Name: "jwt-auth", + Flows: []config.Flow{config.FlowRequest, config.FlowResponse}, + Required: ptrBool(true), + CommitHash: ptrString("abc123"), + }, + Category: "authentication", + }, + expected: "Plugin 'jwt-auth' in category 'authentication':\n" + + " Flows: request, response\n" + + " Required: true\n" + + " Commit Hash: abc123\n", + }, + { + name: "plugin without commit hash", + result: PluginEntryResult{ + PluginEntry: config.PluginEntry{ + Name: "rate-limiter", + Flows: []config.Flow{config.FlowRequest}, + Required: ptrBool(false), + }, + Category: "rate_limiting", + }, + expected: "Plugin 'rate-limiter' in category 'rate_limiting':\n" + + " Flows: request\n" + + " Required: false\n", + }, + { + name: "plugin with nil required defaults to false", + result: PluginEntryResult{ + PluginEntry: config.PluginEntry{ + Name: "logger", + Flows: []config.Flow{config.FlowRequest, config.FlowResponse}, + Required: nil, + }, + Category: "observability", + }, + expected: "Plugin 'logger' in category 'observability':\n" + + " Flows: request, response\n" + + " Required: false\n", + }, + { + name: "plugin with single flow", + result: PluginEntryResult{ + PluginEntry: config.PluginEntry{ + Name: "validator", + Flows: []config.Flow{config.FlowResponse}, + Required: ptrBool(true), + }, + Category: "validation", + }, + expected: "Plugin 'validator' in category 'validation':\n" + + " Flows: response\n" + + " Required: true\n", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginEntryPrinter{} + + err := printer.Item(&buf, tc.result) + require.NoError(t, err) + + require.Equal(t, tc.expected, buf.String()) + }) + } +} + +func TestPluginEntryPrinter_HeaderFooter(t *testing.T) { + t.Parallel() + + t.Run("custom header", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginEntryPrinter{} + + printer.SetHeader(func(w io.Writer, count int) { + _, _ = w.Write([]byte("=== HEADER ===\n")) + }) + + printer.Header(&buf, 1) + require.Equal(t, "=== HEADER ===\n", buf.String()) + }) + + t.Run("custom footer", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginEntryPrinter{} + + printer.SetFooter(func(w io.Writer, count int) { + _, _ = w.Write([]byte("=== FOOTER ===\n")) + }) + + printer.Footer(&buf, 1) + require.Equal(t, "=== FOOTER ===\n", buf.String()) + }) + + t.Run("no header when not set", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginEntryPrinter{} + + printer.Header(&buf, 1) + require.Empty(t, buf.String()) + }) + + t.Run("no footer when not set", func(t *testing.T) { + t.Parallel() + + var buf bytes.Buffer + printer := &PluginEntryPrinter{} + + printer.Footer(&buf, 1) + require.Empty(t, buf.String()) + }) +}