feat(plugins): enhance Rust code generation with typed struct support and improved type handling

Signed-off-by: Deluan <deluan@navidrome.org>
This commit is contained in:
Deluan 2025-12-29 16:16:22 -05:00
parent 10e5f44617
commit 13ca6149a9
15 changed files with 475 additions and 220 deletions

View File

@ -157,6 +157,7 @@ func pythonDefaultValue(p Param) string {
// rustFuncMap returns the template functions for Rust client code generation.
func rustFuncMap(svc Service) template.FuncMap {
knownStructs := svc.KnownStructs()
return template.FuncMap{
"lower": strings.ToLower,
"exportName": func(m Method) string { return m.FunctionName(svc.ExportPrefix()) },
@ -164,6 +165,9 @@ func rustFuncMap(svc Service) template.FuncMap {
"responseType": func(m Method) string { return m.ResponseTypeName(svc.Name) },
"rustFunc": func(m Method) string { return m.RustFunctionName(svc.ExportPrefix()) },
"rustDocComment": RustDocComment,
"rustType": func(p Param) string { return p.RustTypeWithStructs(knownStructs) },
"rustParamType": func(p Param) string { return p.RustParamTypeWithStructs(knownStructs) },
"fieldRustType": func(f FieldDef) string { return f.RustType(knownStructs) },
}
}

View File

@ -58,6 +58,13 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) {
return nil, err
}
// First pass: collect all struct definitions in the file
allStructs := parseStructs(f)
structMap := make(map[string]StructDef)
for _, s := range allStructs {
structMap[s.Name] = s
}
var services []Service
for _, decl := range f.Decls {
@ -91,7 +98,8 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) {
Doc: cleanDoc(docText),
}
// Parse methods
// Parse methods and collect referenced types
referencedTypes := make(map[string]bool)
for _, method := range interfaceType.Methods.List {
if len(method.Names) == 0 {
continue // Embedded interface
@ -114,6 +122,21 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) {
return nil, fmt.Errorf("parsing method %s.%s: %w", typeSpec.Name.Name, method.Names[0].Name, err)
}
service.Methods = append(service.Methods, m)
// Collect referenced types from params and returns
for _, p := range m.Params {
collectReferencedTypes(p.Type, referencedTypes)
}
for _, r := range m.Returns {
collectReferencedTypes(r.Type, referencedTypes)
}
}
// Attach referenced structs to the service
for typeName := range referencedTypes {
if s, exists := structMap[typeName]; exists {
service.Structs = append(service.Structs, s)
}
}
if len(service.Methods) > 0 {
@ -125,6 +148,163 @@ func parseFile(fset *token.FileSet, path string) ([]Service, error) {
return services, nil
}
// parseStructs extracts all struct type definitions from a parsed Go file.
func parseStructs(f *ast.File) []StructDef {
var structs []StructDef
for _, decl := range f.Decls {
genDecl, ok := decl.(*ast.GenDecl)
if !ok || genDecl.Tok != token.TYPE {
continue
}
for _, spec := range genDecl.Specs {
typeSpec, ok := spec.(*ast.TypeSpec)
if !ok {
continue
}
structType, ok := typeSpec.Type.(*ast.StructType)
if !ok {
continue
}
docText, _ := getDocComment(genDecl, typeSpec)
s := StructDef{
Name: typeSpec.Name.Name,
Doc: cleanDoc(docText),
}
// Parse struct fields
for _, field := range structType.Fields.List {
if len(field.Names) == 0 {
continue // Embedded field
}
fieldDef := parseStructField(field)
s.Fields = append(s.Fields, fieldDef...)
}
structs = append(structs, s)
}
}
return structs
}
// parseStructField parses a struct field and returns FieldDef for each name.
func parseStructField(field *ast.Field) []FieldDef {
var fields []FieldDef
typeName := typeToString(field.Type)
// Parse struct tag for JSON field name and omitempty
jsonTag := ""
omitEmpty := false
if field.Tag != nil {
tag := field.Tag.Value
// Remove backticks
tag = strings.Trim(tag, "`")
// Parse json tag
jsonTag, omitEmpty = parseJSONTag(tag)
}
// Get doc comment
var doc string
if field.Doc != nil {
doc = cleanDoc(field.Doc.Text())
}
for _, name := range field.Names {
fieldJSONTag := jsonTag
if fieldJSONTag == "" {
// Default to field name with camelCase
fieldJSONTag = toJSONName(name.Name)
}
fields = append(fields, FieldDef{
Name: name.Name,
Type: typeName,
JSONTag: fieldJSONTag,
OmitEmpty: omitEmpty,
Doc: doc,
})
}
return fields
}
// parseJSONTag extracts the json field name and omitempty flag from a struct tag.
func parseJSONTag(tag string) (name string, omitEmpty bool) {
// Find json:"..." in the tag
for _, part := range strings.Split(tag, " ") {
if strings.HasPrefix(part, `json:"`) {
value := strings.TrimPrefix(part, `json:"`)
value = strings.TrimSuffix(value, `"`)
parts := strings.Split(value, ",")
if len(parts) > 0 && parts[0] != "-" {
name = parts[0]
}
for _, opt := range parts[1:] {
if opt == "omitempty" {
omitEmpty = true
}
}
return
}
}
return "", false
}
// collectReferencedTypes extracts custom type names from a Go type string.
// It handles pointers, slices, and maps, collecting base type names.
func collectReferencedTypes(goType string, refs map[string]bool) {
// Strip pointer
if strings.HasPrefix(goType, "*") {
collectReferencedTypes(goType[1:], refs)
return
}
// Strip slice
if strings.HasPrefix(goType, "[]") {
if goType != "[]byte" {
collectReferencedTypes(goType[2:], refs)
}
return
}
// Handle map
if strings.HasPrefix(goType, "map[") {
rest := goType[4:] // Remove "map["
depth := 1
keyEnd := 0
for i, r := range rest {
if r == '[' {
depth++
} else if r == ']' {
depth--
if depth == 0 {
keyEnd = i
break
}
}
}
keyType := rest[:keyEnd]
valueType := rest[keyEnd+1:]
collectReferencedTypes(keyType, refs)
collectReferencedTypes(valueType, refs)
return
}
// Check if it's a custom type (starts with uppercase, not a builtin)
if len(goType) > 0 && goType[0] >= 'A' && goType[0] <= 'Z' {
switch goType {
case "String", "Bool", "Int", "Int32", "Int64", "Float32", "Float64":
// Not custom types (just capitalized for some reason)
default:
refs[goType] = true
}
}
}
// toJSONName is imported from types.go via the same package
// getDocComment extracts the doc comment for a type spec.
// Returns both the readable doc text and the raw comment text (which includes pragma-style comments).
func getDocComment(genDecl *ast.GenDecl, typeSpec *ast.TypeSpec) (docText, rawText string) {

View File

@ -5,6 +5,23 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
{{- /* Generate struct definitions */ -}}
{{- range .Service.Structs}}
{{if .Doc}}
{{rustDocComment .Doc}}
{{else}}
{{end}}#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct {{.Name}} {
{{- range .Fields}}
{{- if .NeedsDefault}}
#[serde(default)]
{{- end}}
pub {{.RustName}}: {{fieldRustType .}},
{{- end}}
}
{{- end}}
{{- /* Generate request/response types */ -}}
{{- range .Service.Methods}}
{{- if .HasParams}}
@ -12,7 +29,7 @@ use serde::{Deserialize, Serialize};
#[serde(rename_all = "camelCase")]
struct {{requestType .}} {
{{- range .Params}}
{{.RustName}}: {{.RustType}},
{{.RustName}}: {{rustType .}},
{{- end}}
}
{{- end}}
@ -22,7 +39,7 @@ struct {{requestType .}} {
struct {{responseType .}} {
{{- range .Returns}}
#[serde(default)]
{{.RustName}}: {{.RustType}},
{{.RustName}}: {{rustType .}},
{{- end}}
#[serde(default)]
error: Option<String>,
@ -44,7 +61,7 @@ extern "ExtismHost" {
///
/// # Arguments
{{- range .Params}}
/// * `{{.RustName}}` - {{.RustType}} parameter.
/// * `{{.RustName}}` - {{rustType .}} parameter.
{{- end}}
{{- end}}
{{- if .HasReturns}}
@ -59,7 +76,7 @@ extern "ExtismHost" {
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{$p.RustParamType}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{(index .Returns 0).RustType}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{$r.RustType}}{{end}}){{end}}, Error> {
pub fn {{rustFunc .}}({{range $i, $p := .Params}}{{if $i}}, {{end}}{{$p.RustName}}: {{rustParamType $p}}{{end}}) -> Result<{{if eq (len .Returns) 0}}(){{else if eq (len .Returns) 1}}{{rustType (index .Returns 0)}}{{else}}({{range $i, $r := .Returns}}{{if $i}}, {{end}}{{rustType $r}}{{end}}){{end}}, Error> {
let response = unsafe {
{{- if .HasParams}}
{{exportName .}}(Json({{requestType .}} {

View File

@ -7,11 +7,28 @@ import (
// Service represents a parsed host service interface.
type Service struct {
Name string // Service name from annotation (e.g., "SubsonicAPI")
Permission string // Manifest permission key (e.g., "subsonicapi")
Interface string // Go interface name (e.g., "SubsonicAPIService")
Methods []Method // Methods marked with //nd:hostfunc
Doc string // Documentation comment for the service
Name string // Service name from annotation (e.g., "SubsonicAPI")
Permission string // Manifest permission key (e.g., "subsonicapi")
Interface string // Go interface name (e.g., "SubsonicAPIService")
Methods []Method // Methods marked with //nd:hostfunc
Doc string // Documentation comment for the service
Structs []StructDef // Structs used by this service
}
// StructDef represents a Go struct type definition.
type StructDef struct {
Name string // Go struct name (e.g., "Library")
Fields []FieldDef // Struct fields
Doc string // Documentation comment
}
// FieldDef represents a field within a struct.
type FieldDef struct {
Name string // Go field name (e.g., "TotalSongs")
Type string // Go type (e.g., "int32", "*string", "[]User")
JSONTag string // JSON tag value (e.g., "totalSongs,omitempty")
OmitEmpty bool // Whether the field has omitempty tag
Doc string // Field documentation
}
// OutputFileName returns the generated file name for this service.
@ -24,6 +41,15 @@ func (s Service) ExportPrefix() string {
return strings.ToLower(s.Name)
}
// KnownStructs returns a map of struct names defined in this service.
func (s Service) KnownStructs() map[string]bool {
result := make(map[string]bool)
for _, st := range s.Structs {
result[st.Name] = true
}
return result
}
// Method represents a host function method within a service.
type Method struct {
Name string // Go method name (e.g., "Call")
@ -187,60 +213,7 @@ func (p Param) PythonName() string {
// ToRustType converts a Go type to its Rust equivalent.
func ToRustType(goType string) string {
// Handle pointer types
if strings.HasPrefix(goType, "*") {
inner := ToRustType(goType[1:])
return "Option<" + inner + ">"
}
// Handle slice types
if strings.HasPrefix(goType, "[]") {
if goType == "[]byte" {
return "Vec<u8>"
}
inner := ToRustType(goType[2:])
return "Vec<" + inner + ">"
}
// Handle map types
if strings.HasPrefix(goType, "map[") {
// Extract key and value types from map[K]V
rest := goType[4:] // Remove "map["
depth := 1
keyEnd := 0
for i, r := range rest {
if r == '[' {
depth++
} else if r == ']' {
depth--
if depth == 0 {
keyEnd = i
break
}
}
}
keyType := rest[:keyEnd]
valueType := rest[keyEnd+1:]
return "std::collections::HashMap<" + ToRustType(keyType) + ", " + ToRustType(valueType) + ">"
}
switch goType {
case "string":
return "String"
case "int", "int32":
return "i32"
case "int64":
return "i64"
case "float32":
return "f32"
case "float64":
return "f64"
case "bool":
return "bool"
case "interface{}", "any":
return "serde_json::Value"
default:
// For custom struct types, use Value as they need custom definition
return "serde_json::Value"
}
return ToRustTypeWithStructs(goType, nil)
}
// RustParamType returns the Rust type for a function parameter (uses &str for strings).
@ -303,11 +276,24 @@ func (p Param) RustType() string {
return ToRustType(p.Type)
}
// RustTypeWithStructs returns the Rust type using known struct names.
func (p Param) RustTypeWithStructs(knownStructs map[string]bool) string {
return ToRustTypeWithStructs(p.Type, knownStructs)
}
// RustParamType returns the Rust type for this parameter when used as a function argument.
func (p Param) RustParamType() string {
return RustParamType(p.Type)
}
// RustParamTypeWithStructs returns the Rust param type using known struct names.
func (p Param) RustParamTypeWithStructs(knownStructs map[string]bool) string {
if p.Type == "string" {
return "&str"
}
return ToRustTypeWithStructs(p.Type, knownStructs)
}
// RustName returns the snake_case Rust name for this parameter.
func (p Param) RustName() string {
return ToSnakeCase(p.Name)
@ -317,3 +303,82 @@ func (p Param) RustName() string {
func (p Param) NeedsToOwned() bool {
return p.Type == "string"
}
// RustType returns the Rust type for this field, using known struct names.
func (f FieldDef) RustType(knownStructs map[string]bool) string {
return ToRustTypeWithStructs(f.Type, knownStructs)
}
// RustName returns the snake_case Rust name for this field.
func (f FieldDef) RustName() string {
return ToSnakeCase(f.Name)
}
// NeedsDefault returns true if the field needs #[serde(default)] attribute.
// This is true for fields with omitempty tag.
func (f FieldDef) NeedsDefault() bool {
return f.OmitEmpty
}
// ToRustTypeWithStructs converts a Go type to its Rust equivalent,
// using known struct names instead of serde_json::Value.
func ToRustTypeWithStructs(goType string, knownStructs map[string]bool) string {
// Handle pointer types
if strings.HasPrefix(goType, "*") {
inner := ToRustTypeWithStructs(goType[1:], knownStructs)
return "Option<" + inner + ">"
}
// Handle slice types
if strings.HasPrefix(goType, "[]") {
if goType == "[]byte" {
return "Vec<u8>"
}
inner := ToRustTypeWithStructs(goType[2:], knownStructs)
return "Vec<" + inner + ">"
}
// Handle map types
if strings.HasPrefix(goType, "map[") {
// Extract key and value types from map[K]V
rest := goType[4:] // Remove "map["
depth := 1
keyEnd := 0
for i, r := range rest {
if r == '[' {
depth++
} else if r == ']' {
depth--
if depth == 0 {
keyEnd = i
break
}
}
}
keyType := rest[:keyEnd]
valueType := rest[keyEnd+1:]
return "std::collections::HashMap<" + ToRustTypeWithStructs(keyType, knownStructs) + ", " + ToRustTypeWithStructs(valueType, knownStructs) + ">"
}
switch goType {
case "string":
return "String"
case "int", "int32":
return "i32"
case "int64":
return "i64"
case "float32":
return "f32"
case "float64":
return "f64"
case "bool":
return "bool"
case "interface{}", "any":
return "serde_json::Value"
default:
// Check if this is a known struct type
if knownStructs != nil && knownStructs[goType] {
return goType
}
// For unknown custom types, fall back to Value
return "serde_json::Value"
}
}

View File

@ -6,6 +6,19 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User2 {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Filter2 {
pub active: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ComprehensiveSimpleParamsRequest {
@ -25,7 +38,7 @@ struct ComprehensiveSimpleParamsResponse {
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ComprehensiveStructParamRequest {
user: serde_json::Value,
user: User2,
}
#[derive(Debug, Clone, Deserialize)]
@ -39,7 +52,7 @@ struct ComprehensiveStructParamResponse {
#[serde(rename_all = "camelCase")]
struct ComprehensiveMixedParamsRequest {
id: String,
filter: serde_json::Value,
filter: Filter2,
}
#[derive(Debug, Clone, Deserialize)]
@ -84,14 +97,14 @@ struct ComprehensiveNoParamsNoReturnsResponse {
#[serde(rename_all = "camelCase")]
struct ComprehensivePointerParamsRequest {
id: Option<String>,
user: Option<serde_json::Value>,
user: Option<User2>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ComprehensivePointerParamsResponse {
#[serde(default)]
result: Option<serde_json::Value>,
result: Option<User2>,
#[serde(default)]
error: Option<String>,
}
@ -121,7 +134,7 @@ struct ComprehensiveMultipleReturnsRequest {
#[serde(rename_all = "camelCase")]
struct ComprehensiveMultipleReturnsResponse {
#[serde(default)]
results: Vec<serde_json::Value>,
results: Vec<User2>,
#[serde(default)]
total: i32,
#[serde(default)]
@ -186,11 +199,11 @@ pub fn simple_params(name: &str, count: i32) -> Result<String, Error> {
/// Calls the comprehensive_structparam host function.
///
/// # Arguments
/// * `user` - serde_json::Value parameter.
/// * `user` - User2 parameter.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn struct_param(user: serde_json::Value) -> Result<(), Error> {
pub fn struct_param(user: User2) -> Result<(), Error> {
let response = unsafe {
comprehensive_structparam(Json(ComprehensiveStructParamRequest {
user: user,
@ -208,14 +221,14 @@ pub fn struct_param(user: serde_json::Value) -> Result<(), Error> {
///
/// # Arguments
/// * `id` - String parameter.
/// * `filter` - serde_json::Value parameter.
/// * `filter` - Filter2 parameter.
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn mixed_params(id: &str, filter: serde_json::Value) -> Result<i32, Error> {
pub fn mixed_params(id: &str, filter: Filter2) -> Result<i32, Error> {
let response = unsafe {
comprehensive_mixedparams(Json(ComprehensiveMixedParamsRequest {
id: id.to_owned(),
@ -290,14 +303,14 @@ pub fn no_params_no_returns() -> Result<(), Error> {
///
/// # Arguments
/// * `id` - Option<String> parameter.
/// * `user` - Option<serde_json::Value> parameter.
/// * `user` - Option<User2> parameter.
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn pointer_params(id: Option<String>, user: Option<serde_json::Value>) -> Result<Option<serde_json::Value>, Error> {
pub fn pointer_params(id: Option<String>, user: Option<User2>) -> Result<Option<User2>, Error> {
let response = unsafe {
comprehensive_pointerparams(Json(ComprehensivePointerParamsRequest {
id: id,
@ -346,7 +359,7 @@ pub fn map_params(data: std::collections::HashMap<String, serde_json::Value>) ->
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn multiple_returns(query: &str) -> Result<(Vec<serde_json::Value>, i32), Error> {
pub fn multiple_returns(query: &str) -> Result<(Vec<User2>, i32), Error> {
let response = unsafe {
comprehensive_multiplereturns(Json(ComprehensiveMultipleReturnsRequest {
query: query.to_owned(),

View File

@ -6,11 +6,17 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Filter {
pub active: bool,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct ListItemsRequest {
name: String,
filter: serde_json::Value,
filter: Filter,
}
#[derive(Debug, Clone, Deserialize)]
@ -31,14 +37,14 @@ extern "ExtismHost" {
///
/// # Arguments
/// * `name` - String parameter.
/// * `filter` - serde_json::Value parameter.
/// * `filter` - Filter parameter.
///
/// # Returns
/// The count value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn items(name: &str, filter: serde_json::Value) -> Result<i32, Error> {
pub fn items(name: &str, filter: Filter) -> Result<i32, Error> {
let response = unsafe {
list_items(Json(ListItemsRequest {
name: name.to_owned(),

View File

@ -6,6 +6,12 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Result {
pub id: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct SearchFindRequest {
@ -16,7 +22,7 @@ struct SearchFindRequest {
#[serde(rename_all = "camelCase")]
struct SearchFindResponse {
#[serde(default)]
results: Vec<serde_json::Value>,
results: Vec<Result>,
#[serde(default)]
total: i32,
#[serde(default)]
@ -38,7 +44,7 @@ extern "ExtismHost" {
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn find(query: &str) -> Result<(Vec<serde_json::Value>, i32), Error> {
pub fn find(query: &str) -> Result<(Vec<Result>, i32), Error> {
let response = unsafe {
search_find(Json(SearchFindRequest {
query: query.to_owned(),

View File

@ -6,10 +6,17 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Item {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct StoreSaveRequest {
item: serde_json::Value,
item: Item,
}
#[derive(Debug, Clone, Deserialize)]
@ -29,14 +36,14 @@ extern "ExtismHost" {
/// Calls the store_save host function.
///
/// # Arguments
/// * `item` - serde_json::Value parameter.
/// * `item` - Item parameter.
///
/// # Returns
/// The id value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn save(item: serde_json::Value) -> Result<String, Error> {
pub fn save(item: Item) -> Result<String, Error> {
let response = unsafe {
store_save(Json(StoreSaveRequest {
item: item,

View File

@ -6,18 +6,25 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct User {
pub id: String,
pub name: String,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct UsersGetRequest {
id: Option<String>,
filter: Option<serde_json::Value>,
filter: Option<User>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(rename_all = "camelCase")]
struct UsersGetResponse {
#[serde(default)]
result: Option<serde_json::Value>,
result: Option<User>,
#[serde(default)]
error: Option<String>,
}
@ -31,14 +38,14 @@ extern "ExtismHost" {
///
/// # Arguments
/// * `id` - Option<String> parameter.
/// * `filter` - Option<serde_json::Value> parameter.
/// * `filter` - Option<User> parameter.
///
/// # Returns
/// The result value.
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get(id: Option<String>, filter: Option<serde_json::Value>) -> Result<Option<serde_json::Value>, Error> {
pub fn get(id: Option<String>, filter: Option<User>) -> Result<Option<User>, Error> {
let response = unsafe {
users_get(Json(UsersGetRequest {
id: id,

View File

@ -12,6 +12,12 @@ RUST_PLUGINS := $(patsubst %/Cargo.toml,%,$(wildcard */Cargo.toml))
TINYGO := $(shell command -v tinygo 2> /dev/null)
EXTISM_PY := $(shell command -v extism-py 2> /dev/null)
# Allow building plugins without .ndp extension (e.g., make minimal instead of make minimal.ndp)
.PHONY: $(PLUGINS) $(PYTHON_PLUGINS) $(RUST_PLUGINS)
$(PLUGINS): %: %.ndp
$(PYTHON_PLUGINS): %: %.ndp
$(RUST_PLUGINS): %: %.ndp
# Default target: show available plugins
.DEFAULT_GOAL := help
@ -26,12 +32,12 @@ help:
@$(foreach p,$(RUST_PLUGINS),echo " $(p)";)
@echo ""
@echo "Usage:"
@echo " make <plugin>.ndp Build a specific plugin (e.g., make $(firstword $(PLUGINS)).ndp)"
@echo " make <plugin> Build a specific plugin (e.g., make $(firstword $(PLUGINS)))"
@echo " make all Build all plugins"
@echo " make all-go Build all Go plugins"
@echo " make all-python Build all Python plugins (requires extism-py)"
@echo " make all-rust Build all Rust plugins (requires cargo)"
@echo " make clean Remove all built plugins"
@echo " make clean Remove all built plugins (.ndp and .wasm files)"
all: all-go all-python all-rust
@ -44,7 +50,7 @@ all-rust: $(RUST_PLUGINS:%=%.ndp)
clean:
rm -f $(PLUGINS:%=%.ndp) $(PYTHON_PLUGINS:%=%.ndp) $(RUST_PLUGINS:%=%.ndp)
rm -f $(PLUGINS:%=%.wasm) $(PYTHON_PLUGINS:%=%.wasm) $(RUST_PLUGINS:%=%.wasm)
$(foreach p,$(RUST_PLUGINS),cd $(p) && cargo clean 2>/dev/null || true;)
@$(foreach p,$(RUST_PLUGINS),(cd $(p) && cargo clean 2>/dev/null) || true;)
# Build .ndp package from .wasm and manifest.json
# Go plugins
@ -53,9 +59,10 @@ clean:
@cp $< plugin.wasm
zip -j $@ $*/manifest.json plugin.wasm
@rm -f plugin.wasm
@rm -f $<
%.wasm: %/*.go %/go.mod
# Use secondary expansion to properly track all Go source files
.SECONDEXPANSION:
$(PLUGINS:%=%.wasm): %.wasm: $$(shell find % -name '*.go' 2>/dev/null) %/go.mod
ifdef TINYGO
cd $* && tinygo build -target wasip1 -buildmode=c-shared -o ../$@ .
else

View File

@ -11,5 +11,6 @@ crate-type = ["cdylib"]
[dependencies]
extism-pdk = "1.2"
nd-host = { path = "../../host/rust" }
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"

View File

@ -55,18 +55,6 @@ Configure the inspection interval in the Navidrome UI (Settings → Plugins →
|--------|------------------------------------------|--------------|
| `cron` | Cron expression for inspection interval | `@every 1m` |
### Cron Expression Examples
| Expression | Description |
|------------|-------------|
| `@every 1m` | Every minute (default) |
| `@every 5m` | Every 5 minutes |
| `@every 1h` | Every hour |
| `@hourly` | Every hour at minute 0 |
| `@daily` | Every day at midnight |
| `0 */6 * * *` | Every 6 hours |
| `0 9 * * *` | Daily at 9:00 AM |
## Permissions
This plugin requires:

View File

@ -1,8 +1,8 @@
//! Library Inspector Plugin for Navidrome
//!
//! This plugin demonstrates how to use the Library host service in Rust.
//! It periodically logs details about all music libraries and finds the largest
//! file in the root of each library directory.
//! This plugin demonstrates how to use the nd-host library for accessing Navidrome
//! host services in Rust. It periodically logs details about all music libraries
//! and finds the largest file in the root of each library directory.
//!
//! ## Configuration
//!
@ -13,59 +13,14 @@
//! ```
use extism_pdk::*;
use nd_host::{library, scheduler};
use serde::{Deserialize, Serialize};
use std::fs;
// ============================================================================
// Library Types
// ============================================================================
#[derive(Deserialize, Debug)]
#[serde(rename_all = "camelCase")]
#[allow(dead_code)]
struct Library {
id: i32,
name: String,
#[serde(default)]
path: Option<String>,
#[serde(default)]
mount_point: Option<String>,
last_scan_at: i64,
total_songs: i32,
total_albums: i32,
total_artists: i32,
total_size: i64,
total_duration: f64,
}
#[derive(Deserialize)]
struct LibraryGetAllLibrariesResponse {
result: Option<Vec<Library>>,
#[serde(default)]
error: Option<String>,
}
// ============================================================================
// Scheduler Types
// ============================================================================
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct SchedulerScheduleRecurringRequest {
cron_expression: String,
payload: String,
schedule_id: String,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SchedulerScheduleRecurringResponse {
#[serde(default)]
new_schedule_id: Option<String>,
#[serde(default)]
error: Option<String>,
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct SchedulerCallbackInput {
@ -84,54 +39,10 @@ struct InitOutput {
error: Option<String>,
}
// ============================================================================
// Host Function Imports
// ============================================================================
#[host_fn]
extern "ExtismHost" {
fn library_getalllibraries(input: Json<serde_json::Value>) -> Json<LibraryGetAllLibrariesResponse>;
fn scheduler_schedulerecurring(input: Json<SchedulerScheduleRecurringRequest>) -> Json<SchedulerScheduleRecurringResponse>;
}
// ============================================================================
// Helper Functions
// ============================================================================
/// Get all libraries from Navidrome
fn get_all_libraries() -> Result<Vec<Library>, String> {
let response: Json<LibraryGetAllLibrariesResponse> = unsafe {
library_getalllibraries(Json(serde_json::json!({})))
.map_err(|e| format!("Failed to call library_getalllibraries: {:?}", e))?
};
if let Some(err) = response.0.error {
return Err(err);
}
Ok(response.0.result.unwrap_or_default())
}
/// Schedule a recurring task
fn schedule_recurring(cron: &str, payload: &str, id: &str) -> Result<String, String> {
let request = SchedulerScheduleRecurringRequest {
cron_expression: cron.to_string(),
payload: payload.to_string(),
schedule_id: id.to_string(),
};
let response: Json<SchedulerScheduleRecurringResponse> = unsafe {
scheduler_schedulerecurring(Json(request))
.map_err(|e| format!("Failed to schedule task: {:?}", e))?
};
if let Some(err) = response.0.error {
return Err(err);
}
Ok(response.0.new_schedule_id.unwrap_or_default())
}
/// Format bytes into human-readable size
fn format_size(bytes: i64) -> String {
const KB: i64 = 1024;
@ -209,7 +120,7 @@ fn find_largest_file(mount_point: &str) -> Option<(String, u64)> {
fn inspect_libraries() {
info!("=== Library Inspection Started ===");
let libraries = match get_all_libraries() {
let libraries = match library::get_all_libraries() {
Ok(libs) => libs,
Err(e) => {
error!("Failed to get libraries: {}", e);
@ -234,10 +145,10 @@ fn inspect_libraries() {
info!(" Duration: {}", format_duration(lib.total_duration));
// If we have filesystem access, find the largest file
if let Some(mount_point) = &lib.mount_point {
info!(" Mount: {}", mount_point);
if !lib.mount_point.is_empty() {
info!(" Mount: {}", lib.mount_point);
match find_largest_file(mount_point) {
match find_largest_file(&lib.mount_point) {
Some((name, size)) => {
info!(
" Largest file in root: {} ({})",
@ -274,8 +185,8 @@ pub fn nd_on_init() -> FnResult<Json<InitOutput>> {
info!("Scheduling library inspection with cron: {}", cron);
// Schedule the recurring task
match schedule_recurring(&cron, "inspect", "library-inspect") {
// Schedule the recurring task using nd-host scheduler
match scheduler::schedule_recurring(&cron, "inspect", "library-inspect") {
Ok(schedule_id) => {
info!("Scheduled inspection task with ID: {}", schedule_id);
}

View File

@ -26,31 +26,56 @@ nd-host = { path = "../../host/rust" }
Then import the services you need:
```rust
use nd_host::{cache, scheduler, kvstore};
use nd_host::{cache, scheduler, library};
use nd_host::library::Library; // Import the typed struct
#[plugin_fn]
pub fn my_callback(input: String) -> FnResult<String> {
// Use the cache service
cache::cache_set("my_key", b"my_value", 3600)?;
cache::set("my_key", b"my_value", 3600)?;
// Schedule a recurring task
scheduler::scheduler_schedule_recurring("@every 5m", "payload", "task_id")?;
scheduler::schedule_recurring("@every 5m", "payload", "task_id")?;
// Access library data with typed structs
let libraries: Vec<Library> = library::get_all_libraries()?;
for lib in &libraries {
info!("Library: {} with {} songs", lib.name, lib.total_songs);
}
Ok("done".to_string())
}
```
## Typed Structs
Services that work with domain objects provide typed Rust structs instead of
`serde_json::Value`. This enables compile-time type checking and IDE
autocompletion.
For example, the `library` module provides a `Library` struct:
```rust
use nd_host::library::Library;
let libs: Vec<Library> = library::get_all_libraries()?;
println!("First library: {} ({} songs)", libs[0].name, libs[0].total_songs);
```
All structs derive `Debug`, `Clone`, `Serialize`, and `Deserialize` for
convenient use with logging and serialization.
## Available Services
| Module | Description |
|--------|-------------|
| `artwork` | Access album and artist artwork |
| `cache` | Temporary key-value storage with TTL |
| `kvstore` | Persistent key-value storage |
| `library` | Access the music library (albums, artists, tracks) |
| `scheduler` | Schedule one-time and recurring tasks |
| `subsonicapi` | Make Subsonic API calls |
| `websocket` | Send real-time messages to clients |
| Module | Description |
|---------------|----------------------------------------------------|
| `artwork` | Access album and artist artwork |
| `cache` | Temporary key-value storage with TTL |
| `kvstore` | Persistent key-value storage |
| `library` | Access the music library (albums, artists, tracks) |
| `scheduler` | Schedule one-time and recurring tasks |
| `subsonicapi` | Make Subsonic API calls |
| `websocket` | Send real-time messages to clients |
## Building Plugins

View File

@ -6,6 +6,24 @@
use extism_pdk::*;
use serde::{Deserialize, Serialize};
/// Library represents a music library with metadata.
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct Library {
pub id: i32,
pub name: String,
#[serde(default)]
pub path: String,
#[serde(default)]
pub mount_point: String,
pub last_scan_at: i64,
pub total_songs: i32,
pub total_albums: i32,
pub total_artists: i32,
pub total_size: i64,
pub total_duration: f64,
}
#[derive(Debug, Clone, Serialize)]
#[serde(rename_all = "camelCase")]
struct LibraryGetLibraryRequest {
@ -16,7 +34,7 @@ struct LibraryGetLibraryRequest {
#[serde(rename_all = "camelCase")]
struct LibraryGetLibraryResponse {
#[serde(default)]
result: Option<serde_json::Value>,
result: Option<Library>,
#[serde(default)]
error: Option<String>,
}
@ -25,7 +43,7 @@ struct LibraryGetLibraryResponse {
#[serde(rename_all = "camelCase")]
struct LibraryGetAllLibrariesResponse {
#[serde(default)]
result: Vec<serde_json::Value>,
result: Vec<Library>,
#[serde(default)]
error: Option<String>,
}
@ -51,7 +69,7 @@ extern "ExtismHost" {
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_library(id: i32) -> Result<Option<serde_json::Value>, Error> {
pub fn get_library(id: i32) -> Result<Option<Library>, Error> {
let response = unsafe {
library_getlibrary(Json(LibraryGetLibraryRequest {
id: id,
@ -74,7 +92,7 @@ pub fn get_library(id: i32) -> Result<Option<serde_json::Value>, Error> {
///
/// # Errors
/// Returns an error if the host function call fails.
pub fn get_all_libraries() -> Result<Vec<serde_json::Value>, Error> {
pub fn get_all_libraries() -> Result<Vec<Library>, Error> {
let response = unsafe {
library_getalllibraries(Json(serde_json::json!({})))?
};