mirror of
https://github.com/navidrome/navidrome.git
synced 2026-08-01 07:21:17 +00:00
* feat(plugins): add public Track and Artist DTOs for host services
* feat(plugins): add Matcher host-service interface and MatchSong DTO
* feat(plugins): generate Matcher host wrappers, PDK clients, and matcher permission
* feat(plugins): implement Matcher host service and MediaFile-to-Track converter
Also fixes an ndpgen bug where ParseDirectory parsed each host-service file
in isolation, so a service method referencing a struct defined in another
file of the same package (host.Track in track.go) could not be resolved.
ParseDirectory now collects package-wide structs in a first pass, mirroring
ParseCapabilities; PDK clients regenerated cleanly via make gen.
* feat(plugins): register Matcher host service in the manager
* test(plugins): add Matcher host service integration test plugin
* refactor(plugins): simplify matcher converter and parser file collection
- toTrack: use gg.V for nil-able field derefs and slice.Map for genres/
participants, removing the repeated nil-guard blocks and inner loop
- manager_loader: drop the redundant ds==nil guard (loadEnabledPlugins
already gates a nil DataStore), matching the other service entries
- ndpgen parser: extract collectGoFiles, shared by ParseDirectory and
ParseCapabilities instead of duplicating the file-filter loop
* fix(plugins): keep nullable Track numerics as pointers
ReplayGain values, BitDepth, and BPM are nullable in model.MediaFile, and 0
is a valid measured ReplayGain value. Flattening them to value types with
omitempty made a real 0 indistinguishable from absent. Model them as *float64
/*int32 so plugins can tell 'no data' from a measured 0. Regenerated PDK
clients; converter passes the model pointers through (RG) or maps *int->*int32
(BitDepth/BPM).
* refactor(plugins): trim redundant pass labels in ndpgen ParseDirectory
The function doc already explains the two-pass approach; the inline labels
restated it. Reduce to bare waypoints.
* fix(plugins): gate Track.Path on library filesystem permission
MatchSongs copied mf.Path into every result unconditionally, letting a plugin
with only the matcher permission enumerate on-disk file paths by matching known
songs. Gate Path behind library.filesystem, matching the Library host service.
toTrack is now a method carrying the permission flag.
* refactor(plugins): align MatchSong JSON casing and parse Go files once
- MatchSong: artistMBID/albumMBID JSON tags -> artistMbid/albumMbid so the Go
wire format matches the Rust SDK's camelCase serialization (cross-SDK fix)
- MatchSongs doc reworded to language-neutral 'empty (absent)' so generated
Rust/Python client docs no longer say Go-specific 'nil'
- ndpgen: parse each package file once (parseGoFiles) and reuse the ASTs across
both passes in ParseDirectory and ParseCapabilities, instead of re-parsing
* refactor(plugins): use shared types for Matcher host service
Move the Matcher host service onto the shared plugins/types package instead
of the host-local MatchSong and Track structs. MatchSongs now takes
[]types.SongRef and returns []*types.Track, dropping host.MatchSong and moving
host.Track (with its host.Artist dependency collapsed onto types.ArtistRef) into
plugins/types. ArtistRef gains SortName and SubRole so it can back a track's
Participants.
SongRef gains a millisecond-precision DurationMs field that supersedes the now
deprecated seconds-based Duration, with DurationInMs() resolving the effective
value and SetDurationMs() keeping both fields in sync when populating a SongRef
to send to a plugin.
The ndpgen host-wrapper template only ever imported context, json and extism, so
a host service referencing the shared types package produced uncompilable code.
Emit the plugins/types import when the service references shared types directly
(gated on the existing Service.ImportsSharedTypes), matching the client template,
and cover it with GenerateHost tests. This removes the need for host-local
re-export aliases. Regenerated the Go/Rust/Python PDK and capability schemas
accordingly.
* test(plugins): cover SongRef duration and artist conversion
Add unit coverage for the new SongRef behavior: SetDurationMs populating both
DurationMs and the deprecated seconds field, and the SongRef-to-agents.Song
conversion preferring DurationMs over Duration and the Artists list over the
scalar Artist/ArtistMBID.
Extract the inline SongRef-to-agents.Song closure in MatchSongs into a named
toAgentSong function so the conversion can be asserted directly rather than only
through the opaque matcher. The end-to-end wire shape of the moved types is
already validated by the existing MatcherService integration test, so no new
WASM-boundary test is needed.
* fix(plugins): harden and unify SongRef-to-agents.Song duration conversion
Address findings from a code review of the matcher host service:
- DurationInMs now clamps a negative deprecated-seconds value to 0 instead of
converting it through uint32, which previously wrapped a value like -1s into a
~49-day duration that corrupted the matcher's duration-proximity tiebreaker.
- Replace the unused SetDurationMs(uint32) with SetDuration(seconds float32),
which takes the unit callers actually hold (model.MediaFile.Duration is
float32 seconds) and centralizes the seconds-to-ms conversion. Wire it into
mediaFileToSongRef so outbound SongRefs carry both duration fields in sync.
- Make the metadata-agent path use DurationInMs() so every consumer of the
shared SongRef honors the DurationMs-over-Duration precedence contract; a
plugin sending only DurationMs no longer loses its duration on that path.
- Collapse the matcher's duplicate toAgentSong/agentArtists helpers into the
existing songRefToAgentSong converter, so there is a single SongRef-to-Song
mapping. Tests narrowed to the duration cases, with artist precedence still
covered in metadata_agent_test.go.
* feat(plugins): allow Matcher host service to scope a match to a user
Add an options struct to the Matcher host service so a plugin can run a match as
a specific user. When MatchOptions.Username is set, the match is run in that
user's context: their favourites and ratings inform the matcher's tiebreaker, and
the returned tracks carry that user's per-user annotations (Starred, StarredAt,
Rating, PlayCount, PlayDate, added to types.Track). An empty username preserves
the previous unscoped behaviour.
Cross-user access is gated by the same allowedUsers/allUsers permission the Users
and SubsonicAPI host services use: an unknown username, or one the plugin is not
permitted to act as, returns an error. User-library access applies automatically
once the user is in context (applyLibraryFilter). Independently, results are now
restricted to the libraries the plugin itself may access via the precomputed
libraryAccess set, dropping any matched track outside that set (the input index
stays unmatched) — this applies even without a username and even for an
admin-scoped user.
core/matcher is unchanged: it already loads and uses annotations and applies
user-library filtering from context, so the feature works by deriving the request
context and post-filtering by plugin library access in the host adapter. The new
opts parameter and the Track annotation fields are propagated to all PDK clients
(Go/Rust/Python) by make gen.
* fix(plugins): correct Matcher library scope and unify user-access checks
Address findings from a code review of the user-scoped Matcher host service:
- The plugin-library post-filter previously dropped every match for a plugin that
holds only the matcher permission, because library config is tied to the Library
permission and a matcher-only plugin has none (empty allowedLibraries,
AllLibraries=false). Gate the filter on whether the plugin actually declared the
Library permission: matcher-only plugins are no longer library-restricted, while
plugins that opt into a library scope are enforced as before. The per-user
library filter (applyLibraryFilter) still applies whenever a non-admin user is
scoped.
- resolveUser collapsed every FindByUsername error (including transient DB
failures) into a misleading "not found". Extract a shared userAccess type
(alongside libraryAccess) whose resolve() distinguishes model.ErrNotFound from a
real backend error and authorizes the user against the allowed set. The Matcher
service now uses it, and host_subsonicapi shares the same userAccess type for its
permission check (preserving its existing error messages), removing a third
divergent copy of the resolve-and-authorize logic.
- Document in the matcher tests that the mock MediaFileRepo returns annotations
unconditionally, so the unit tests cover the adapter's scoped-flag gating and
access checks but not the SQL per-user join. Add tests for the library-permission
gating and for surfacing a backend error instead of masking it as not-found.
* fix(plugins): require a library scope for Matcher, fail closed
Reverse the permissive default introduced when fixing the library post-filter: a
Matcher plugin now must be granted a library scope (all libraries, or at least one
specific library) and MatchSongs rejects the request with "no libraries
configured" when it has none, instead of either silently matching nothing or
defaulting to every library.
This mirrors how the SubsonicAPI host service requires a user scope
(checkPermissions errors with "no users configured" when none is set): the check
is a runtime guard via libraryAccess.configured(), needs no manifest changes, and
keeps the failure loud rather than silent. The per-match library post-filter then
always applies, and the restrictLibraries flag added in the previous commit is
removed.
* fix(plugins): require library permission for matcher; guard nil user
Close the gap where a plugin declaring only the matcher permission loaded
successfully but failed every MatchSongs call with "no libraries configured",
with no way for an admin to grant a library scope (the library-config UI is gated
on the library permission). Add a cross-field manifest rule, mirroring the
existing "subsonicapi requires users" rule, so the matcher permission requires
the library permission to be declared. A matcher plugin therefore also surfaces
the library-config panel and is subject to the existing load/enable-time library
configuration gate, making the fail-closed library check reachable and fixable
rather than a silent dead end. The test plugin manifest now declares the library
permission accordingly.
Also restore a defensive nil-user guard in userAccess.resolve: if a DataStore's
FindByUsername ever returns (nil, nil) instead of model.ErrNotFound, return a
clean "not found" error rather than dereferencing a nil *model.User.
* feat(plugins): expose track AverageRating in Matcher results
Add AverageRating to the Matcher's Track DTO. Unlike the per-user annotations
(Starred, Rating, PlayCount, ...), AverageRating is an aggregate stored on the
track itself and is loaded regardless of the request user, so it is populated
unconditionally rather than gated on a scoped username. Propagated to the PDK
types by make gen.
Signed-off-by: Deluan <deluan@navidrome.org>
* style(plugins): trim verbose comments in matcher host service
Condense the over-long explanatory comments added across the matcher host
service to one-liners that state the why, and simplify the ptrInt32/unixPtr
helpers to Go 1.26's new(value). No behavior change.
* refactor(plugins): pass userAccess into newSubsonicAPIService
Move newUserAccess construction to the loader call site so the SubsonicAPI service
constructor takes a userAccess value directly, matching newMatcherService. Pure
refactor: the service already stored a userAccess internally, so behavior and error
messages are unchanged.
* fix(plugins): regenerate PDK and drop omitempty from AverageRating
Re-run make gen so the generated PDK doc comments match the source comment
trimmed in an earlier commit (the source was simplified but the PDK was not
regenerated, leaving the committed files stale — a 'generated files up to date'
hazard).
Also drop omitempty from Track.AverageRating: it is always set (0 when unrated),
so it should be present in the payload like the other always-set fields
(BirthTime/CreatedAt/UpdatedAt), not dropped at zero. Tag change propagated to the
PDK by the same regeneration.
* fix(plugins): reject user-scoped match before lookup when plugin has no user scope
A matcher plugin requires the library permission but not the users permission, so
a matcher-only plugin always has an empty user scope (allUsers=false, no allowed
users). MatchSongs still ran FindByUsername for any opts.Username before checking
authorization and returned distinguishable errors ('user X not found' vs 'not
allowed to act as user X'), letting such a plugin enumerate account names from the
error text.
Guard userAccess.resolve to reject with a single fixed error before the lookup when
the plugin has no user scope, mirroring how the SubsonicAPI service short-circuits
with 'no users configured'. The unscoped match path (no username) is unaffected, so
matcher-only plugins still match normally.
* fix(plugins): run unscoped matcher as admin, not the inherited request user
A matcher host call can arrive on a context that already carries a request user
(e.g. a plugin capability invoked while serving that user's request — extism
propagates the call context into host functions). With no opts.Username, MatchSongs
passed that context straight through, so the media-file repository applied the
caller's library filter and per-user annotation ranking to an explicitly unscoped
match.
Set the user context explicitly: a username scopes to that user (overriding any
inherited one), and an unscoped match runs under adminContext so only the plugin's
own library scope constrains results. Adds tests using a context-capturing
DataStore to assert the user the matcher resolves in both cases.
* chore(plugins): drop the generated Python matcher PDK
The Python plugin PDK is no longer supported (ndpgen generates only Go and Rust
clients), so remove the stale generated nd_host_matcher.py rather than leave a
client that drifts from the host interface.
* docs(plugins): deprecate SongRef.Artist/ArtistMBID in favor of Artists
Mark the scalar single-artist fields deprecated; Artists (the ArtistRef list) is
the preferred way to supply artist data and already takes precedence for matching.
Propagated to the PDK and capability schemas by make gen.
* refactor(plugins): flatten Track.Participants and add Role to ArtistRef
Change Track.Participants from map[role][]ArtistRef to a flat []ArtistRef, and give
ArtistRef a Role field (the participation category: artist/composer/performer/...)
alongside SubRole (a specialization within a role, e.g. the instrument for a
performer). In the flat list each entry now self-describes its role rather than
relying on a map key, matching how SongRef.Artists is already a flat list; the
converter tags each entry with its role and emits them in a stable role order.
Propagated to the PDK and capability schemas by make gen.
---------
Signed-off-by: Deluan <deluan@navidrome.org>
1707 lines
55 KiB
Go
1707 lines
55 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)"))
|
|
|
|
// Check []byte return uses args.Get(0).([]byte)
|
|
Expect(codeStr).To(ContainSubstring("args.Get(0).([]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"))
|
|
})
|
|
})
|
|
})
|
|
|
|
func writeFile(path, content string) error {
|
|
return os.WriteFile(path, []byte(content), 0600)
|
|
}
|