navidrome/plugins/cmd/ndpgen/internal/generator_test.go
Deluan Quintão 5a5311a9c0
fix(plugins): make generated mock stubs nil-safe for nilable returns (#5909)
The ndpgen mock accessors asserted return types unconditionally, so a plugin
test using Return(nil, ...) — the natural way to model an empty result or a
pagination terminal page — panicked on the untyped-nil type assertion instead
of returning the zero value. Guard pointer, slice, map and any returns in both
the host-service client stubs and the PDK stub, and regenerate.
2026-08-08 21:57:44 -04:00

1799 lines
58 KiB
Go

package internal
import (
"go/format"
"os"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
var _ = Describe("Generator", func() {
Describe("GenerateHost", func() {
It("should generate valid Go code for a simple service with strings", func() {
// All methods use JSON request/response types
svc := Service{
Name: "SubsonicAPI",
Permission: "subsonicapi",
Interface: "SubsonicAPIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
// Verify the code is valid Go
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package host"))
// All methods now use request type for JSON protocol
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallRequest struct"))
Expect(codeStr).To(ContainSubstring(`Uri string `))
// Response type with error handling
Expect(codeStr).To(ContainSubstring("type SubsonicAPICallResponse struct"))
Expect(codeStr).To(ContainSubstring(`Response string `))
Expect(codeStr).To(ContainSubstring(`Error string `))
// Check for registration function
Expect(codeStr).To(ContainSubstring("func RegisterSubsonicAPIHostFunctions(service SubsonicAPIService)"))
// Check for host function name
Expect(codeStr).To(ContainSubstring(`"subsonicapi_call"`))
// Check for JSON unmarshal (all methods use JSON now)
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
})
It("should generate code for methods without parameters", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoParams",
HasError: true,
Returns: []Param{NewParam("result", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Methods without params don't need a request type - no params to serialize
Expect(codeStr).NotTo(ContainSubstring("type TestNoParamsRequest struct"))
// But still uses PTR input/output for consistency
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
})
It("should generate code for methods without return values", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "NoReturn",
HasError: true,
Params: []Param{NewParam("input", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
})
It("should generate code for multiple methods", func() {
svc := Service{
Name: "Scheduler",
Permission: "scheduler",
Interface: "SchedulerService",
Methods: []Method{
{
Name: "ScheduleRecurring",
HasError: true,
Params: []Param{NewParam("cronExpression", "string")},
Returns: []Param{NewParam("scheduleID", "string")},
},
{
Name: "ScheduleOneTime",
HasError: true,
Params: []Param{NewParam("delaySeconds", "int32")},
Returns: []Param{NewParam("scheduleID", "string")},
},
{
Name: "CancelSchedule",
HasError: true,
Params: []Param{NewParam("scheduleID", "string")},
Returns: []Param{NewParam("canceled", "bool")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("scheduler_schedulerecurring"))
Expect(codeStr).To(ContainSubstring("scheduler_scheduleonetime"))
Expect(codeStr).To(ContainSubstring("scheduler_cancelschedule"))
})
It("should handle multiple simple parameters with JSON", func() {
// All params use JSON - single PTR input
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "MultiParam",
HasError: true,
Params: []Param{
NewParam("name", "string"),
NewParam("count", "int32"),
NewParam("enabled", "bool"),
},
Returns: []Param{NewParam("result", "string")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// All methods use request type with JSON protocol
Expect(codeStr).To(ContainSubstring("type TestMultiParamRequest struct"))
// Check for JSON unmarshal (all methods use JSON now)
Expect(codeStr).To(ContainSubstring("json.Unmarshal"))
// Check that input/output ValueType both use PTR (JSON)
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
})
It("should use single PTR for mixed simple and complex params", func() {
// When any param needs JSON, all are bundled into one request struct
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "MixedParam",
HasError: true,
Params: []Param{
NewParam("id", "string"), // simple (PTR for string)
NewParam("tags", "[]string"), // complex - needs JSON
},
Returns: []Param{NewParam("count", "int32")}, // simple
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Request type IS needed because of complex param
Expect(codeStr).To(ContainSubstring("type TestMixedParamRequest struct"))
// When using request type, only ONE PTR for input (the JSON request)
Expect(codeStr).To(MatchRegexp(`\[\]extism\.ValueType\{extism\.ValueTypePTR\},\s*\[\]extism\.ValueType\{extism\.ValueTypePTR\}`))
})
It("should generate proper JSON tags for complex types", func() {
// Complex types (structs, slices, maps) need JSON serialization
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
HasError: true,
Params: []Param{NewParam("inputValue", "[]string")}, // slice needs JSON
Returns: []Param{NewParam("outputValue", "map[string]string")}, // map needs JSON
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Complex params need request type with JSON tags
Expect(codeStr).To(ContainSubstring(`json:"inputValue"`))
// Complex returns need response type with JSON tags
Expect(codeStr).To(ContainSubstring(`json:"outputValue,omitempty"`))
})
It("should include required imports", func() {
// Service with complex types needs JSON import
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
HasError: true,
Params: []Param{NewParam("data", "MyStruct")}, // struct needs JSON
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring(`"context"`))
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
It("should always include json import for JSON protocol", func() {
// All services use JSON protocol, so json import is always needed
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Method",
Params: []Param{NewParam("count", "int32")},
Returns: []Param{NewParam("result", "int64")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring(`"context"`))
Expect(codeStr).To(ContainSubstring(`"encoding/json"`))
Expect(codeStr).To(ContainSubstring(`extism "github.com/extism/go-sdk"`))
})
It("imports the shared types package when a method references types directly", func() {
svc := Service{
Name: "Matcher",
Interface: "MatcherService",
Methods: []Method{
{
Name: "MatchSongs",
HasError: true,
Params: []Param{NewParam("songs", "[]types.SongRef")},
Returns: []Param{NewParam("results", "[]*types.Track")},
},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`))
Expect(codeStr).To(ContainSubstring("Songs []types.SongRef"))
})
It("does not import the shared types package when no method references types", func() {
svc := Service{
Name: "Test",
Interface: "TestService",
Methods: []Method{
{Name: "Method", Params: []Param{NewParam("count", "int32")}},
},
}
code, err := GenerateHost(svc, "host")
Expect(err).NotTo(HaveOccurred())
Expect(string(code)).NotTo(ContainSubstring(`"github.com/navidrome/navidrome/plugins/types"`))
})
})
Describe("toJSONName", func() {
It("should convert to camelCase matching Rust serde behavior", func() {
Expect(toJSONName("InputValue")).To(Equal("inputValue"))
Expect(toJSONName("URI")).To(Equal("uri"))
Expect(toJSONName("id")).To(Equal("id"))
Expect(toJSONName("ID")).To(Equal("id"))
Expect(toJSONName("ConnectionID")).To(Equal("connectionId"))
Expect(toJSONName("NewConnectionID")).To(Equal("newConnectionId"))
Expect(toJSONName("XMLHTTPRequest")).To(Equal("xmlhttpRequest"))
Expect(toJSONName("APIKey")).To(Equal("apiKey"))
})
It("should handle empty string", func() {
Expect(toJSONName("")).To(Equal(""))
})
})
Describe("NewParam", func() {
It("should create param with auto-generated JSON name", func() {
p := NewParam("MyParam", "string")
Expect(p.Name).To(Equal("MyParam"))
Expect(p.Type).To(Equal("string"))
Expect(p.JSONName).To(Equal("myParam"))
})
})
Describe("Method.IsOptionPattern", func() {
It("should return true for (value, exists bool) pattern", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "exists", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeTrue())
})
It("should return true for (value, ok bool) pattern", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "int64"},
{Name: "ok", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeTrue())
})
It("should return true for (value, found bool) pattern", func() {
m := Method{
Returns: []Param{
{Name: "data", Type: "[]byte"},
{Name: "found", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeTrue())
})
It("should be case insensitive for bool name", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "EXISTS", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeTrue())
})
It("should return false for single return", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "string"},
},
}
Expect(m.IsOptionPattern()).To(BeFalse())
})
It("should return false for more than two returns", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "count", Type: "int"},
{Name: "exists", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeFalse())
})
It("should return false when second return is not bool", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "count", Type: "int"},
},
}
Expect(m.IsOptionPattern()).To(BeFalse())
})
It("should return false when bool is not named exists/ok/found", func() {
m := Method{
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "success", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeFalse())
})
It("should return false for Has() pattern where first return is bool", func() {
// Has(key) -> (exists bool) should NOT be treated as Option pattern
m := Method{
Returns: []Param{
{Name: "exists", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeFalse())
})
It("should return false when first return is bool (preserves Has-like methods)", func() {
// Even with two returns, if first is bool, don't convert to Option<bool>
m := Method{
Returns: []Param{
{Name: "result", Type: "bool"},
{Name: "exists", Type: "bool"},
},
}
Expect(m.IsOptionPattern()).To(BeFalse())
})
})
Describe("ToSnakeCase", func() {
It("should convert PascalCase to snake_case", func() {
Expect(ToSnakeCase("ScheduleRecurring")).To(Equal("schedule_recurring"))
Expect(ToSnakeCase("GetString")).To(Equal("get_string"))
Expect(ToSnakeCase("simple")).To(Equal("simple"))
})
It("should handle acronyms correctly", func() {
Expect(ToSnakeCase("ID")).To(Equal("id"))
Expect(ToSnakeCase("ScheduleID")).To(Equal("schedule_id"))
Expect(ToSnakeCase("NewScheduleID")).To(Equal("new_schedule_id"))
Expect(ToSnakeCase("XMLParser")).To(Equal("xml_parser"))
Expect(ToSnakeCase("GetHTTPResponse")).To(Equal("get_http_response"))
})
})
Describe("GenerateGoDoc", func() {
It("should generate valid doc.go content for multiple services", func() {
services := []Service{
{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Doc: "CacheService provides temporary key-value storage with TTL.",
},
{
Name: "Scheduler",
Permission: "scheduler",
Interface: "SchedulerService",
Doc: "SchedulerService manages scheduled tasks.",
},
}
code, err := GenerateGoDoc(services, "ndpdk")
Expect(err).NotTo(HaveOccurred())
// Verify it's valid Go code
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package ndpdk"))
// Check for package documentation
Expect(codeStr).To(ContainSubstring("Package ndpdk provides Navidrome Plugin Development Kit wrappers"))
// Check that services are listed
Expect(codeStr).To(ContainSubstring("Cache:"))
Expect(codeStr).To(ContainSubstring("Scheduler:"))
})
})
Describe("GenerateGoMod", func() {
It("should generate valid go.mod content", func() {
code, err := GenerateGoMod()
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for module declaration (consolidated PDK path at pdk/go level)
Expect(codeStr).To(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go"))
// Ensure it's not the old host-specific path
Expect(codeStr).NotTo(ContainSubstring("module github.com/navidrome/navidrome/plugins/pdk/go/host"))
// Check for Go version
Expect(codeStr).To(ContainSubstring("go 1.25"))
// Check for extism-go-pdk dependency
Expect(codeStr).To(ContainSubstring("github.com/extism/go-pdk"))
})
})
Describe("GenerateClientGo", func() {
It("should include errors import when service has methods with errors", func() {
svc := Service{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Methods: []Method{
{
Name: "Get",
HasError: true,
Params: []Param{NewParam("key", "string")},
Returns: []Param{NewParam("value", "string")},
},
},
}
code, err := GenerateClientGo(svc, "host")
Expect(err).NotTo(HaveOccurred())
// Verify the code is valid Go (can't actually compile without wasip1)
codeStr := string(code)
// Check for errors import when methods have errors
Expect(codeStr).To(ContainSubstring(`"errors"`))
Expect(codeStr).To(ContainSubstring("errors.New"))
})
It("should not include errors import when service has no methods with errors", func() {
svc := Service{
Name: "Config",
Permission: "config",
Interface: "ConfigService",
Methods: []Method{
{
Name: "Get",
HasError: false,
Params: []Param{NewParam("key", "string")},
Returns: []Param{NewParam("value", "string"), NewParam("exists", "bool")},
},
{
Name: "List",
HasError: false,
Params: []Param{NewParam("prefix", "string")},
Returns: []Param{NewParam("keys", "[]string")},
},
},
}
code, err := GenerateClientGo(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check that errors is NOT imported when no methods have errors
Expect(codeStr).NotTo(ContainSubstring(`"errors"`))
Expect(codeStr).NotTo(ContainSubstring("errors.New"))
})
It("should generate valid Go code structure", func() {
svc := Service{
Name: "SubsonicAPI",
Permission: "subsonicapi",
Interface: "SubsonicAPIService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateClientGo(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for generated header
Expect(codeStr).To(ContainSubstring("Code generated by ndpgen. DO NOT EDIT."))
// Check for build tag
Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package host"))
// Check for wasmimport directive
Expect(codeStr).To(ContainSubstring("//go:wasmimport extism:host/user"))
// Check for PDK import
Expect(codeStr).To(ContainSubstring("github.com/navidrome/navidrome/plugins/pdk/go/pdk"))
})
})
Describe("GenerateClientGoStub", func() {
It("should generate valid mock code with testify/mock", func() {
svc := Service{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Doc: "CacheService provides caching capabilities.",
Methods: []Method{
{
Name: "Get",
Doc: "Get retrieves a value from the cache.",
Params: []Param{
{Name: "key", Type: "string"},
},
Returns: []Param{
{Name: "value", Type: "string"},
{Name: "exists", Type: "bool"},
},
},
},
}
code, err := GenerateClientGoStub(svc, "ndpdk")
Expect(err).NotTo(HaveOccurred())
// Verify it's valid Go code
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for build tag (non-WASM)
Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package ndpdk"))
// Check for mock comment
Expect(codeStr).To(ContainSubstring("mock implementations for non-WASM builds"))
// Check for testify/mock import
Expect(codeStr).To(ContainSubstring(`"github.com/stretchr/testify/mock"`))
// Check for private mock struct
Expect(codeStr).To(ContainSubstring("type mockCacheService struct"))
Expect(codeStr).To(ContainSubstring("mock.Mock"))
// Check for exported mock instance
Expect(codeStr).To(ContainSubstring("var CacheMock = &mockCacheService{}"))
// Check for mock method
Expect(codeStr).To(ContainSubstring("func (m *mockCacheService) Get(key string)"))
Expect(codeStr).To(ContainSubstring("m.Called(key)"))
// Check for wrapper function delegating to mock
Expect(codeStr).To(ContainSubstring("func CacheGet(key string)"))
Expect(codeStr).To(ContainSubstring("return CacheMock.Get(key)"))
// Stub files should NOT have request/response types (they're not needed)
Expect(codeStr).NotTo(ContainSubstring("Request struct"))
Expect(codeStr).NotTo(ContainSubstring("Response struct"))
})
It("should generate correct mock return values for different types", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "GetString",
Params: []Param{
{Name: "key", Type: "string"},
},
Returns: []Param{
{Name: "value", Type: "string"},
},
HasError: true,
},
{
Name: "GetInt64",
Params: []Param{
{Name: "key", Type: "string"},
},
Returns: []Param{
{Name: "value", Type: "int64"},
{Name: "exists", Type: "bool"},
},
HasError: true,
},
{
Name: "GetBytes",
Params: []Param{
{Name: "key", Type: "string"},
},
Returns: []Param{
{Name: "value", Type: "[]byte"},
},
HasError: true,
},
},
}
code, err := GenerateClientGoStub(svc, "ndpdk")
Expect(err).NotTo(HaveOccurred())
// Verify it's valid Go code
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check string return uses args.String(0)
Expect(codeStr).To(ContainSubstring("args.String(0)"))
// Check int64 return uses args.Get(0).(int64)
Expect(codeStr).To(ContainSubstring("args.Get(0).(int64)"))
// Check bool return uses args.Bool(1)
Expect(codeStr).To(ContainSubstring("args.Bool(1)"))
// []byte is nil-able, so it gets a guarded extraction
Expect(codeStr).To(ContainSubstring("var r0 []byte"))
Expect(codeStr).To(ContainSubstring("r0 = v.([]byte)"))
// Check error returns use args.Error(N)
Expect(codeStr).To(ContainSubstring("args.Error("))
})
})
Describe("Integration", func() {
It("should generate compilable code from parsed source", func() {
// This is an integration test that verifies the full pipeline
src := `package host
import "context"
// TestService is a test service.
//nd:hostservice name=Test permission=test
type TestService interface {
// DoSomething does something.
//nd:hostfunc
DoSomething(ctx context.Context, input string) (output string, err error)
}
`
// Create temporary directory
tmpDir := GinkgoT().TempDir()
path := tmpDir + "/test.go"
err := writeFile(path, src)
Expect(err).NotTo(HaveOccurred())
// Parse
services, err := ParseDirectory(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(services).To(HaveLen(1))
// Generate
code, err := GenerateHost(services[0], "host")
Expect(err).NotTo(HaveOccurred())
// Format (validates syntax)
formatted, err := format.Source(code)
Expect(err).NotTo(HaveOccurred())
// Verify key elements
codeStr := string(formatted)
Expect(codeStr).To(ContainSubstring("RegisterTestHostFunctions"))
Expect(codeStr).To(ContainSubstring(`"test_dosomething"`))
})
})
Describe("GenerateCapabilityGo", func() {
It("should generate valid Go code for a non-required capability", func() {
cap := Capability{
Name: "metadata",
Interface: "MetadataAgent",
Required: false,
Doc: "MetadataAgent provides metadata retrieval.",
Methods: []Export{
{
Name: "GetArtistBiography",
ExportName: "nd_get_artist_biography",
Input: Param{Type: "ArtistInput"},
Output: Param{Type: "ArtistBiographyOutput"},
Doc: "Returns artist biography",
},
{
Name: "GetArtistImages",
ExportName: "nd_get_artist_images",
Input: Param{Type: "ArtistInput"},
Output: Param{Type: "ArtistImagesOutput"},
Doc: "Returns artist images",
},
},
Structs: []StructDef{
{
Name: "ArtistInput",
Fields: []FieldDef{
{Name: "ID", Type: "string", JSONTag: "id"},
{Name: "Name", Type: "string", JSONTag: "name"},
},
},
{
Name: "ArtistBiographyOutput",
Fields: []FieldDef{
{Name: "Biography", Type: "string", JSONTag: "biography"},
},
},
{
Name: "ArtistImagesOutput",
Fields: []FieldDef{
{Name: "Images", Type: "[]ImageInfo", JSONTag: "images"},
},
},
{
Name: "ImageInfo",
Fields: []FieldDef{
{Name: "URL", Type: "string", JSONTag: "url"},
{Name: "Size", Type: "int32", JSONTag: "size"},
},
},
},
}
code, err := GenerateCapabilityGo(cap, "metadata")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for build tag
Expect(codeStr).To(ContainSubstring("//go:build wasip1"))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package metadata"))
// Check for marker interface (non-required)
Expect(codeStr).To(ContainSubstring("type Metadata interface{}"))
// Check for provider interfaces
Expect(codeStr).To(ContainSubstring("type ArtistBiographyProvider interface"))
Expect(codeStr).To(ContainSubstring("type ArtistImagesProvider interface"))
// Check for Register function with type assertions
Expect(codeStr).To(ContainSubstring("func Register(impl Metadata)"))
Expect(codeStr).To(ContainSubstring("impl.(ArtistBiographyProvider)"))
// Check for export wrappers
Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_get_artist_biography"))
Expect(codeStr).To(ContainSubstring("func _NdGetArtistBiography()"))
// Check for NotImplementedCode handling
Expect(codeStr).To(ContainSubstring("NotImplementedCode"))
Expect(codeStr).To(ContainSubstring("return NotImplementedCode"))
// Check struct definitions
Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
Expect(codeStr).To(ContainSubstring("type ImageInfo struct"))
})
It("should generate valid Go code for a required capability", func() {
cap := Capability{
Name: "scrobbler",
Interface: "Scrobbler",
Required: true,
Methods: []Export{
{
Name: "IsAuthorized",
ExportName: "nd_scrobbler_is_authorized",
Input: Param{Type: "AuthInput"},
Output: Param{Type: "AuthOutput"},
},
{
Name: "Scrobble",
ExportName: "nd_scrobbler_scrobble",
Input: Param{Type: "ScrobbleInput"},
Output: Param{Type: "ScrobblerOutput"},
},
},
Structs: []StructDef{
{Name: "AuthInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
{Name: "AuthOutput", Fields: []FieldDef{{Name: "Authorized", Type: "bool", JSONTag: "authorized"}}},
{Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
{Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "Error", Type: "*string", JSONTag: "error", OmitEmpty: true}}},
},
}
code, err := GenerateCapabilityGo(cap, "scrobbler")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for full interface (required capability)
Expect(codeStr).To(ContainSubstring("type Scrobbler interface {"))
Expect(codeStr).To(ContainSubstring("IsAuthorized(AuthInput) (AuthOutput, error)"))
Expect(codeStr).To(ContainSubstring("Scrobble(ScrobbleInput) (ScrobblerOutput, error)"))
// Should NOT have provider interfaces for required capability
Expect(codeStr).NotTo(ContainSubstring("AuthProvider interface"))
// Register should directly assign methods
Expect(codeStr).To(ContainSubstring("func Register(impl Scrobbler)"))
Expect(codeStr).To(ContainSubstring("impl.IsAuthorized"))
})
It("should include type aliases and consts", func() {
cap := Capability{
Name: "scrobbler",
Interface: "Scrobbler",
Required: true,
Methods: []Export{
{
Name: "Scrobble",
ExportName: "nd_scrobble",
Input: Param{Type: "ScrobbleInput"},
Output: Param{Type: "ScrobblerOutput"},
},
},
Structs: []StructDef{
{Name: "ScrobbleInput", Fields: []FieldDef{{Name: "UserID", Type: "string", JSONTag: "userId"}}},
{Name: "ScrobblerOutput", Fields: []FieldDef{{Name: "ErrorType", Type: "*ScrobblerErrorType", JSONTag: "errorType", OmitEmpty: true}}},
},
TypeAliases: []TypeAlias{
{Name: "ScrobblerErrorType", Type: "string", Doc: "ScrobblerErrorType indicates error handling."},
},
Consts: []ConstGroup{
{
Type: "ScrobblerErrorType",
Values: []ConstDef{
{Name: "ScrobblerErrorNone", Value: `"none"`, Doc: "No error"},
{Name: "ScrobblerErrorRetry", Value: `"retry"`, Doc: "Retry later"},
},
},
},
}
code, err := GenerateCapabilityGo(cap, "scrobbler")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check type alias
Expect(codeStr).To(ContainSubstring("type ScrobblerErrorType string"))
// Check consts - all consts should have type annotation
Expect(codeStr).To(ContainSubstring("ScrobblerErrorNone ScrobblerErrorType ="))
Expect(codeStr).To(ContainSubstring(`"none"`))
Expect(codeStr).To(ContainSubstring("ScrobblerErrorRetry ScrobblerErrorType ="))
Expect(codeStr).To(ContainSubstring(`"retry"`))
})
It("emits a deprecated alias and types import for a shared-aliased capability", func() {
cap := Capability{
Name: "scrobbler",
Interface: "Scrobbler",
Required: true,
Methods: []Export{{
Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
Input: Param{Name: "input", Type: "NowPlayingRequest"},
}},
Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
{Name: "Track", Type: "TrackInfo", JSONTag: "track"},
}}},
SharedAliases: []SharedAlias{{
Name: "TrackInfo", Target: "types.TrackInfo",
Doc: "Deprecated: use types.TrackInfo.",
Def: StructDef{Name: "TrackInfo", Fields: []FieldDef{{Name: "Title", Type: "string", JSONTag: "title"}}},
}},
}
code, err := GenerateCapabilityGo(cap, "scrobbler")
Expect(err).NotTo(HaveOccurred())
out := string(code)
Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
Expect(out).To(ContainSubstring("// Deprecated: use types.TrackInfo."))
Expect(out).To(ContainSubstring("type TrackInfo = types.TrackInfo"))
Expect(out).NotTo(ContainSubstring("type TrackInfo struct"))
})
It("emits the types import for a direct types.X field with no deprecated alias", func() {
cap := Capability{
Name: "scrobbler",
Interface: "Scrobbler",
Required: true,
Methods: []Export{{
Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
Input: Param{Name: "input", Type: "NowPlayingRequest"},
}},
Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
{Name: "Song", Type: "types.SongRef", JSONTag: "song"},
}}},
// No SharedAliases: the field references the canonical type directly.
}
code, err := GenerateCapabilityGo(cap, "scrobbler")
Expect(err).NotTo(HaveOccurred())
out := string(code)
Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
Expect(out).To(ContainSubstring("types.SongRef"))
})
It("emits the types import for a direct types.X method input/output", func() {
cap := Capability{
Name: "demo",
Interface: "Demo",
Required: true,
Methods: []Export{{
Name: "Lookup", ExportName: "nd_demo_lookup",
Input: Param{Name: "input", Type: "types.SongRef"},
Output: Param{Name: "output", Type: "types.SongRef"},
}},
// No structs, no aliases: the method signature references the shared type directly.
}
code, err := GenerateCapabilityGo(cap, "demo")
Expect(err).NotTo(HaveOccurred())
out := string(code)
Expect(out).To(ContainSubstring(`"github.com/navidrome/navidrome/plugins/pdk/go/types"`))
Expect(out).To(ContainSubstring("types.SongRef"))
})
})
Describe("GenerateCapabilityGoStub", func() {
It("should generate valid stub code for non-WASM builds", func() {
cap := Capability{
Name: "metadata",
Interface: "MetadataAgent",
Required: false,
Methods: []Export{
{
Name: "GetArtistBiography",
ExportName: "nd_get_artist_biography",
Input: Param{Type: "ArtistInput"},
Output: Param{Type: "ArtistBiographyOutput"},
},
},
Structs: []StructDef{
{Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "ArtistBiographyOutput", Fields: []FieldDef{{Name: "Biography", Type: "string", JSONTag: "biography"}}},
},
}
code, err := GenerateCapabilityGoStub(cap, "metadata")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check for non-WASM build tag
Expect(codeStr).To(ContainSubstring("//go:build !wasip1"))
// Check for package declaration
Expect(codeStr).To(ContainSubstring("package metadata"))
// Check for no-op Register
Expect(codeStr).To(ContainSubstring("func Register(_ Metadata) {}"))
// Check struct definitions are present
Expect(codeStr).To(ContainSubstring("type ArtistInput struct"))
// Check there are no export wrappers
Expect(codeStr).NotTo(ContainSubstring("//go:wasmexport"))
Expect(codeStr).NotTo(ContainSubstring("pdk.InputJSON"))
})
})
Describe("End-to-end capability generation", func() {
It("should parse and generate capability code from source", func() {
src := `package capabilities
// Lifecycle provides plugin lifecycle hooks.
//nd:capability name=lifecycle
type Lifecycle interface {
// OnInit is called when the plugin is loaded.
//nd:export name=nd_on_init
OnInit(OnInitInput) (OnInitOutput, error)
}
// OnInitInput is the input for OnInit.
type OnInitInput struct {
}
// OnInitOutput is the output for OnInit.
type OnInitOutput struct {
// Error is the error message if initialization failed.
Error *string ` + "`json:\"error,omitempty\"`" + `
}
`
// Create temporary directory
tmpDir := GinkgoT().TempDir()
path := tmpDir + "/lifecycle.go"
err := writeFile(path, src)
Expect(err).NotTo(HaveOccurred())
// Parse
capabilities, err := ParseCapabilities(tmpDir)
Expect(err).NotTo(HaveOccurred())
Expect(capabilities).To(HaveLen(1))
cap := capabilities[0]
Expect(cap.Name).To(Equal("lifecycle"))
Expect(cap.Methods).To(HaveLen(1))
// Generate WASM code
code, err := GenerateCapabilityGo(cap, "lifecycle")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("//go:wasmexport nd_on_init"))
Expect(codeStr).To(ContainSubstring("type InitProvider interface"))
// Generate stub code
stubCode, err := GenerateCapabilityGoStub(cap, "lifecycle")
Expect(err).NotTo(HaveOccurred())
stubStr := string(stubCode)
Expect(stubStr).To(ContainSubstring("//go:build !wasip1"))
Expect(stubStr).To(ContainSubstring("func Register(_ Lifecycle) {}"))
})
})
})
var _ = Describe("Shared Types Generation", func() {
It("emits a Rust types crate root with serde derives", func() {
structs := []StructDef{
{Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{
{Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true},
{Name: "Name", Type: "string", JSONTag: "name"},
}},
}
code, err := GenerateSharedTypesRust(structs)
Expect(err).NotTo(HaveOccurred())
out := string(code)
Expect(out).To(ContainSubstring("use serde::{Deserialize, Serialize};"))
Expect(out).To(ContainSubstring("pub struct ArtistRef {"))
Expect(out).To(ContainSubstring(`#[serde(rename_all = "camelCase")]`))
Expect(out).To(ContainSubstring("pub name: String,"))
})
It("emits a flat Go types package with no imports", func() {
structs := []StructDef{
{Name: "ArtistRef", Doc: "ArtistRef references an artist.", Fields: []FieldDef{
{Name: "ID", Type: "string", JSONTag: "id", OmitEmpty: true},
{Name: "Name", Type: "string", JSONTag: "name"},
}},
}
code, err := GenerateSharedTypesGo(structs, "types")
Expect(err).NotTo(HaveOccurred())
out := string(code)
Expect(out).To(ContainSubstring("package types"))
Expect(out).To(ContainSubstring("type ArtistRef struct {"))
Expect(out).To(ContainSubstring("ID string `json:\"id,omitempty\"`"))
Expect(out).To(ContainSubstring("Name string `json:\"name\"`"))
Expect(out).NotTo(ContainSubstring("import"))
})
It("emits base64 serde for Vec<u8> fields in the Rust types crate", func() {
structs := []StructDef{
{Name: "Payload", Doc: "Payload carries raw bytes.", Fields: []FieldDef{
{Name: "Data", Type: "[]byte", JSONTag: "data"},
}},
}
code, err := GenerateSharedTypesRust(structs)
Expect(err).NotTo(HaveOccurred())
out := string(code)
Expect(out).To(ContainSubstring("mod base64_bytes"))
Expect(out).To(ContainSubstring("use base64::Engine as _"))
Expect(out).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
})
})
var _ = Describe("Rust Generation", func() {
Describe("skipSerializingFunc", func() {
It("should return Option::is_none for pointer types", func() {
Expect(skipSerializingFunc("*string")).To(Equal("Option::is_none"))
Expect(skipSerializingFunc("*MyStruct")).To(Equal("Option::is_none"))
})
It("should return the matching emptiness predicate for slice and map types", func() {
// The predicate must match the rendered Rust type: []T -> Vec<T>, map[K]V -> HashMap<K,V>.
Expect(skipSerializingFunc("[]string")).To(Equal("Vec::is_empty"))
Expect(skipSerializingFunc("[]int32")).To(Equal("Vec::is_empty"))
Expect(skipSerializingFunc("map[string]int")).To(Equal("HashMap::is_empty"))
})
It("should return String::is_empty for string type", func() {
Expect(skipSerializingFunc("string")).To(Equal("String::is_empty"))
})
It("should return std::ops::Not::not for bool type", func() {
Expect(skipSerializingFunc("bool")).To(Equal("std::ops::Not::not"))
})
It("should return is_zero_* functions for numeric types", func() {
Expect(skipSerializingFunc("int32")).To(Equal("is_zero_i32"))
Expect(skipSerializingFunc("uint32")).To(Equal("is_zero_u32"))
Expect(skipSerializingFunc("int64")).To(Equal("is_zero_i64"))
Expect(skipSerializingFunc("uint64")).To(Equal("is_zero_u64"))
Expect(skipSerializingFunc("float32")).To(Equal("is_zero_f32"))
Expect(skipSerializingFunc("float64")).To(Equal("is_zero_f64"))
})
It("should return Option::is_none for unknown types", func() {
Expect(skipSerializingFunc("CustomType")).To(Equal("Option::is_none"))
})
})
Describe("rustOutputType", func() {
It("should convert Go primitives to Rust primitives", func() {
Expect(rustOutputType("bool")).To(Equal("bool"))
Expect(rustOutputType("string")).To(Equal("String"))
Expect(rustOutputType("int")).To(Equal("i32"))
Expect(rustOutputType("int32")).To(Equal("i32"))
Expect(rustOutputType("int64")).To(Equal("i64"))
Expect(rustOutputType("float32")).To(Equal("f32"))
Expect(rustOutputType("float64")).To(Equal("f64"))
})
It("should strip pointer prefix", func() {
// NOTE: This behavior is incorrect for pointer to primitives.
// "*string" returns "string" instead of "String", which would generate
// invalid Rust code. No current capability uses this pattern.
// See TODO in rustOutputType function.
Expect(rustOutputType("*string")).To(Equal("string"))
Expect(rustOutputType("*MyStruct")).To(Equal("MyStruct"))
})
It("should pass through unknown types", func() {
Expect(rustOutputType("CustomType")).To(Equal("CustomType"))
Expect(rustOutputType("MyStruct")).To(Equal("MyStruct"))
})
})
Describe("isPrimitiveRustType", func() {
It("should return true for primitive Go types", func() {
Expect(isPrimitiveRustType("bool")).To(BeTrue())
Expect(isPrimitiveRustType("string")).To(BeTrue())
Expect(isPrimitiveRustType("int")).To(BeTrue())
Expect(isPrimitiveRustType("int32")).To(BeTrue())
Expect(isPrimitiveRustType("int64")).To(BeTrue())
Expect(isPrimitiveRustType("float32")).To(BeTrue())
Expect(isPrimitiveRustType("float64")).To(BeTrue())
})
It("should return false for non-primitive types", func() {
Expect(isPrimitiveRustType("MyStruct")).To(BeFalse())
Expect(isPrimitiveRustType("CustomType")).To(BeFalse())
Expect(isPrimitiveRustType("[]string")).To(BeFalse())
Expect(isPrimitiveRustType("map[string]int")).To(BeFalse())
})
It("should handle pointer types by stripping prefix", func() {
Expect(isPrimitiveRustType("*string")).To(BeTrue())
Expect(isPrimitiveRustType("*int64")).To(BeTrue())
Expect(isPrimitiveRustType("*MyStruct")).To(BeFalse())
})
})
Describe("GenerateCapabilityRust", func() {
It("should generate valid Rust code with primitive output types", func() {
cap := Capability{
Name: "test",
Interface: "TestAgent",
Required: true,
SourceFile: "test",
Methods: []Export{
{
Name: "GetBool",
ExportName: "nd_get_bool",
Input: Param{Type: "BoolInput"},
Output: Param{Type: "bool"},
},
{
Name: "GetString",
ExportName: "nd_get_string",
Input: Param{Type: "StrInput"},
Output: Param{Type: "string"},
},
{
Name: "GetInt",
ExportName: "nd_get_int",
Input: Param{Type: "IntInput"},
Output: Param{Type: "int32"},
},
},
Structs: []StructDef{
{Name: "BoolInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "StrInput", Fields: []FieldDef{{Name: "Key", Type: "string", JSONTag: "key"}}},
{Name: "IntInput", Fields: []FieldDef{{Name: "Index", Type: "int32", JSONTag: "index"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Check that primitive output types are not prefixed with $crate::
// The template should use isPrimitiveRust to determine this
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<bool>>"))
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<String>>"))
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<i32>>"))
// Verify that primitive output types don't use $crate:: prefix in FnResult
// The pattern "$crate::test::bool>" would indicate incorrect generation
Expect(codeStr).NotTo(ContainSubstring("$crate::test::bool>"))
Expect(codeStr).NotTo(ContainSubstring("$crate::test::String>"))
Expect(codeStr).NotTo(ContainSubstring("$crate::test::i32>"))
})
It("should generate valid Rust code with struct output types", func() {
cap := Capability{
Name: "metadata",
Interface: "MetadataAgent",
Required: true,
SourceFile: "metadata",
Methods: []Export{
{
Name: "GetArtist",
ExportName: "nd_get_artist",
Input: Param{Type: "ArtistInput"},
Output: Param{Type: "ArtistOutput"},
},
},
Structs: []StructDef{
{Name: "ArtistInput", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "ArtistOutput", Fields: []FieldDef{{Name: "Name", Type: "string", JSONTag: "name"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Non-primitive struct types should use $crate:: prefix
Expect(codeStr).To(ContainSubstring("$crate::metadata::ArtistOutput"))
})
It("should generate valid Rust code with pointer output types", func() {
cap := Capability{
Name: "test",
Interface: "TestAgent",
Required: true,
SourceFile: "test",
Methods: []Export{
{
Name: "GetOptionalStruct",
ExportName: "nd_get_optional_struct",
Input: Param{Type: "Input"},
Output: Param{Type: "*Output"},
},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
{Name: "Output", Fields: []FieldDef{{Name: "Value", Type: "string", JSONTag: "value"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Pointer to struct should strip pointer and use struct type with $crate::
Expect(codeStr).To(ContainSubstring("$crate::test::Output>"))
// Pointer output types should NOT have Option<> wrapping - Result handles optionality
Expect(codeStr).NotTo(ContainSubstring("Option<"))
})
It("translates a shared type used directly as a method input/output", func() {
cap := Capability{
Name: "demo", Interface: "Demo", Required: true,
Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo",
Input: Param{Name: "input", Type: "types.SongRef"},
Output: Param{Name: "output", Type: "types.SongRef"}}},
// No structs, no aliases: the method signature references the shared type directly.
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
out := string(code)
// The shared type must resolve to the canonical crate path, not pass through
// as the invalid Go selector `types.SongRef`.
Expect(out).To(ContainSubstring("nd_pdk_types::SongRef"))
Expect(out).NotTo(ContainSubstring("types.SongRef"))
})
It("routes shared macro types through $crate so umbrella-crate plugins resolve them", func() {
cap := Capability{
Name: "demo", Interface: "Demo", Required: true,
Methods: []Export{{Name: "Echo", ExportName: "nd_demo_echo",
Input: Param{Name: "input", Type: "types.SongRef"},
Output: Param{Name: "output", Type: "types.SongRef"}}},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
out := string(code)
// Inside the export macro (expanded in the downstream plugin crate, which depends
// on the umbrella nd-pdk only), the shared type must be reachable via $crate, not
// by naming the transitive nd_pdk_types crate directly.
Expect(out).To(ContainSubstring("extism_pdk::Json<$crate::types::SongRef>"))
Expect(out).NotTo(ContainSubstring("extism_pdk::Json<nd_pdk_types::SongRef>"))
})
It("emits a deprecated Rust type alias for shared types", func() {
cap := Capability{
Name: "scrobbler", Interface: "Scrobbler", Required: true,
Methods: []Export{{Name: "NowPlaying", ExportName: "nd_scrobbler_now_playing",
Input: Param{Name: "input", Type: "NowPlayingRequest"}}},
Structs: []StructDef{{Name: "NowPlayingRequest", Fields: []FieldDef{
{Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}},
SharedAliases: []SharedAlias{{
Name: "TrackInfo", Target: "types.TrackInfo",
Doc: "Deprecated: use types.TrackInfo.",
}},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
out := string(code)
// Note points authors at the umbrella path (nd-pdk re-exports nd_pdk_types as `types`);
// the alias target stays the real crate path so it resolves inside nd-pdk-capabilities.
Expect(out).To(ContainSubstring(`#[deprecated(note = "use nd_pdk::types::TrackInfo")]`))
Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::TrackInfo;"))
})
It("keeps struct-field type when the type name is a shared alias (regression: was serde_json::Value)", func() {
// Wrapper has a field whose type is only in SharedAliases, not Structs.
// The field must render as `pub track: nd_pdk_types::TrackInfo` (canonical
// path), not as the local deprecated alias and not as serde_json::Value.
cap := Capability{
Name: "test", Interface: "TestAgent", Required: true,
Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit",
Input: Param{Name: "req", Type: "Wrapper"}}},
Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{
{Name: "Track", Type: "TrackInfo", JSONTag: "track"}}}},
SharedAliases: []SharedAlias{{
Name: "TrackInfo", Target: "types.TrackInfo",
Doc: "Deprecated: use types.TrackInfo.",
}},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
out := string(code)
// Field must use the canonical nd_pdk_types:: path, not the local alias.
Expect(out).To(ContainSubstring("nd_pdk_types::TrackInfo"))
Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value"))
Expect(out).NotTo(ContainSubstring("pub track: TrackInfo,"))
})
It("renders a qualified types.X field as nd_pdk_types::X and keeps the renamed re-export", func() {
// The capability references the shared type by its canonical qualified
// name (types.Track) while the deprecated alias keeps the old name.
cap := Capability{
Name: "test", Interface: "TestAgent", Required: true,
Methods: []Export{{Name: "Submit", ExportName: "nd_test_submit",
Input: Param{Name: "req", Type: "Wrapper"}}},
Structs: []StructDef{{Name: "Wrapper", Fields: []FieldDef{
{Name: "Track", Type: "types.Track", JSONTag: "track"}}}},
SharedAliases: []SharedAlias{{
Name: "TrackInfo", Target: "types.Track",
Doc: "Deprecated: use types.Track.",
}},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
out := string(code)
// Field uses the canonical qualified path (resolved from the types. prefix).
Expect(out).To(ContainSubstring("pub track: nd_pdk_types::Track,"))
// The deprecated alias is still re-exported under its kept name.
Expect(out).To(ContainSubstring("pub type TrackInfo = nd_pdk_types::Track;"))
Expect(out).NotTo(ContainSubstring("pub track: serde_json::Value"))
})
It("should include all float types correctly", func() {
cap := Capability{
Name: "test",
Interface: "TestAgent",
Required: true,
SourceFile: "test",
Methods: []Export{
{
Name: "GetFloat32",
ExportName: "nd_get_float32",
Input: Param{Type: "Input"},
Output: Param{Type: "float32"},
},
{
Name: "GetFloat64",
ExportName: "nd_get_float64",
Input: Param{Type: "Input"},
Output: Param{Type: "float64"},
},
},
Structs: []StructDef{
{Name: "Input", Fields: []FieldDef{{Name: "ID", Type: "string", JSONTag: "id"}}},
},
}
code, err := GenerateCapabilityRust(cap)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<f32>>"))
Expect(codeStr).To(ContainSubstring("FnResult<extism_pdk::Json<f64>>"))
})
})
Describe("GenerateClientRust", func() {
It("should generate Option<T> for (value, exists bool) pattern", func() {
svc := Service{
Name: "Config",
Permission: "config",
Interface: "ConfigService",
Methods: []Method{
{
Name: "Get",
Params: []Param{
{Name: "key", Type: "string", JSONName: "key"},
},
Returns: []Param{
{Name: "value", Type: "string", JSONName: "value"},
{Name: "exists", Type: "bool", JSONName: "exists"},
},
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should generate Option<String> return type, not (String, bool)
Expect(codeStr).To(ContainSubstring("Result<Option<String>, Error>"))
Expect(codeStr).NotTo(ContainSubstring("Result<(String, bool), Error>"))
// Should generate Some/None logic
Expect(codeStr).To(ContainSubstring("Ok(Some("))
Expect(codeStr).To(ContainSubstring("Ok(None)"))
})
It("should generate tuple for non-option multi-return", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "GetStats",
Returns: []Param{
{Name: "count", Type: "int64", JSONName: "count"},
{Name: "size", Type: "int64", JSONName: "size"},
},
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should generate tuple return type
Expect(codeStr).To(ContainSubstring("Result<(i64, i64), Error>"))
Expect(codeStr).NotTo(ContainSubstring("Option<"))
})
It("should NOT generate Option for Has() pattern where first return is bool", func() {
svc := Service{
Name: "Cache",
Permission: "cache",
Interface: "CacheService",
Methods: []Method{
{
Name: "Has",
Params: []Param{
{Name: "key", Type: "string", JSONName: "key"},
},
Returns: []Param{
{Name: "exists", Type: "bool", JSONName: "exists"},
},
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should generate simple bool return, not Option
Expect(codeStr).To(ContainSubstring("Result<bool, Error>"))
Expect(codeStr).NotTo(ContainSubstring("Option<bool>"))
})
It("should generate base64 serde for Vec<u8> fields", func() {
svc := Service{
Name: "Codec",
Permission: "codec",
Interface: "CodecService",
Methods: []Method{
{
Name: "Encode",
HasError: true,
Params: []Param{NewParam("data", "[]byte")},
Returns: []Param{NewParam("result", "[]byte")},
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
// Should generate base64_bytes serde module
Expect(codeStr).To(ContainSubstring("mod base64_bytes"))
Expect(codeStr).To(ContainSubstring("use base64::Engine as _"))
// Should add serde(with = "base64_bytes") on Vec<u8> fields
Expect(codeStr).To(ContainSubstring(`#[serde(with = "base64_bytes")]`))
})
It("resolves a shared alias used in a method param/return to its canonical crate path", func() {
svc := Service{
Name: "Matcher",
Permission: "matcher",
Interface: "MatcherService",
Methods: []Method{
{
Name: "MatchSongs",
HasError: true,
Params: []Param{NewParam("query", "string")},
// Return uses the deprecated alias name directly.
Returns: []Param{NewParam("matches", "[]Track")},
},
},
SharedAliases: []SharedAlias{{
Name: "Track", Target: "types.Track",
Def: StructDef{Name: "Track", Fields: []FieldDef{
{Name: "Title", Type: "string", JSONTag: "title"}}},
}},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
out := string(code)
// The alias must resolve to the shared crate type; a bare `Track` is undefined
// in nd-pdk-host and would not compile.
Expect(out).To(ContainSubstring("nd_pdk_types::Track"))
Expect(out).NotTo(ContainSubstring("Vec<Track>"))
})
It("should not generate base64 module when no byte fields", func() {
svc := Service{
Name: "Test",
Permission: "test",
Interface: "TestService",
Methods: []Method{
{
Name: "Call",
HasError: true,
Params: []Param{NewParam("uri", "string")},
Returns: []Param{NewParam("response", "string")},
},
},
}
code, err := GenerateClientRust(svc)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).NotTo(ContainSubstring("mod base64_bytes"))
Expect(codeStr).NotTo(ContainSubstring("use base64"))
})
})
Describe("nil-safe mock accessors", func() {
Describe("GenerateClientGoStub", func() {
It("guards pointer, slice and map returns so Return(nil, ...) does not panic", func() {
svc := Service{
Name: "Paged",
Interface: "PagedService",
Methods: []Method{
{
Name: "GetPage",
HasError: true,
Params: []Param{NewParam("query", "string")},
Returns: []Param{NewParam("items", "[]Item"), NewParam("next", "*PageOptions")},
},
{
Name: "GetLabels",
HasError: true,
Params: []Param{NewParam("id", "string")},
Returns: []Param{NewParam("labels", "map[string]string")},
},
},
}
code, err := GenerateClientGoStub(svc, "host")
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("var r0 []Item"))
Expect(codeStr).To(ContainSubstring("var r1 *PageOptions"))
Expect(codeStr).To(ContainSubstring("if v := args.Get(1); v != nil {"))
Expect(codeStr).To(ContainSubstring("r1 = v.(*PageOptions)"))
Expect(codeStr).To(ContainSubstring("return r0, r1, args.Error(2)"))
Expect(codeStr).To(ContainSubstring("var r0 map[string]string"))
Expect(codeStr).NotTo(ContainSubstring("args.Get(0).([]Item)"))
Expect(codeStr).NotTo(ContainSubstring("args.Get(1).(*PageOptions)"))
})
It("keeps non-nilable returns as inline accessors", func() {
svc := Service{
Name: "Counter",
Interface: "CounterService",
Methods: []Method{
{
Name: "Count",
HasError: true,
Params: []Param{NewParam("id", "string")},
Returns: []Param{NewParam("count", "int64"), NewParam("name", "string")},
},
},
}
code, err := GenerateClientGoStub(svc, "host")
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("return args.Get(0).(int64), args.String(1), args.Error(2)"))
Expect(codeStr).NotTo(ContainSubstring("var r0"))
})
})
Describe("GeneratePDKGoStub", func() {
It("guards pointer and slice returns", func() {
symbols := &PDKSymbols{
Functions: []PDKFunc{
{
Name: "NewHTTPRequest",
Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}},
Returns: []PDKReturn{{Type: "*HTTPRequest"}},
},
{
Name: "Input",
Returns: []PDKReturn{{Type: "[]byte"}},
},
},
}
code, err := GeneratePDKGoStub(symbols)
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
codeStr := string(code)
Expect(codeStr).To(ContainSubstring("var r0 *HTTPRequest"))
Expect(codeStr).To(ContainSubstring("r0 = v.(*HTTPRequest)"))
Expect(codeStr).To(ContainSubstring("var r0 []byte"))
Expect(codeStr).NotTo(ContainSubstring("return args.Get(0).(*HTTPRequest)"))
})
})
})
})
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0600)
}