Skip to main contentworkspace, features, and build configuration | IoT Worker

workspace, features, and build configuration

As C projects grow, they often accumulate:

several static libraries and command-line tools
public and internal headers
Makefile subdirectories
CONFIG_XXX / #ifdef switches
Debug/Release compiler flags
target platform differences

Rust projects face the same issues with different tools:

workspace -> manages several crates
crate     -> boundary for a library or executable target
feature   -> optional capabilities and optional dependencies
profile   -> debug/release build parameters
cfg       -> platform and conditional compilation

This article does not cover every Cargo feature. It focuses on the organization needed for libraries, CLI tools, and C/Rust boundaries.

A C project may have:

project/
  libcore/
  libffi/
  tools/
  tests/

Rust uses a workspace:

[workspace]
members = [
    "crates/device-core",
    "crates/device-ffi",
    "tools/device-cli",
]
resolver = "2"

A typical layout:

project/
  Cargo.toml
  crates/
    device-core/
      Cargo.toml
      src/lib.rs
    device-ffi/
      Cargo.toml
      src/lib.rs
  tools/
    device-cli/
      Cargo.toml
      src/main.rs

Benefits:

  • several crates share one Cargo.lock
  • the whole project can be tested together
  • internal library, FFI wrapper, and CLI tool stay separate
  • release boundaries become clearer

For a C developer, a workspace is the top-level project. Each crate is similar to a library, tool, or adapter layer.

Split Crates by API Responsibility

Do not split many crates just to look modular. A crate boundary is heavier than mod:

mod   -> code organization inside one crate
crate -> compilation unit, dependency boundary, release boundary

A practical split:

device-core -> pure Rust business logic, little or no FFI
device-ffi  -> extern "C" wrappers, C ABI, generated headers
device-cli  -> command-line entry point, argument parsing, output

device-core can be reused by the CLI, tests, and FFI layer. device-ffi only translates the C ABI into the safe internal Rust API.

This is similar to separating core.c, ffi.c, and main.c, but Rust crate boundaries more strongly affect dependencies and visibility.

workspace dependencies Keep Versions Together

In multi-crate projects, dependency versions drift if every crate writes them separately.

Put common dependencies at the workspace root:

[workspace.dependencies]
thiserror = "1"
clap = { version = "4", features = ["derive"] }

Member crates use:

[dependencies]
thiserror.workspace = true

This centralizes upgrades and avoids different crates quietly using different versions.

It is like controlling third-party library versions at the top level of a C/C++ project instead of letting every subdirectory choose its own.

features Express Optional Capabilities

C often uses macro switches:

#ifdef ENABLE_JSON
/* json support */
#endif

Rust usually uses Cargo features:

[features]
default = ["std"]
std = []
json = ["dep:serde", "dep:serde_json"]

[dependencies]
serde = { version = "1", optional = true, features = ["derive"] }
serde_json = { version = "1", optional = true }

Code uses:

#[cfg(feature = "json")]
pub fn encode_json(value: &Packet) -> Result<String, serde_json::Error> {
    serde_json::to_string(value)
}

Callers enable:

[dependencies]
device-core = { path = "../device-core", features = ["json"] }

Features are good for optional capabilities: optional dependencies, protocol formats, CLI subcommands, std support, and similar compile-time choices.

features Are Additive, Not Exclusive Configuration

Cargo features have an important rule: features are additive.

If one part of the dependency graph enables json and another does not, the crate is usually built with json enabled.

Do not design features as mutually exclusive switches:

[features]
backend-a = []
backend-b = []

and assume only one can be selected. Dependency unification may enable both.

If mutually exclusive backends are unavoidable, check explicitly:

#[cfg(all(feature = "backend-a", feature = "backend-b"))]
compile_error!("features backend-a and backend-b cannot be enabled together");

Prefer additive features:

support json
support tracing
support ffi
support cli

instead of “choose one mode.”

cfg Handles Platform Differences

Use cfg for platform differences:

#[cfg(target_os = "linux")]
fn platform_name() -> &'static str {
    "linux"
}

#[cfg(target_os = "windows")]
fn platform_name() -> &'static str {
    "windows"
}

It can also combine with features:

#[cfg(all(target_os = "linux", feature = "udev"))]
mod linux_udev;

For a C developer, cfg is similar to compile-time conditions, but Rust code is usually easier to maintain when platform differences are contained in small modules instead of scattered everywhere.

For example:

platform/
  mod.rs
  linux.rs
  windows.rs

Expose one Rust API and select the implementation internally.

profiles Control Debug/Release Behavior

C often uses CFLAGS=-O2 -g. Rust uses profiles:

[profile.release]
opt-level = 3
lto = "thin"
debug = "line-tables-only"
strip = "symbols"
panic = "abort"

These affect optimization, debug info, link-time optimization, symbols, and panic strategy.

Do not over-tune profiles too early. A practical rule:

development should keep fast builds and useful debug info
release can consider LTO, strip, panic=abort, and codegen-units
for FFI and crash analysis, do not throw away all debug information

Profiles are part of delivery configuration and connect directly to release builds and binary size.

Do Not Put Every Configuration into features

Many C projects turn macros into a universal configuration system. Do not use Cargo features as runtime or deployment configuration.

Good feature candidates:

compile JSON support
compile FFI exports
depend on a system library
enable std

Poor feature candidates:

device IP address
log level
config file path
runtime timeout
product serial number

Runtime configuration belongs in config files, environment variables, command-line arguments, or higher-level APIs.

A useful test:

changes dependencies or code size -> maybe a feature
changes per deployment or run      -> not a feature

Be Conservative with FFI Crate features

If a crate exports a C ABI, do not let features casually change the header and ABI.

Risky pattern:

#[cfg(feature = "extra")]
#[unsafe(no_mangle)]
pub extern "C" fn device_extra() {}

Now the same library name may export different symbols depending on features. If the C header and the actual library were built with different features, linking or runtime behavior breaks.

Safer approaches:

  • keep the C ABI surface small and stable
  • expose optional capabilities through version or capability query functions
  • generate and ship headers together with the library
  • use different library names or explicit versions for different ABI variants

Rust-internal features can be flexible; C ABI features should be conservative.

What to Keep

The first mapping for Rust project organization is:

C top-level project / Makefiles -> Cargo workspace
C sub-library / tool            -> crate
C header visibility             -> pub / pub(crate) / crate boundary
CONFIG_XXX / optional library    -> Cargo feature
platform macro                   -> cfg(target_os, target_arch, ...)
CFLAGS Debug/Release             -> Cargo profile
central third-party versions     -> workspace.dependencies
deployment-time configuration    -> environment/config file/CLI args, not features

Workspaces organize multiple crates, features describe optional compile-time capabilities, and profiles describe build behavior. Keeping those separate is easier to maintain than a pile of #ifdefs and Makefile variables.

The next article covers release builds, cross compilation, and binary size: how the final artifact is optimized, built for the target platform, and kept under control.