diff --git a/config/loader.go b/config/loader.go index 2cad9a5..cad66cf 100644 --- a/config/loader.go +++ b/config/loader.go @@ -192,9 +192,11 @@ func GetConfig() *Config { // workflowFileData represents the YAML structure of workflow.yaml for read-modify-write. // kept in config package to avoid import cycle with plugin package. +// all top-level sections must be listed here to survive round-trip serialization. type workflowFileData struct { Statuses []map[string]interface{} `yaml:"statuses,omitempty"` Plugins []map[string]interface{} `yaml:"views"` + Triggers []map[string]interface{} `yaml:"triggers,omitempty"` } // readWorkflowFile reads and unmarshals workflow.yaml from the given path. diff --git a/config/loader_test.go b/config/loader_test.go index c285d66..3743729 100644 --- a/config/loader_test.go +++ b/config/loader_test.go @@ -4,6 +4,8 @@ import ( "os" "path/filepath" "testing" + + "gopkg.in/yaml.v3" ) func TestLoadConfig(t *testing.T) { @@ -400,6 +402,94 @@ func TestLoadConfigAIAgentDefault(t *testing.T) { } } +func TestSavePluginViewMode_PreservesTriggers(t *testing.T) { + tmpDir := t.TempDir() + + // write a workflow.yaml that includes triggers + workflowContent := `statuses: + - key: backlog + label: Backlog + default: true + - key: done + label: Done + done: true +views: + - name: Kanban + default: true + key: "F1" + lanes: + - name: Done + filter: status = 'done' + action: status = 'done' + sort: Priority, CreatedAt +triggers: + - description: block completion with open dependencies + ruki: > + before update + where new.status = "done" and new.dependsOn any status != "done" + deny "cannot complete: has open dependencies" + - description: no jumping from backlog to done + ruki: > + before update + where old.status = "backlog" and new.status = "done" + deny "cannot move directly from backlog to done" +` + workflowPath := filepath.Join(tmpDir, "workflow.yaml") + if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil { + t.Fatal(err) + } + + // simulate what SavePluginViewMode does: read → modify → write + wf, err := readWorkflowFile(workflowPath) + if err != nil { + t.Fatalf("readWorkflowFile failed: %v", err) + } + + // modify a view mode (same as SavePluginViewMode logic) + if len(wf.Plugins) > 0 { + wf.Plugins[0]["view"] = "compact" + } + + if err := writeWorkflowFile(workflowPath, wf); err != nil { + t.Fatalf("writeWorkflowFile failed: %v", err) + } + + // verify triggers survived the round-trip by reading raw YAML + rawData, err := os.ReadFile(workflowPath) + if err != nil { + t.Fatalf("reading workflow.yaml after write: %v", err) + } + + var raw map[string]interface{} + if err := yaml.Unmarshal(rawData, &raw); err != nil { + t.Fatalf("parsing raw YAML: %v", err) + } + triggers, ok := raw["triggers"] + if !ok { + t.Fatal("triggers section missing after round-trip write") + } + triggerList, ok := triggers.([]interface{}) + if !ok { + t.Fatalf("triggers is not a list, got %T", triggers) + } + if len(triggerList) != 2 { + t.Fatalf("expected 2 triggers after round-trip, got %d", len(triggerList)) + } + + // also verify via typed struct + wf2, err := readWorkflowFile(workflowPath) + if err != nil { + t.Fatalf("readWorkflowFile after write failed: %v", err) + } + if len(wf2.Triggers) != 2 { + t.Fatalf("expected 2 triggers in struct after round-trip, got %d", len(wf2.Triggers)) + } + desc0, _ := wf2.Triggers[0]["description"].(string) + if desc0 != "block completion with open dependencies" { + t.Errorf("trigger[0] description = %q, want %q", desc0, "block completion with open dependencies") + } +} + func TestGetConfig(t *testing.T) { // Reset appConfig appConfig = nil