Multi-Crate Workspaces
You need to split your robotics project into multiple crates: shared message types, a hardware abstraction layer, algorithm libraries, and the main binary. HORUS workspaces let you organize these as members that compile together, share dependencies, and reference each other directly, without publishing anything to a registry.
When To Use This
- Your project has shared message types used by multiple crates
- You need a driver abstraction layer that separates hardware from application logic
- Algorithm libraries (PID, SLAM, path planning) should be reusable across projects
- Build times are slow because the entire project recompiles on every change
Use a single-file project instead if you are prototyping or have a simple system with all nodes in one file.
Prerequisites
- A HORUS project initialized with
horus new <name> --workspace - Familiarity with horus.toml (workspace and member configuration)
- Familiarity with Rust crate structure (
src/lib.rs,src/main.rs)
Why Workspaces
A single-crate project works fine for prototypes, but production robotics code benefits from separation:
- Shared message types — Define sensor readings, commands, and state once. Every crate imports them
- Driver abstraction — Isolate hardware-specific code behind traits. Swap real hardware for simulation without touching application logic
- Algorithm libraries — PID controllers, path planners, and SLAM modules become reusable across projects
- Faster compilation — Only changed crates recompile. Your message types crate rarely changes, so it compiles once
- Clear ownership — Each crate has a focused purpose. Code review and testing stay manageable
Creating a Workspace
horus new my-robot --workspace -r
This generates a workspace scaffold with a single binary member:
my-robot/
├── horus.toml # [workspace] members = ["crates/*"]
├── .horus/
│ ├── Cargo.toml # Generated workspace Cargo.toml
│ └── my-robot/
│ └── Cargo.toml # Generated member Cargo.toml
└── crates/
└── my-robot/
├── horus.toml # [package] name = "my-robot"
└── src/
└── main.rs
The root horus.toml defines workspace-level settings. Each member under crates/ has its own horus.toml for package-specific configuration.
Adding Library Members
Add library crates alongside your binary:
cd my-robot
horus new messages --lib -r -o crates
horus new driver --lib -r -o crates
Your workspace now looks like this:
my-robot/
├── horus.toml
├── .horus/
└── crates/
├── messages/
│ ├── horus.toml
│ └── src/
│ └── lib.rs
├── driver/
│ ├── horus.toml
│ └── src/
│ └── lib.rs
└── my-robot/
├── horus.toml
└── src/
└── main.rs
Workspace horus.toml Format
The root horus.toml defines workspace membership and shared dependencies:
# Root horus.toml
[workspace]
members = ["crates/*"]
exclude = ["crates/experimental"]
[workspace.dependencies]
horus_library = "0.1.9"
serde = { version = "1.0", source = "crates.io", features = ["derive"] }
nalgebra = { version = "0.33", source = "crates.io" }
members accepts glob patterns. "crates/*" includes every directory under crates/.
exclude removes specific directories from the glob match. Useful for in-progress crates that should not build with the rest of the workspace.
[workspace.dependencies] centralizes version and source declarations. Members inherit these without repeating version numbers.
Member horus.toml Format
Each member declares its own package metadata and dependencies:
# crates/messages/horus.toml
[package]
name = "my-robot-messages"
version = "0.1.0"
type = "lib"
[dependencies]
serde = { workspace = true }
The workspace = true marker tells horus to pull the version, source, and features from the root [workspace.dependencies] table.
Target Types
The type field in a member's [package] table controls what gets built:
| Type | What it produces | Use case |
|---|---|---|
"bin" | Executable binary (default) | Main application, CLI tools |
"lib" | Library crate | Shared types, drivers, algorithms |
"both" | Library + binary in same crate | Library with a companion CLI |
# A crate that is both a library and a binary
[package]
name = "my-robot-driver"
version = "0.1.0"
type = "both"
Dependency Inheritance
When a member uses workspace = true, it inherits the version, source, and features from the root [workspace.dependencies]:
# Root horus.toml
[workspace.dependencies]
serde = { version = "1.0", source = "crates.io", features = ["derive"] }
# crates/messages/horus.toml
[dependencies]
serde = { workspace = true }
# Inherits: version = "1.0", source = "crates.io", features = ["derive"]
Members can add extra features on top of the inherited ones:
# crates/driver/horus.toml
[dependencies]
serde = { workspace = true, features = ["alloc"] }
# Gets "derive" from workspace + "alloc" from this member
This keeps version management in one place. Bump serde once in the root, and every member picks up the change.
Inter-Member Dependencies
Members reference each other with path dependencies:
# crates/controller/horus.toml
[package]
name = "my-robot-controller"
version = "0.1.0"
type = "lib"
[dependencies]
my-robot-messages = { path = "../messages" }
my-robot-driver = { path = "../driver" }
horus_library = { workspace = true }
Path dependencies resolve relative to the member's directory. The build system handles everything — no publishing or installation required.
Building and Running
Workspace-level commands operate on all members:
horus build # Build all members
horus build -p messages # Build specific member
horus run -p my-robot # Run specific binary
horus test # Test all members
horus test -p driver # Test specific member
Native cargo commands also work through the generated build files:
cargo build --workspace # Build everything via native toolchain
cargo test --workspace # Test everything
cargo clippy --workspace # Lint everything
Generated Build Files
Horus generates native build files in .horus/ from your horus.toml declarations. You should never edit these directly:
.horus/
├── Cargo.toml # [workspace] with members list
├── my-robot-messages/
│ └── Cargo.toml # [lib] pointing to ../../crates/messages/src/
├── my-robot-driver/
│ └── Cargo.toml # [lib] pointing to ../../crates/driver/src/
├── my-robot-controller/
│ └── Cargo.toml # [lib] pointing to ../../crates/controller/src/
├── my-robot/
│ └── Cargo.toml # [[bin]] pointing to ../../crates/my-robot/src/
└── target/ # Shared build artifacts for all members
The generated Cargo.toml files point back to your source directories. All members share a single target/ directory, so common dependencies compile once.
Running horus build regenerates these files if your horus.toml has changed, then invokes cargo against the .horus/ workspace.
Example: Complete Robotics Workspace
Here is a realistic project structure for an autonomous robot:
my-robot/
├── horus.toml
└── crates/
├── messages/ # Shared types
│ ├── horus.toml
│ └── src/lib.rs
├── driver/ # Hardware abstraction
│ ├── horus.toml
│ └── src/lib.rs
├── controller/ # Algorithms
│ ├── horus.toml
│ └── src/lib.rs
└── my-robot/ # Main binary
├── horus.toml
└── src/main.rs
messages crate
Define sensor readings and commands that every other crate shares:
// simplified
// crates/messages/src/lib.rs
use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct LaserScan {
pub ranges: Vec<f32>,
pub angle_min: f32,
pub angle_max: f32,
pub range_max: f32,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct CmdVel {
pub linear_x: f64,
pub angular_z: f64,
}
#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct Odometry {
pub x: f64,
pub y: f64,
pub theta: f64,
}
# crates/messages/horus.toml
[package]
name = "my-robot-messages"
version = "0.1.0"
type = "lib"
[dependencies]
serde = { workspace = true }
driver crate
Abstract hardware behind traits so you can swap real sensors for simulated ones:
// simplified
// crates/driver/src/lib.rs
use my_robot_messages::{CmdVel, LaserScan, Odometry};
pub trait LidarDriver: Send + Sync {
fn scan(&self) -> LaserScan;
}
pub trait MotorDriver: Send + Sync {
fn send_velocity(&self, cmd: &CmdVel);
fn read_odometry(&self) -> Odometry;
}
# crates/driver/horus.toml
[package]
name = "my-robot-driver"
version = "0.1.0"
type = "lib"
[dependencies]
my-robot-messages = { path = "../messages" }
controller crate
Implement algorithms that depend on message types and driver traits:
// simplified
// crates/controller/src/lib.rs
use my_robot_messages::{CmdVel, LaserScan};
pub struct ObstacleAvoider {
pub min_distance: f32,
pub turn_speed: f64,
}
impl ObstacleAvoider {
pub fn compute(&self, scan: &LaserScan) -> CmdVel {
let closest = scan.ranges.iter().cloned().fold(f32::MAX, f32::min);
if closest < self.min_distance {
CmdVel { linear_x: 0.0, angular_z: self.turn_speed }
} else {
CmdVel { linear_x: 0.5, angular_z: 0.0 }
}
}
}
# crates/controller/horus.toml
[package]
name = "my-robot-controller"
version = "0.1.0"
type = "lib"
[dependencies]
my-robot-messages = { path = "../messages" }
Main binary
Tie everything together with horus nodes:
// simplified
// crates/my-robot/src/main.rs
use horus_library::prelude::*;
use my_robot_controller::ObstacleAvoider;
use my_robot_messages::{CmdVel, LaserScan};
fn main() -> Result<()> {
let avoider = ObstacleAvoider {
min_distance: 0.5,
turn_speed: 1.0,
};
let mut sched = Scheduler::new().tick_rate(100_u64.hz());
sched.add(LidarNode::new()?).order(0).build()?;
sched.add(Controller::new()?).order(1).build()?;
sched.run()
}
struct LidarNode {
scan_pub: Topic<LaserScan>,
}
impl LidarNode {
fn new() -> Result<Self> {
Ok(Self { scan_pub: Topic::new("scan")? })
}
}
impl Node for LidarNode {
fn name(&self) -> &str { "lidar_node" }
fn tick(&mut self) {
let scan = LaserScan::new();
self.scan_pub.send(scan);
}
}
struct Controller {
scan_sub: Topic<LaserScan>,
cmd_pub: Topic<CmdVel>,
}
impl Controller {
fn new() -> Result<Self> {
Ok(Self {
scan_sub: Topic::new("scan")?,
cmd_pub: Topic::new("cmd_vel")?,
})
}
}
impl Node for Controller {
fn name(&self) -> &str { "controller" }
fn tick(&mut self) {
if let Some(scan) = self.scan_sub.recv() {
self.cmd_pub.send(CmdVel::new(0.5, 0.0));
}
}
}
# crates/my-robot/horus.toml
[package]
name = "my-robot"
version = "0.1.0"
[dependencies]
horus_library = { workspace = true }
my-robot-messages = { path = "../messages" }
my-robot-controller = { path = "../controller" }
Root workspace configuration
# Root horus.toml
[workspace]
members = ["crates/*"]
[workspace.dependencies]
horus_library = "0.1.9"
serde = { version = "1.0", source = "crates.io", features = ["derive"] }
Build and run the whole project:
horus build # Compiles messages, driver, controller, then my-robot
horus run -p my-robot # Runs the main binary
horus test # Tests all crates
Common Errors
| Symptom | Cause | Fix |
|---|---|---|
unresolved import for a workspace member | Member not added as a path dependency | Add my-member = { path = "../member" } to the consuming crate's horus.toml |
workspace = true not resolving | Dependency not declared in root [workspace.dependencies] | Add the dependency to the root horus.toml [workspace.dependencies] table |
horus build compiles everything every time | Source directory symlinks or timestamps changing | Ensure .horus/ generated Cargo.toml points to correct ../../crates/ paths |
| Member not included in build | Glob pattern does not match directory | Check members = ["crates/*"] matches your directory structure, or add explicit member paths |
| Feature merge conflicts | Same dependency with different features across members | Centralize features in [workspace.dependencies] and use workspace = true everywhere |
See Also
- horus.toml — Project manifest format for workspaces and members
- CLI Reference —
horus build,horus run -p, and workspace commands - Native Tool Integration — Using
cargonatively inside HORUS workspaces - Package Management — Publishing workspace members to the registry