diff options
| -rw-r--r-- | internal/appconfig/app_feature_sections.go | 40 | ||||
| -rw-r--r-- | internal/appconfig/app_feature_sections_test.go | 68 | ||||
| -rw-r--r-- | internal/appconfig/app_sections.go | 30 | ||||
| -rw-r--r-- | internal/appconfig/app_sections_test.go | 50 | ||||
| -rw-r--r-- | internal/appconfig/config_features_test.go | 4 | ||||
| -rw-r--r-- | internal/appconfig/config_load.go | 4 | ||||
| -rw-r--r-- | internal/appconfig/config_types.go | 8 | ||||
| -rw-r--r-- | internal/appconfig/feature_sections.go | 50 | ||||
| -rw-r--r-- | internal/hexaimcp/run.go | 2 | ||||
| -rw-r--r-- | internal/hexaimcp/run_test.go | 18 | ||||
| -rw-r--r-- | internal/lsp/ignore_test.go | 2 | ||||
| -rw-r--r-- | internal/slashcommands/syncer.go | 3 | ||||
| -rw-r--r-- | internal/slashcommands/syncer_test.go | 22 | ||||
| -rw-r--r-- | internal/tmuxedit/run_test.go | 4 |
14 files changed, 232 insertions, 73 deletions
diff --git a/internal/appconfig/app_feature_sections.go b/internal/appconfig/app_feature_sections.go new file mode 100644 index 0000000..2513946 --- /dev/null +++ b/internal/appconfig/app_feature_sections.go @@ -0,0 +1,40 @@ +package appconfig + +import "slices" + +// This file provides fine-grained, read-only accessors for the individual +// feature subsystems embedded in FeatureConfig. They let a consumer depend on +// just the subsystem config it needs (e.g. the MCP server only needs MCPConfig) +// instead of receiving the whole App God-struct. Each accessor returns a deep +// copy so callers cannot mutate App's internal slices. + +// IgnoreSection returns a copy of the gitignore-aware filtering settings. +func (a *App) IgnoreSection() IgnoreConfig { + c := a.IgnoreConfig + c.IgnoreExtraPatterns = slices.Clone(a.IgnoreExtraPatterns) + return c +} + +// StatsSection returns the usage statistics settings. +func (a *App) StatsSection() StatsConfig { + return a.StatsConfig +} + +// TmuxEditSection returns a copy of the tmux popup editor settings. +func (a *App) TmuxEditSection() TmuxEditConfig { + c := a.TmuxEditConfig + c.TmuxEditAgents = append([]TmuxEditAgentCfg{}, a.TmuxEditAgents...) + return c +} + +// TmuxActionSection returns a copy of the tmux action menu settings. +func (a *App) TmuxActionSection() TmuxActionConfig { + c := a.TmuxActionConfig + c.TmuxActionMenu = append([]TmuxActionMenuEntry{}, a.TmuxActionMenu...) + return c +} + +// MCPSection returns the Model Context Protocol server settings. +func (a *App) MCPSection() MCPConfig { + return a.MCPConfig +} diff --git a/internal/appconfig/app_feature_sections_test.go b/internal/appconfig/app_feature_sections_test.go new file mode 100644 index 0000000..1f9ac2d --- /dev/null +++ b/internal/appconfig/app_feature_sections_test.go @@ -0,0 +1,68 @@ +package appconfig + +import ( + "reflect" + "testing" +) + +// buildFeatureApp returns an App whose FeatureConfig subsystems are all +// populated, used to verify the fine-grained section accessors. +func buildFeatureApp() App { + cfg := App{} + cfg.ApplyFeatureSection(testFeatureConfig()) + return cfg +} + +func TestIgnoreSectionCopiesAndReads(t *testing.T) { + cfg := buildFeatureApp() + got := cfg.IgnoreSection() + if !reflect.DeepEqual(got.IgnoreExtraPatterns, []string{"vendor/**", "tmp/**"}) { + t.Fatalf("IgnoreExtraPatterns = %v", got.IgnoreExtraPatterns) + } + // Mutating the returned copy must not affect the source App. + got.IgnoreExtraPatterns[0] = "mutated" + if cfg.IgnoreExtraPatterns[0] == "mutated" { + t.Fatal("IgnoreSection did not return a defensive copy") + } +} + +func TestStatsSectionReads(t *testing.T) { + cfg := buildFeatureApp() + if got := cfg.StatsSection(); got.StatsWindowMinutes != 15 { + t.Fatalf("StatsWindowMinutes = %d, want 15", got.StatsWindowMinutes) + } +} + +func TestTmuxEditSectionCopies(t *testing.T) { + cfg := buildFeatureApp() + got := cfg.TmuxEditSection() + if got.TmuxEditDefaultAgent != "codex" || len(got.TmuxEditAgents) != 1 { + t.Fatalf("unexpected tmux edit section: %+v", got) + } + got.TmuxEditAgents[0].Name = "mutated" + if cfg.TmuxEditAgents[0].Name == "mutated" { + t.Fatal("TmuxEditSection did not return a defensive copy") + } +} + +func TestTmuxActionSectionCopies(t *testing.T) { + cfg := App{} + cfg.TmuxActionMenu = []TmuxActionMenuEntry{{Kind: "rewrite"}} + got := cfg.TmuxActionSection() + if len(got.TmuxActionMenu) != 1 || got.TmuxActionMenu[0].Kind != "rewrite" { + t.Fatalf("unexpected tmux action section: %+v", got) + } + got.TmuxActionMenu[0].Kind = "mutated" + if cfg.TmuxActionMenu[0].Kind == "mutated" { + t.Fatal("TmuxActionSection did not return a defensive copy") + } +} + +func TestMCPSectionReads(t *testing.T) { + cfg := buildFeatureApp() + got := cfg.MCPSection() + if got.MCPPromptsDir != ".hexai/prompts" || !got.MCPSlashCommandSync || + got.MCPSlashCommandDir != ".hexai/slash" { + t.Fatalf("unexpected MCP section: %+v", got) + } +} diff --git a/internal/appconfig/app_sections.go b/internal/appconfig/app_sections.go index 05b3171..5919db1 100644 --- a/internal/appconfig/app_sections.go +++ b/internal/appconfig/app_sections.go @@ -102,26 +102,18 @@ type PromptConfig struct { TmuxCustomMenuHotkey string `json:"-"` } -// FeatureConfig contains non-LLM feature toggles/integration settings. -// It is embedded in App; fields use json:"-" since features are not exposed via JSON. +// FeatureConfig groups the non-LLM feature subsystems. Rather than a flat +// grab-bag, it now embeds one cohesive struct per subsystem (see +// feature_sections.go). Embedding keeps field promotion intact so existing flat +// access (e.g. cfg.MCPPromptsDir) and the JSON shape are unchanged; the split +// just makes the subsystem boundaries explicit and lets callers depend on a +// single subsystem via the App.*Section accessors. type FeatureConfig struct { - // Stats - StatsWindowMinutes int `json:"-"` - // Ignore: gitignore-aware file filtering for LSP - IgnoreGitignore *bool `json:"-"` - IgnoreExtraPatterns []string `json:"-"` - IgnoreLSPNotify *bool `json:"-"` - // TmuxEdit: popup editor settings for hexai-tmux-edit - TmuxEditPopupWidth string `json:"-"` - TmuxEditPopupHeight string `json:"-"` - TmuxEditDefaultAgent string `json:"-"` - TmuxEditAgents []TmuxEditAgentCfg `json:"-"` - // TmuxAction: configurable main menu for hexai-tmux-action - TmuxActionMenu []TmuxActionMenuEntry `json:"-"` - // MCP: Model Context Protocol server settings - MCPPromptsDir string `json:"-"` // Directory for prompt storage - MCPSlashCommandSync bool `json:"-"` // Enable slash command sync - MCPSlashCommandDir string `json:"-"` // Directory for slash command files + StatsConfig // usage statistics window + IgnoreConfig // gitignore-aware file filtering for LSP + TmuxEditConfig // popup editor settings for hexai-tmux-edit + TmuxActionConfig // configurable main menu for hexai-tmux-action + MCPConfig // Model Context Protocol server settings } // AppSections is the focused split of App into subsystem-specific config groups. diff --git a/internal/appconfig/app_sections_test.go b/internal/appconfig/app_sections_test.go index e26d3f3..bcd1cbe 100644 --- a/internal/appconfig/app_sections_test.go +++ b/internal/appconfig/app_sections_test.go @@ -169,28 +169,34 @@ func testPromptConfig() PromptConfig { func testFeatureConfig() FeatureConfig { return FeatureConfig{ - StatsWindowMinutes: 15, - IgnoreGitignore: sectionBoolPtr(true), - IgnoreExtraPatterns: []string{"vendor/**", "tmp/**"}, - IgnoreLSPNotify: sectionBoolPtr(false), - TmuxEditPopupWidth: "80%", - TmuxEditPopupHeight: "75%", - TmuxEditDefaultAgent: "codex", - TmuxEditAgents: []TmuxEditAgentCfg{{ - Name: "codex", - DisplayName: "Codex", - DetectPattern: "(?i)codex", - SectionPattern: "section", - PromptPattern: "prompt", - StripPatterns: []string{"x", "y"}, - ClearFirst: sectionBoolPtr(true), - ClearKeys: "C-u", - NewlineKeys: "S-Enter", - SubmitKeys: "Enter", - }}, - MCPPromptsDir: ".hexai/prompts", - MCPSlashCommandSync: true, - MCPSlashCommandDir: ".hexai/slash", + StatsConfig: StatsConfig{StatsWindowMinutes: 15}, + IgnoreConfig: IgnoreConfig{ + IgnoreGitignore: sectionBoolPtr(true), + IgnoreExtraPatterns: []string{"vendor/**", "tmp/**"}, + IgnoreLSPNotify: sectionBoolPtr(false), + }, + TmuxEditConfig: TmuxEditConfig{ + TmuxEditPopupWidth: "80%", + TmuxEditPopupHeight: "75%", + TmuxEditDefaultAgent: "codex", + TmuxEditAgents: []TmuxEditAgentCfg{{ + Name: "codex", + DisplayName: "Codex", + DetectPattern: "(?i)codex", + SectionPattern: "section", + PromptPattern: "prompt", + StripPatterns: []string{"x", "y"}, + ClearFirst: sectionBoolPtr(true), + ClearKeys: "C-u", + NewlineKeys: "S-Enter", + SubmitKeys: "Enter", + }}, + }, + MCPConfig: MCPConfig{ + MCPPromptsDir: ".hexai/prompts", + MCPSlashCommandSync: true, + MCPSlashCommandDir: ".hexai/slash", + }, } } diff --git a/internal/appconfig/config_features_test.go b/internal/appconfig/config_features_test.go index 77beb07..2b8c769 100644 --- a/internal/appconfig/config_features_test.go +++ b/internal/appconfig/config_features_test.go @@ -183,13 +183,13 @@ func TestTmuxEditConfig_Merge(t *testing.T) { clearHexaiEnv(t) a := newDefaultConfig() b := App{ - FeatureConfig: FeatureConfig{ + FeatureConfig: FeatureConfig{TmuxEditConfig: TmuxEditConfig{ TmuxEditPopupWidth: "70%", TmuxEditDefaultAgent: "amp", TmuxEditAgents: []TmuxEditAgentCfg{ {Name: "amp", DisplayName: "Amp"}, }, - }, + }}, } a.mergeWith(&b) if a.TmuxEditPopupWidth != "70%" { diff --git a/internal/appconfig/config_load.go b/internal/appconfig/config_load.go index 1e4c6b7..ccbf49f 100644 --- a/internal/appconfig/config_load.go +++ b/internal/appconfig/config_load.go @@ -388,11 +388,11 @@ func applyIgnoreSection(fc *fileConfig, out *App) { if fc.Ignore.Gitignore == nil && len(fc.Ignore.ExtraPatterns) == 0 && fc.Ignore.LSPNotifyIgnored == nil { return } - tmp := App{FeatureConfig: FeatureConfig{ + tmp := App{FeatureConfig: FeatureConfig{IgnoreConfig: IgnoreConfig{ IgnoreGitignore: fc.Ignore.Gitignore, IgnoreExtraPatterns: fc.Ignore.ExtraPatterns, IgnoreLSPNotify: fc.Ignore.LSPNotifyIgnored, - }} + }}} out.mergeBasics(&tmp) } diff --git a/internal/appconfig/config_types.go b/internal/appconfig/config_types.go index 8d50f35..7069b21 100644 --- a/internal/appconfig/config_types.go +++ b/internal/appconfig/config_types.go @@ -106,10 +106,12 @@ func newDefaultConfig() App { }, PromptConfig: defaultPromptConfig(), FeatureConfig: FeatureConfig{ - StatsWindowMinutes: 60, + StatsConfig: StatsConfig{StatsWindowMinutes: 60}, // Ignore: respect .gitignore by default, notify in LSP by default - IgnoreGitignore: boolPtr(true), - IgnoreLSPNotify: boolPtr(true), + IgnoreConfig: IgnoreConfig{ + IgnoreGitignore: boolPtr(true), + IgnoreLSPNotify: boolPtr(true), + }, }, } } diff --git a/internal/appconfig/feature_sections.go b/internal/appconfig/feature_sections.go new file mode 100644 index 0000000..9216400 --- /dev/null +++ b/internal/appconfig/feature_sections.go @@ -0,0 +1,50 @@ +package appconfig + +// This file defines the cohesive per-subsystem config structs that make up +// FeatureConfig. The old FeatureConfig was a grab-bag that mixed five unrelated +// non-LLM subsystems (ignore filtering, stats, tmux popup editor, tmux action +// menu, MCP server). Splitting them into named structs documents the seams +// between subsystems and lets consumers depend on a single subsystem's config +// (via the *Section accessors on App) instead of the whole App God-struct. +// +// Each struct is embedded into FeatureConfig, so Go field promotion keeps the +// historical flat access (e.g. cfg.MCPPromptsDir) working for read sites across +// the codebase. Only composite literals that set these leaf fields directly on +// FeatureConfig had to move to the nested form. + +// IgnoreConfig controls gitignore-aware file filtering for the LSP server. +// Files matching these patterns are skipped for completions and code actions. +type IgnoreConfig struct { + // IgnoreGitignore enables respecting .gitignore entries (default true). + IgnoreGitignore *bool `json:"-"` + // IgnoreExtraPatterns are additional glob patterns to always ignore. + IgnoreExtraPatterns []string `json:"-"` + // IgnoreLSPNotify controls whether the LSP notifies when ignoring a file. + IgnoreLSPNotify *bool `json:"-"` +} + +// StatsConfig holds settings for the usage statistics subsystem. +type StatsConfig struct { + // StatsWindowMinutes is the rolling window (in minutes) used for stats. + StatsWindowMinutes int `json:"-"` +} + +// TmuxEditConfig configures the tmux popup editor feature (hexai-tmux-edit). +type TmuxEditConfig struct { + TmuxEditPopupWidth string `json:"-"` + TmuxEditPopupHeight string `json:"-"` + TmuxEditDefaultAgent string `json:"-"` + TmuxEditAgents []TmuxEditAgentCfg `json:"-"` +} + +// TmuxActionConfig configures the main menu for hexai-tmux-action. +type TmuxActionConfig struct { + TmuxActionMenu []TmuxActionMenuEntry `json:"-"` +} + +// MCPConfig holds Model Context Protocol server settings. +type MCPConfig struct { + MCPPromptsDir string `json:"-"` // Directory for prompt storage + MCPSlashCommandSync bool `json:"-"` // Enable slash command sync + MCPSlashCommandDir string `json:"-"` // Directory for slash command files +} diff --git a/internal/hexaimcp/run.go b/internal/hexaimcp/run.go index 7c487c3..7043ed2 100644 --- a/internal/hexaimcp/run.go +++ b/internal/hexaimcp/run.go @@ -205,7 +205,7 @@ func expandPath(path string) (string, error) { // createSyncer creates a slash command syncer from config. // Returns nil syncer if sync is disabled. func createSyncer(cfg appconfig.App, logger *log.Logger) (*slashcommands.Syncer, error) { - syncer, err := slashcommands.NewSyncer(cfg) + syncer, err := slashcommands.NewSyncer(cfg.MCPSection()) if err != nil { return nil, err } diff --git a/internal/hexaimcp/run_test.go b/internal/hexaimcp/run_test.go index 09f4f87..d567f2e 100644 --- a/internal/hexaimcp/run_test.go +++ b/internal/hexaimcp/run_test.go @@ -113,7 +113,7 @@ func TestGetPromptsDir(t *testing.T) { for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{MCPPromptsDir: tt.cfgValue}, + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{MCPPromptsDir: tt.cfgValue}}, } result, err := getPromptsDir(cfg) @@ -425,7 +425,7 @@ func TestGetPromptsDir_XDGDataHome(t *testing.T) { // TestGetPromptsDir_TildeInConfig verifies tilde expansion for config path. func TestGetPromptsDir_TildeInConfig(t *testing.T) { cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{MCPPromptsDir: "~/my-prompts"}, + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{MCPPromptsDir: "~/my-prompts"}}, } result, err := getPromptsDir(cfg) @@ -450,7 +450,7 @@ func TestGetPromptsDir_TildeInConfig(t *testing.T) { func TestCreateSyncer_Disabled(t *testing.T) { logger := log.New(io.Discard, "", 0) cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{MCPSlashCommandSync: false}, + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{MCPSlashCommandSync: false}}, } syncer, err := createSyncer(cfg, logger) @@ -468,10 +468,10 @@ func TestCreateSyncer_Enabled(t *testing.T) { tmpDir := t.TempDir() logger := log.New(io.Discard, "", 0) cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{ MCPSlashCommandSync: true, MCPSlashCommandDir: tmpDir, - }, + }}, } syncer, err := createSyncer(cfg, logger) @@ -488,10 +488,10 @@ func TestCreateSyncer_Enabled(t *testing.T) { func TestCreateSyncer_Error(t *testing.T) { logger := log.New(io.Discard, "", 0) cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{ MCPSlashCommandSync: true, MCPSlashCommandDir: "", - }, + }}, } _, err := createSyncer(cfg, logger) @@ -651,11 +651,11 @@ func TestApplyOverrides(t *testing.T) { t.Run("does not overwrite with zero values", func(t *testing.T) { cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{ MCPPromptsDir: "/existing/prompts", MCPSlashCommandSync: true, MCPSlashCommandDir: "/existing/cmds", - }, + }}, } overrides := MCPOverrides{} // all zero values applyOverrides(&cfg, overrides) diff --git a/internal/lsp/ignore_test.go b/internal/lsp/ignore_test.go index 31ed828..b1899f2 100644 --- a/internal/lsp/ignore_test.go +++ b/internal/lsp/ignore_test.go @@ -21,7 +21,7 @@ func newIgnoreTestServer(gitRoot string, useGI bool, extra []string, notifyIgnor ChatSuffix: ">", ChatPrefixes: []string{"?", "!", ":", ";"}, }, - FeatureConfig: appconfig.FeatureConfig{IgnoreLSPNotify: notifyIgnored}, + FeatureConfig: appconfig.FeatureConfig{IgnoreConfig: appconfig.IgnoreConfig{IgnoreLSPNotify: notifyIgnored}}, } s := &Server{ logger: log.New(io.Discard, "", 0), diff --git a/internal/slashcommands/syncer.go b/internal/slashcommands/syncer.go index 1268e7b..674b974 100644 --- a/internal/slashcommands/syncer.go +++ b/internal/slashcommands/syncer.go @@ -30,7 +30,8 @@ type Syncer struct { // NewSyncer creates a new syncer and validates the commands directory. // Returns error if directory cannot be created or is not writable. -func NewSyncer(cfg appconfig.App) (*Syncer, error) { +// It depends only on the MCP subsystem config rather than the whole App. +func NewSyncer(cfg appconfig.MCPConfig) (*Syncer, error) { if !cfg.MCPSlashCommandSync { return &Syncer{enabled: false}, nil } diff --git a/internal/slashcommands/syncer_test.go b/internal/slashcommands/syncer_test.go index 01c6d28..b7ffe47 100644 --- a/internal/slashcommands/syncer_test.go +++ b/internal/slashcommands/syncer_test.go @@ -13,10 +13,10 @@ import ( func TestNewSyncer_Disabled(t *testing.T) { cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{MCPSlashCommandSync: false}, + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{MCPSlashCommandSync: false}}, } - syncer, err := NewSyncer(cfg) + syncer, err := NewSyncer(cfg.MCPSection()) if err != nil { t.Fatalf("NewSyncer() with disabled sync failed: %v", err) } @@ -28,13 +28,13 @@ func TestNewSyncer_Disabled(t *testing.T) { func TestNewSyncer_NoDirectory(t *testing.T) { cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{ MCPSlashCommandSync: true, MCPSlashCommandDir: "", - }, + }}, } - _, err := NewSyncer(cfg) + _, err := NewSyncer(cfg.MCPSection()) if err == nil { t.Error("NewSyncer() should fail when directory is not configured") } @@ -45,13 +45,13 @@ func TestNewSyncer_CreatesDirectory(t *testing.T) { testDir := filepath.Join(tmpDir, "test-commands") cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{ MCPSlashCommandSync: true, MCPSlashCommandDir: testDir, - }, + }}, } - syncer, err := NewSyncer(cfg) + syncer, err := NewSyncer(cfg.MCPSection()) if err != nil { t.Fatalf("NewSyncer() failed: %v", err) } @@ -75,13 +75,13 @@ func TestNewSyncer_ExpandsHomeDirectory(t *testing.T) { defer os.Setenv("HOME", home) cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{MCPConfig: appconfig.MCPConfig{ MCPSlashCommandSync: true, MCPSlashCommandDir: "~/test-commands", - }, + }}, } - syncer, err := NewSyncer(cfg) + syncer, err := NewSyncer(cfg.MCPSection()) if err != nil { t.Fatalf("NewSyncer() failed: %v", err) } diff --git a/internal/tmuxedit/run_test.go b/internal/tmuxedit/run_test.go index e8ca6c1..f528b95 100644 --- a/internal/tmuxedit/run_test.go +++ b/internal/tmuxedit/run_test.go @@ -167,10 +167,10 @@ func TestRunWithConfig_CustomDimensions(t *testing.T) { sendKeys = func(string, ...string) error { return nil } cfg := appconfig.App{ - FeatureConfig: appconfig.FeatureConfig{ + FeatureConfig: appconfig.FeatureConfig{TmuxEditConfig: appconfig.TmuxEditConfig{ TmuxEditPopupWidth: "90%", TmuxEditPopupHeight: "85%", - }, + }}, } err := runWithConfig(Options{}, cfg) if err != nil { |
