A Rust SDK for calling other Logos modules from within a Logos module. It consumes the language-neutral lp_* C ABI from logos-protocol directly, and — together with logos-module-builder and its logos-lidl-gen code generator — gives you typed, generated clients for every module you depend on: typed sync/async calls and typed event subscriptions, with no hand-written IPC.
When you write a Logos module in Rust, everything that crosses the module boundary is generated from your .lidl contracts: the module-impl C ABI scaffold your code plugs into, and a typed client for each module you depend on. This SDK is the runtime those generated clients are built on — it binds the lp_* C ABI, serializes parameters, drives the callback trampolines, and delivers results over channels.
In day-to-day module code you rarely name a type from this crate directly. You call modules().<dep>.<method>(...) (or its _async twin), subscribe with on_<event>(), and read your module's context with context() — all generated. The SDK is what makes those calls work.
The SDK is a pure Rust rlib with no build-time C dependency. On the standard authoring path (a cdylib module built by logos-module-builder, interface = "cdylib") the lp_* symbols resolve against the logos-protocol archive already linked into the plugin — one protocol stack shared by the generated Qt glue and your Rust code.
You don't add this SDK to a Cargo.toml by hand or write a build.rs. A Rust module is:
- a
.lidlcontract (or a Rust trait the builder derives one from), - an impl of the generated trait, plus a one-line
logos_module_install()hook, - a
metadata.jsonwhosecodegen.rustblock tells the builder to run the Rust generator and compile your crate.
logos-module-builder then supplies this SDK as your crate's logos-rust-sdk dependency (matched to the generator it ran, so there is no version skew), runs logos-lidl-gen to emit the scaffold (generated/provider_gen.rs), compiles the crate to a static archive, and links it into the plugin. Your flake.nix and CMakeLists.txt end up as small as a C++ module's.
The complete, runnable walkthrough — a Rust provider, a C++ provider, and a Rust consumer wired together — is the executable doc-test doctests/cross-language-composition.test.yaml.
Everything in this section is generated by the builder from the contracts and emitted into your crate. This is the entire cross-module surface you write against — every call is typed; there is no string-keyed dispatch in module code.
A module you list in dependencies gets a typed client on the modules() aggregate, reached by its module name. The host auto-loads it with you.
impl MyModule for MyImpl {
// Synchronous typed call.
fn total(&mut self) -> i64 {
modules().counter_module.increment(1).unwrap_or(-1)
}
// Asynchronous twin: returns immediately; the typed result is delivered to
// the callback on the module's event loop, after this method returns.
fn bump_async(&mut self) {
modules().counter_module.increment_async(1, |res| {
if let Ok(v) = res { /* stash v; read it back from a later method */ }
});
}
}Code against a contract shape (declared in interface_dependencies) and bind it to a concrete provider chosen at runtime — no build-time coupling to any particular module:
fn hello(&mut self, name: String) -> String {
greeter::GreeterClient::bind("cpp_greeter_module")
.greet(&name)
.unwrap_or_else(|e| format!("greet failed: {}", e))
}Each event in a dependency's contract generates a typed subscription. The returned EventSubscription owns its client share, so it keeps receiving after the proxy is dropped — move it into a listener thread and iterate it:
let mut calc = modules().rust_calc_module;
if let Ok(sub) = calc.on_computed() {
std::thread::spawn(move || {
for ev in sub {
if let Some(e) = rust_calc_module::RustCalcModuleClient::decode_computed(&ev) {
// e.total — the decoded, typed payload
}
}
});
}The host stamps each loaded instance with its identity. Read it through the generated context():
fn whereami(&mut self) -> String {
match context() {
Some(c) => format!(
"module_path={} | instance_id={} | persistence={}",
c.module_path, c.instance_id, c.instance_persistence_path
),
None => "context not ready".to_string(),
}
}instance_persistence_path is a writable, per-instance directory — the place to keep a module's on-disk state.
The handful of SDK types that surface directly in module code:
on_<event>() returns an EventSubscription: a Send handle bundling the event
channel with ownership of the lp subscription and a share of the client (dropping
a bare proxy would otherwise silently kill the subscription). It supports
recv(), try_recv(), blocking iteration (for ev in sub), and unsubscribes on
drop. Each item is an EventData, which the generated decode_<event>() turns
into the typed payload struct:
pub struct EventData {
pub event: String, // event name
pub data: serde_json::Value, // raw payload as JSON — use decode_<event>() for the typed form
}Every typed call and subscription returns Result<_, LogosError>:
| Variant | Cause |
|---|---|
PluginCallFailed |
Method call returned an error from the remote module |
EventListenerFailed |
Event registration failed |
InvalidString |
A string argument contained a null byte |
JsonError |
Parameter serialization failed |
ChannelClosed |
The callback channel was dropped unexpectedly |
Other |
Miscellaneous error with a descriptive message |
logos-rust-sdk declares extern "C" bindings to the lp_* protocol functions
but links no C library at Rust compilation time. The crate compiles to a
staticlib containing unresolved lp_* references, which resolve at the final
plugin link:
librust_my_module.a (your Rust staticlib, contains unresolved lp_* refs)
↓
CMake links plugin .dylib
+ logos-protocol archive ← lp_* resolved here (one shared stack
↓ with the generated Qt glue)
my_module_plugin.dylib (complete, loadable Logos module)
The builder's cmake/LogosModule.cmake stages and links the archive from
codegen.rust automatically — the author writes no link lines.
The SDK itself has no standalone Nix build artifact — it is a library crate consumed by module builds. To work on it:
# Enter a dev shell with Rust toolchain
nix develop
# Run unit tests (params serialization, etc.)
cargo testTwo complementary checks exercise the SDK and the Rust-module pipeline it builds on (the cross-language composition showcases — typed calls and events crossing the C++/Rust boundary in both directions — live in logos-module-builder's doctests, since they exercise the builder across both SDKs):
-
IPC integration test (
tests/) — builds a minimal provider + caller module pair on the cdylib authoring path: each fixture is a.lidlcontract from whichlidl-gen --providergenerates the Rust module-impl C ABI scaffold (logos_module_*exports, typed trait,RustModuleContext) and logos-module-builder (interface = "cdylib") generates the uniform Qt glue. The author writes the trait impl plus a#[no_mangle] fn logos_module_install()hook; the plugin links one logos-protocol stack shared by the glue and the SDK, so the host token forwarded throughlogos_module_accept_tokenauthenticates the caller's outboundadd()call. Verified end-to-end throughlogoscore:nix build 'path:./tests#checks.x86_64-linux.ipc-test' \ --override-input logos-rust-sdk path:. --print-build-logs -
Executable doc-test (
doctests/cross-language-composition.test.yaml) — a step-by-step, runnable tutorial that writes three modules from scratch on the builder-driven cdylib path (nobuild.rs): a Rust provider, a C++ provider, and a Rust consumer that ties them together. It packages each as an.lgx, installs them withlgpm, and drives the consumer through alogoscoredaemon to exercise the whole consumer surface — module context (module_path/instance_id/instance_persistence_path), sync and async typed calls, typed event subscription, and both a concrete and an interface dependency. Run it with the shareddoctestCLI:cd doctests && ./run.sh
The rendered tutorial is committed at
doctests/outputs/cross-language-composition/cross-language-composition.md.