From 13ca6149a9b0d4633cf130fbbd4bd1f4db87e3d8 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 29 Dec 2025 16:16:22 -0500 Subject: [PATCH] feat(plugins): enhance Rust code generation with typed struct support and improved type handling Signed-off-by: Deluan --- plugins/cmd/hostgen/internal/generator.go | 4 + plugins/cmd/hostgen/internal/parser.go | 182 ++++++++++++++++- .../internal/templates/client_rs.rs.tmpl | 25 ++- plugins/cmd/hostgen/internal/types.go | 183 ++++++++++++------ .../testdata/comprehensive_client_expected.rs | 37 ++-- .../hostgen/testdata/list_client_expected.rs | 12 +- .../testdata/search_client_expected.rs | 10 +- .../hostgen/testdata/store_client_expected.rs | 13 +- .../hostgen/testdata/users_client_expected.rs | 15 +- plugins/examples/Makefile | 17 +- plugins/examples/library-inspector/Cargo.toml | 1 + plugins/examples/library-inspector/README.md | 12 -- plugins/examples/library-inspector/src/lib.rs | 109 +---------- plugins/host/rust/README.md | 49 +++-- plugins/host/rust/nd_host_library.rs | 26 ++- 15 files changed, 475 insertions(+), 220 deletions(-) diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go index f5f767d58..e940d5294 100644 --- a/plugins/cmd/hostgen/internal/generator.go +++ b/plugins/cmd/hostgen/internal/generator.go @@ -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) }, } } diff --git a/plugins/cmd/hostgen/internal/parser.go b/plugins/cmd/hostgen/internal/parser.go index 346ca6990..48ffdeb0e 100644 --- a/plugins/cmd/hostgen/internal/parser.go +++ b/plugins/cmd/hostgen/internal/parser.go @@ -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) { diff --git a/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl b/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl index 16afd76ac..3bd86779d 100644 --- a/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl +++ b/plugins/cmd/hostgen/internal/templates/client_rs.rs.tmpl @@ -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, @@ -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 .}} { diff --git a/plugins/cmd/hostgen/internal/types.go b/plugins/cmd/hostgen/internal/types.go index 381f756b8..af5ccfb6b 100644 --- a/plugins/cmd/hostgen/internal/types.go +++ b/plugins/cmd/hostgen/internal/types.go @@ -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" - } - 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" + } + 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" + } +} diff --git a/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs b/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs index 772e127f0..a78d17609 100644 --- a/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs +++ b/plugins/cmd/hostgen/testdata/comprehensive_client_expected.rs @@ -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, - user: Option, + user: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct ComprehensivePointerParamsResponse { #[serde(default)] - result: Option, + result: Option, #[serde(default)] error: Option, } @@ -121,7 +134,7 @@ struct ComprehensiveMultipleReturnsRequest { #[serde(rename_all = "camelCase")] struct ComprehensiveMultipleReturnsResponse { #[serde(default)] - results: Vec, + results: Vec, #[serde(default)] total: i32, #[serde(default)] @@ -186,11 +199,11 @@ pub fn simple_params(name: &str, count: i32) -> Result { /// 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 { +pub fn mixed_params(id: &str, filter: Filter2) -> Result { 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 parameter. -/// * `user` - Option parameter. +/// * `user` - Option parameter. /// /// # Returns /// The result value. /// /// # Errors /// Returns an error if the host function call fails. -pub fn pointer_params(id: Option, user: Option) -> Result, Error> { +pub fn pointer_params(id: Option, user: Option) -> Result, Error> { let response = unsafe { comprehensive_pointerparams(Json(ComprehensivePointerParamsRequest { id: id, @@ -346,7 +359,7 @@ pub fn map_params(data: std::collections::HashMap) -> /// /// # Errors /// Returns an error if the host function call fails. -pub fn multiple_returns(query: &str) -> Result<(Vec, i32), Error> { +pub fn multiple_returns(query: &str) -> Result<(Vec, i32), Error> { let response = unsafe { comprehensive_multiplereturns(Json(ComprehensiveMultipleReturnsRequest { query: query.to_owned(), diff --git a/plugins/cmd/hostgen/testdata/list_client_expected.rs b/plugins/cmd/hostgen/testdata/list_client_expected.rs index b01de8192..ec3ec2d57 100644 --- a/plugins/cmd/hostgen/testdata/list_client_expected.rs +++ b/plugins/cmd/hostgen/testdata/list_client_expected.rs @@ -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 { +pub fn items(name: &str, filter: Filter) -> Result { let response = unsafe { list_items(Json(ListItemsRequest { name: name.to_owned(), diff --git a/plugins/cmd/hostgen/testdata/search_client_expected.rs b/plugins/cmd/hostgen/testdata/search_client_expected.rs index 1d1b20281..a6fa1bfc1 100644 --- a/plugins/cmd/hostgen/testdata/search_client_expected.rs +++ b/plugins/cmd/hostgen/testdata/search_client_expected.rs @@ -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, + results: Vec, #[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, i32), Error> { +pub fn find(query: &str) -> Result<(Vec, i32), Error> { let response = unsafe { search_find(Json(SearchFindRequest { query: query.to_owned(), diff --git a/plugins/cmd/hostgen/testdata/store_client_expected.rs b/plugins/cmd/hostgen/testdata/store_client_expected.rs index b03914e46..5ad8cdafe 100644 --- a/plugins/cmd/hostgen/testdata/store_client_expected.rs +++ b/plugins/cmd/hostgen/testdata/store_client_expected.rs @@ -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 { +pub fn save(item: Item) -> Result { let response = unsafe { store_save(Json(StoreSaveRequest { item: item, diff --git a/plugins/cmd/hostgen/testdata/users_client_expected.rs b/plugins/cmd/hostgen/testdata/users_client_expected.rs index 15323f1ef..081db0356 100644 --- a/plugins/cmd/hostgen/testdata/users_client_expected.rs +++ b/plugins/cmd/hostgen/testdata/users_client_expected.rs @@ -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, - filter: Option, + filter: Option, } #[derive(Debug, Clone, Deserialize)] #[serde(rename_all = "camelCase")] struct UsersGetResponse { #[serde(default)] - result: Option, + result: Option, #[serde(default)] error: Option, } @@ -31,14 +38,14 @@ extern "ExtismHost" { /// /// # Arguments /// * `id` - Option parameter. -/// * `filter` - Option parameter. +/// * `filter` - Option parameter. /// /// # Returns /// The result value. /// /// # Errors /// Returns an error if the host function call fails. -pub fn get(id: Option, filter: Option) -> Result, Error> { +pub fn get(id: Option, filter: Option) -> Result, Error> { let response = unsafe { users_get(Json(UsersGetRequest { id: id, diff --git a/plugins/examples/Makefile b/plugins/examples/Makefile index 4d50df0c7..224ec11a8 100644 --- a/plugins/examples/Makefile +++ b/plugins/examples/Makefile @@ -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 .ndp Build a specific plugin (e.g., make $(firstword $(PLUGINS)).ndp)" + @echo " make 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 diff --git a/plugins/examples/library-inspector/Cargo.toml b/plugins/examples/library-inspector/Cargo.toml index 801f70521..2d4b5e70c 100644 --- a/plugins/examples/library-inspector/Cargo.toml +++ b/plugins/examples/library-inspector/Cargo.toml @@ -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" diff --git a/plugins/examples/library-inspector/README.md b/plugins/examples/library-inspector/README.md index dc159d3d9..37c672679 100644 --- a/plugins/examples/library-inspector/README.md +++ b/plugins/examples/library-inspector/README.md @@ -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: diff --git a/plugins/examples/library-inspector/src/lib.rs b/plugins/examples/library-inspector/src/lib.rs index 01a8af406..a7901698b 100644 --- a/plugins/examples/library-inspector/src/lib.rs +++ b/plugins/examples/library-inspector/src/lib.rs @@ -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, - #[serde(default)] - mount_point: Option, - 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>, - #[serde(default)] - error: Option, -} - // ============================================================================ // 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, - #[serde(default)] - error: Option, -} - #[derive(Deserialize)] #[serde(rename_all = "camelCase")] struct SchedulerCallbackInput { @@ -84,54 +39,10 @@ struct InitOutput { error: Option, } -// ============================================================================ -// Host Function Imports -// ============================================================================ - -#[host_fn] -extern "ExtismHost" { - fn library_getalllibraries(input: Json) -> Json; - fn scheduler_schedulerecurring(input: Json) -> Json; -} - // ============================================================================ // Helper Functions // ============================================================================ -/// Get all libraries from Navidrome -fn get_all_libraries() -> Result, String> { - let response: Json = 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 { - let request = SchedulerScheduleRecurringRequest { - cron_expression: cron.to_string(), - payload: payload.to_string(), - schedule_id: id.to_string(), - }; - - let response: Json = 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> { 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); } diff --git a/plugins/host/rust/README.md b/plugins/host/rust/README.md index 0e6e74cae..8aa625e1c 100644 --- a/plugins/host/rust/README.md +++ b/plugins/host/rust/README.md @@ -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 { // 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::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::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 diff --git a/plugins/host/rust/nd_host_library.rs b/plugins/host/rust/nd_host_library.rs index 3a6a1bbbb..ca6bf958d 100644 --- a/plugins/host/rust/nd_host_library.rs +++ b/plugins/host/rust/nd_host_library.rs @@ -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, + result: Option, #[serde(default)] error: Option, } @@ -25,7 +43,7 @@ struct LibraryGetLibraryResponse { #[serde(rename_all = "camelCase")] struct LibraryGetAllLibrariesResponse { #[serde(default)] - result: Vec, + result: Vec, #[serde(default)] error: Option, } @@ -51,7 +69,7 @@ extern "ExtismHost" { /// /// # Errors /// Returns an error if the host function call fails. -pub fn get_library(id: i32) -> Result, Error> { +pub fn get_library(id: i32) -> Result, Error> { let response = unsafe { library_getlibrary(Json(LibraryGetLibraryRequest { id: id, @@ -74,7 +92,7 @@ pub fn get_library(id: i32) -> Result, Error> { /// /// # Errors /// Returns an error if the host function call fails. -pub fn get_all_libraries() -> Result, Error> { +pub fn get_all_libraries() -> Result, Error> { let response = unsafe { library_getalllibraries(Json(serde_json::json!({})))? };