diff --git a/cmd/config.go b/cmd/config.go index ad37831..9dd3c30 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -1,6 +1,7 @@ package cmd import ( + "encoding/json" "fmt" "io" "os" @@ -41,23 +42,80 @@ func newConfigCheckCmd(load configLoadFunc, path configPathFunc) *cobra.Command } func newConfigCheckCmdWithKeymap(load configLoadFunc, path configPathFunc, loadKeymap keymapLoadFunc) *cobra.Command { - return &cobra.Command{ - Use: "check", - Short: "Validate the active grut configuration", + var asJSON bool + cmd := &cobra.Command{ + Use: "check", + Short: "Validate the active grut configuration", + SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - cfgPath := path() - cfg, err := load() - if err != nil { - fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", cfgPath) - return fmt.Errorf("config check failed: %w", err) + return runConfigCheck(cmd, load, path, loadKeymap, asJSON) + }, + } + cmd.Flags().BoolVar(&asJSON, "json", false, "Output the config check report as JSON") + return cmd +} + +type configCheckReport struct { + OK bool `json:"ok"` + ConfigPath string `json:"config_path"` + Error string `json:"error,omitempty"` + KeybindingConflicts []keybindingConflict `json:"keybinding_conflicts"` +} + +type keybindingConflict struct { + Key string `json:"key"` + Mode string `json:"mode"` + Context string `json:"context"` + Actions []string `json:"actions"` +} + +func runConfigCheck(cmd *cobra.Command, load configLoadFunc, path configPathFunc, loadKeymap keymapLoadFunc, asJSON bool) error { + cfgPath := path() + report := configCheckReport{ConfigPath: cfgPath, KeybindingConflicts: []keybindingConflict{}} + cfg, err := load() + if err != nil { + report.Error = err.Error() + if asJSON { + if encodeErr := writeConfigCheckJSON(cmd.OutOrStdout(), report); encodeErr != nil { + return encodeErr } - if err := checkKeybindings(cmd, cfg, cfgPath, loadKeymap); err != nil { - return err + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", cfgPath) + } + return fmt.Errorf("config check failed: %w", err) + } + + conflicts, err := detectConfigKeybindingConflicts(cfg, loadKeymap) + if err != nil { + report.Error = fmt.Sprintf("keybindings: %v", err) + if asJSON { + if encodeErr := writeConfigCheckJSON(cmd.OutOrStdout(), report); encodeErr != nil { + return encodeErr } - fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\nOK\n", cfgPath) - return nil - }, + } else { + fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", cfgPath) + } + return fmt.Errorf("config check failed: keybindings: %w", err) + } + report.KeybindingConflicts = conflicts + if len(conflicts) > 0 { + report.Error = fmt.Sprintf("%d keybinding conflict(s) found", len(conflicts)) + if asJSON { + if encodeErr := writeConfigCheckJSON(cmd.OutOrStdout(), report); encodeErr != nil { + return encodeErr + } + } else { + writeKeybindingConflicts(cmd.OutOrStdout(), cfgPath, conflicts) + } + return fmt.Errorf("config check failed: %d keybinding conflict(s) found", len(conflicts)) + } + + report.OK = true + if asJSON { + return writeConfigCheckJSON(cmd.OutOrStdout(), report) } + fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\nOK\n", cfgPath) + return nil } const configPathCommandName = "path" @@ -75,25 +133,43 @@ func newConfigPathCmd(path configPathFunc, dataPath dataPathFunc) *cobra.Command const defaultKeybindingScheme = "default" -func checkKeybindings(cmd *cobra.Command, cfg *config.Config, cfgPath string, loadKeymap keymapLoadFunc) error { +func detectConfigKeybindingConflicts(cfg *config.Config, loadKeymap keymapLoadFunc) ([]keybindingConflict, error) { scheme := cfg.General.KeybindingScheme if scheme == "" { scheme = defaultKeybindingScheme } km, err := loadKeymap(scheme) if err != nil { - fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\n", cfgPath) - return fmt.Errorf("config check failed: keybindings: %w", err) + return nil, err } conflicts := keymap.DetectConflicts(km.Bindings()) - if len(conflicts) == 0 { - return nil + out := make([]keybindingConflict, 0, len(conflicts)) + for _, conflict := range conflicts { + out = append(out, keybindingConflict{ + Key: conflict.Key, + Mode: conflict.Mode.String(), + Context: conflict.Context, + Actions: conflict.Actions, + }) } - fmt.Fprintf(cmd.OutOrStdout(), "Config: %s\nKeybinding conflicts:\n", cfgPath) + return out, nil +} + +func writeKeybindingConflicts(out io.Writer, cfgPath string, conflicts []keybindingConflict) { + fmt.Fprintf(out, "Config: %s\nKeybinding conflicts:\n", cfgPath) for _, conflict := range conflicts { - fmt.Fprintf(cmd.OutOrStdout(), "- %s\n", conflict.String()) + ctx := conflict.Context + if ctx == "" { + ctx = "(all)" + } + fmt.Fprintf(out, "- key %q in mode %s context %s: actions %v\n", conflict.Key, conflict.Mode, ctx, conflict.Actions) } - return fmt.Errorf("config check failed: %d keybinding conflict(s) found", len(conflicts)) +} + +func writeConfigCheckJSON(out io.Writer, report configCheckReport) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(report) } // newConfigGetCmd builds the "config get" subcommand, which prints a single diff --git a/cmd/config_test.go b/cmd/config_test.go index 1758436..1ae6ca1 100644 --- a/cmd/config_test.go +++ b/cmd/config_test.go @@ -2,6 +2,7 @@ package cmd import ( "bytes" + "encoding/json" "errors" "os" "path/filepath" @@ -33,6 +34,71 @@ func TestConfigCheckSuccess(t *testing.T) { assert.Contains(t, out.String(), "OK") } +func TestConfigCheckJSONSuccess(t *testing.T) { + cmd := newConfigCheckCmd( + func() (*config.Config, error) { return &config.Config{}, nil }, + func() string { return windowsConfigPath }, + ) + cmd.SetArgs([]string{"--json"}) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.NoError(t, err) + var report configCheckReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + assert.True(t, report.OK) + assert.Equal(t, windowsConfigPath, report.ConfigPath) + assert.Empty(t, report.KeybindingConflicts) +} + +func TestConfigCheckJSONFailure(t *testing.T) { + cmd := newConfigCheckCmd( + func() (*config.Config, error) { return nil, errors.New("config preview.width: must be 1-100") }, + func() string { return windowsConfigPath }, + ) + cmd.SetArgs([]string{"--json"}) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.Error(t, err) + var report configCheckReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + assert.False(t, report.OK) + assert.Equal(t, windowsConfigPath, report.ConfigPath) + assert.Contains(t, report.Error, "preview.width") +} + +func TestConfigCheckJSONReportsKeybindingConflicts(t *testing.T) { + cmd := newConfigCheckCmdWithKeymap( + func() (*config.Config, error) { + return &config.Config{General: config.GeneralConfig{KeybindingScheme: "custom"}}, nil + }, + func() string { return windowsConfigPath }, + func(string) (*keymap.Keymap, error) { + return keymap.NewKeymapFromBindings([]keymap.Binding{ + {Key: "x", Mode: keymap.ModePanel, Action: "one"}, + {Key: "x", Mode: keymap.ModePanel, Action: "two"}, + }), nil + }, + ) + cmd.SetArgs([]string{"--json"}) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.Error(t, err) + var report configCheckReport + require.NoError(t, json.Unmarshal(out.Bytes(), &report)) + require.Len(t, report.KeybindingConflicts, 1) + assert.Equal(t, "x", report.KeybindingConflicts[0].Key) + assert.Equal(t, []string{"one", "two"}, report.KeybindingConflicts[0].Actions) +} + func TestConfigCheckFailure(t *testing.T) { cmd := newConfigCheckCmd( func() (*config.Config, error) { return nil, errors.New("config preview.width: must be 1-100") }, @@ -75,6 +141,26 @@ func TestConfigCheckReportsKeybindingConflicts(t *testing.T) { assert.Contains(t, out.String(), "two") } +func TestConfigCheckKeymapLoadFailurePrintsConfigPath(t *testing.T) { + cmd := newConfigCheckCmdWithKeymap( + func() (*config.Config, error) { + return &config.Config{General: config.GeneralConfig{KeybindingScheme: "custom"}}, nil + }, + func() string { return windowsConfigPath }, + func(string) (*keymap.Keymap, error) { + return nil, errors.New("scheme not found") + }, + ) + var out bytes.Buffer + cmd.SetOut(&out) + + err := cmd.Execute() + + require.Error(t, err) + assert.Contains(t, err.Error(), "keybindings") + assert.Contains(t, out.String(), windowsConfigPath) +} + func TestConfigPathPrintsResolvedPaths(t *testing.T) { tests := []struct { name string