navidrome/plugins/cmd/ndpgen/internal/generator_test.go
Deluan Quintão 1072e9f7eb
chore(plugins): document requiredHosts rules and deprecate pdk.NewHTTPRequest (#6129)
* fix(plugins): align the Python HTTP example with the repo's host-call pattern

Bind http_send with raw memory offsets like nowplaying-py does, drop
guards for fields the host always sends, and document how plugins
without a PDK call host services and which built-in HTTP APIs are
disabled.

* docs(plugins): document the private-address rules for HTTP requiredHosts

Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can.

* docs(plugins): document the private-address rules for requiredHosts

Explain in the README and manifest schema that named hosts can't reach
private addresses while IP/CIDR entries and a bare "*" can, for both
HTTP and WebSocket. Inline the single-use HTTP isHostAllowed wrapper.

* feat(plugins): derive Default for Rust host service structs

The ndpgen client.rs template now adds Default to the derive list of host
service structs, as the capability and shared types templates already do.
Plugin authors can now set only the fields they need, for example
HTTPRequest { method, url, ..Default::default() }. The webhook-rs and
discord-rich-presence-rs examples use this form now. The golden files and
the generated nd-pdk-host crate are updated to match.

* feat(plugins): deprecate pdk.NewHTTPRequest in the Go PDK

Navidrome no longer enables extism's http_request host function, so a
request built with pdk.NewHTTPRequest always fails. ndpgen now reads a small
deprecation table and writes a Deprecated: paragraph for the listed extism
functions, in both the WASM wrapper and the native stub. Linters and IDEs
now point plugin authors to host.HTTPSend. The PDK example tests used to
teach NewHTTPRequest. They now use host.HTTPSend and host.HTTPMock.

* docs(plugins): correct requiredHosts rules for websocket and private addresses

Two statements in the plugin docs did not match the code.

The WebSocket section claimed requiredHosts behaves like HTTP. It does not:
host_httpclient.go only consults the allowlist when the list is non-empty and
otherwise falls back to allowing public addresses, while host_websocket.go
always calls isHostInAllowlist, so an absent list blocks every connection.

The HTTP section claimed a named host can never reach a private address.
checkPrivateDial scans the whole requiredHosts list, so a named host does
reach a private address when the same list also holds a covering IP or CIDR.

Reworded both, plus the matching requiredHosts descriptions in
manifest-schema.json, and regenerated manifest_gen.go.
2026-09-12 13:59:46 -04:00

1825 lines
59 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)"))
})
})
Describe("Deprecated PDK functions", func() {
symbols := &PDKSymbols{
Functions: []PDKFunc{
{
Name: "NewHTTPRequest",
Doc: "NewHTTPRequest returns a new `HTTPRequest`.",
Params: []PDKParam{{Name: "method", Type: "HTTPMethod"}, {Name: "url", Type: "string"}},
Returns: []PDKReturn{{Type: "*HTTPRequest"}},
Deprecated: "Use host.HTTPSend instead.",
},
},
}
DescribeTable("emits a Deprecated paragraph after the doc line",
func(generate func(*PDKSymbols) ([]byte, error)) {
code, err := generate(symbols)
Expect(err).NotTo(HaveOccurred())
_, err = format.Source(code)
Expect(err).NotTo(HaveOccurred())
Expect(string(code)).To(ContainSubstring(
"// NewHTTPRequest NewHTTPRequest returns a new `HTTPRequest`.\n//\n// Deprecated: Use host.HTTPSend instead.\nfunc NewHTTPRequest("))
},
Entry("WASM wrapper", GeneratePDKGo),
Entry("native stub", GeneratePDKGoStub),
)
})
})
})
func writeFile(path, content string) error {
return os.WriteFile(path, []byte(content), 0600)
}