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}