Complete guide for developers contributing to or extending StarForge.
- Plugin Version Compatibility
- Getting Started
- Development Setup
- Project Structure
- Code Style Guide
- Adding New Features
- Testing
- Documentation
- Common Tasks
- Debugging
- Release Process
StarForge enforces version compatibility when loading plugins to prevent subtle runtime failures caused by ABI or API mismatches.
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.yplugins are only compatible with a0.x.yStarForge core (major0).1.x.yplugins are only compatible with a1.x.yStarForge core (major1).- Minor and patch bumps within the same major are considered backwards-compatible.
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.
-
Pin the StarForge version in your plugin's
Cargo.toml:[dependencies] # Use the same major version as the StarForge binary your users will run. starforge = "0.1"
-
Use the
export_plugin!macro — it embeds bothrustc_versionandcore_versionautomatically at compile time:use starforge::export_plugin; export_plugin!(register); fn register(registrar: &mut dyn starforge::plugins::PluginRegistrar) { registrar.register_plugin(Box::new(MyPlugin)); }
-
Rebuild when StarForge's major version changes. Check the running version with
starforge --versionand compare it to the version your plugin was built against (shown instarforge plugin loadoutput under "Built for StarForge"). -
Use the same Rust toolchain as the StarForge binary. The easiest way is to keep a
rust-toolchain.tomlin your plugin repo that mirrors the one in the StarForge repo.
# See which StarForge version is running
starforge --version
# See which version each installed plugin was built for
starforge plugin loadThe load command prints a "Built for StarForge" line for every successfully
loaded plugin, and a descriptive error for any that fail the check.
- Rust: 1.80 or higher (install via rustup)
- Git: For version control
- Stellar CLI: For contract operations (optional)
- Docker: For containerized development (optional)
# Clone repository
git clone https://github.com/YOUR_USERNAME/starforge.git
cd starforge
# Build in debug mode
cargo build
# Build in release mode
cargo build --release
# Run tests
cargo test
# Run with logging
RUST_LOG=debug cargo run -- wallet listRecommended extensions:
rust-analyzer- Rust language supportcrates- Cargo.toml dependency managementBetter TOML- TOML syntax highlightingError Lens- Inline error display
.vscode/settings.json:
{
"rust-analyzer.checkOnSave.command": "clippy",
"rust-analyzer.cargo.features": "all",
"editor.formatOnSave": true
}Install the Rust plugin and configure:
- Enable Clippy for code analysis
- Set rustfmt as formatter
- Enable external linter
# Enable debug logging
export RUST_LOG=debug
# Disable telemetry during development
export STARFORGE_TELEMETRY=false
# Use custom config directory
export STARFORGE_CONFIG_DIR=~/.starforge-dev# 1. Create feature branch
git checkout -b feature/my-feature
# 2. Make changes
# ... edit files ...
# 3. Run tests
cargo test
# 4. Check formatting
cargo fmt --check
# 5. Run clippy
cargo clippy -- -D warnings
# 6. Build
cargo build
# 7. Test manually
cargo run -- <command>
# 8. Run smoke tests (optional but recommended)
./scripts/e2e-smoke.sh
# 9. Commit
git add .
git commit -m "feat: add my feature"
# 10. Push and create PR
git push origin feature/my-featuresrc/
├── main.rs # Entry point
├── commands/ # User-facing commands
│ ├── mod.rs # Module exports
│ ├── wallet.rs # Wallet operations
│ ├── template.rs # Template marketplace
│ └── ...
├── utils/ # Shared utilities
│ ├── mod.rs # Module exports
│ ├── config.rs # Configuration
│ ├── templates.rs # Template system
│ └── ...
└── plugins/ # Plugin system
├── mod.rs # Module exports
├── interface.rs # Plugin traits
└── ...
- Commands:
<noun>.rs(e.g.,wallet.rs,network.rs) - Utilities:
<function>.rs(e.g.,config.rs,crypto.rs) - Tests:
<module>_test.rsor inline#[cfg(test)] mod tests
Each module should follow this structure:
// 1. Imports
use crate::utils::config;
use anyhow::Result;
// 2. Type definitions
pub struct MyStruct { /* ... */ }
pub enum MyEnum { /* ... */ }
// 3. Public API
pub fn public_function() -> Result<()> { /* ... */ }
// 4. Private helpers
fn private_helper() { /* ... */ }
// 5. Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_something() { /* ... */ }
}Follow the Rust Style Guide:
// ✅ Good
pub fn create_wallet(name: String, encrypt: bool) -> Result<()> {
validate_name(&name)?;
let keypair = generate_keypair();
save_wallet(name, keypair, encrypt)
}
// ❌ Bad
pub fn CreateWallet(Name: String, Encrypt: bool) -> Result<()> {
ValidateName(&Name)?;
let KeyPair = GenerateKeypair();
SaveWallet(Name, KeyPair, Encrypt)
}| Type | Convention | Example |
|---|---|---|
| Functions | snake_case |
fetch_account() |
| Types | PascalCase |
WalletEntry |
| Constants | SCREAMING_SNAKE_CASE |
MAX_RETRIES |
| Modules | snake_case |
hardware_wallet |
| Lifetimes | 'lowercase |
'a, 'static |
// ✅ Use Result and ? operator
pub fn operation() -> Result<()> {
let data = load_data()?;
process_data(data)?;
Ok(())
}
// ✅ Add context to errors
fs::read_to_string(&path)
.with_context(|| format!("Failed to read {}", path.display()))?
// ❌ Don't use unwrap() in production code
let data = load_data().unwrap(); // Bad!
// ✅ Use expect() with clear message for programmer errors
let data = load_data()
.expect("Config should be initialized in main()");/// Fetches account information from Horizon API.
///
/// # Arguments
///
/// * `public_key` - The Stellar public key (G...)
/// * `network` - Network name ("testnet" or "mainnet")
///
/// # Returns
///
/// Returns `AccountResponse` with balance and sequence information.
///
/// # Errors
///
/// Returns error if:
/// - Account doesn't exist on the network
/// - Network is unreachable
/// - Response parsing fails
///
/// # Example
///
/// ```
/// let account = fetch_account("GABC...", "testnet")?;
/// println!("Balance: {}", account.balances[0].balance);
/// ```
pub fn fetch_account(public_key: &str, network: &str) -> Result<AccountResponse> {
// Implementation
}// ✅ Explain WHY, not WHAT
// Use shallow clone to reduce bandwidth and disk usage
git_clone(&url, "--depth", "1");
// ❌ Don't state the obvious
// Clone the repository
git_clone(&url);
// ✅ TODO comments with context
// TODO(username): Add retry logic after implementing exponential backoff
// ❌ Vague TODOs
// TODO: fix thisStep 1: Create command file
touch src/commands/mycommand.rsStep 2: Define command structure
// src/commands/mycommand.rs
use anyhow::Result;
use clap::Subcommand;
use crate::utils::print as p;
#[derive(Subcommand)]
pub enum MyCommands {
/// Do something useful
Action {
/// Input parameter
#[arg(long)]
input: String,
},
}
pub fn handle(cmd: MyCommands) -> Result<()> {
match cmd {
MyCommands::Action { input } => action(input),
}
}
fn action(input: String) -> Result<()> {
p::header("My Command");
p::kv("Input", &input);
// Your logic here
p::success("Done!");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_action() {
// Test your command
}
}Step 3: Register in mod.rs
// src/commands/mod.rs
pub mod mycommand;Step 4: Add to main CLI
// src/main.rs
#[derive(Subcommand)]
enum Commands {
// ... existing commands
/// My new command
#[command(subcommand)]
MyCommand(commands::mycommand::MyCommands),
}
// In main():
let result = match cli.command {
// ... existing matches
Commands::MyCommand(cmd) => commands::mycommand::handle(cmd),
};Step 5: Update documentation
# Update README.md with new command
# Add examples to examples/ directory
# Update ARCHITECTURE.md if neededStep 1: Create utility file
touch src/utils/myutil.rsStep 2: Implement functionality
// src/utils/myutil.rs
use anyhow::Result;
/// Does something useful
pub fn do_something(input: &str) -> Result<String> {
// Implementation
Ok(format!("Processed: {}", input))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_do_something() {
let result = do_something("test").unwrap();
assert_eq!(result, "Processed: test");
}
}Step 3: Register in mod.rs
// src/utils/mod.rs
pub mod myutil;Step 4: Use in commands
use crate::utils::myutil;
fn my_command() -> Result<()> {
let result = myutil::do_something("input")?;
println!("{}", result);
Ok(())
}Step 1: Create template directory
mkdir -p templates/examples/my-template/srcStep 2: Add template files
# templates/examples/my-template/Cargo.toml
[package]
name = "{{PROJECT_NAME}}"
version = "0.1.0"
edition = "2021"
[dependencies]
soroban-sdk = "21.0.0"// templates/examples/my-template/src/lib.rs
#![no_std]
use soroban_sdk::{contract, contractimpl, Env};
#[contract]
pub struct {{PROJECT_NAME_PASCAL}};
#[contractimpl]
impl {{PROJECT_NAME_PASCAL}} {
pub fn hello(env: Env) -> String {
String::from_str(&env, "Hello from {{PROJECT_NAME}}")
}
}Step 3: Add to registry
// templates/registry.json
{
"templates": [
{
"name": "my-template",
"version": "1.0.0",
"description": "My awesome template",
"author": "Your Name",
"tags": ["example"],
"source": {
"type": "local",
"path": "templates/examples/my-template"
},
"created_at": "2025-01-01T00:00:00Z",
"updated_at": "2025-01-01T00:00:00Z",
"downloads": 0,
"verified": false
}
]
}#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_basic_functionality() {
let result = my_function("input");
assert!(result.is_ok());
assert_eq!(result.unwrap(), "expected");
}
#[test]
fn test_error_case() {
let result = my_function("");
assert!(result.is_err());
}
#[test]
#[should_panic(expected = "Invalid input")]
fn test_panic() {
panic_function();
}
}// tests/integration_test.rs
use starforge::utils::config;
use tempfile::TempDir;
#[test]
fn test_config_lifecycle() {
let temp = TempDir::new().unwrap();
let config_path = temp.path().join("config.toml");
// Test save
let config = config::Config::default();
config::save(&config).unwrap();
// Test load
let loaded = config::load().unwrap();
assert_eq!(loaded.version, config.version);
}# Run all tests
cargo test
# Run specific test
cargo test test_name
# Run tests with output
cargo test -- --nocapture
# Run tests in specific module
cargo test config::tests
# Run integration tests only
cargo test --test integration_test
# Run with coverage (requires tarpaulin)
cargo tarpaulin --out HtmlStarForge includes an end-to-end smoke test script that verifies basic functionality across all major commands.
Location: scripts/e2e-smoke.sh
Running Smoke Tests:
# Build the project first
cargo build --release
# Run smoke tests (without network tests)
./scripts/e2e-smoke.sh
# Run smoke tests with network tests (requires internet)
STARFORGE_E2E=1 ./scripts/e2e-smoke.shWhat the smoke test covers:
-
Basic Commands
starforge info- System informationstarforge --version- Version displaystarforge --help- Help text
-
Wallet Operations
wallet create- Create test walletwallet list- List walletswallet show- Display wallet details
-
Network Operations
network show- Display network configurationnetwork test- Test network connectivity (requiresSTARFORGE_E2E=1)wallet fund- Fund testnet wallet (requiresSTARFORGE_E2E=1)
-
Template Operations
template list- List available templatestemplate search- Search templates
-
Other Commands
completions- Generate shell completions
Network Test Gating:
Network tests are gated behind the STARFORGE_E2E=1 environment variable because they:
- Require internet connectivity
- Depend on external services (Stellar testnet, Friendbot)
- May be slow or flaky in CI environments
- Can hit rate limits
To skip network tests in CI:
# .github/workflows/ci.yml
- name: Run smoke tests
run: ./scripts/e2e-smoke.sh # Skips network tests by defaultTo run full tests locally:
STARFORGE_E2E=1 ./scripts/e2e-smoke.shExit Codes:
0- All tests passed1- One or more tests failed
Cleanup:
The smoke test automatically cleans up test wallets on exit. If cleanup fails, you may need to manually remove test wallets:
# List wallets to find test wallets
starforge wallet list
# Remove test wallet (when delete command is implemented)
# starforge wallet delete smoke-test-<timestamp>tests/
├── integration_test.rs # Integration tests
├── template_test.rs # Template-specific tests
└── common/
└── mod.rs # Shared test utilities
/// Module-level documentation
///
/// This module handles wallet operations including creation,
/// listing, and management of Stellar keypairs.
/// Function documentation
///
/// Creates a new wallet with the given name.
///
/// # Arguments
///
/// * `name` - Wallet identifier
/// * `encrypt` - Whether to encrypt the secret key
///
/// # Returns
///
/// Returns `Ok(())` on success, or an error if:
/// - Wallet name already exists
/// - Keypair generation fails
/// - Config save fails
pub fn create_wallet(name: String, encrypt: bool) -> Result<()> {
// Implementation
}Update these files when adding features:
- README.md - Main documentation, usage examples
- ARCHITECTURE.md - Architecture and design decisions
- DEVELOPER_GUIDE.md - This file
- Feature-specific docs - Detailed feature documentation
- Use clear, concise language
- Include code examples
- Explain WHY, not just WHAT
- Keep examples up-to-date
- Add diagrams for complex flows
# Add to Cargo.toml
cargo add <crate-name>
# Add with specific version
cargo add <crate-name>@1.0.0
# Add with features
cargo add <crate-name> --features feature1,feature2
# Add as dev dependency
cargo add --dev <crate-name># Update all dependencies
cargo update
# Update specific dependency
cargo update <crate-name>
# Check for outdated dependencies
cargo outdated# Run clippy
cargo clippy
# Deny all warnings
cargo clippy -- -D warnings
# Fix automatically (when possible)
cargo clippy --fix# Format all code
cargo fmt
# Check formatting without changing
cargo fmt --check
# Format specific file
rustfmt src/main.rsShell completion scripts are generated by build.rs into the completions/ directory.
# Regenerate completions (bash/zsh/fish)
cargo build# Build docs
cargo doc
# Build and open in browser
cargo doc --open
# Include private items
cargo doc --document-private-items# Run benchmarks
cargo bench
# Run specific benchmark
cargo bench benchmark_name
# Save baseline
cargo bench -- --save-baseline my-baseline
# Compare to baseline
cargo bench -- --baseline my-baselineThe shell command supports connecting to a local Soroban sandbox via Docker:
# Start the interactive shell against a local Docker Soroban sandbox
starforge shell --contract ./target/wasm32-unknown-unknown/release/my_contract.wasm --network docker-testnetWhen --network docker-testnet is used, StarForge:
- Ensures the Docker containers defined in
docker-compose.ymlare running (includesstellar-testnetandsoroban-rpc) - Runs contract invocations inside the Docker network where the Soroban RPC is available at
http://soroban-rpc:8000 - Routes all RPC calls through the local sandbox instead of Stellar testnet
The docker-compose.yml at the project root defines:
- stellar-testnet: A full Stellar + Soroban RPC node on
localhost:8000 - soroban-rpc: Dedicated Soroban RPC endpoint on
localhost:8001
Prerequisites:
- Docker and docker-compose installed
- Docker daemon running
// Add to Cargo.toml
[dependencies]
log = "0.4"
env_logger = "0.10"
// In main.rs
env_logger::init();
// In code
use log::{debug, info, warn, error};
debug!("Debug message: {:?}", value);
info!("Info message");
warn!("Warning message");
error!("Error message");# Enable all debug logs
RUST_LOG=debug cargo run -- wallet list
# Enable specific module
RUST_LOG=starforge::commands::wallet=debug cargo run -- wallet list
# Multiple modules
RUST_LOG=starforge::commands=debug,starforge::utils=info cargo run# Build with debug symbols
cargo build
# Run with gdb
rust-gdb target/debug/starforge
# Set breakpoint
(gdb) break src/main.rs:42
# Run
(gdb) run wallet list
# Step through
(gdb) step
(gdb) next
# Print variable
(gdb) print variable_nameIssue: Compilation errors after updating dependencies
# Solution: Clean and rebuild
cargo clean
cargo buildIssue: Tests failing intermittently
# Solution: Run tests serially
cargo test -- --test-threads=1Issue: Slow compilation
# Solution: Use sccache
cargo install sccache
export RUSTC_WRAPPER=sccache- Update version in
Cargo.toml - Update version in
src/main.rs(if hardcoded) - Update CHANGELOG.md
- Commit:
git commit -m "chore: bump version to X.Y.Z"
# 1. Tag the release
git tag -a v0.2.0 -m "Release v0.2.0"
# 2. Push tag
git push origin v0.2.0
# 3. Build release binaries
cargo build --release
# 4. Create GitHub release
# - Go to GitHub releases
# - Create new release from tag
# - Upload binaries
# - Add release notes- All tests passing
- Documentation updated
- CHANGELOG.md updated
- Version bumped
- Release notes prepared
- Binaries built for all platforms
- GitHub release created
- Announcement posted
// ✅ Use Result for fallible operations
pub fn operation() -> Result<()> {
let data = load_data()?;
process(data)?;
Ok(())
}
// ✅ Add context to errors
load_data()
.with_context(|| "Failed to load configuration")?
// ✅ Create custom error types for complex cases
#[derive(Debug, thiserror::Error)]
pub enum MyError {
#[error("Invalid input: {0}")]
InvalidInput(String),
#[error("Network error")]
Network(#[from] ureq::Error),
}// ✅ Load config once, pass as reference
let config = config::load()?;
process_with_config(&config)?;
// ❌ Don't reload config repeatedly
fn process() {
let config = config::load().unwrap(); // Bad!
// ...
}// ✅ Provide progress indicators
p::step(1, 3, "Loading configuration...");
p::step(2, 3, "Processing data...");
p::step(3, 3, "Saving results...");
// ✅ Show helpful error messages
anyhow::bail!(
"Wallet '{}' not found.\n\nTry: starforge wallet list",
name
);// ✅ Test edge cases
#[test]
fn test_empty_input() { /* ... */ }
#[test]
fn test_invalid_input() { /* ... */ }
#[test]
fn test_boundary_conditions() { /* ... */ }
// ✅ Use descriptive test names
#[test]
fn creates_wallet_with_encrypted_key_when_encrypt_flag_is_true() {
// ...
}- Fork the repository
- Create a feature branch
- Make your changes
- Test thoroughly
- Document your changes
- Submit a pull request
- Code follows style guide
- Tests added/updated
- Documentation updated
- Commit messages follow convention
- No merge conflicts
- CI passes
<type>(<scope>): <subject>
<body>
<footer>
Types:
feat: New featurefix: Bug fixdocs: Documentationstyle: Formattingrefactor: Code restructuringtest: Adding testschore: Maintenance
Examples:
feat(wallet): add hardware wallet support
Implements Ledger and Trezor integration for secure key storage.
Closes #123
- rust-analyzer - IDE support
- clippy - Linter
- rustfmt - Formatter
- cargo-edit - Dependency management
- Issues: GitHub Issues
- Discussions: GitHub Discussions
- Discord: Join the Stellar Discord
- Email: maintainer@example.com
Happy Coding! 🚀