diff --git a/config/pipeline.go b/config/pipeline.go index 37cd31e4..274de740 100644 --- a/config/pipeline.go +++ b/config/pipeline.go @@ -22,4 +22,12 @@ type PipelineStepConfig struct { Config map[string]any `json:"config,omitempty" yaml:"config,omitempty"` OnError string `json:"on_error,omitempty" yaml:"on_error,omitempty"` Timeout string `json:"timeout,omitempty" yaml:"timeout,omitempty"` + // SkipIf is an optional Go template expression. When it evaluates to a + // truthy value (non-empty, not "false", not "0"), the step is skipped and + // the pipeline continues with the next step. Falsy or absent → execute. + SkipIf string `json:"skip_if,omitempty" yaml:"skip_if,omitempty"` + // If is the logical inverse of SkipIf: the step executes only when the + // template evaluates to truthy. Falsy or absent with no SkipIf → execute. + // When both SkipIf and If are set, SkipIf takes precedence. + If string `json:"if,omitempty" yaml:"if,omitempty"` } diff --git a/engine.go b/engine.go index 32a37652..5554cecb 100644 --- a/engine.go +++ b/engine.go @@ -966,10 +966,14 @@ func parseRoutePipelineSteps(stepsRaw []any) []config.PipelineStepConfig { if name == "" || stepType == "" { continue } + skipIf, _ := stepMap["skip_if"].(string) + ifExpr, _ := stepMap["if"].(string) cfgs = append(cfgs, config.PipelineStepConfig{ Name: name, Type: stepType, Config: stepConfig, + SkipIf: skipIf, + If: ifExpr, }) } return cfgs @@ -1006,6 +1010,12 @@ func (e *StdEngine) buildPipelineSteps(pipelineName string, stepCfgs []config.Pi if err != nil { return nil, fmt.Errorf("step %q (type %s): %w", sc.Name, sc.Type, err) } + + // Wrap the step with skip_if / if guard when either field is set. + if sc.SkipIf != "" || sc.If != "" { + step = module.NewSkippableStep(step, sc.SkipIf, sc.If) + } + steps = append(steps, step) } diff --git a/module/pipeline_step_skip.go b/module/pipeline_step_skip.go new file mode 100644 index 00000000..4a28207d --- /dev/null +++ b/module/pipeline_step_skip.go @@ -0,0 +1,95 @@ +package module + +import ( + "context" + "strings" + + "github.com/GoCodeAlone/workflow/interfaces" +) + +// SkippableStep wraps a PipelineStep with optional skip_if / if guard expressions. +// +// skip_if: when the resolved template is truthy (non-empty, not "false", not "0"), +// the step is skipped. Falsy → execute. +// +// if: the logical inverse of skip_if. When the resolved template is truthy, +// the step executes. Falsy → skip. +// +// When both fields are set, skip_if takes precedence. +// When neither is set, the step always executes (backward compatible). +type SkippableStep struct { + inner interfaces.PipelineStep + skipIf string // Go template; truthy result → skip + ifExpr string // Go template; falsy result → skip + tmpl *TemplateEngine +} + +// NewSkippableStep creates a SkippableStep wrapping inner. +// skipIf and ifExpr may be empty strings to disable the respective guard. +func NewSkippableStep(inner interfaces.PipelineStep, skipIf, ifExpr string) *SkippableStep { + return &SkippableStep{ + inner: inner, + skipIf: skipIf, + ifExpr: ifExpr, + tmpl: NewTemplateEngine(), + } +} + +// Name delegates to the wrapped step. +func (s *SkippableStep) Name() string { + return s.inner.Name() +} + +// Execute evaluates skip_if / if guards and either skips or delegates to the +// wrapped step. +func (s *SkippableStep) Execute(ctx context.Context, pc *PipelineContext) (*interfaces.StepResult, error) { + // Evaluate skip_if (takes precedence when both are set) + if s.skipIf != "" { + val, err := s.tmpl.Resolve(s.skipIf, pc) + if err != nil { + // Template resolution errors are non-fatal: treat as falsy (execute). + val = "" + } + if isTruthy(val) { + return skippedResult("skip_if evaluated to true"), nil + } + } + + // Evaluate if (inverse logic: falsy → skip) + if s.ifExpr != "" { + val, err := s.tmpl.Resolve(s.ifExpr, pc) + if err != nil { + // Template resolution errors are non-fatal: treat as falsy (skip). + val = "" + } + if !isTruthy(val) { + return skippedResult("if evaluated to false"), nil + } + } + + return s.inner.Execute(ctx, pc) +} + +// isTruthy returns true when the resolved template value should cause a +// skip_if guard to trigger (or an if guard to execute). +// Falsy values: empty string, "false", "0". +// Everything else is truthy. +func isTruthy(val string) bool { + trimmed := strings.TrimSpace(val) + switch trimmed { + case "", "false", "0": + return false + default: + return true + } +} + +// skippedResult builds the standard output for a step that was skipped by a guard. +func skippedResult(reason string) *interfaces.StepResult { + return &interfaces.StepResult{ + Output: map[string]any{ + "skipped": true, + "reason": reason, + }, + } +} diff --git a/module/pipeline_step_skip_test.go b/module/pipeline_step_skip_test.go new file mode 100644 index 00000000..23d8d932 --- /dev/null +++ b/module/pipeline_step_skip_test.go @@ -0,0 +1,264 @@ +package module + +import ( + "context" + "testing" +) + +// TestStepSkipIf_SkipsWhenTrue verifies that a step with skip_if evaluating to +// a truthy string is not executed. +func TestStepSkipIf_SkipsWhenTrue(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + wrapped := NewSkippableStep(inner, "{{ if true }}true{{ end }}", "") + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Inner step should NOT have executed + if len(inner.execLog) != 0 { + t.Errorf("inner step should not execute when skip_if is truthy, but executed %d time(s)", len(inner.execLog)) + } + + // Result should indicate skipped + if result == nil { + t.Fatal("expected non-nil result for skipped step") + } + if result.Output["skipped"] != true { + t.Errorf("expected skipped=true in output, got %v", result.Output["skipped"]) + } +} + +// TestStepSkipIf_ExecutesWhenFalse verifies that a step with skip_if evaluating +// to an empty/falsy string is executed normally. +func TestStepSkipIf_ExecutesWhenFalse(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + // Template produces empty string → falsy → do NOT skip → execute + wrapped := NewSkippableStep(inner, "{{ if false }}true{{ end }}", "") + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + // Inner step SHOULD have executed + if len(inner.execLog) != 1 { + t.Errorf("expected inner step to execute once, got %d", len(inner.execLog)) + } + + if result == nil || result.Output["ran"] != true { + t.Errorf("expected inner step output, got %v", result) + } +} + +// TestStepSkipIf_OutputContainsSkippedFlag verifies the output of a skipped step. +func TestStepSkipIf_OutputContainsSkippedFlag(t *testing.T) { + inner := newMockStep("inner", map[string]any{}) + wrapped := NewSkippableStep(inner, "true", "") // literal "true" + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if result.Output["skipped"] != true { + t.Errorf("expected skipped=true, got %v", result.Output["skipped"]) + } + if result.Output["reason"] == nil || result.Output["reason"] == "" { + t.Errorf("expected non-empty reason in output, got %v", result.Output["reason"]) + } +} + +// TestStepIf_ExecutesWhenTrue verifies that a step with `if` evaluating to a +// non-empty string is executed. +func TestStepIf_ExecutesWhenTrue(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + // if="some-value" → truthy → execute + wrapped := NewSkippableStep(inner, "", "some-value") + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 1 { + t.Errorf("expected inner step to execute once, got %d", len(inner.execLog)) + } + if result == nil || result.Output["ran"] != true { + t.Errorf("expected inner step output") + } +} + +// TestStepIf_SkipsWhenFalse verifies that a step with `if` evaluating to empty +// is skipped. +func TestStepIf_SkipsWhenFalse(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + // if="" → falsy → skip + wrapped := NewSkippableStep(inner, "", "{{ if false }}yes{{ end }}") + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 0 { + t.Errorf("inner step should not execute when `if` is falsy") + } + if result.Output["skipped"] != true { + t.Errorf("expected skipped=true, got %v", result.Output["skipped"]) + } +} + +// TestStepSkipIf_TemplateResolution verifies that skip_if templates have access +// to step outputs and current context. +func TestStepSkipIf_TemplateResolution(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + // skip_if reads from current context — if feature_flag == "off", skip + wrapped := NewSkippableStep(inner, `{{ if eq .feature_flag "off" }}true{{ end }}`, "") + + // feature_flag=off → skip_if = "true" → skip + pc := NewPipelineContext(map[string]any{"feature_flag": "off"}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 0 { + t.Errorf("expected inner step to be skipped when feature_flag=off") + } + if result.Output["skipped"] != true { + t.Errorf("expected skipped=true in output") + } + + // Now with feature_flag=on → skip_if = "" → execute + inner2 := newMockStep("inner2", map[string]any{"ran": true}) + wrapped2 := NewSkippableStep(inner2, `{{ if eq .feature_flag "off" }}true{{ end }}`, "") + + pc2 := NewPipelineContext(map[string]any{"feature_flag": "on"}, nil) + result2, err := wrapped2.Execute(context.Background(), pc2) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner2.execLog) != 1 { + t.Errorf("expected inner2 step to execute when feature_flag=on") + } + if result2.Output["ran"] != true { + t.Errorf("expected inner step output when not skipped") + } +} + +// TestStepSkipIf_TemplateAccessesStepOutputs verifies that skip_if can read +// outputs from previously-executed steps. +func TestStepSkipIf_TemplateAccessesStepOutputs(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + // skip_if reads from a previous step's output + wrapped := NewSkippableStep(inner, `{{ index .steps "check" "should_skip" }}`, "") + + pc := NewPipelineContext(map[string]any{}, nil) + pc.MergeStepOutput("check", map[string]any{"should_skip": "true"}) + + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 0 { + t.Errorf("expected step to be skipped based on previous step output") + } + if result.Output["skipped"] != true { + t.Errorf("expected skipped=true") + } +} + +// TestStepSkipIf_AbsentMeansExecute verifies that steps without skip_if or if +// always execute (backward compatibility). +func TestStepSkipIf_AbsentMeansExecute(t *testing.T) { + inner := newMockStep("plain", map[string]any{"value": 42}) + // No skip_if, no if → always execute + wrapped := NewSkippableStep(inner, "", "") + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 1 { + t.Errorf("expected inner step to execute once, got %d", len(inner.execLog)) + } + if result == nil || result.Output["value"] != 42 { + t.Errorf("expected inner step output, got %v", result) + } +} + +// TestStepSkipIf_FalseStringIsFalsy verifies that the string "false" is treated +// as falsy (do not skip). +func TestStepSkipIf_FalseStringIsFalsy(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + wrapped := NewSkippableStep(inner, "false", "") + + pc := NewPipelineContext(map[string]any{}, nil) + _, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 1 { + t.Errorf("expected inner step to execute when skip_if='false', got %d executions", len(inner.execLog)) + } +} + +// TestStepSkipIf_ZeroStringIsFalsy verifies that the string "0" is treated as +// falsy (do not skip). +func TestStepSkipIf_ZeroStringIsFalsy(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + wrapped := NewSkippableStep(inner, "0", "") + + pc := NewPipelineContext(map[string]any{}, nil) + _, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 1 { + t.Errorf("expected inner step to execute when skip_if='0', got %d executions", len(inner.execLog)) + } +} + +// TestSkippableStep_Name verifies that the wrapper delegates Name() to the inner step. +func TestSkippableStep_Name(t *testing.T) { + inner := newMockStep("my-step", map[string]any{}) + wrapped := NewSkippableStep(inner, "true", "") + + if wrapped.Name() != "my-step" { + t.Errorf("expected name 'my-step', got %q", wrapped.Name()) + } +} + +// TestStepSkipIf_BothFieldsSet_SkipIfTakesPrecedence verifies that when both +// skip_if and if are set, skip_if takes precedence. +func TestStepSkipIf_BothFieldsSet_SkipIfTakesPrecedence(t *testing.T) { + inner := newMockStep("inner", map[string]any{"ran": true}) + // skip_if=true AND if=true → skip (skip_if wins) + wrapped := NewSkippableStep(inner, "true", "also-true") + + pc := NewPipelineContext(map[string]any{}, nil) + result, err := wrapped.Execute(context.Background(), pc) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + if len(inner.execLog) != 0 { + t.Errorf("expected step to be skipped when skip_if=true") + } + if result.Output["skipped"] != true { + t.Errorf("expected skipped=true") + } +}