diff --git a/config/loader.go b/config/loader.go index 1a298f5..08631da 100644 --- a/config/loader.go +++ b/config/loader.go @@ -3,6 +3,7 @@ package config // Viper configuration loader: reads config.yaml from the binary's directory import ( + "fmt" "io" "log/slog" "os" @@ -12,6 +13,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/spf13/pflag" "github.com/spf13/viper" + "gopkg.in/yaml.v3" ) // Config holds all application configuration loaded from config.yaml @@ -161,50 +163,93 @@ func GetInt(key string) int { return viper.GetInt(key) } -// SaveBoardViewMode saves the board view mode to config.yaml -// Deprecated: Use SavePluginViewMode("Board", -1, viewMode) instead -func SaveBoardViewMode(viewMode string) error { - viper.Set("board.view", viewMode) - return saveConfig() +// workflowFileData represents the YAML structure of workflow.yaml for read-modify-write. +// kept in config package to avoid import cycle with plugin package. +type workflowFileData struct { + Plugins []map[string]interface{} `yaml:"plugins"` } -// GetBoardViewMode loads the board view mode from config -// Priority: plugins array entry with name "Board", then default +// readWorkflowFile reads and unmarshals workflow.yaml from the given path. +func readWorkflowFile(path string) (*workflowFileData, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, fmt.Errorf("reading workflow.yaml: %w", err) + } + var wf workflowFileData + if err := yaml.Unmarshal(data, &wf); err != nil { + return nil, fmt.Errorf("parsing workflow.yaml: %w", err) + } + return &wf, nil +} + +// writeWorkflowFile marshals and writes workflow.yaml to the given path. +func writeWorkflowFile(path string, wf *workflowFileData) error { + data, err := yaml.Marshal(wf) + if err != nil { + return fmt.Errorf("marshaling workflow.yaml: %w", err) + } + //nolint:gosec // G306: 0644 is appropriate for config file + if err := os.WriteFile(path, data, 0644); err != nil { + return fmt.Errorf("writing workflow.yaml: %w", err) + } + return nil +} + +// GetBoardViewMode loads the board view mode from workflow.yaml. +// Returns "expanded" as default if not found. func GetBoardViewMode() string { - // Check plugins array - var currentPlugins []map[string]interface{} - if err := viper.UnmarshalKey("plugins", ¤tPlugins); err == nil { - for _, p := range currentPlugins { - if name, ok := p["name"].(string); ok && name == "Board" { - if view, ok := p["view"].(string); ok && view != "" { - return view - } + return getPluginViewModeFromWorkflow("Board", "expanded") +} + +// getPluginViewModeFromWorkflow reads a plugin's view mode from workflow.yaml by name. +func getPluginViewModeFromWorkflow(pluginName string, defaultValue string) string { + path := FindWorkflowFile() + if path == "" { + return defaultValue + } + + wf, err := readWorkflowFile(path) + if err != nil { + slog.Debug("failed to read workflow.yaml for view mode", "error", err) + return defaultValue + } + + for _, p := range wf.Plugins { + if name, ok := p["name"].(string); ok && name == pluginName { + if view, ok := p["view"].(string); ok && view != "" { + return view } } } - // Default - return "expanded" + return defaultValue } -// SavePluginViewMode saves a plugin's view mode to config.yaml -// This function updates or creates the plugin entry in the plugins array -// configIndex: index in config array (-1 to create new entry by name) +// SavePluginViewMode saves a plugin's view mode to workflow.yaml. +// configIndex: index in workflow.yaml plugins array (-1 to find/create by name) func SavePluginViewMode(pluginName string, configIndex int, viewMode string) error { - // Get current plugins configuration - var currentPlugins []map[string]interface{} - if err := viper.UnmarshalKey("plugins", ¤tPlugins); err != nil { - // If no plugins exist or unmarshal fails, start with empty array - currentPlugins = []map[string]interface{}{} + path := FindWorkflowFile() + if path == "" { + // create workflow.yaml in project config dir + path = DefaultWorkflowFilePath() } - if configIndex >= 0 && configIndex < len(currentPlugins) { - // Update existing config entry (works for inline, file-based, or hybrid) - currentPlugins[configIndex]["view"] = viewMode + var wf *workflowFileData + + // try to read existing file + if existing, err := readWorkflowFile(path); err == nil { + wf = existing } else { - // Embedded plugin or missing entry - check if name-based entry already exists + wf = &workflowFileData{} + } + + if configIndex >= 0 && configIndex < len(wf.Plugins) { + // update existing entry by index + wf.Plugins[configIndex]["view"] = viewMode + } else { + // find by name or create new entry existingIndex := -1 - for i, p := range currentPlugins { + for i, p := range wf.Plugins { if name, ok := p["name"].(string); ok && name == pluginName { existingIndex = i break @@ -212,21 +257,17 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err } if existingIndex >= 0 { - // Update existing name-based entry - currentPlugins[existingIndex]["view"] = viewMode + wf.Plugins[existingIndex]["view"] = viewMode } else { - // Create new name-based entry newEntry := map[string]interface{}{ "name": pluginName, "view": viewMode, } - currentPlugins = append(currentPlugins, newEntry) + wf.Plugins = append(wf.Plugins, newEntry) } } - // Save back to viper - viper.Set("plugins", currentPlugins) - return saveConfig() + return writeWorkflowFile(path, wf) } // SaveHeaderVisible saves the header visibility setting to config.yaml diff --git a/config/paths.go b/config/paths.go index fd08dfb..10ba3a3 100644 --- a/config/paths.go +++ b/config/paths.go @@ -302,6 +302,36 @@ func GetPluginSearchPaths() []string { return mustGetPathManager().PluginSearchPaths() } +// defaultWorkflowFilename is the default name for the workflow configuration file +const defaultWorkflowFilename = "workflow.yaml" + +// FindWorkflowFile searches for workflow.yaml in config search paths. +// Search order: project config dir → user config dir → current directory (cwd). +// Returns the first found path or empty string if not found. +func FindWorkflowFile() string { + searchPaths := GetPluginSearchPaths() + + var paths []string + for _, dir := range searchPaths { + paths = append(paths, filepath.Join(dir, defaultWorkflowFilename)) + } + paths = append(paths, defaultWorkflowFilename) // relative to cwd + + for _, path := range paths { + if _, err := os.Stat(path); err == nil { + return path + } + } + + return "" +} + +// DefaultWorkflowFilePath returns the default path for creating a new workflow.yaml +// (in the project config dir, i.e. .doc/tiki/) +func DefaultWorkflowFilePath() string { + return filepath.Join(mustGetPathManager().TaskDir(), defaultWorkflowFilename) +} + // GetTemplateFile returns the path to the user's custom new.md template func GetTemplateFile() string { return mustGetPathManager().TemplateFile() diff --git a/config/system.go b/config/system.go index e9d4bc3..7dcacbb 100644 --- a/config/system.go +++ b/config/system.go @@ -128,6 +128,31 @@ func BootstrapSystem() error { } createdFiles = append(createdFiles, linkedPath) + // Write default config.yaml + defaultConfig := `logging: + level: error +header: + visible: true +tiki: + maxPoints: 10 +appearance: + theme: auto + gradientThreshold: 256 +` + configPath := GetProjectConfigFile() + if err := os.WriteFile(configPath, []byte(defaultConfig), 0644); err != nil { + return fmt.Errorf("write default config.yaml: %w", err) + } + createdFiles = append(createdFiles, configPath) + + // Write default workflow.yaml + defaultWorkflow := "plugins: []\n" + workflowPath := DefaultWorkflowFilePath() + if err := os.WriteFile(workflowPath, []byte(defaultWorkflow), 0644); err != nil { + return fmt.Errorf("write default workflow.yaml: %w", err) + } + createdFiles = append(createdFiles, workflowPath) + // Git add all created files gitArgs := append([]string{"add"}, createdFiles...) //nolint:gosec // G204: git command with controlled file paths diff --git a/integration/lane_action_test.go b/integration/lane_action_test.go index 81d3a14..65b1e7c 100644 --- a/integration/lane_action_test.go +++ b/integration/lane_action_test.go @@ -1,41 +1,47 @@ package integration import ( + "os" + "path/filepath" "testing" "github.com/boolean-maybe/tiki/model" - "github.com/boolean-maybe/tiki/plugin" "github.com/boolean-maybe/tiki/task" "github.com/boolean-maybe/tiki/testutil" "github.com/gdamore/tcell/v2" - "github.com/spf13/viper" ) func TestPluginView_MoveTaskAppliesLaneAction(t *testing.T) { - originalPlugins := viper.Get("plugins") - viper.Set("plugins", []plugin.PluginRef{ - { - Name: "ActionTest", - Key: "F4", - Lanes: []plugin.PluginLaneConfig{ - { - Name: "Backlog", - Columns: 1, - Filter: "status = 'backlog'", - Action: "status=backlog, tags-=[moved]", - }, - { - Name: "Done", - Columns: 1, - Filter: "status = 'done'", - Action: "status=done, tags+=[moved]", - }, - }, - }, - }) + // create a temp workflow.yaml with the test plugin + tmpDir := t.TempDir() + workflowContent := `plugins: + - name: ActionTest + key: "F4" + lanes: + - name: Backlog + columns: 1 + filter: status = 'backlog' + action: status=backlog, tags-=[moved] + - name: Done + columns: 1 + filter: status = 'done' + action: status=done, tags+=[moved] +` + if err := os.WriteFile(filepath.Join(tmpDir, "workflow.yaml"), []byte(workflowContent), 0644); err != nil { + t.Fatalf("failed to write workflow.yaml: %v", err) + } + + // chdir so FindWorkflowFile() picks it up + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get cwd: %v", err) + } + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to chdir: %v", err) + } t.Cleanup(func() { - viper.Set("plugins", originalPlugins) + _ = os.Chdir(origDir) }) ta := testutil.NewTestApp(t) diff --git a/internal/bootstrap/init.go b/internal/bootstrap/init.go index f6d5753..10801c6 100644 --- a/internal/bootstrap/init.go +++ b/internal/bootstrap/init.go @@ -54,18 +54,8 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { return nil, err } - // Phase 2: Configuration and logging - cfg, err := LoadConfig() - if err != nil { - return nil, err - } - logLevel := InitLogging(cfg) - - // Phase 2.5: System information collection and gradient support initialization - // Collect early (before app creation) using terminfo lookup for future visual adjustments - systemInfo := InitColorAndGradientSupport(cfg) - - // Phase 3: Project initialization + // Phase 2: Project initialization (creates dirs, seeds files, writes default config/workflow) + // runs before LoadConfig so that config.yaml and workflow.yaml exist on first launch proceed, err := EnsureProjectInitialized(tikiSkillContent, dokiSkillContent) if err != nil { return nil, err @@ -74,6 +64,17 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) { return nil, nil // User chose not to proceed } + // Phase 3: Configuration and logging + cfg, err := LoadConfig() + if err != nil { + return nil, err + } + logLevel := InitLogging(cfg) + + // Phase 3.5: System information collection and gradient support initialization + // Collect early (before app creation) using terminfo lookup for future visual adjustments + systemInfo := InitColorAndGradientSupport(cfg) + // Phase 4: Store initialization tikiStore, taskStore, err := InitStores() if err != nil { diff --git a/plugin/definition.go b/plugin/definition.go index 413c13f..ed7dcb7 100644 --- a/plugin/definition.go +++ b/plugin/definition.go @@ -104,24 +104,3 @@ type TikiLane struct { Filter filter.FilterExpr Action LaneAction } - -// PluginRef is the entry in config.yaml that references a plugin file or defines it inline -type PluginRef struct { - // File reference (for file-based and hybrid modes) - File string `mapstructure:"file"` - - // Inline definition fields (for inline and hybrid modes) - Name string `mapstructure:"name"` - Foreground string `mapstructure:"foreground"` - Background string `mapstructure:"background"` - Key string `mapstructure:"key"` - Filter string `mapstructure:"filter"` - Sort string `mapstructure:"sort"` - View string `mapstructure:"view"` - Type string `mapstructure:"type"` - Fetcher string `mapstructure:"fetcher"` - Text string `mapstructure:"text"` - URL string `mapstructure:"url"` - Lanes []PluginLaneConfig `mapstructure:"lanes"` - Actions []PluginActionConfig `mapstructure:"actions"` -} diff --git a/plugin/embed/backlog.yaml b/plugin/embed/backlog.yaml deleted file mode 100644 index e82e8bd..0000000 --- a/plugin/embed/backlog.yaml +++ /dev/null @@ -1,13 +0,0 @@ -name: Backlog -foreground: "#5fff87" -background: "#0b3d2e" -key: "F3" -lanes: - - name: Backlog - columns: 4 - filter: status = 'backlog' and type != 'epic' -actions: - - key: "b" - label: "Add to board" - action: status = 'ready' -sort: Priority, ID diff --git a/plugin/embed/documentation.yaml b/plugin/embed/documentation.yaml deleted file mode 100644 index de255fb..0000000 --- a/plugin/embed/documentation.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: Docs -type: doki -fetcher: file -url: "index.md" -foreground: "#ff9966" -background: "#2b3a42" -key: "F2" diff --git a/plugin/embed/help.yaml b/plugin/embed/help.yaml deleted file mode 100644 index 736920d..0000000 --- a/plugin/embed/help.yaml +++ /dev/null @@ -1,7 +0,0 @@ -name: Help -type: doki -fetcher: internal -text: "Help" -foreground: "#bcbcbc" -background: "#003399" -key: "?" \ No newline at end of file diff --git a/plugin/embed/kanban.yaml b/plugin/embed/kanban.yaml deleted file mode 100644 index 5d35024..0000000 --- a/plugin/embed/kanban.yaml +++ /dev/null @@ -1,18 +0,0 @@ -name: Kanban -foreground: "#87ceeb" -background: "#25496a" -key: "F1" -lanes: - - name: Ready - filter: status = 'ready' and type != 'epic' - action: status = 'ready' - - name: In Progress - filter: status = 'in_progress' and type != 'epic' - action: status = 'in_progress' - - name: Review - filter: status = 'review' and type != 'epic' - action: status = 'review' - - name: Done - filter: status = 'done' and type != 'epic' - action: status = 'done' -sort: Priority, CreatedAt \ No newline at end of file diff --git a/plugin/embed/recent.yaml b/plugin/embed/recent.yaml deleted file mode 100644 index a8f3289..0000000 --- a/plugin/embed/recent.yaml +++ /dev/null @@ -1,9 +0,0 @@ -name: Recent -foreground: "#f4d6a6" -background: "#5a3d1b" -key: Ctrl-R -lanes: - - name: Recent - columns: 4 - filter: NOW - UpdatedAt < 24hours -sort: UpdatedAt DESC \ No newline at end of file diff --git a/plugin/embed/roadmap.yaml b/plugin/embed/roadmap.yaml deleted file mode 100644 index e514aba..0000000 --- a/plugin/embed/roadmap.yaml +++ /dev/null @@ -1,19 +0,0 @@ -name: Roadmap -foreground: "#e2e8f0" -background: "#2a5f5a" -key: "F4" -lanes: - - name: Now - columns: 1 - filter: type = 'epic' AND status = 'ready' - action: status = 'ready' - - name: Next - columns: 1 - filter: type = 'epic' AND status = 'backlog' AND priority = 1 - action: status = 'backlog', priority = 1 - - name: Later - columns: 2 - filter: type = 'epic' AND status = 'backlog' AND priority > 1 - action: status = 'backlog', priority = 2 -sort: Priority, Points DESC -view: expanded \ No newline at end of file diff --git a/plugin/embed/workflow.yaml b/plugin/embed/workflow.yaml new file mode 100644 index 0000000..5fe8251 --- /dev/null +++ b/plugin/embed/workflow.yaml @@ -0,0 +1,74 @@ +plugins: + - name: Kanban + foreground: "#87ceeb" + background: "#25496a" + key: "F1" + lanes: + - name: Ready + filter: status = 'ready' and type != 'epic' + action: status = 'ready' + - name: In Progress + filter: status = 'in_progress' and type != 'epic' + action: status = 'in_progress' + - name: Review + filter: status = 'review' and type != 'epic' + action: status = 'review' + - name: Done + filter: status = 'done' and type != 'epic' + action: status = 'done' + sort: Priority, CreatedAt + - name: Backlog + foreground: "#5fff87" + background: "#0b3d2e" + key: "F3" + lanes: + - name: Backlog + columns: 4 + filter: status = 'backlog' and type != 'epic' + actions: + - key: "b" + label: "Add to board" + action: status = 'ready' + sort: Priority, ID + - name: Recent + foreground: "#f4d6a6" + background: "#5a3d1b" + key: Ctrl-R + lanes: + - name: Recent + columns: 4 + filter: NOW - UpdatedAt < 24hours + sort: UpdatedAt DESC + - name: Roadmap + foreground: "#e2e8f0" + background: "#2a5f5a" + key: "F4" + lanes: + - name: Now + columns: 1 + filter: type = 'epic' AND status = 'ready' + action: status = 'ready' + - name: Next + columns: 1 + filter: type = 'epic' AND status = 'backlog' AND priority = 1 + action: status = 'backlog', priority = 1 + - name: Later + columns: 2 + filter: type = 'epic' AND status = 'backlog' AND priority > 1 + action: status = 'backlog', priority = 2 + sort: Priority, Points DESC + view: expanded + - name: Help + type: doki + fetcher: internal + text: "Help" + foreground: "#bcbcbc" + background: "#003399" + key: "?" + - name: Docs + type: doki + fetcher: file + url: "index.md" + foreground: "#ff9966" + background: "#2b3a42" + key: "F2" diff --git a/plugin/embedded.go b/plugin/embedded.go index b11dc63..424197c 100644 --- a/plugin/embedded.go +++ b/plugin/embedded.go @@ -3,68 +3,43 @@ package plugin import ( _ "embed" "log/slog" + + "gopkg.in/yaml.v3" ) -//go:embed embed/kanban.yaml -var kanbanYAML string +//go:embed embed/workflow.yaml +var embeddedWorkflowYAML string -//go:embed embed/recent.yaml -var recentYAML string - -//go:embed embed/roadmap.yaml -var roadmapYAML string - -//go:embed embed/backlog.yaml -var backlogYAML string - -//go:embed embed/help.yaml -var helpYAML string - -//go:embed embed/documentation.yaml -var documentationYAML string - -// loadEmbeddedPlugin parses a single embedded plugin and sets its ConfigIndex to -1 -func loadEmbeddedPlugin(yamlContent string, sourceName string) Plugin { - p, err := parsePluginYAML([]byte(yamlContent), sourceName) - if err != nil { - slog.Error("failed to parse embedded plugin", "source", sourceName, "error", err) +// loadEmbeddedPlugins loads the built-in default plugins from the embedded workflow.yaml +func loadEmbeddedPlugins() []Plugin { + var wf WorkflowFile + if err := yaml.Unmarshal([]byte(embeddedWorkflowYAML), &wf); err != nil { + slog.Error("failed to parse embedded workflow.yaml", "error", err) return nil } - // Set ConfigIndex = -1 for both TikiPlugin and DokiPlugin - switch plugin := p.(type) { - case *TikiPlugin: - plugin.ConfigIndex = -1 - case *DokiPlugin: - plugin.ConfigIndex = -1 - } - - return p -} - -// loadEmbeddedPlugins loads the built-in default plugins (Kanban, Backlog, Recent, Roadmap, Help, and Documentation) -func loadEmbeddedPlugins() []Plugin { var plugins []Plugin - - // Define embedded plugins with their YAML content and source names - // Kanban is first so it becomes the default view - embeddedPlugins := []struct { - yaml string - source string - }{ - {kanbanYAML, "embedded:kanban"}, - {backlogYAML, "embedded:backlog"}, - {recentYAML, "embedded:recent"}, - {roadmapYAML, "embedded:roadmap"}, - {helpYAML, "embedded:help"}, - {documentationYAML, "embedded:documentation"}, - } - - // Load each embedded plugin - for _, ep := range embeddedPlugins { - if p := loadEmbeddedPlugin(ep.yaml, ep.source); p != nil { - plugins = append(plugins, p) + for _, cfg := range wf.Plugins { + if cfg.Name == "" { + slog.Warn("skipping embedded plugin with no name") + continue } + + p, err := parsePluginConfig(cfg, "embedded:"+cfg.Name) + if err != nil { + slog.Error("failed to parse embedded plugin", "name", cfg.Name, "error", err) + continue + } + + // mark as embedded (not from user config) + switch plugin := p.(type) { + case *TikiPlugin: + plugin.ConfigIndex = -1 + case *DokiPlugin: + plugin.ConfigIndex = -1 + } + + plugins = append(plugins, p) } return plugins diff --git a/plugin/fileresolver.go b/plugin/fileresolver.go deleted file mode 100644 index dad1ea2..0000000 --- a/plugin/fileresolver.go +++ /dev/null @@ -1,41 +0,0 @@ -package plugin - -import ( - "os" - "path/filepath" - - "github.com/boolean-maybe/tiki/config" -) - -// findPluginFile searches for the plugin file in various locations -// Search order: absolute path → project config dir → user config dir -func findPluginFile(filename string) string { - // If filename is absolute, try it directly - if filepath.IsAbs(filename) { - if _, err := os.Stat(filename); err == nil { - return filename - } - return "" - } - - // Get search paths from PathManager - // Search order: project config dir → user config dir - searchPaths := config.GetPluginSearchPaths() - - // Build full list of paths to check - var paths []string - paths = append(paths, filename) // Try as-is first (relative to cwd) - - for _, dir := range searchPaths { - paths = append(paths, filepath.Join(dir, filename)) - } - - // Search for the file - for _, path := range paths { - if _, err := os.Stat(path); err == nil { - return path - } - } - - return "" -} diff --git a/plugin/fileresolver_test.go b/plugin/fileresolver_test.go deleted file mode 100644 index eef2e13..0000000 --- a/plugin/fileresolver_test.go +++ /dev/null @@ -1,134 +0,0 @@ -package plugin - -import ( - "os" - "path/filepath" - "testing" -) - -func TestFindPluginFile(t *testing.T) { - // Create a temporary directory for testing - tmpDir := t.TempDir() - - // Create test files in different locations - currentDir := tmpDir - testFile := "test-plugin.yaml" - testFilePath := filepath.Join(currentDir, testFile) - - // Create the test file - if err := os.WriteFile(testFilePath, []byte("name: test"), 0644); err != nil { - t.Fatalf("Failed to create test file: %v", err) - } - - // Change to temp directory for testing - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - defer func() { _ = os.Chdir(origDir) }() - - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Failed to change to temp directory: %v", err) - } - - tests := []struct { - name string - filename string - wantPath string - wantFound bool - }{ - { - name: "absolute path", - filename: testFilePath, - wantPath: testFilePath, - wantFound: true, - }, - { - name: "relative path in current dir", - filename: testFile, - wantPath: testFile, - wantFound: true, - }, - { - name: "non-existent file", - filename: "nonexistent.yaml", - wantPath: "", - wantFound: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - got := findPluginFile(tt.filename) - - if tt.wantFound { - if got == "" { - t.Errorf("findPluginFile(%q) = empty, want non-empty path", - tt.filename) - } - // Verify the file exists at the returned path - if _, err := os.Stat(got); err != nil { - t.Errorf("findPluginFile returned path %q that doesn't exist: %v", - got, err) - } - } else { - if got != "" { - t.Errorf("findPluginFile(%q) = %q, want empty string", - tt.filename, got) - } - } - }) - } -} - -func TestFindPluginFile_SearchOrder(t *testing.T) { - // Create temporary directories - tmpDir := t.TempDir() - subDir := filepath.Join(tmpDir, "subdir") - //nolint:gosec // G301: test directory permissions - if err := os.MkdirAll(subDir, 0755); err != nil { - t.Fatalf("Failed to create subdirectory: %v", err) - } - - // Create test files in different locations with same name - testFile := "plugin.yaml" - currentFile := filepath.Join(tmpDir, testFile) - subFile := filepath.Join(subDir, testFile) - - // Create files - if err := os.WriteFile(currentFile, []byte("current"), 0644); err != nil { - t.Fatalf("Failed to create current file: %v", err) - } - if err := os.WriteFile(subFile, []byte("sub"), 0644); err != nil { - t.Fatalf("Failed to create sub file: %v", err) - } - - // Change to temp directory - origDir, err := os.Getwd() - if err != nil { - t.Fatalf("Failed to get current directory: %v", err) - } - defer func() { _ = os.Chdir(origDir) }() - - if err := os.Chdir(tmpDir); err != nil { - t.Fatalf("Failed to change to temp directory: %v", err) - } - - // Test that current directory is preferred in search order - got := findPluginFile(testFile) - if got == "" { - t.Fatal("findPluginFile returned empty path") - } - - // Read the file to verify which one was found - content, err := os.ReadFile(got) - if err != nil { - t.Fatalf("Failed to read found file: %v", err) - } - - // Should find the one in current directory first - if string(content) != "current" { - t.Errorf("findPluginFile found wrong file: got content %q, want %q", - string(content), "current") - } -} diff --git a/plugin/loader.go b/plugin/loader.go index 4eaa2fe..0ebe871 100644 --- a/plugin/loader.go +++ b/plugin/loader.go @@ -5,58 +5,72 @@ import ( "log/slog" "os" - "github.com/spf13/viper" + "github.com/boolean-maybe/tiki/config" "gopkg.in/yaml.v3" ) -// loadConfiguredPlugins loads plugins defined in config.yaml +// WorkflowFile represents the YAML structure of a workflow.yaml file +type WorkflowFile struct { + Plugins []pluginFileConfig `yaml:"plugins"` +} + +// loadConfiguredPlugins loads plugins defined in workflow.yaml func loadConfiguredPlugins() []Plugin { - // Get plugin refs from config.yaml - var refs []PluginRef - if err := viper.UnmarshalKey("plugins", &refs); err != nil { - // Not an error if plugins key doesn't exist - slog.Debug("no plugins configured or failed to parse", "error", err) + workflowPath := config.FindWorkflowFile() + if workflowPath == "" { + slog.Debug("no workflow.yaml found") return nil } - if len(refs) == 0 { - return nil // no plugins configured + data, err := os.ReadFile(workflowPath) + if err != nil { + slog.Warn("failed to read workflow.yaml", "path", workflowPath, "error", err) + return nil + } + + var wf WorkflowFile + if err := yaml.Unmarshal(data, &wf); err != nil { + slog.Warn("failed to parse workflow.yaml", "path", workflowPath, "error", err) + return nil + } + + if len(wf.Plugins) == 0 { + return nil } var plugins []Plugin - - for i, ref := range refs { - // Validate before loading - if err := validatePluginRef(ref); err != nil { - slog.Warn("invalid plugin configuration", "error", err) + for i, cfg := range wf.Plugins { + if cfg.Name == "" { + slog.Warn("skipping plugin with no name in workflow.yaml", "index", i) continue } - plugin, err := loadPluginFromRef(ref) + source := fmt.Sprintf("%s:%s", workflowPath, cfg.Name) + p, err := parsePluginConfig(cfg, source) if err != nil { - slog.Warn("failed to load plugin", "name", ref.Name, "file", ref.File, "error", err) - continue // Skip failed plugins, continue with others + slog.Warn("failed to load plugin from workflow.yaml", "name", cfg.Name, "error", err) + continue } - // Set config index (need type assertion or helper) - if p, ok := plugin.(*TikiPlugin); ok { - p.ConfigIndex = i - } else if p, ok := plugin.(*DokiPlugin); ok { - p.ConfigIndex = i + // set config index to position in workflow.yaml + if tp, ok := p.(*TikiPlugin); ok { + tp.ConfigIndex = i + } else if dp, ok := p.(*DokiPlugin); ok { + dp.ConfigIndex = i } - plugins = append(plugins, plugin) - pk, pr, pm := plugin.GetActivationKey() - slog.Info("loaded plugin", "name", plugin.GetName(), "key", keyName(pk, pr), "modifier", pm) + plugins = append(plugins, p) + pk, pr, pm := p.GetActivationKey() + slog.Info("loaded plugin", "name", p.GetName(), "key", keyName(pk, pr), "modifier", pm) } return plugins } -// LoadPlugins loads all plugins: embedded defaults (Recent, Roadmap) plus configured plugins from config.yaml -// Configured plugins with the same name as embedded plugins will be merged (configured fields override embedded) +// LoadPlugins loads all plugins: embedded defaults plus configured plugins from workflow.yaml. +// Configured plugins with the same name as embedded plugins will be merged (configured fields override embedded). func LoadPlugins() ([]Plugin, error) { - // Load embedded default plugins first (maintains order) + // load embedded default plugins first (maintains order) embedded := loadEmbeddedPlugins() embeddedByName := make(map[string]Plugin) for _, p := range embedded { @@ -65,29 +79,28 @@ func LoadPlugins() ([]Plugin, error) { slog.Info("loaded embedded plugin", "name", p.GetName(), "key", keyName(pk, pr), "modifier", pm) } - // Load configured plugins (may override embedded ones) + // load configured plugins (may override embedded ones) configured := loadConfiguredPlugins() - // Track which embedded plugins were overridden and merge them + // track which embedded plugins were overridden and merge them overridden := make(map[string]bool) mergedConfigured := make([]Plugin, 0, len(configured)) for _, configPlugin := range configured { if embeddedPlugin, ok := embeddedByName[configPlugin.GetName()]; ok { - // Merge: embedded plugin fields + configured overrides + // merge: embedded plugin fields + configured overrides merged := mergePluginDefinitions(embeddedPlugin, configPlugin) mergedConfigured = append(mergedConfigured, merged) overridden[configPlugin.GetName()] = true slog.Info("plugin override (merged)", "name", configPlugin.GetName(), "from", embeddedPlugin.GetFilePath(), "to", configPlugin.GetFilePath()) } else { - // New plugin (not an override) + // new plugin (not an override) mergedConfigured = append(mergedConfigured, configPlugin) } } - // Build final list: non-overridden embedded plugins + merged configured plugins - // This preserves order: embedded plugins first (in their original order), then configured + // build final list: non-overridden embedded plugins + merged configured plugins var plugins []Plugin for _, p := range embedded { if !overridden[p.GetName()] { @@ -98,58 +111,3 @@ func LoadPlugins() ([]Plugin, error) { return plugins, nil } - -// loadPluginFromRef loads a single plugin from a PluginRef, handling three modes: -// 1. Fully inline (no file): all fields in config.yaml -// 2. File-based (file only): reference external YAML -// 3. Hybrid (file + overrides): file provides base, inline overrides -func loadPluginFromRef(ref PluginRef) (Plugin, error) { - var cfg pluginFileConfig - var source string - - if ref.File != "" { - // File-based or hybrid mode - pluginPath := findPluginFile(ref.File) - if pluginPath == "" { - return nil, fmt.Errorf("plugin file not found: %s", ref.File) - } - - data, err := os.ReadFile(pluginPath) - if err != nil { - return nil, fmt.Errorf("reading file: %w", err) - } - - if err := yaml.Unmarshal(data, &cfg); err != nil { - return nil, fmt.Errorf("parsing yaml: %w", err) - } - - source = pluginPath - - // Apply inline overrides - cfg = mergePluginConfigs(cfg, ref) - } else { - // Fully inline mode - cfg = pluginFileConfig{ - Name: ref.Name, - Foreground: ref.Foreground, - Background: ref.Background, - Key: ref.Key, - Filter: ref.Filter, - Sort: ref.Sort, - View: ref.View, - Type: ref.Type, - Fetcher: ref.Fetcher, - Text: ref.Text, - URL: ref.URL, - Lanes: ref.Lanes, - } - source = "inline:" + ref.Name - } - - // Validate: must have name - if cfg.Name == "" { - return nil, fmt.Errorf("plugin must have a name") - } - - return parsePluginConfig(cfg, source) -} diff --git a/plugin/loader_test.go b/plugin/loader_test.go index 2564d05..a263920 100644 --- a/plugin/loader_test.go +++ b/plugin/loader_test.go @@ -9,8 +9,8 @@ import ( taskpkg "github.com/boolean-maybe/tiki/task" ) -func TestLoadPluginFromRef_FullyInline(t *testing.T) { - ref := PluginRef{ +func TestParsePluginConfig_FullyInline(t *testing.T) { + cfg := pluginFileConfig{ Name: "Inline Test", Foreground: "#ffffff", Background: "#000000", @@ -22,7 +22,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) { View: "expanded", } - def, err := loadPluginFromRef(ref) + def, err := parsePluginConfig(cfg, "test") if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -52,7 +52,7 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) { t.Errorf("Expected sort 'Priority DESC', got %+v", tp.Sort) } - // Test filter evaluation + // test filter evaluation task := &taskpkg.Task{ ID: "TIKI-1", Status: taskpkg.StatusReady, @@ -63,15 +63,15 @@ func TestLoadPluginFromRef_FullyInline(t *testing.T) { } } -func TestLoadPluginFromRef_InlineMinimal(t *testing.T) { - ref := PluginRef{ +func TestParsePluginConfig_Minimal(t *testing.T) { + cfg := pluginFileConfig{ Name: "Minimal", Lanes: []PluginLaneConfig{ {Name: "Bugs", Filter: "type = 'bug'"}, }, } - def, err := loadPluginFromRef(ref) + def, err := parsePluginConfig(cfg, "test") if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -90,220 +90,29 @@ func TestLoadPluginFromRef_InlineMinimal(t *testing.T) { } } -func TestLoadPluginFromRef_FileBased(t *testing.T) { - // Create temp plugin file - tmpDir := t.TempDir() - pluginFile := filepath.Join(tmpDir, "test-plugin.yaml") - content := `name: Test Plugin -foreground: "#ff0000" -background: "#0000ff" -key: T -lanes: - - name: In Progress - filter: status = 'in_progress' -sort: Priority, UpdatedAt DESC -view: compact -` - if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write plugin file: %v", err) - } - - ref := PluginRef{ - File: pluginFile, // Use absolute path - } - - def, err := loadPluginFromRef(ref) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - tp, ok := def.(*TikiPlugin) - if !ok { - t.Fatalf("Expected TikiPlugin, got %T", def) - } - - if tp.Name != "Test Plugin" { - t.Errorf("Expected name 'Test Plugin', got '%s'", tp.Name) - } - - if tp.Rune != 'T' { - t.Errorf("Expected rune 'T', got '%c'", tp.Rune) - } - - if tp.ViewMode != "compact" { - t.Errorf("Expected view mode 'compact', got '%s'", tp.ViewMode) - } -} - -func TestLoadPluginFromRef_Hybrid(t *testing.T) { - // Create temp plugin file with base config - tmpDir := t.TempDir() - pluginFile := filepath.Join(tmpDir, "base-plugin.yaml") - content := `name: Base Plugin -foreground: "#ff0000" -background: "#0000ff" -key: L -lanes: - - name: Todo - filter: status = 'ready' -sort: Priority -view: compact -` - if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write plugin file: %v", err) - } - - // Override view and key - ref := PluginRef{ - File: pluginFile, // Use absolute path - View: "expanded", - Key: "H", - } - - def, err := loadPluginFromRef(ref) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - tp, ok := def.(*TikiPlugin) - if !ok { - t.Fatalf("Expected TikiPlugin, got %T", def) - } - - // Base fields should be from file - if tp.Name != "Base Plugin" { - t.Errorf("Expected name 'Base Plugin', got '%s'", tp.Name) - } - - if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil { - t.Error("Expected lane filter from file") - } - - // Overridden fields should be from inline - if tp.Rune != 'H' { - t.Errorf("Expected rune 'H' (overridden), got '%c'", tp.Rune) - } - - if tp.ViewMode != "expanded" { - t.Errorf("Expected view mode 'expanded' (overridden), got '%s'", tp.ViewMode) - } -} - -func TestLoadPluginFromRef_HybridMultipleOverrides(t *testing.T) { - // Create temp plugin file - tmpDir := t.TempDir() - pluginFile := filepath.Join(tmpDir, "multi-plugin.yaml") - content := `name: Multi Plugin -foreground: "#ffffff" -background: "#000000" -key: M -lanes: - - name: Todo - filter: status = 'ready' -sort: Priority -view: compact -` - if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write plugin file: %v", err) - } - - // Override multiple fields - ref := PluginRef{ - File: pluginFile, // Use absolute path - Key: "X", - Lanes: []PluginLaneConfig{ - {Name: "In Progress", Filter: "status = 'in_progress'"}, - }, - Sort: "UpdatedAt DESC", - View: "expanded", - Foreground: "#00ff00", - } - - def, err := loadPluginFromRef(ref) - if err != nil { - t.Fatalf("Expected no error, got: %v", err) - } - - tp, ok := def.(*TikiPlugin) - if !ok { - t.Fatalf("Expected TikiPlugin, got %T", def) - } - - // Check overridden values - if tp.Rune != 'X' { - t.Errorf("Expected rune 'X', got '%c'", tp.Rune) - } - - if tp.ViewMode != "expanded" { - t.Errorf("Expected view 'expanded', got '%s'", tp.ViewMode) - } - - // Verify filter override - task := &taskpkg.Task{ - ID: "TIKI-1", - Status: taskpkg.StatusInProgress, - } - if len(tp.Lanes) != 1 || tp.Lanes[0].Filter == nil { - t.Fatal("Expected overridden lane filter") - } - if !tp.Lanes[0].Filter.Evaluate(task, time.Now(), "testuser") { - t.Error("Expected overridden filter to match in_progress task") - } - - todoTask := &taskpkg.Task{ - ID: "TIKI-2", - Status: taskpkg.StatusReady, - } - if tp.Lanes[0].Filter.Evaluate(todoTask, time.Now(), "testuser") { - t.Error("Expected overridden filter to NOT match todo task") - } -} - -func TestLoadPluginFromRef_MissingFile(t *testing.T) { - ref := PluginRef{ - File: "nonexistent.yaml", - } - - _, err := loadPluginFromRef(ref) - if err == nil { - t.Fatal("Expected error for missing file") - } - - if err.Error() != "plugin file not found: nonexistent.yaml" { - t.Errorf("Expected 'file not found' error, got: %v", err) - } -} - -func TestLoadPluginFromRef_NoName(t *testing.T) { - // Inline plugin without name - ref := PluginRef{ +func TestParsePluginConfig_NoName(t *testing.T) { + cfg := pluginFileConfig{ Lanes: []PluginLaneConfig{ {Name: "Todo", Filter: "status = 'ready'"}, }, } - _, err := loadPluginFromRef(ref) + _, err := parsePluginConfig(cfg, "test") if err == nil { t.Fatal("Expected error for plugin without name") } - - if err.Error() != "plugin must have a name" { - t.Errorf("Expected 'must have a name' error, got: %v", err) - } } -// Tests for merger functions moved to merger_test.go - func TestPluginTypeExplicit(t *testing.T) { - // 1. Inline plugin with type doki - ref := PluginRef{ + // inline plugin with type doki + cfg := pluginFileConfig{ Name: "Type Doki Test", Type: "doki", Fetcher: "internal", Text: "some text", } - def, err := loadPluginFromRef(ref) + def, err := parsePluginConfig(cfg, "test") if err != nil { t.Fatalf("Expected no error, got: %v", err) } @@ -315,70 +124,113 @@ func TestPluginTypeExplicit(t *testing.T) { if _, ok := def.(*DokiPlugin); !ok { t.Errorf("Expected DokiPlugin type assertion to succeed") } +} - // 2. File-based plugin with type doki +func TestLoadConfiguredPlugins_WorkflowFile(t *testing.T) { + // create a temp directory with a workflow.yaml tmpDir := t.TempDir() - pluginFile := filepath.Join(tmpDir, "type-doki.yaml") - content := `name: File Type Doki -type: doki -fetcher: file -url: http://example.com/resource + workflowContent := `plugins: + - name: TestBoard + key: "F5" + lanes: + - name: Ready + filter: status = 'ready' + sort: Priority + - name: TestDocs + type: doki + fetcher: internal + text: "hello" + key: "D" ` - if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write plugin file: %v", err) + workflowPath := filepath.Join(tmpDir, "workflow.yaml") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write workflow.yaml: %v", err) } - refFile := PluginRef{ - File: pluginFile, // Use absolute path - } - - defFile, err := loadPluginFromRef(refFile) + // change to temp dir so FindWorkflowFile() finds it in cwd + origDir, err := os.Getwd() if err != nil { - t.Fatalf("Expected no error, got: %v", err) + t.Fatalf("Failed to get cwd: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) } - if defFile.GetType() != "doki" { - t.Errorf("Expected type 'doki' for file plugin, got '%s'", defFile.GetType()) + plugins := loadConfiguredPlugins() + if len(plugins) != 2 { + t.Fatalf("Expected 2 plugins, got %d", len(plugins)) + } + + if plugins[0].GetName() != "TestBoard" { + t.Errorf("Expected first plugin 'TestBoard', got '%s'", plugins[0].GetName()) + } + if plugins[1].GetName() != "TestDocs" { + t.Errorf("Expected second plugin 'TestDocs', got '%s'", plugins[1].GetName()) + } + + // verify config indices + if plugins[0].GetConfigIndex() != 0 { + t.Errorf("Expected config index 0, got %d", plugins[0].GetConfigIndex()) + } + if plugins[1].GetConfigIndex() != 1 { + t.Errorf("Expected config index 1, got %d", plugins[1].GetConfigIndex()) } } -func TestPluginTypeOverride(t *testing.T) { - // File specifies tiki, override specifies doki - // This scenario tests if we can override an embedded/file plugin type. - // Current mergePluginDefinitions only merges Tiki->Tiki. - // If types mismatch, it returns the override. - +func TestLoadConfiguredPlugins_NoWorkflowFile(t *testing.T) { tmpDir := t.TempDir() - pluginFile := filepath.Join(tmpDir, "type-override.yaml") - content := `name: Type Override -type: tiki -` - if err := os.WriteFile(pluginFile, []byte(content), 0644); err != nil { - t.Fatalf("Failed to write plugin file: %v", err) - } - ref := PluginRef{ - File: pluginFile, // Use absolute path - Type: "doki", - Fetcher: "internal", - Text: "override text", - } - - // loadPluginFromRef calls mergePluginConfigs but NOT mergePluginDefinitions. - // mergePluginConfigs updates the config struct. - // parsePluginConfig then creates the struct. - // So this test checks mergePluginConfigs logic + parsing logic. - - def, err := loadPluginFromRef(ref) + origDir, err := os.Getwd() if err != nil { - t.Fatalf("Expected no error, got: %v", err) + t.Fatalf("Failed to get cwd: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) } - if def.GetType() != "doki" { - t.Errorf("Expected type 'doki' (overridden), got '%s'", def.GetType()) - } - - if _, ok := def.(*DokiPlugin); !ok { - t.Errorf("Expected DokiPlugin type assertion to succeed") + plugins := loadConfiguredPlugins() + if plugins != nil { + t.Errorf("Expected nil plugins when no workflow.yaml, got %d", len(plugins)) + } +} + +func TestLoadConfiguredPlugins_InvalidPlugin(t *testing.T) { + tmpDir := t.TempDir() + workflowContent := `plugins: + - name: Valid + key: "V" + lanes: + - name: Todo + filter: status = 'ready' + - name: Invalid + type: unknown +` + workflowPath := filepath.Join(tmpDir, "workflow.yaml") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatalf("Failed to write workflow.yaml: %v", err) + } + + origDir, err := os.Getwd() + if err != nil { + t.Fatalf("Failed to get cwd: %v", err) + } + defer func() { _ = os.Chdir(origDir) }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("Failed to chdir: %v", err) + } + + // should load valid plugin and skip invalid one + plugins := loadConfiguredPlugins() + if len(plugins) != 1 { + t.Fatalf("Expected 1 valid plugin (invalid skipped), got %d", len(plugins)) + } + + if plugins[0].GetName() != "Valid" { + t.Errorf("Expected plugin 'Valid', got '%s'", plugins[0].GetName()) } } diff --git a/plugin/merger.go b/plugin/merger.go index 765ce3f..bea875c 100644 --- a/plugin/merger.go +++ b/plugin/merger.go @@ -1,8 +1,6 @@ package plugin import ( - "fmt" - "github.com/gdamore/tcell/v2" ) @@ -23,54 +21,6 @@ type pluginFileConfig struct { Actions []PluginActionConfig `yaml:"actions"` } -// mergePluginConfigs merges file-based config (base) with inline overrides -// Inline values override file values for any non-empty field -func mergePluginConfigs(base pluginFileConfig, overrides PluginRef) pluginFileConfig { - result := base - - if overrides.Name != "" { - result.Name = overrides.Name - } - if overrides.Foreground != "" { - result.Foreground = overrides.Foreground - } - if overrides.Background != "" { - result.Background = overrides.Background - } - if overrides.Key != "" { - result.Key = overrides.Key - } - if overrides.Filter != "" { - result.Filter = overrides.Filter - } - if overrides.Sort != "" { - result.Sort = overrides.Sort - } - if overrides.View != "" { - result.View = overrides.View - } - if overrides.Type != "" { - result.Type = overrides.Type - } - if overrides.Fetcher != "" { - result.Fetcher = overrides.Fetcher - } - if overrides.Text != "" { - result.Text = overrides.Text - } - if overrides.URL != "" { - result.URL = overrides.URL - } - if len(overrides.Lanes) > 0 { - result.Lanes = overrides.Lanes - } - if len(overrides.Actions) > 0 { - result.Actions = overrides.Actions - } - - return result -} - // mergePluginDefinitions merges an embedded plugin (base) with a configured override // Override fields replace base fields only if they are non-zero/non-empty func mergePluginDefinitions(base Plugin, override Plugin) Plugin { @@ -130,29 +80,3 @@ func mergePluginDefinitions(base Plugin, override Plugin) Plugin { // just return the override. return override } - -// validatePluginRef validates a PluginRef before loading -func validatePluginRef(ref PluginRef) error { - if ref.File != "" { - // File-based or hybrid - name is optional (can come from file) - return nil - } - - // Fully inline - must have name - if ref.Name == "" { - return fmt.Errorf("inline plugin must specify 'name' field") - } - - // Should have at least one configuration field - hasContent := ref.Key != "" || ref.Filter != "" || - ref.Sort != "" || ref.Foreground != "" || - ref.Background != "" || ref.View != "" || ref.Type != "" || - ref.Fetcher != "" || ref.Text != "" || ref.URL != "" || - len(ref.Lanes) > 0 || len(ref.Actions) > 0 - - if !hasContent { - return fmt.Errorf("inline plugin '%s' has no configuration fields", ref.Name) - } - - return nil -} diff --git a/plugin/merger_test.go b/plugin/merger_test.go index 84c75c5..b9097c5 100644 --- a/plugin/merger_test.go +++ b/plugin/merger_test.go @@ -8,167 +8,6 @@ import ( "github.com/boolean-maybe/tiki/plugin/filter" ) -func TestMergePluginConfigs(t *testing.T) { - base := pluginFileConfig{ - Name: "Base", - Foreground: "#ff0000", - Background: "#0000ff", - Key: "L", - Lanes: []PluginLaneConfig{ - {Name: "Todo", Filter: "status = 'ready'"}, - }, - Sort: "Priority", - View: "compact", - } - - overrides := PluginRef{ - View: "expanded", - Key: "O", - } - - result := mergePluginConfigs(base, overrides) - - // Base fields should remain - if result.Name != "Base" { - t.Errorf("Expected name 'Base', got '%s'", result.Name) - } - if len(result.Lanes) != 1 || result.Lanes[0].Filter != "status = 'ready'" { - t.Errorf("Expected lanes from base, got %+v", result.Lanes) - } - if result.Foreground != "#ff0000" { - t.Errorf("Expected foreground from base, got '%s'", result.Foreground) - } - - // Overridden fields - if result.View != "expanded" { - t.Errorf("Expected view 'expanded', got '%s'", result.View) - } - if result.Key != "O" { - t.Errorf("Expected key 'O', got '%s'", result.Key) - } -} - -func TestMergePluginConfigs_AllOverrides(t *testing.T) { - base := pluginFileConfig{ - Name: "Base", - Foreground: "#ff0000", - Background: "#0000ff", - Key: "L", - Lanes: []PluginLaneConfig{ - {Name: "Todo", Filter: "status = 'ready'"}, - }, - Sort: "Priority", - View: "compact", - } - - overrides := PluginRef{ - Name: "Overridden", - Foreground: "#00ff00", - Background: "#000000", - Key: "O", - Lanes: []PluginLaneConfig{ - {Name: "Done", Filter: "status = 'done'"}, - }, - Sort: "UpdatedAt DESC", - View: "expanded", - } - - result := mergePluginConfigs(base, overrides) - - // All fields should be overridden - if result.Name != "Overridden" { - t.Errorf("Expected name 'Overridden', got '%s'", result.Name) - } - if result.Foreground != "#00ff00" { - t.Errorf("Expected foreground '#00ff00', got '%s'", result.Foreground) - } - if result.Background != "#000000" { - t.Errorf("Expected background '#000000', got '%s'", result.Background) - } - if result.Key != "O" { - t.Errorf("Expected key 'O', got '%s'", result.Key) - } - if len(result.Lanes) != 1 || result.Lanes[0].Filter != "status = 'done'" { - t.Errorf("Expected lane filter 'status = 'done'', got %+v", result.Lanes) - } - if result.Sort != "UpdatedAt DESC" { - t.Errorf("Expected sort 'UpdatedAt DESC', got '%s'", result.Sort) - } - if result.View != "expanded" { - t.Errorf("Expected view 'expanded', got '%s'", result.View) - } -} - -func TestValidatePluginRef_FileBased(t *testing.T) { - ref := PluginRef{ - File: "plugin.yaml", - } - - err := validatePluginRef(ref) - if err != nil { - t.Errorf("Expected no error for file-based plugin, got: %v", err) - } -} - -func TestValidatePluginRef_Hybrid(t *testing.T) { - ref := PluginRef{ - File: "plugin.yaml", - View: "expanded", - } - - err := validatePluginRef(ref) - if err != nil { - t.Errorf("Expected no error for hybrid plugin, got: %v", err) - } -} - -func TestValidatePluginRef_InlineValid(t *testing.T) { - ref := PluginRef{ - Name: "Test", - Lanes: []PluginLaneConfig{ - {Name: "Todo", Filter: "status = 'ready'"}, - }, - } - - err := validatePluginRef(ref) - if err != nil { - t.Errorf("Expected no error for valid inline plugin, got: %v", err) - } -} - -func TestValidatePluginRef_InlineNoName(t *testing.T) { - ref := PluginRef{ - Lanes: []PluginLaneConfig{ - {Name: "Todo", Filter: "status = 'ready'"}, - }, - } - - err := validatePluginRef(ref) - if err == nil { - t.Fatal("Expected error for inline plugin without name") - } - - if err.Error() != "inline plugin must specify 'name' field" { - t.Errorf("Expected 'must specify name' error, got: %v", err) - } -} - -func TestValidatePluginRef_InlineNoContent(t *testing.T) { - ref := PluginRef{ - Name: "Empty", - } - - err := validatePluginRef(ref) - if err == nil { - t.Fatal("Expected error for inline plugin with no content") - } - - expected := "inline plugin 'Empty' has no configuration fields" - if err.Error() != expected { - t.Errorf("Expected '%s', got: %v", expected, err) - } -} - func TestMergePluginDefinitions_TikiToTiki(t *testing.T) { baseFilter, _ := filter.ParseFilter("status = 'ready'") baseSort, _ := ParseSort("Priority") diff --git a/plugin/parser.go b/plugin/parser.go index 12ab83b..dafca5d 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -13,6 +13,10 @@ import ( // parsePluginConfig parses a pluginFileConfig into a Plugin func parsePluginConfig(cfg pluginFileConfig, source string) (Plugin, error) { + if cfg.Name == "" { + return nil, fmt.Errorf("plugin must have a name (%s)", source) + } + // Common fields // Use ColorDefault as sentinel so views can detect "not specified" and use theme-appropriate colors fg := parseColor(cfg.Foreground, tcell.ColorDefault) diff --git a/plugin/parser_test.go b/plugin/parser_test.go index 24cf1f6..cc09d17 100644 --- a/plugin/parser_test.go +++ b/plugin/parser_test.go @@ -8,12 +8,12 @@ import ( func TestDokiValidation(t *testing.T) { tests := []struct { name string - ref PluginRef + cfg pluginFileConfig wantError string }{ { name: "Missing Fetcher", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Invalid Doki", Type: "doki", }, @@ -21,7 +21,7 @@ func TestDokiValidation(t *testing.T) { }, { name: "Invalid Fetcher", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Invalid Fetcher", Type: "doki", Fetcher: "http", @@ -30,7 +30,7 @@ func TestDokiValidation(t *testing.T) { }, { name: "File Fetcher Missing URL", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "File No URL", Type: "doki", Fetcher: "file", @@ -39,7 +39,7 @@ func TestDokiValidation(t *testing.T) { }, { name: "Internal Fetcher Missing Text", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Internal No Text", Type: "doki", Fetcher: "internal", @@ -48,7 +48,7 @@ func TestDokiValidation(t *testing.T) { }, { name: "Doki with Tiki fields", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Doki with Filter", Type: "doki", Fetcher: "internal", @@ -59,7 +59,7 @@ func TestDokiValidation(t *testing.T) { }, { name: "Valid File Fetcher", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Valid File", Type: "doki", Fetcher: "file", @@ -69,7 +69,7 @@ func TestDokiValidation(t *testing.T) { }, { name: "Valid Internal Fetcher", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Valid Internal", Type: "doki", Fetcher: "internal", @@ -81,7 +81,7 @@ func TestDokiValidation(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := loadPluginFromRef(tc.ref) + _, err := parsePluginConfig(tc.cfg, "test") if tc.wantError != "" { if err == nil { t.Errorf("Expected error containing '%s', got nil", tc.wantError) @@ -100,12 +100,12 @@ func TestDokiValidation(t *testing.T) { func TestTikiValidation(t *testing.T) { tests := []struct { name string - ref PluginRef + cfg pluginFileConfig wantError string }{ { name: "Tiki with Doki fields (Fetcher)", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Tiki with Fetcher", Type: "tiki", Filter: "status='ready'", @@ -115,7 +115,7 @@ func TestTikiValidation(t *testing.T) { }, { name: "Tiki with Doki fields (Text)", - ref: PluginRef{ + cfg: pluginFileConfig{ Name: "Tiki with Text", Type: "tiki", Filter: "status='ready'", @@ -127,7 +127,7 @@ func TestTikiValidation(t *testing.T) { for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { - _, err := loadPluginFromRef(tc.ref) + _, err := parsePluginConfig(tc.cfg, "test") if tc.wantError != "" { if err == nil { t.Errorf("Expected error containing '%s', got nil", tc.wantError)