Skip to main content

genja_plugin_manager/
build_support.rs

1//! Build-time support for copying plugin libraries into the Cargo output directory.
2//!
3//! This module provides utilities for integrating plugin libraries into Rust applications
4//! at build time. It is designed to be used from `build.rs` scripts to automatically copy
5//! plugin dynamic libraries from their build locations into the application's output directory,
6//! making them available for runtime loading.
7//!
8//! # Overview
9//!
10//! The plugin system requires dynamic libraries to be available at runtime in a known location.
11//! This module solves that problem by:
12//!
13//! 1. Reading plugin declarations from `[package.metadata.plugins]` in `Cargo.toml`
14//! 2. Resolving plugin library paths (supporting both absolute and relative paths)
15//! 3. Copying plugin libraries to `target/{PROFILE}/plugins/`
16//! 4. Emitting `cargo:rerun-if-changed` directives for proper incremental builds
17//!
18//! # Plugin Declaration Format
19//!
20//! Plugins are declared in the consuming application's `Cargo.toml` under
21//! `[package.metadata.plugins]`. The format supports both individual plugins and
22//! grouped plugins:
23//!
24//! ```toml
25//! [package.metadata.plugins]
26//! # Individual plugin
27//! ssh_plugin = "target/{PROFILE}/libssh_plugin.so"
28//!
29//! # Grouped plugins
30//! [package.metadata.plugins.connection]
31//! ssh = "../plugins/ssh/target/{PROFILE}/libssh.so"
32//! telnet = "../plugins/telnet/target/{PROFILE}/libtelnet.so"
33//!
34//! # Absolute paths are also supported
35//! [package.metadata.plugins.system]
36//! audit = "/opt/genja/plugins/libaudit.so"
37//! ```
38//!
39//! # Path Resolution
40//!
41//! Plugin paths can be specified in three ways:
42//!
43//! 1. **Relative paths**: Resolved relative to the manifest directory (where `Cargo.toml` lives)
44//! 2. **Absolute paths**: Used as-is without modification
45//! 3. **Profile placeholders**: The `{PROFILE}` placeholder is replaced with the current build
46//!    profile ("debug" or "release")
47//!
48//! # Build Integration
49//!
50//! To use this module in your application, add a `build.rs` file to your project root:
51//!
52//! ```no_run
53//! // build.rs
54//! fn main() {
55//!     genja_plugin_manager::build_support::copy_plugins_from_manifest()
56//!         .expect("Failed to copy plugins");
57//! }
58//! ```
59//!
60//! Then declare your plugins in `Cargo.toml`:
61//!
62//! ```toml
63//! [package.metadata.plugins]
64//! my_plugin = "target/{PROFILE}/libmy_plugin.so"
65//! ```
66//!
67//! # Runtime Loading
68//!
69//! After build-time copying, plugins can be loaded at runtime using the plugin manager:
70//!
71//! ```no_run
72//! use genja_plugin_manager::PluginManager;
73//!
74//! let mut manager = PluginManager::new();
75//! manager.load_plugins_from_directory("target/debug/plugins")?;
76//! # Ok::<(), Box<dyn std::error::Error>>(())
77//! ```
78//!
79//! # Cross-Platform Considerations
80//!
81//! The helper reads only `[package.metadata.plugins]`, so the plugin path you
82//! declare there must use the correct filename for the OS building the
83//! application:
84//!
85//! Linux:
86//!
87//! ```toml
88//! [package.metadata.plugins]
89//! my_plugin = "target/{PROFILE}/libmy_plugin.so"
90//! ```
91//!
92//! macOS:
93//!
94//! ```toml
95//! [package.metadata.plugins]
96//! my_plugin = "target/{PROFILE}/libmy_plugin.dylib"
97//! ```
98//!
99//! Windows:
100//!
101//! ```toml
102//! [package.metadata.plugins]
103//! my_plugin = "target/{PROFILE}/my_plugin.dll"
104//! ```
105//!
106//! # Error Handling
107//!
108//! All functions in this module return `io::Result<()>`. Common error scenarios include:
109//!
110//! - Missing environment variables (`CARGO_MANIFEST_DIR`, `OUT_DIR`, `PROFILE`)
111//! - Invalid `Cargo.toml` syntax or structure
112//! - Missing plugin source files
113//! - Permission errors when creating directories or copying files
114//! - Invalid plugin path configurations (e.g., paths with no filename)
115//!
116//! # Examples
117//!
118//! ## Basic Usage
119//!
120//! ```no_run
121//! // build.rs
122//! fn main() {
123//!     genja_plugin_manager::build_support::copy_plugins_from_manifest()
124//!         .expect("Failed to copy plugins");
125//! }
126//! ```
127//!
128//! ## With Error Handling
129//!
130//! ```no_run
131//! // build.rs
132//! fn main() {
133//!     if let Err(e) = genja_plugin_manager::build_support::copy_plugins_from_manifest() {
134//!         eprintln!("Warning: Failed to copy plugins: {}", e);
135//!         eprintln!("Plugins may not be available at runtime");
136//!     }
137//! }
138//! ```
139//!
140//! ## Multiple Plugin Groups
141//!
142//! ```toml
143//! [package.metadata.plugins.connection]
144//! ssh = "target/{PROFILE}/libssh.so"
145//! telnet = "target/{PROFILE}/libtelnet.so"
146//!
147//! [package.metadata.plugins.inventory]
148//! file = "target/{PROFILE}/libfile_inventory.so"
149//! database = "target/{PROFILE}/libdb_inventory.so"
150//!
151//! [package.metadata.plugins.runner]
152//! threaded = "target/{PROFILE}/libthreaded_runner.so"
153//! serial = "target/{PROFILE}/libserial_runner.so"
154//! ```
155//!
156//! # Implementation Notes
157//!
158//! - The module uses `cargo:rerun-if-changed` directives to ensure plugins are recopied
159//!   when source files change
160//! - Plugin directory structure is created automatically if it doesn't exist
161//! - Existing plugin files in the destination are overwritten without warning
162//! - The module processes nested plugin groups recursively
163//! - Profile resolution happens before path resolution, allowing profile-specific paths
164
165use std::env;
166use std::fs;
167use std::io;
168use std::path::{Path, PathBuf};
169
170/// Copy plugin libraries declared in `[package.metadata.plugins]` from the
171/// calling application's `Cargo.toml` into `target/{PROFILE}/plugins`.
172///
173/// This helper is intended to be called from an end-user application's
174/// `build.rs`, where `CARGO_MANIFEST_DIR`, `OUT_DIR`, and `PROFILE` all refer
175/// to the consuming application rather than a dependency crate.
176///
177/// # Behavior
178///
179/// The function reads the `Cargo.toml` manifest from the directory specified by
180/// the `CARGO_MANIFEST_DIR` environment variable and looks for plugin entries under
181/// `[package.metadata.plugins]`. If found, it copies the specified plugin libraries
182/// to a `plugins` subdirectory within the Cargo profile output directory
183/// (e.g., `target/debug/plugins` or `target/release/plugins`).
184///
185/// Plugin paths can contain the `{PROFILE}` placeholder, which will be replaced
186/// with the current build profile (e.g., "debug" or "release").
187///
188/// # Returns
189///
190/// Returns `Ok(())` if the operation succeeds or if no plugins are declared.
191/// Returns an `Err` containing an `io::Error` if:
192/// - Required environment variables (`CARGO_MANIFEST_DIR`, `OUT_DIR`, `PROFILE`) are not set
193/// - The manifest file cannot be read or parsed
194/// - The profile output directory cannot be resolved
195/// - Plugin files cannot be copied to the destination
196///
197/// # Errors
198///
199/// This function will return an error if:
200/// - The `CARGO_MANIFEST_DIR`, `OUT_DIR`, or `PROFILE` environment variables are not set
201/// - The `Cargo.toml` file cannot be read or contains invalid TOML
202/// - The Cargo profile output directory structure is unexpected
203/// - The destination `plugins` directory cannot be created
204/// - Any plugin file cannot be copied to the destination
205///
206/// # Examples
207///
208/// ```no_run
209/// // In your build.rs:
210/// fn main() {
211///     genja_plugin_manager::build_support::copy_plugins_from_manifest()
212///         .expect("Failed to copy plugins");
213/// }
214/// ```
215pub fn copy_plugins_from_manifest() -> io::Result<()> {
216    let manifest_dir = PathBuf::from(env::var("CARGO_MANIFEST_DIR").map_err(io::Error::other)?);
217    let manifest_path = manifest_dir.join("Cargo.toml");
218    println!("cargo:rerun-if-changed={}", manifest_path.display());
219
220    let manifest = fs::read_to_string(&manifest_path)?;
221    let value: toml::Value =
222        toml::from_str(&manifest).map_err(|err| io::Error::new(io::ErrorKind::InvalidData, err))?;
223
224    let Some(plugins) = value
225        .get("package")
226        .and_then(|value| value.get("metadata"))
227        .and_then(|value| value.get("plugins"))
228    else {
229        return Ok(());
230    };
231
232    let out_dir = PathBuf::from(env::var("OUT_DIR").map_err(io::Error::other)?);
233    let profile_dir = out_dir
234        .ancestors()
235        .nth(3)
236        .ok_or_else(|| io::Error::other("failed to resolve Cargo profile output directory"))?;
237    let plugin_dir = profile_dir.join("plugins");
238    fs::create_dir_all(&plugin_dir)?;
239
240    let profile = env::var("PROFILE").map_err(io::Error::other)?;
241    copy_plugin_entries(plugins, &manifest_dir, &plugin_dir, &profile)
242}
243
244/// Recursively copies plugin entries from TOML configuration to the plugin directory.
245///
246/// This function processes plugin entries from the `[package.metadata.plugins]` section
247/// of a `Cargo.toml` manifest. It handles both string paths (individual plugin files)
248/// and nested tables (groups of plugins), copying each plugin library to the specified
249/// plugin directory.
250///
251/// For string entries, the function:
252/// - Replaces `{PROFILE}` placeholders with the actual build profile
253/// - Resolves relative paths against the manifest directory
254/// - Emits `cargo:rerun-if-changed` directives for build system integration
255/// - Copies the plugin file to the destination directory
256///
257/// For table entries, the function recursively processes all nested values.
258///
259/// # Parameters
260///
261/// * `value` - A TOML value representing either a plugin path (string) or a nested
262///   table of plugin entries. Must be either a `toml::Value::String` or
263///   `toml::Value::Table`.
264/// * `manifest_dir` - The directory containing the `Cargo.toml` manifest file. Used
265///   as the base directory for resolving relative plugin paths.
266/// * `plugin_dir` - The destination directory where plugin libraries should be copied.
267///   Typically `target/{PROFILE}/plugins`.
268/// * `profile` - The current Cargo build profile (e.g., "debug" or "release"). Used
269///   to replace `{PROFILE}` placeholders in plugin paths.
270///
271/// # Returns
272///
273/// Returns `Ok(())` if all plugin entries are successfully processed and copied.
274/// Returns an `Err` containing an `io::Error` if:
275/// - A plugin path is not a valid string or table
276/// - A plugin path has no filename component
277/// - A plugin file cannot be read or copied
278/// - Any nested entry fails to process
279///
280/// # Errors
281///
282/// This function will return an error if:
283/// - The `value` is neither a string nor a table
284/// - A plugin path string has no filename (e.g., ends with `/`)
285/// - A source plugin file does not exist or cannot be read
286/// - The destination directory is not writable
287/// - File copy operations fail for any reason
288fn copy_plugin_entries(
289    value: &toml::Value,
290    manifest_dir: &Path,
291    plugin_dir: &Path,
292    profile: &str,
293) -> io::Result<()> {
294    match value {
295        toml::Value::String(raw_path) => {
296            let resolved = raw_path.replace("{PROFILE}", profile);
297            let source = resolve_source_path(manifest_dir, &resolved);
298            println!("cargo:rerun-if-changed={}", source.display());
299
300            let filename = source.file_name().ok_or_else(|| {
301                io::Error::new(
302                    io::ErrorKind::InvalidInput,
303                    format!("plugin path has no filename: {}", source.display()),
304                )
305            })?;
306            let destination = plugin_dir.join(filename);
307            fs::copy(&source, &destination)?;
308            Ok(())
309        }
310        toml::Value::Table(table) => {
311            for nested in table.values() {
312                copy_plugin_entries(nested, manifest_dir, plugin_dir, profile)?;
313            }
314            Ok(())
315        }
316        _ => Err(io::Error::new(
317            io::ErrorKind::InvalidData,
318            "package.metadata.plugins entries must be strings or tables of strings",
319        )),
320    }
321}
322
323/// Resolves a plugin source path relative to the manifest directory.
324///
325/// This function takes a raw path string and resolves it to an absolute path.
326/// If the path is already absolute, it is returned as-is. If the path is relative,
327/// it is joined with the manifest directory to produce an absolute path.
328///
329/// # Parameters
330///
331/// * `manifest_dir` - The base directory containing the `Cargo.toml` manifest file.
332///   Used as the reference point for resolving relative paths.
333/// * `raw_path` - The raw path string from the plugin configuration. Can be either
334///   an absolute path or a relative path.
335///
336/// # Returns
337///
338/// Returns a `PathBuf` containing the resolved absolute path. If `raw_path` is
339/// absolute, it is returned unchanged. If `raw_path` is relative, it is resolved
340/// relative to `manifest_dir`.
341fn resolve_source_path(manifest_dir: &Path, raw_path: &str) -> PathBuf {
342    let path = PathBuf::from(raw_path);
343    if path.is_absolute() {
344        path
345    } else {
346        manifest_dir.join(path)
347    }
348}
349
350#[cfg(test)]
351mod tests {
352    use super::resolve_source_path;
353    use std::path::Path;
354
355    #[test]
356    fn resolve_source_path_uses_manifest_dir_for_relative_paths() {
357        let base = Path::new("/tmp/app");
358        let resolved = resolve_source_path(base, "target/debug/libplugin.so");
359        assert_eq!(resolved, base.join("target/debug/libplugin.so"));
360    }
361
362    #[test]
363    fn resolve_source_path_preserves_absolute_paths() {
364        let base = Path::new("/tmp/app");
365        let resolved = resolve_source_path(base, "/opt/plugins/libplugin.so");
366        assert_eq!(resolved, Path::new("/opt/plugins/libplugin.so"));
367    }
368}