Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 93 additions & 10 deletions DEVELOPER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,99 @@ Complete guide for developers contributing to or extending StarForge.

## Table of Contents

1. [Getting Started](#getting-started)
2. [Development Setup](#development-setup)
3. [Project Structure](#project-structure)
4. [Code Style Guide](#code-style-guide)
5. [Adding New Features](#adding-new-features)
6. [Testing](#testing)
7. [Documentation](#documentation)
8. [Common Tasks](#common-tasks)
9. [Debugging](#debugging)
10. [Release Process](#release-process)
1. [Plugin Version Compatibility](#plugin-version-compatibility)
2. [Getting Started](#getting-started)
3. [Development Setup](#development-setup)
4. [Project Structure](#project-structure)
5. [Code Style Guide](#code-style-guide)
6. [Adding New Features](#adding-new-features)
7. [Testing](#testing)
8. [Documentation](#documentation)
9. [Common Tasks](#common-tasks)
10. [Debugging](#debugging)
11. [Release Process](#release-process)

---

## Plugin Version Compatibility

StarForge enforces version compatibility when loading plugins to prevent subtle
runtime failures caused by ABI or API mismatches.

### How it works

Every plugin shared library must export a `PLUGIN_DECLARATION` symbol (provided
automatically by the `export_plugin!` macro). When `starforge plugin load` runs,
the loader checks two fields in that declaration:

| Field | What is checked | Failure behaviour |
|---|---|---|
| `rustc_version` | Must match the exact rustc version used to build StarForge | Hard error — load aborted |
| `core_version` | **Major** version must match StarForge's own `CARGO_PKG_VERSION` | Hard error — load aborted |

The compatibility rule for `core_version` follows semantic versioning:

- `0.x.y` plugins are **only** compatible with a `0.x.y` StarForge core (major `0`).
- `1.x.y` plugins are **only** compatible with a `1.x.y` StarForge core (major `1`).
- Minor and patch bumps within the same major are considered backwards-compatible.

### Error messages

When a plugin fails the version check you will see a clear message, for example:

```
Error: Plugin version incompatibility in 'libmy_plugin.so':
Plugin was built for StarForge 0.1.0
Running StarForge 1.0.0

The major version must match. Rebuild the plugin against
StarForge 1.0.0 or install a compatible StarForge version.
See DEVELOPER_GUIDE.md § "Plugin Version Compatibility" for details.
```

### Writing a compatible plugin

1. **Pin the StarForge version** in your plugin's `Cargo.toml`:

```toml
[dependencies]
# Use the same major version as the StarForge binary your users will run.
starforge = "0.1"
```

2. **Use the `export_plugin!` macro** — it embeds both `rustc_version` and
`core_version` automatically at compile time:

```rust
use starforge::export_plugin;

export_plugin!(register);

fn register(registrar: &mut dyn starforge::plugins::PluginRegistrar) {
registrar.register_plugin(Box::new(MyPlugin));
}
```

3. **Rebuild when StarForge's major version changes.** Check the running version
with `starforge --version` and compare it to the version your plugin was built
against (shown in `starforge plugin load` output under "Built for StarForge").

4. **Use the same Rust toolchain** as the StarForge binary. The easiest way is
to keep a `rust-toolchain.toml` in your plugin repo that mirrors the one in
the StarForge repo.

### Checking compatibility without loading

```bash
# See which StarForge version is running
starforge --version

# See which version each installed plugin was built for
starforge plugin load
```

The `load` command prints a "Built for StarForge" line for every successfully
loaded plugin, and a descriptive error for any that fail the check.

---

Expand Down
6 changes: 5 additions & 1 deletion src/commands/plugin.rs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
use crate::plugins::interface::CORE_VERSION;
use crate::plugins::{registry, PluginManager};
use crate::utils::print as p;
use anyhow::{Context, Result};
Expand Down Expand Up @@ -58,6 +59,7 @@ fn list() -> Result<()> {
return Ok(());
}

p::kv("StarForge core version", CORE_VERSION);
p::separator();
for (i, pl) in reg.plugins.iter().enumerate() {
println!(" {:>2}. {}", i + 1, pl.name);
Expand Down Expand Up @@ -93,9 +95,11 @@ fn load() -> Result<()> {
return Ok(());
}

p::kv("StarForge core version", CORE_VERSION);
p::separator();
for (name, desc) in loaded {
for (name, desc, built_for) in loaded {
p::kv_accent(name, desc);
p::kv("Built for StarForge", built_for);
}
p::separator();
Ok(())
Expand Down
58 changes: 51 additions & 7 deletions src/plugins/interface.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,10 @@ pub trait Plugin: Any + Send + Sync {
fn name(&self) -> &'static str;
fn version(&self) -> &'static str;
fn description(&self) -> &'static str;

fn on_load(&self) {}
fn on_unload(&self) {}

fn execute(&self, args: &[String]) -> Result<(), String>;
}

Expand All @@ -26,13 +26,57 @@ macro_rules! export_plugin {
($register:expr) => {
#[doc(hidden)]
#[no_mangle]
pub static PLUGIN_DECLARATION: $crate::plugins::PluginDeclaration = $crate::plugins::PluginDeclaration {
rustc_version: $crate::plugins::interface::RUSTC_VERSION,
core_version: $crate::plugins::interface::CORE_VERSION,
register: $register,
};
pub static PLUGIN_DECLARATION: $crate::plugins::PluginDeclaration =
$crate::plugins::PluginDeclaration {
rustc_version: $crate::plugins::interface::RUSTC_VERSION,
core_version: $crate::plugins::interface::CORE_VERSION,
register: $register,
};
};
}

pub const RUSTC_VERSION: &str = env!("RUSTC_VERSION");
pub const CORE_VERSION: &str = env!("CARGO_PKG_VERSION");

/// Extract the major version component from a semver string (e.g. "1.2.3" → "1").
/// Returns the full string unchanged if it cannot be parsed.
fn major(version: &str) -> &str {
match version.find('.') {
Some(pos) => &version[..pos],
None => version,
}
}

/// Returns `true` when `plugin_version` is compatible with the running StarForge core.
///
/// Compatibility rule: the **major** version must match exactly. A plugin built
/// against `0.x.y` is incompatible with a core running `1.x.y`, and vice-versa.
/// Patch and minor bumps within the same major are considered backwards-compatible.
pub fn is_core_version_compatible(plugin_version: &str) -> bool {
major(plugin_version) == major(CORE_VERSION)
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn same_version_is_compatible() {
assert!(is_core_version_compatible(CORE_VERSION));
}

#[test]
fn different_major_is_incompatible() {
// Construct a version with a different major than CORE_VERSION.
let core_major: u64 = major(CORE_VERSION).parse().unwrap_or(0);
let other = format!("{}.0.0", core_major + 1);
assert!(!is_core_version_compatible(&other));
}

#[test]
fn same_major_different_minor_is_compatible() {
let core_major = major(CORE_VERSION);
let other = format!("{}.99.0", core_major);
assert!(is_core_version_compatible(&other));
}
}
59 changes: 43 additions & 16 deletions src/plugins/loader.rs
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
use crate::plugins::interface::{Plugin, PluginDeclaration, PluginRegistrar, CORE_VERSION, RUSTC_VERSION};
use crate::plugins::interface::{
is_core_version_compatible, Plugin, PluginDeclaration, PluginRegistrar, CORE_VERSION,
RUSTC_VERSION,
};
use anyhow::{Context, Result};
use libloading::{Library, Symbol};
use std::collections::HashMap;
use std::ffi::OsStr;
use std::rc::Rc;

pub struct PluginManager {
plugins: HashMap<String, Box<dyn Plugin>>,
/// Maps plugin name → (plugin, core_version it was built against).
plugins: HashMap<String, (Box<dyn Plugin>, String)>,
libraries: Vec<Rc<Library>>,
}

Expand All @@ -25,50 +29,73 @@ impl PluginManager {
}

/// # Safety
/// The caller must ensure the plugin at `path` is a valid starforge plugin
/// The caller must ensure the plugin at `path` is a valid StarForge plugin
/// compiled with a compatible Rust toolchain and ABI.
pub unsafe fn load_plugin<P: AsRef<OsStr>>(&mut self, path: P) -> Result<()> {
let library = Rc::new(Library::new(path).context("Failed to load library")?);
let path_display = path.as_ref().to_string_lossy().to_string();
let library =
Rc::new(Library::new(path).context("Failed to load library")?);

let decl: Symbol<*mut PluginDeclaration> = library
.get(b"PLUGIN_DECLARATION")
.context("Failed to find PLUGIN_DECLARATION symbol")?;
.context("Failed to find PLUGIN_DECLARATION symbol — is this a StarForge plugin?")?;

let decl = &**decl;

// ── rustc ABI check ──────────────────────────────────────────────────
if decl.rustc_version != RUSTC_VERSION {
anyhow::bail!(
"Plugin rustc version mismatch: expected {}, found {}",
RUSTC_VERSION,
decl.rustc_version
"Plugin ABI mismatch in '{path_display}':\n \
Plugin was compiled with rustc {plugin_rustc}\n \
StarForge requires rustc {core_rustc}\n\n \
Rebuild the plugin with the same Rust toolchain used to build StarForge.",
path_display = path_display,
plugin_rustc = decl.rustc_version,
core_rustc = RUSTC_VERSION,
);
}

if decl.core_version != CORE_VERSION {
// We could be more lenient here, but let's be strict for now
println!("Warning: Plugin core version mismatch: core={}, plugin={}", CORE_VERSION, decl.core_version);
// ── StarForge core version check ─────────────────────────────────────
if !is_core_version_compatible(decl.core_version) {
anyhow::bail!(
"Plugin version incompatibility in '{path_display}':\n \
Plugin was built for StarForge {plugin_core}\n \
Running StarForge {core}\n\n \
The major version must match. Rebuild the plugin against \
StarForge {core} or install a compatible StarForge version.\n \
See DEVELOPER_GUIDE.md § \"Plugin Version Compatibility\" for details.",
path_display = path_display,
plugin_core = decl.core_version,
core = CORE_VERSION,
);
}

let mut registrar = ProxyRegistrar::new();
(decl.register)(&mut registrar);

let plugin_core_version = decl.core_version.to_string();
for plugin in registrar.plugins {
let name = plugin.name().to_string();
plugin.on_load();
self.plugins.insert(name, plugin);
self.plugins
.insert(name, (plugin, plugin_core_version.clone()));
}

self.libraries.push(library);

Ok(())
}

pub fn list_plugins(&self) -> Vec<(&str, &str)> {
self.plugins.iter().map(|(n, p)| (n.as_str(), p.description())).collect()
/// Returns `(name, description, built_for_core_version)` for every loaded plugin.
pub fn list_plugins(&self) -> Vec<(&str, &str, &str)> {
self.plugins
.iter()
.map(|(n, (p, cv))| (n.as_str(), p.description(), cv.as_str()))
.collect()
}

pub fn execute(&self, name: &str, args: &[String]) -> Result<(), String> {
if let Some(plugin) = self.plugins.get(name) {
if let Some((plugin, _)) = self.plugins.get(name) {
plugin.execute(args)
} else {
Err(format!("Plugin '{}' not found", name))
Expand Down