package main import ( "context" "os" "path/filepath" "strings" "testing" "ucl.lmika.dev/ucl" "ucl.lmika.dev/ucl/builtins" ) func newTestPlugins() (*PluginFilters, *ucl.Inst) { plugins := newPluginFilters() inst := ucl.New( ucl.WithModule(plugins.Module()), ucl.WithModule(builtins.Strs()), ) return plugins, inst } func writePluginFile(t *testing.T, dir, name, content string) { t.Helper() path := filepath.Join(dir, name) if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil { t.Fatal(err) } if err := os.WriteFile(path, []byte(content), 0644); err != nil { t.Fatal(err) } } func TestPluginFilters_LoadDir(t *testing.T) { ctx := context.Background() t.Run("registers filter from upper-case example", func(t *testing.T) { plugins, inst := newTestPlugins() dir := t.TempDir() writePluginFile(t, dir, "upper.ucl", ` d:filter "String: To Upper Case" { |in| strs:to-upper $in } `) plugins.LoadDir(ctx, inst, dir) if len(plugins.Errors()) != 0 { t.Fatalf("unexpected errors: %v", plugins.Errors()) } proc, ok := plugins.filters["plugin:string-to-upper-case"] if !ok { t.Fatalf("filter not registered, have %v", plugins.filters) } if proc.Label != "String: To Upper Case" { t.Errorf("label = %q", proc.Label) } res, err := proc.Filter(ctx, "abc") if err != nil { t.Fatal(err) } if res.Output != "ABC" || res.Append { t.Errorf("got %+v", res) } }) t.Run("finds nested files and ignores non-ucl files", func(t *testing.T) { plugins, inst := newTestPlugins() dir := t.TempDir() writePluginFile(t, dir, "sub/dir/nested.ucl", `d:filter "Nested" { |in| $in }`) writePluginFile(t, dir, "notes.txt", `d:filter "Ignored" { |in| $in }`) writePluginFile(t, dir, "README.md", `this is not ucl {`) plugins.LoadDir(ctx, inst, dir) if len(plugins.Errors()) != 0 { t.Fatalf("unexpected errors: %v", plugins.Errors()) } if _, ok := plugins.filters["plugin:nested"]; !ok { t.Error("nested filter not registered") } if _, ok := plugins.filters["plugin:ignored"]; ok { t.Error("non-ucl file should have been ignored") } }) t.Run("missing directory is not an error", func(t *testing.T) { plugins, inst := newTestPlugins() plugins.LoadDir(ctx, inst, filepath.Join(t.TempDir(), "does-not-exist")) if len(plugins.Errors()) != 0 { t.Fatalf("unexpected errors: %v", plugins.Errors()) } if len(plugins.filters) != 0 { t.Errorf("unexpected filters: %v", plugins.filters) } }) t.Run("bad file is reported and sibling still loads", func(t *testing.T) { plugins, inst := newTestPlugins() dir := t.TempDir() writePluginFile(t, dir, "a-bad.ucl", `d:filter "Broken" { |in| `) writePluginFile(t, dir, "b-good.ucl", `d:filter "Good" { |in| $in }`) writePluginFile(t, dir, "c-bad-usage.ucl", `d:filter "Only Label"`) plugins.LoadDir(ctx, inst, dir) errs := plugins.Errors() if len(errs) != 2 { t.Fatalf("expected 2 errors, got %v", errs) } if !strings.Contains(errs[0].Error(), "a-bad.ucl") { t.Errorf("first error should name the file: %v", errs[0]) } if !strings.Contains(errs[1].Error(), "c-bad-usage.ucl") { t.Errorf("second error should name the file: %v", errs[1]) } if _, ok := plugins.filters["plugin:good"]; !ok { t.Error("good filter not registered") } if _, ok := plugins.filters["plugin:broken"]; ok { t.Error("broken filter should not be registered") } }) t.Run("later definition with same label wins", func(t *testing.T) { plugins, inst := newTestPlugins() dir := t.TempDir() writePluginFile(t, dir, "01-first.ucl", `d:filter "Dup" { |in| "first" }`) writePluginFile(t, dir, "02-second.ucl", `d:filter "Dup" { |in| "second" }`) plugins.LoadDir(ctx, inst, dir) if len(plugins.filters) != 1 { t.Fatalf("expected 1 filter, got %v", plugins.filters) } res, err := plugins.filters["plugin:dup"].Filter(ctx, "x") if err != nil { t.Fatal(err) } if res.Output != "second" { t.Errorf("output = %q", res.Output) } }) t.Run("procs defined in plugin files are reusable", func(t *testing.T) { plugins, inst := newTestPlugins() dir := t.TempDir() writePluginFile(t, dir, "helpers.ucl", ` proc shout { |s| strs:to-upper $s } d:filter "Shout" { |in| shout $in } `) plugins.LoadDir(ctx, inst, dir) if len(plugins.Errors()) != 0 { t.Fatalf("unexpected errors: %v", plugins.Errors()) } res, err := plugins.filters["plugin:shout"].Filter(ctx, "hi") if err != nil { t.Fatal(err) } if res.Output != "HI" { t.Errorf("output = %q", res.Output) } }) } func TestPluginFilters_ProcResults(t *testing.T) { ctx := context.Background() tests := []struct { name string script string input string want string wantErr string }{ { name: "string result used as-is", script: `d:filter "F" { |in| strs:to-upper $in }`, input: "abc", want: "ABC", }, { name: "non-string result is stringified", script: `d:filter "F" { |in| add 40 2 }`, input: "ignored", want: "42", }, { name: "nil result is an error", script: `d:filter "F" { |in| }`, input: "abc", wantErr: "filter returned no value", }, { name: "error raised in proc propagates", script: `d:filter "F" { |in| error "boom" }`, input: "abc", wantErr: "boom", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { plugins, inst := newTestPlugins() if _, err := inst.EvalString(ctx, tt.script); err != nil { t.Fatal(err) } proc, ok := plugins.filters["plugin:f"] if !ok { t.Fatalf("filter not registered") } res, err := proc.Filter(ctx, tt.input) if tt.wantErr != "" { if err == nil || !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("expected error containing %q, got %v (res %+v)", tt.wantErr, err, res) } return } if err != nil { t.Fatal(err) } if res.Output != tt.want { t.Errorf("output = %q, want %q", res.Output, tt.want) } }) } } func TestPluginFilters_FilterBuiltinValidation(t *testing.T) { ctx := context.Background() tests := []struct { name string script string }{ {name: "missing proc", script: `d:filter "Label"`}, {name: "no args", script: `d:filter`}, {name: "empty label", script: `d:filter " " { |in| $in }`}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { plugins, inst := newTestPlugins() if _, err := inst.EvalString(ctx, tt.script); err == nil { t.Fatal("expected an error") } if len(plugins.filters) != 0 { t.Errorf("no filter should be registered, got %v", plugins.filters) } }) } } func TestSlugify(t *testing.T) { tests := []struct { in string want string }{ {"String: To Upper Case", "string-to-upper-case"}, {" Lines: Go Template… ", "lines-go-template"}, {"already-a-slug", "already-a-slug"}, {"MiXeD CaSe 123", "mixed-case-123"}, {"---", ""}, } for _, tt := range tests { if got := slugify(tt.in); got != tt.want { t.Errorf("slugify(%q) = %q, want %q", tt.in, got, tt.want) } } } func TestApp_PluginIntegration(t *testing.T) { ctx := context.Background() app := NewApp(nil) dir := t.TempDir() writePluginFile(t, dir, "upper.ucl", `d:filter "String: To Upper Case" { |in| strs:to-upper $in }`) writePluginFile(t, dir, "rev.ucl", `d:filter "Custom: Echo" { |in| $in }`) app.plugins.LoadDir(ctx, app.uclInst, dir) t.Run("ListProcessors shadows built-in by label", func(t *testing.T) { resp := app.ListProcessors() byLabel := map[string][]string{} for _, r := range resp { byLabel[r.Label] = append(byLabel[r.Label], r.Name) } if names := byLabel["String: To Upper Case"]; len(names) != 1 || names[0] != "plugin:string-to-upper-case" { t.Errorf("expected only the plugin entry for the shadowed label, got %v", names) } if names := byLabel["Custom: Echo"]; len(names) != 1 || names[0] != "plugin:custom-echo" { t.Errorf("expected plugin entry, got %v", names) } if names := byLabel["String: To Lower Case"]; len(names) != 1 || names[0] != "lower-case" { t.Errorf("built-in should be unaffected, got %v", names) } for i := 1; i < len(resp); i++ { if resp[i-1].Label > resp[i].Label { t.Errorf("response not sorted by label at %d: %q > %q", i, resp[i-1].Label, resp[i].Label) } } }) t.Run("lookupProcessor prefers plugin then falls back to built-in", func(t *testing.T) { if proc, ok := app.lookupProcessor("plugin:custom-echo"); !ok || proc.Label != "Custom: Echo" { t.Errorf("plugin lookup failed: %v %v", proc, ok) } if proc, ok := app.lookupProcessor("lower-case"); !ok || proc.Label != "String: To Lower Case" { t.Errorf("built-in lookup failed: %v %v", proc, ok) } if _, ok := app.lookupProcessor("nope"); ok { t.Error("unknown key should not be found") } }) }