From 7afe906d112fe0b6545177f3f6cb490eb7584202 Mon Sep 17 00:00:00 2001 From: Deluan Date: Mon, 29 Dec 2025 14:37:02 -0500 Subject: [PATCH] feat(plugins): generate Rust lib.rs file to expose host function wrappers Signed-off-by: Deluan --- plugins/cmd/hostgen/integration_test.go | 14 +++++- plugins/cmd/hostgen/internal/generator.go | 42 ++++++++++++++++++ .../hostgen/internal/templates/lib_rs.rs.tmpl | 43 +++++++++++++++++++ plugins/cmd/hostgen/main.go | 38 ++++++++++++++++ plugins/host/rust/lib.rs | 22 +++++----- 5 files changed, 147 insertions(+), 12 deletions(-) create mode 100644 plugins/cmd/hostgen/internal/templates/lib_rs.rs.tmpl diff --git a/plugins/cmd/hostgen/integration_test.go b/plugins/cmd/hostgen/integration_test.go index 733c94bf5..63362a52e 100644 --- a/plugins/cmd/hostgen/integration_test.go +++ b/plugins/cmd/hostgen/integration_test.go @@ -266,9 +266,19 @@ type ServiceB interface { rustDir := filepath.Join(outputDir, "rust") rsClientEntries, err := os.ReadDir(rustDir) Expect(err).ToNot(HaveOccurred()) - Expect(rsClientEntries).To(HaveLen(1), "Expected exactly one Rust client file") + Expect(rsClientEntries).To(HaveLen(2), "Expected Rust client file and lib.rs") - rsClientActual, err := os.ReadFile(filepath.Join(rustDir, rsClientEntries[0].Name())) + // Find the client file (not lib.rs) + var rsClientName string + for _, entry := range rsClientEntries { + if entry.Name() != "lib.rs" { + rsClientName = entry.Name() + break + } + } + Expect(rsClientName).ToNot(BeEmpty(), "Expected to find Rust client file") + + rsClientActual, err := os.ReadFile(filepath.Join(rustDir, rsClientName)) Expect(err).ToNot(HaveOccurred()) Expect(string(rsClientActual)).To(Equal(rsClientExpected), "Rust client code mismatch") diff --git a/plugins/cmd/hostgen/internal/generator.go b/plugins/cmd/hostgen/internal/generator.go index df0eb6da7..f5f767d58 100644 --- a/plugins/cmd/hostgen/internal/generator.go +++ b/plugins/cmd/hostgen/internal/generator.go @@ -190,3 +190,45 @@ func GenerateClientRust(svc Service) ([]byte, error) { return buf.Bytes(), nil } + +// firstLine returns the first line of a multi-line string, with the first word removed. +func firstLine(s string) string { + line := s + if idx := strings.Index(s, "\n"); idx >= 0 { + line = s[:idx] + } + // Remove the first word (service name like "ArtworkService") + if idx := strings.Index(line, " "); idx >= 0 { + line = line[idx+1:] + } + return line +} + +// GenerateRustLib generates the lib.rs file that exposes all service modules. +func GenerateRustLib(services []Service) ([]byte, error) { + tmplContent, err := templatesFS.ReadFile("templates/lib_rs.rs.tmpl") + if err != nil { + return nil, fmt.Errorf("reading Rust lib template: %w", err) + } + + tmpl, err := template.New("lib_rs").Funcs(template.FuncMap{ + "lower": strings.ToLower, + "firstLine": firstLine, + }).Parse(string(tmplContent)) + if err != nil { + return nil, fmt.Errorf("parsing template: %w", err) + } + + data := struct { + Services []Service + }{ + Services: services, + } + + var buf bytes.Buffer + if err := tmpl.Execute(&buf, data); err != nil { + return nil, fmt.Errorf("executing template: %w", err) + } + + return buf.Bytes(), nil +} diff --git a/plugins/cmd/hostgen/internal/templates/lib_rs.rs.tmpl b/plugins/cmd/hostgen/internal/templates/lib_rs.rs.tmpl new file mode 100644 index 000000000..7edbb0f62 --- /dev/null +++ b/plugins/cmd/hostgen/internal/templates/lib_rs.rs.tmpl @@ -0,0 +1,43 @@ +// Code generated by hostgen. DO NOT EDIT. +// +//! Navidrome Host Function Wrappers for Rust Plugins +//! +//! This crate provides idiomatic Rust wrappers for all Navidrome host services. +//! It is auto-generated by the hostgen tool and should not be edited manually. +//! +//! # Usage +//! +//! Add this crate as a dependency in your plugin's Cargo.toml: +//! +//! ```toml +//! [dependencies] +//! nd-host = { path = "../../host/rust" } +//! ``` +//! +//! Then import the services you need: +//! +//! ```ignore +//! use nd_host::{cache, scheduler}; +//! +//! fn my_plugin_function() -> Result<(), extism_pdk::Error> { +//! // Use the cache service +//! cache::set_string("my_key", "my_value", 3600)?; +//! +//! // Schedule a recurring task +//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; +//! +//! Ok(()) +//! } +//! ``` +//! +//! # Available Services +//! +{{- range .Services}} +//! - [`{{.Name | lower}}`] - {{if .Doc}}{{.Doc | firstLine}}{{else}}{{.Name}} service{{end}} +{{- end}} +{{range .Services}} +#[path = "nd_host_{{.Name | lower}}.rs"] +pub mod {{.Name | lower}}; +{{end}} +// Re-export commonly used types from extism-pdk for convenience +pub use extism_pdk::Error; diff --git a/plugins/cmd/hostgen/main.go b/plugins/cmd/hostgen/main.go index 545d601a5..b3ad416a4 100644 --- a/plugins/cmd/hostgen/main.go +++ b/plugins/cmd/hostgen/main.go @@ -147,6 +147,14 @@ func main() { } } } + + // Generate Rust lib.rs to expose all modules + if generateRsClient && len(services) > 0 { + if err := generateRustLibFile(services, absOutput, *dryRun, *verbose); err != nil { + fmt.Fprintf(os.Stderr, "Error generating Rust lib.rs: %v\n", err) + os.Exit(1) + } + } } // generateHostCode generates host-side code for a service. @@ -275,3 +283,33 @@ func generateRustClientCode(svc internal.Service, outputDir string, dryRun, verb } return nil } + +// generateRustLibFile generates the lib.rs file that exposes all Rust modules. +func generateRustLibFile(services []internal.Service, outputDir string, dryRun, verbose bool) error { + code, err := internal.GenerateRustLib(services) + if err != nil { + return fmt.Errorf("generating lib.rs: %w", err) + } + + clientDir := filepath.Join(outputDir, "rust") + libFile := filepath.Join(clientDir, "lib.rs") + + if dryRun { + fmt.Printf("=== %s ===\n%s\n", libFile, code) + return nil + } + + // Create rust/ subdirectory if needed + if err := os.MkdirAll(clientDir, 0755); err != nil { + return fmt.Errorf("creating rust client directory: %w", err) + } + + if err := os.WriteFile(libFile, code, 0600); err != nil { + return fmt.Errorf("writing file: %w", err) + } + + if verbose { + fmt.Printf("Generated Rust lib.rs: %s\n", libFile) + } + return nil +} diff --git a/plugins/host/rust/lib.rs b/plugins/host/rust/lib.rs index 6102f64a3..b71861398 100644 --- a/plugins/host/rust/lib.rs +++ b/plugins/host/rust/lib.rs @@ -1,3 +1,5 @@ +// Code generated by hostgen. DO NOT EDIT. +// //! Navidrome Host Function Wrappers for Rust Plugins //! //! This crate provides idiomatic Rust wrappers for all Navidrome host services. @@ -15,14 +17,14 @@ //! Then import the services you need: //! //! ```ignore -//! use nd_host::{cache, scheduler, kvstore}; +//! use nd_host::{cache, scheduler}; //! //! fn my_plugin_function() -> Result<(), extism_pdk::Error> { //! // Use the cache service -//! cache::cache_set("my_key", b"my_value", 3600)?; +//! cache::set_string("my_key", "my_value", 3600)?; //! //! // Schedule a recurring task -//! scheduler::scheduler_schedule_recurring("@every 5m", "payload", "task_id")?; +//! scheduler::schedule_recurring("@every 5m", "payload", "task_id")?; //! //! Ok(()) //! } @@ -30,13 +32,13 @@ //! //! # Available Services //! -//! - [`artwork`] - Access album and artist artwork -//! - [`cache`] - Temporary key-value storage with TTL -//! - [`kvstore`] - Persistent key-value storage -//! - [`library`] - Access the music library -//! - [`scheduler`] - Schedule one-time and recurring tasks -//! - [`subsonicapi`] - Make Subsonic API calls -//! - [`websocket`] - Send real-time messages to clients +//! - [`artwork`] - provides artwork URL generation capabilities for plugins. +//! - [`cache`] - provides in-memory TTL-based caching capabilities for plugins. +//! - [`kvstore`] - provides persistent key-value storage for plugins. +//! - [`library`] - provides access to music library metadata for plugins. +//! - [`scheduler`] - provides task scheduling capabilities for plugins. +//! - [`subsonicapi`] - provides access to Navidrome's Subsonic API from plugins. +//! - [`websocket`] - provides WebSocket communication capabilities for plugins. #[path = "nd_host_artwork.rs"] pub mod artwork;