consolidate plugin files

This commit is contained in:
booleanmaybe 2026-02-10 17:40:35 -05:00
parent aa94c897d6
commit 24917ecd84
22 changed files with 448 additions and 988 deletions

View file

@ -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", &currentPlugins); 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", &currentPlugins); 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

View file

@ -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()

View file

@ -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

View file

@ -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)

View file

@ -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 {

View file

@ -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"`
}

View file

@ -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

View file

@ -1,7 +0,0 @@
name: Docs
type: doki
fetcher: file
url: "index.md"
foreground: "#ff9966"
background: "#2b3a42"
key: "F2"

View file

@ -1,7 +0,0 @@
name: Help
type: doki
fetcher: internal
text: "Help"
foreground: "#bcbcbc"
background: "#003399"
key: "?"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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"

View file

@ -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

View file

@ -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 ""
}

View file

@ -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")
}
}

View file

@ -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)
}

View file

@ -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())
}
}

View file

@ -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
}

View file

@ -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")

View file

@ -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)

View file

@ -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)