Skip to content

Search is only available in production builds. Try building and previewing the site to test it out locally.

rstgcalls FFI Scaffold Implementation Plan

Источник: dev/fera/rstgcalls/docs/superpowers/plans/2026-10-17-rstgcalls-ffi-scaffold.md (read-only; сгенерировано scripts/sync-docs.mjs)

rstgcalls FFI Scaffold Implementation Plan

Section titled “rstgcalls FFI Scaffold Implementation Plan”

For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Stand up a compilable, documented rstgcalls Rust workspace that binds Telegram’s C++ tgcalls WebRTC stack behind a safe, idiomatic API — with unsafe confined to the -sys crate and one gated backend module, and cargo check/clippy/test green offline today (before the C++ WebRTC tree is available).

Architecture: Two crates in a Cargo workspace. rstgcalls-sys owns all unsafe and all C/C++ contact: its build.rs runs bindgen against a hand-written pure C-ABI bridge header (include/rstgcalls_bridge.h) — POD structs, an opaque rstgcalls_instance handle, and int32_t return codes — not against the tgcalls C++ classes (which drag in the entire WebRTC tree and cannot be parsed without it). A feature-gated C++ translation unit implements the bridge: ffi-stub compiles an in-memory fake (src/bridge_stub.cpp) so the whole FFI chain links and is testable with zero WebRTC; link-tgcalls (later, blocked on libwebrtc) compiles the real bridge against the submodule. rstgcalls (the safe crate) wraps the sys surface in a CallBackend trait with two impls (MockBackend, TgcallsBackend), a typestate CallManager<Idle|Active>, and value types — unsafe_code = "deny" at the crate root with a single scoped #[allow(unsafe_code)] on the gated backend module.

Tech Stack: Rust edition 2024, MSRV 1.100, toolchain nightly-2026-09-10; bindgen 0.72, cc 1.2, thiserror 2.0, trybuild 1.0 (all present in the local cargo registry cache → offline builds work); clang 23 / libclang; cmake 4.4.4. System C++ libs present: openssl 3.6.5, ffmpeg (libavcodec/format/util 63.1.102), opus 1.6.1.

Spec: ../project-design/context/ — ADR-018 (tgcalls bindings now, pure-Rust re:call deferred), ADR-014 (contract tests mandatory for any trait with >1 impl), ADR-015 (typestate/enum over boolean flags), TECHSPEC §10 item 8 + §4 (calls ship as their own nylon-ring module calls-tgcalls, isolated), QA.md cross-cutting gates. Workspace conventions mirror ../fera/CLAUDE.md: conventional commits in English, never push, green check+clippy+tests before each commit.

  • Never push to origin. Local commits only; the owner reviews and pushes. (project CLAUDE.md)
  • Conventional commits, English. (feat:, build:, test:, docs:, chore:).
  • Green before every commit: cargo check, cargo clippy --all-targets, cargo test. All must pass offline (--offline) in this environment.
  • unsafe is confined to: (a) the generated bindings in rstgcalls-sys, and (b) the single gated backend module in the safe crate. Nowhere else.
  • Lint policy (from ../fera): crate-root unsafe_code = "deny" (NOT forbid — forbid is terminal and cannot be relaxed by the scoped allow the backend needs; rustc E0453). clippy::all + clippy::pedantic = deny. warnings = "deny", missing_docs warn (promote to error for public API by documenting everything), unused_results deny.
  • Edition 2024, MSRV 1.100, resolver “3”.
  • No system-package installs. If a build step needs a lib that isn’t present, record it in TODO.md and report — do not dnf/apt install.
  • bindgen offline gotcha (proven): must pass explicit --target=<TARGET> (bindgen 0.72 asserts host==target and panics otherwise) AND clang++-discovered -isystem C++ include paths (libclang does not auto-discover the C++ stdlib). Discovery is portable: run clang++ -std=c++20 -E -x c++ -v - and parse the block between #include <...> search starts here: and End of search list.
  • Cross-repo deps: committed manifests stay on git deps (the-fera org); local co-dev uses .cargo/config.toml paths overrides (gitignored).

The spec is a vision doc; these are the inputs/failure modes it implies but no single test exercises, most-likely-to-bite first. Each is pinned to the owning task’s test step.

  1. A caller pushes audio before joining → must be unrepresentable, not a runtime panic. Pinned by Task 8 (trybuild compile-fail: push_audio is only defined on CallManager<Active>).
  2. The C++ bridge returns a non-zero error code (backend unavailable, stopped, invalid arg) → safe layer must map every documented code to a typed Error, never silently succeed or leak a raw int. Pinned by Task 4 (error mapping tests) and Task 6 (stub returns ERR_STOPPED after stop; contract test asserts the mapping).
  3. A malformed AudioFrame (zero channels, zero sample rate, sample slice length not a multiple of channels) → must be rejected at the safe boundary before any pointer crosses FFI. Pinned by Task 4 (AudioFrame::new validation tests).
  4. The real C++/WebRTC backend is absent (today’s reality) → the default feature build and its full test suite must still be green with no link step. Pinned by Task 1 + Task 5 + Task 7 (default-feature cargo test passes using MockBackend only) and Task 10 (feature-matrix verification).
  5. The two CallBackend impls drift in semantics → a contract suite must run identically against both (ADR-014). Pinned by Task 5 (contract fn defined + run vs MockBackend) and Task 6 (same contract fn run vs TgcallsBackend under ffi-stub).
  6. Stray unsafe sneaks into the safe crate outside the backend module → must fail to compile. Pinned by Task 10 (a temporary out-of-module unsafe block is confirmed to be rejected by crate-root deny, then reverted; and grep confirms confinement).
  7. tgcalls_instance handle leaks if a CallManager/TgcallsBackend is dropped without leave() → Drop must stop+release. Pinned by Task 6 (stub tracks live-handle count; test asserts it returns to zero after drop).

rstgcalls/
├── Cargo.toml # workspace root: resolver 3, members, workspace.package, workspace.lints
├── rust-toolchain.toml # nightly-2026-09-10, clippy+rustfmt, minimal profile
├── clippy.toml # msrv 1.100 + thresholds (mirror ../fera)
├── .gitignore # target/, .cargo/config.toml, generated
├── README.md # (Task 9) build, feature matrix, system deps
├── TODO.md # (Task 9) plan, blocking C++ deps, next steps
├── tgcalls/ # submodule (already present @ d20de8e8)
└── crates/
├── sys/ # rstgcalls-sys — ALL unsafe + C/C++ contact
│ ├── Cargo.toml
│ ├── build.rs # portable clang++ include discovery → bindgen bridge.h → OUT_DIR/bindings.rs; feature-gated cc
│ ├── include/
│ │ └── rstgcalls_bridge.h # pure C ABI: opaque handle, POD structs, int32_t codes, extern "C" fns
│ └── src/
│ ├── lib.rs # include!(bindings.rs); allow(non_snake_case, …)
│ └── bridge_stub.cpp # (Task 3) ffi-stub: in-memory fake instance, deterministic codes
└── safe/ # rstgcalls — safe idiomatic wrapper
├── Cargo.toml # optional dep on sys; features default/ffi-stub/link-tgcalls
├── src/
│ ├── lib.rs # crate docs, deny(unsafe_code), re-exports
│ ├── error.rs # (Task 4) Error enum (thiserror), Result
│ ├── types.rs # (Task 4) ConnectionState, NetworkType, AudioFrame, InitConfig, FinalState
│ ├── backend.rs # (Task 5) CallBackend trait + MockBackend
│ └── ffi_backend.rs # (Task 6) #[cfg(gated)] #[allow(unsafe_code)] TgcallsBackend
├── src/manager.rs # (Task 7) CallManager<Idle|Active> typestate
└── tests/
├── contract.rs # (Task 5) run_backend_contract<B>() + per-impl invocations
└── ui.rs # (Task 8) trybuild harness
└── ui/*.rs,*.stderr

Task 1: Workspace scaffold + lint/toolchain config

Section titled “Task 1: Workspace scaffold + lint/toolchain config”

Files:

  • Create: Cargo.toml, rust-toolchain.toml, clippy.toml, .gitignore
  • Create: crates/sys/Cargo.toml, crates/sys/src/lib.rs
  • Create: crates/safe/Cargo.toml, crates/safe/src/lib.rs

Interfaces:

  • Consumes: nothing (first task).

  • Produces: a two-member workspace whose crates compile empty. rstgcalls-sys declares features ffi-stub and link-tgcalls (both no-op for now). rstgcalls (safe) declares optional dep rstgcalls-sys and features default (empty), ffi-stub = ["dep:rstgcalls-sys","rstgcalls-sys/ffi-stub"], link-tgcalls = ["dep:rstgcalls-sys","rstgcalls-sys/link-tgcalls"]. Later tasks add modules to these crates.

  • Step 1: Write the root Cargo.toml

[workspace]
resolver = "3"
members = ["crates/sys", "crates/safe"]
[workspace.package]
version = "0.1.0"
edition = "2024"
rust-version = "1.100"
license = "TBD"
repository = "https://github.com/the-fera/rstgcalls"
[workspace.lints.rust]
unsafe_code = "deny"
warnings = "deny"
rust_2018_idioms = { level = "deny", priority = -1 }
unused_lifetimes = "deny"
unused_qualifications = "deny"
unused_results = "deny"
missing_debug_implementations = "warn"
missing_docs = "warn"
[workspace.lints.clippy]
all = { level = "deny", priority = -1 }
pedantic = { level = "deny", priority = -1 }

Note: rust-version at [workspace.package] is inherited by members via rust-version.workspace = true; do not leave it unused (cargo warns on unused workspace.package fields under warnings = "deny"). Each member that inherits it must actually reference it.

  • Step 2: Write rust-toolchain.toml (mirror ../fera)
[toolchain]
channel = "nightly-2026-09-10"
components = ["rustfmt", "clippy"]
profile = "minimal"
  • Step 3: Write clippy.toml (mirror ../fera)
msrv = "1.100.0"
avoid-breaking-exported-api = false
cognitive-complexity-threshold = 15
too-many-arguments-threshold = 5
too-many-lines-threshold = 150
type-complexity-threshold = 200
  • Step 4: Write .gitignore
/target
/crates/*/target
.cargo/config.toml

(.cargo/config.toml holds local paths overrides for cross-repo co-dev; never committed per project CLAUDE.md.)

  • Step 5: Write crates/sys/Cargo.toml
[package]
name = "rstgcalls-sys"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Raw FFI bindings to tgcalls (Telegram C++ WebRTC stack). All unsafe lives here."
build = "build.rs"
[features]
default = []
# Compile an in-memory fake bridge so the FFI chain links and is testable with no WebRTC.
ffi-stub = []
# Compile the real bridge against the tgcalls submodule (requires libwebrtc; see TODO.md).
link-tgcalls = []
[lints]
workspace = true
[lints.rust]
# This crate is the unsafe boundary; the generated bindings and any manual FFI are unsafe by nature.
unsafe_code = "allow"

Note: [lints.rust] unsafe_code = "allow" here is intentional and does not conflict with the workspace deny — it narrows it for the sys crate only. Document why in a comment (as above).

  • Step 6: Write crates/sys/src/lib.rs (placeholder; Task 2 replaces with include!)
#![allow(non_camel_case_types, non_upper_case_globals, non_snake_case)]
#![allow(clippy::all, clippy::pedantic)]
//! Raw FFI bindings to tgcalls.
//!
//! This crate is the single home of `unsafe` and all C/C++ contact. It is
//! generated from a pure C-ABI bridge header by `build.rs`; consumers should
//! use the safe `rstgcalls` crate instead.
// Task 2 adds: include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
  • Step 7: Write a stub crates/sys/build.rs so the crate builds before Task 2 fleshes it out
fn main() {
println!("cargo:rerun-if-changed=build.rs");
}
  • Step 8: Write crates/safe/Cargo.toml
[package]
name = "rstgcalls"
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
description = "Safe, idiomatic Rust wrapper over tgcalls (Telegram C++ WebRTC stack)."
[dependencies]
rstgcalls-sys = { path = "../sys", optional = true }
thiserror = "2"
[features]
default = []
# Link the in-memory stub backend; enables the FFI round-trip tests with no WebRTC.
ffi-stub = ["dep:rstgcalls-sys", "rstgcalls-sys/ffi-stub"]
# Link the real tgcalls backend (requires libwebrtc; see TODO.md).
link-tgcalls = ["dep:rstgcalls-sys", "rstgcalls-sys/link-tgcalls"]
[lints]
workspace = true
  • Step 9: Write crates/safe/src/lib.rs (placeholder; later tasks add modules)
#![warn(missing_docs)]
//! Safe, idiomatic Rust bindings to **tgcalls** — Telegram's C++ WebRTC
//! calling stack — for the Fera ecosystem (see ADR-018).
//!
//! `unsafe` is confined to the gated `ffi_backend` module (the only FFI
//! boundary) and to the `rstgcalls-sys` crate. The crate root inherits
//! `unsafe_code = "deny"` from the workspace.
//!
//! # Backends
//! - [`backend::MockBackend`]: in-process, always available, for tests/CI.
//! - `ffi_backend::TgcallsBackend` (features `ffi-stub` / `link-tgcalls`):
//! real bridge over the C ABI.
// Modules added in later tasks: error, types, backend, manager, ffi_backend.
  • Step 10: Verify the empty workspace builds and lints clean

Run: cargo check --offline && cargo clippy --offline --all-targets && cargo test --offline Expected: all three succeed; zero warnings (both crates compile with only doc comments). cargo metadata --offline lists both members.

  • Step 11: Commit
Terminal window
git add Cargo.toml rust-toolchain.toml clippy.toml .gitignore crates/
git commit -m "build: scaffold rstgcalls workspace (sys + safe crates, lints, toolchain)
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 2: rstgcalls-sys C-ABI bridge header + bindgen build.rs

Section titled “Task 2: rstgcalls-sys C-ABI bridge header + bindgen build.rs”

Files:

  • Create: crates/sys/include/rstgcalls_bridge.h
  • Modify: crates/sys/build.rs (full bindgen driver)
  • Modify: crates/sys/src/lib.rs (add include! of generated bindings)

Interfaces:

  • Consumes: the workspace from Task 1.

  • Produces (the C ABI every later task relies on — exact names/types):

    • Opaque handle: typedef struct rstgcalls_instance rstgcalls_instance;
    • Return codes (int32_t): RSTGCALLS_OK = 0, RSTGCALLS_ERR_NULL_ARG = -1, RSTGCALLS_ERR_BACKEND_UNAVAILABLE = -2, RSTGCALLS_ERR_STOPPED = -3, RSTGCALLS_ERR_INVALID_ARG = -4.
    • POD structs: rstgcalls_config { double initialization_timeout; double receive_timeout; int32_t data_saving; uint8_t enable_p2p; uint8_t allow_tcp; int32_t max_api_layer; int32_t protocol_version; }, rstgcalls_audio_frame { const int16_t *samples; size_t num_samples; size_t num_channels; uint32_t samples_per_sec; int64_t elapsed_time_ms; }, rstgcalls_final_state { uint64_t bytes_sent; uint64_t bytes_received; int32_t is_rating_suggested; }.
    • extern "C" functions: rstgcalls_instance_create(const rstgcalls_config*, rstgcalls_instance** out) -> int32_t, rstgcalls_instance_push_audio(rstgcalls_instance*, const rstgcalls_audio_frame*) -> int32_t, rstgcalls_instance_receive_signaling(rstgcalls_instance*, const uint8_t* data, size_t len) -> int32_t, rstgcalls_instance_set_network_type(rstgcalls_instance*, int32_t) -> int32_t, rstgcalls_instance_set_mute(rstgcalls_instance*, uint8_t) -> int32_t, rstgcalls_instance_get_state(rstgcalls_instance*) -> int32_t, rstgcalls_instance_stop(rstgcalls_instance*, rstgcalls_final_state* out) -> int32_t.
    • build.rs generates $OUT_DIR/bindings.rs and (under ffi-stub/link-tgcalls) invokes cc.
  • Step 1: Write crates/sys/include/rstgcalls_bridge.h with the exact ABI above. Header guard #pragma once; #include <stdint.h> and <stddef.h>; wrap the whole surface in #ifdef __cplusplus extern "C" { … }. Add a #define for each return code. Do not include any tgcalls or webrtc header — this file must parse standalone. Document each function’s contract (when it returns which code) in comments.

  • Step 2: Write the failing verification test — a build.rs-time assertion is not a cargo test; instead add crates/sys/tests/abi_present.rs:

// Proves the generated bindings expose the expected ABI surface and that
// AudioFrame's layout matches the C++ struct (56 bytes on x86_64 per tgcalls).
#[test]
fn abi_symbols_and_layout() {
use rstgcalls_sys::*;
// Types exist and are the expected size/align.
assert_eq!(core::mem::size_of::<rstgcalls_audio_frame>(), 40);
assert_eq!(core::mem::size_of::<rstgcalls_config>(), 40);
assert_eq!(RSTGCALLS_OK, 0);
assert_eq!(RSTGCALLS_ERR_STOPPED, -3);
// Function pointers resolve (link-time presence checked under ffi-stub).
let _f: unsafe extern "C" fn(*const rstgcalls_config, *mut *mut rstgcalls_instance) -> i32 =
rstgcalls_instance_create;
}

Note: the exact size_of values must be computed from the real layout once generated (Task 2 step 4) and corrected here — do not trust the numbers in this plan blindly; the point of the test is that they are stable and match the C++ structs. rstgcalls_config has {double,double,i32,u8,u8,i32,i32} → 8+8+4+1+1+4+4 with padding = 32 or 40 depending on alignment; set the assertion to the actual generated value.

  • Step 3: Run the test to verify it fails

Run: cargo test --offline -p rstgcalls-sys Expected: FAIL to compile — rstgcalls_sys::rstgcalls_audio_frame etc. not found (bindings not yet generated).

  • Step 4: Write crates/sys/build.rs (portable bindgen driver)

Approach: discover C++ system include paths from clang++ at build time (no hardcoded versions), then run bindgen against the bridge header with an explicit --target. Emit bindings.rs to OUT_DIR. Under ffi-stub, cc-compile src/bridge_stub.cpp (Task 3); under link-tgcalls, cc-compile the real bridge (later). The full body:

use std::path::Path;
use std::process::{Command, Stdio};
fn main() {
println!("cargo:rerun-if-changed=include/rstgcalls_bridge.h");
println!("cargo:rerun-if-changed=build.rs");
println!("cargo:rerun-if-changed=src/bridge_stub.cpp");
let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR");
let target = std::env::var("TARGET").expect("TARGET");
let mut b = bindgen::Builder::default()
.header("include/rstgcalls_bridge.h")
// bindgen 0.72 asserts host==target and panics without an explicit target.
.clang_arg(format!("--target={target}"))
.clang_arg("-x").clang_arg("c++").clang_arg("-std=c++20");
for p in discover_cxx_includes() {
b = b.clang_arg(format!("-isystem{p}"));
}
b.generate().expect("bindgen bridge.h")
.write_to_file(Path::new(&out_dir).join("bindings.rs"))
.expect("write bindings.rs");
if std::env::var_os("CARGO_FEATURE_FFI_STUB").is_some() {
cc::Build::new().cpp(true).std("c++20").include(".")
.file("src/bridge_stub.cpp").compile("rstgcalls_bridge_stub");
}
// link-tgcalls branch added later (blocked on libwebrtc; see TODO.md).
}
/// Parse the "#include <...> search starts here:" block from clang++ -v so we
/// never hardcode C++ stdlib / clang resource-dir paths.
fn discover_cxx_includes() -> Vec<String> {
let out = Command::new("clang++")
.args(["-std=c++20", "-E", "-x", "c++", "-v", "-"])
.stdin(Stdio::null()).output().expect("run clang++");
let stderr = String::from_utf8_lossy(&out.stderr);
let mut paths = Vec::new();
let mut in_block = false;
for line in stderr.lines() {
if line.contains("#include <...> search starts here:") { in_block = true; continue; }
if line.contains("End of search list.") { break; }
if in_block {
if let Some(p) = line.trim().split_whitespace().next() {
paths.push(p.to_string());
}
}
}
paths
}

Add to crates/sys/Cargo.toml under a new section:

[build-dependencies]
bindgen = "0.72"
cc = "1.2"

Keep build.rs itself pedantic-clean (the discovery loop above avoids the else if/trim-before-split_whitespace clippy lints by using split_whitespace().next() on an already-trimed line — that ordering is what clippy pedantic wants; if clippy still flags, restructure to a single iterator chain).

  • Step 5: Update crates/sys/src/lib.rs to pull in the generated bindings
#![allow(non_camel_case_types, non_upper_case_globals, non_snake_case)]
#![allow(clippy::all, clippy::pedantic)]
//! Raw FFI bindings to tgcalls — see crate docs on the safe `rstgcalls` crate.
//! All `unsafe` and C/C++ contact lives here.
include!(concat!(env!("OUT_DIR"), "/bindings.rs"));
  • Step 6: Run the ABI test to verify it passes

Run: cargo test --offline -p rstgcalls-sys Expected: PASS. bindings.rs generated in OUT_DIR; the abi_symbols_and_layout test compiles and passes with the actual size_of values (correct the assertions in Step 2 to match what bindgen emitted, then re-run).

  • Step 7: Verify clippy is clean across the sys crate

Run: cargo clippy --offline --all-targets -p rstgcalls-sys Expected: no warnings.

  • Step 8: Commit
Terminal window
git add crates/sys/
git commit -m "build(sys): bindgen C-ABI bridge header with portable clang++ include discovery
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 3: ffi-stub C++ bridge implementation

Section titled “Task 3: ffi-stub C++ bridge implementation”

Files:

  • Create: crates/sys/src/bridge_stub.cpp

Interfaces:

  • Consumes: the C ABI from Task 2 (include/rstgcalls_bridge.h).

  • Produces: a linkable in-memory implementation of every extern "C" bridge function with deterministic, contract-honoring behavior:

    • create allocates a heap rstgcalls_instance (a small C++ struct holding bool stopped, bool muted, int32_t network_type, a counter of pushed audio frames, and a std::vector<uint8_t> of received signaling), writes it to *out, returns OK; returns ERR_NULL_ARG if out == nullptr.
    • push_audio returns ERR_STOPPED if the instance is stopped, ERR_NULL_ARG if either pointer is null, ERR_INVALID_ARG if num_channels == 0 || samples_per_sec == 0, else increments the frame counter and returns OK.
    • receive_signaling appends [data, data+len) to the instance buffer; same null/stopped guards.
    • set_network_type/set_mute store the value (null/stopped guarded), return OK.
    • get_state returns 0 (WaitInit) when not stopped, 3 (Failed/Stopped sentinel — document which) when stopped.
    • stop fills *out (if non-null) with zeroed rstgcalls_final_state, marks stopped, deletes the instance, returns OK. (The safe layer nulls its handle after stop, so delete-on-stop is safe; Task 6 relies on this for the leak test.)
    • A process-global std::atomic<int> rstgcalls_stub_live_instances{0} incremented on create / decremented on stop, plus extern "C" int32_t rstgcalls_stub_live_count() — used by Task 6’s leak test. Declare it in the header under #ifdef RSTGCALLS_STUB_TEST_HOOKS or unconditionally (simplest: unconditional, documented as stub-only).
  • Step 1: Write crates/sys/src/bridge_stub.cpp implementing the contract above. Include "include/rstgcalls_bridge.h". Use a plain C++ struct for rstgcalls_instance. Keep it self-contained (no webrtc, no tgcalls). Add #include <atomic>, <vector>, <cstring>.

  • Step 2: Verify it compiles + links via the sys crate

Run: cargo build --offline -p rstgcalls-sys --features ffi-stub Expected: cc compiles bridge_stub.cpp into librstgcalls_bridge_stub.a; build succeeds with no undefined symbols at the archive stage.

  • Step 3: Commit
Terminal window
git add crates/sys/src/bridge_stub.cpp
git commit -m "feat(sys): in-memory ffi-stub C++ bridge for offline FFI testing
Co-Authored-By: Claude Fable 5 <[email protected]>"

Files:

  • Create: crates/safe/src/error.rs, crates/safe/src/types.rs
  • Modify: crates/safe/src/lib.rs (add mod error; mod types; + pub use)

Interfaces:

  • Consumes: nothing at runtime yet (pure value types); the return-code constants exist in rstgcalls-sys but this task defines the safe mapping table.

  • Produces (types later tasks rely on — exact names):

    • error::Error (thiserror): variants BackendUnavailable, Stopped, NullArg, InvalidArg, Unknown(i32). error::Result<T> = core::result::Result<T, Error>.
    • error::Error::from_code(code: i32) -> Result<()>: maps RSTGCALLS_OK→Ok(()), -1→NullArg, -2→BackendUnavailable, -3→Stopped, -4→InvalidArg, other→Unknown(code). (Read the actual constants from rstgcalls_sys under the gated feature; under default feature hardcode the documented values with a comment, since sys isn’t linked. Prefer: gate the from_code that reads sys constants behind cfg(feature) and provide a default-feature path using the literal values — keep one source of truth by defining the literals in types.rs and referencing them.)
    • types::ConnectionState enum mirroring tgcalls State: WaitInit, WaitInitAck, Established, Failed, Reconnecting (ADR-015: enum, not booleans). ConnectionState::from_raw(i32) -> Self.
    • types::NetworkType enum mirroring tgcalls: Unknown, Gprs, Edge, ThirdGeneration, Hspa, Lte, WiFi, Ethernet, OtherHighSpeed, OtherLowSpeed, OtherMobile, Dialup. to_raw(&self) -> i32.
    • types::DataSaving enum: Never, Mobile, Always.
    • types::ProtocolVersion enum: V0, V1.
    • types::AudioFrame<'a> — borrowed view { samples: &'a [i16], channels: usize, samples_per_sec: u32, elapsed_time_ms: i64 } with AudioFrame::new(samples: &'a [i16], channels: usize, samples_per_sec: u32) -> Result<Self> validating channels > 0, samples_per_sec > 0, !samples.is_empty(), and samples.len() % channels == 0 (else InvalidArg).
    • types::InitConfig — owned builder-ish struct { initialization_timeout: f64, receive_timeout: f64, data_saving: DataSaving, enable_p2p: bool, allow_tcp: bool, max_api_layer: i32, protocol_version: ProtocolVersion } with Default (sensible: 30.0 / 30.0 / Never / true / false / 0 / V0) and #[must_use] fn …(self) -> Self setters.
    • types::FinalState — { bytes_sent: u64, bytes_received: u64, is_rating_suggested: bool }.
  • Step 1: Write crates/safe/src/error.rs with the Error enum, Result alias, and from_code. Every public item documented (missing_docs is warn→ effectively required).

  • Step 2: Write crates/safe/src/types.rs with all enums/structs above. Implement from_raw/to_raw for the mirror enums (exhaustive match; unknown raw → a documented fallback, e.g. ConnectionState::WaitInit or a #[non_exhaustive]-safe default). Implement AudioFrame::new validation and InitConfig Default + setters.

  • Step 3: Write the failing tests in crates/safe/src/types.rs (#[cfg(test)] mod tests) and crates/safe/src/error.rs:

// types.rs tests
#[test] fn audio_frame_rejects_zero_channels() {
assert!(matches!(AudioFrame::new(&[0i16; 480], 0, 48000), Err(Error::InvalidArg)));
}
#[test] fn audio_frame_rejects_zero_sample_rate() {
assert!(matches!(AudioFrame::new(&[0i16; 480], 1, 0), Err(Error::InvalidArg)));
}
#[test] fn audio_frame_rejects_len_not_multiple_of_channels() {
assert!(matches!(AudioFrame::new(&[0i16; 481], 2, 48000), Err(Error::InvalidArg)));
}
#[test] fn audio_frame_accepts_valid() {
assert!(AudioFrame::new(&[0i16; 960], 2, 48000).is_ok());
}
#[test] fn init_config_default_is_sane() {
let c = InitConfig::default();
assert!(c.enable_p2p && c.initialization_timeout > 0.0);
}
// error.rs tests — every documented code maps, unknown does not panic
#[test] fn error_from_code_maps_all() {
assert!(Error::from_code(0).is_ok());
assert_eq!(Error::from_code(-1), Err(Error::NullArg));
assert_eq!(Error::from_code(-2), Err(Error::BackendUnavailable));
assert_eq!(Error::from_code(-3), Err(Error::Stopped));
assert_eq!(Error::from_code(-4), Err(Error::InvalidArg));
assert_eq!(Error::from_code(-999), Err(Error::Unknown(-999)));
}
  • Step 4: Run tests to verify they fail, then pass

Run: cargo test --offline -p rstgcalls (first FAIL: types undefined; after implementing, PASS).

  • Step 5: Verify clippy pedantic clean — cargo clippy --offline --all-targets -p rstgcalls.

  • Step 6: Commit

Terminal window
git add crates/safe/src/error.rs crates/safe/src/types.rs crates/safe/src/lib.rs
git commit -m "feat(safe): value types (AudioFrame, InitConfig, ConnectionState) + error mapping
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 5: CallBackend trait + MockBackend + contract suite (ADR-014)

Section titled “Task 5: CallBackend trait + MockBackend + contract suite (ADR-014)”

Files:

  • Create: crates/safe/src/backend.rs
  • Create: crates/safe/tests/contract.rs
  • Modify: crates/safe/src/lib.rs (add mod backend; + pub use backend::{CallBackend, MockBackend};)

Interfaces:

  • Consumes: error::{Error, Result}, types::{AudioFrame, ConnectionState, NetworkType, FinalState} from Task 4.

  • Produces (the trait both impls satisfy — ADR-014 requires a contract suite run against every impl):

    • pub trait CallBackend with methods:
      • fn push_audio(&mut self, frame: &AudioFrame<'_>) -> Result<()>
      • fn receive_signaling(&mut self, data: &[u8]) -> Result<()>
      • fn set_network_type(&mut self, nt: NetworkType) -> Result<()>
      • fn set_mute(&mut self, muted: bool) -> Result<()>
      • fn state(&self) -> ConnectionState
      • fn stop(&mut self) -> Result<FinalState>
    • MockBackend { stopped: bool, frames: usize, muted: bool, net: NetworkType } (fields private) implementing the trait in-process: push_audio after stop → Err(Stopped); before stop with a valid frame → Ok and increments frames; stop sets stopped and returns a FinalState. Provide MockBackend::new() and Default.
    • tests/contract.rs exposes fn run_backend_contract<B>(make: impl Fn() -> Result<B>) asserting the shared semantics (see test below), and invokes it for MockBackend unconditionally and for TgcallsBackend under #[cfg(feature = "ffi-stub")] (added in Task 6).
  • Step 1: Write crates/safe/src/backend.rs — the CallBackend trait (documented, object-safe: no generics/Self-returning methods) and MockBackend.

  • Step 2: Write crates/safe/tests/contract.rs with the reusable contract function:

use rstgcalls::{AudioFrame, CallBackend, ConnectionState, Error, FinalState, MockBackend, NetworkType};
/// ADR-014: one contract suite, run against EVERY CallBackend impl.
pub fn run_backend_contract<B: CallBackend>(make: impl Fn() -> B) {
let mut b = make();
// starts not-stopped
assert!(!matches!(b.state(), ConnectionState::Failed));
// push a valid frame → ok
let f = AudioFrame::new(&[0i16; 960], 2, 48000).expect("valid frame");
assert!(b.push_audio(&f).is_ok());
// signaling → ok
assert!(b.receive_signaling(&[1, 2, 3, 4]).is_ok());
// mute / network → ok
assert!(b.set_mute(true).is_ok());
assert!(b.set_network_type(NetworkType::WiFi).is_ok());
// stop → ok, returns FinalState
let fs: FinalState = b.stop().expect("stop ok");
let _ = fs.bytes_sent;
// after stop: pushing audio is an error, not a panic
assert!(matches!(b.push_audio(&f), Err(Error::Stopped)));
assert!(matches!(b.stop(), Err(Error::Stopped)) || b.stop().is_ok()); // idempotent-or-Stopped, impl-defined but must not panic
}
#[test] fn mock_backend_satisfies_contract() {
run_backend_contract(MockBackend::new);
}

(Keep the post-stop contract precise once both impls exist: the shared invariant is “does not panic and returns Stopped or Ok”. Task 6 makes TgcallsBackend return Stopped, and this line tightens to assert!(matches!(b.push_audio(&f), Err(Error::Stopped))) for both.)

  • Step 3: Run the contract test to verify it fails, then passes

Run: cargo test --offline -p rstgcalls --test contract Expected: FAIL first (trait/impl undefined), then PASS with MockBackend.

  • Step 4: Verify default-feature full suite + clippy

Run: cargo test --offline && cargo clippy --offline --all-targets Expected: green, no warnings. This is Review Focus #4 (default build green with no C++ link).

  • Step 5: Commit
Terminal window
git add crates/safe/src/backend.rs crates/safe/src/lib.rs crates/safe/tests/contract.rs
git commit -m "feat(safe): CallBackend trait + MockBackend with ADR-014 contract suite
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 6: TgcallsBackend (gated, the only unsafe) + contract + leak test

Section titled “Task 6: TgcallsBackend (gated, the only unsafe) + contract + leak test”

Files:

  • Create: crates/safe/src/ffi_backend.rs
  • Modify: crates/safe/src/lib.rs (add #[cfg(any(feature="ffi-stub",feature="link-tgcalls"))] mod ffi_backend; + gated pub use ffi_backend::TgcallsBackend;)
  • Modify: crates/safe/tests/contract.rs (run the contract against TgcallsBackend under ffi-stub)

Interfaces:

  • Consumes: rstgcalls_sys::* (Task 2 ABI), the stub (Task 3), error, types, CallBackend (Task 5).

  • Produces:

    • pub struct TgcallsBackend { raw: *mut rstgcalls_sys::rstgcalls_instance } — the sole home of unsafe in the safe crate, behind #[allow(unsafe_code)] on the module. unsafe impl Send (documented: the C++ instance is thread-confined in real tgcalls; the handle is moved as a unit).
    • TgcallsBackend::create(cfg: &InitConfig) -> Result<Self>: builds a rstgcalls_config from InitConfig, calls rstgcalls_instance_create, maps the code via Error::from_code, stores the handle (null-checks out).
    • impl CallBackend for TgcallsBackend: each method builds the C arg (e.g. AudioFrame → rstgcalls_audio_frame with samples.as_ptr()), calls the FFI fn, maps the code. stop calls rstgcalls_instance_stop, fills a FinalState, then nulls self.raw so it is not double-freed.
    • impl Drop for TgcallsBackend: if raw is non-null, call stop (best-effort, ignore result) — guarantees no leak (Review Focus #7).
  • Step 1: Write crates/safe/src/ffi_backend.rs. Structure:

//! Real bridge backend. THE ONLY `unsafe` in the safe crate lives here.
#![cfg(any(feature = "ffi-stub", feature = "link-tgcalls"))]
#[allow(unsafe_code)] // FFI boundary; crate root is deny(unsafe_code).
mod inner {
use crate::error::{Error, Result};
use crate::types::{AudioFrame, ConnectionState, FinalState, InitConfig, NetworkType};
use crate::backend::CallBackend;
use rstgcalls_sys as sys;
/// Owns one tgcalls call instance for its lifetime.
pub struct TgcallsBackend { raw: *mut sys::rstgcalls_instance }
// SAFETY: the handle is moved as a unit; tgcalls confines the instance to
// its own threads and all access goes through &mut self here.
unsafe impl Send for TgcallsBackend {}
impl TgcallsBackend {
/// Create a call instance from config.
pub fn create(cfg: &InitConfig) -> Result<Self> {
let c = sys::rstgcalls_config { /* fields from cfg, enums → raw */ };
let mut out: *mut sys::rstgcalls_instance = core::ptr::null_mut();
// SAFETY: `out` is a valid mutable pointer to a local; create
// writes a non-null handle on OK and leaves it null otherwise.
let rc = unsafe { sys::rstgcalls_instance_create(&c, &mut out) };
Error::from_code(rc)?;
if out.is_null() { return Err(Error::BackendUnavailable); }
Ok(Self { raw: out })
}
fn guard(&self) -> Result<()> {
if self.raw.is_null() { Err(Error::Stopped) } else { Ok(()) }
}
}
impl CallBackend for TgcallsBackend {
fn push_audio(&mut self, f: &AudioFrame<'_>) -> Result<()> {
self.guard()?;
let cf = sys::rstgcalls_audio_frame {
samples: f.samples.as_ptr(), num_samples: f.samples.len(),
num_channels: f.channels, samples_per_sec: f.samples_per_sec,
elapsed_time_ms: f.elapsed_time_ms,
};
// SAFETY: guard ensured self.raw non-null; cf borrows f for the call.
let rc = unsafe { sys::rstgcalls_instance_push_audio(self.raw, &cf) };
Error::from_code(rc)
}
// receive_signaling / set_network_type / set_mute / state / stop:
// same guard → build C arg → unsafe call → Error::from_code.
// stop() additionally nulls self.raw after a successful call.
fn stop(&mut self) -> Result<FinalState> { /* call, fill FinalState, self.raw = null */ }
fn state(&self) -> ConnectionState { /* get_state → ConnectionState::from_raw; Stopped→Failed */ }
fn receive_signaling(&mut self, data: &[u8]) -> Result<()> { /* guard + ptr/len */ }
fn set_network_type(&mut self, nt: NetworkType) -> Result<()> { /* guard + to_raw */ }
fn set_mute(&mut self, muted: bool) -> Result<()> { /* guard + u8 */ }
}
impl Drop for TgcallsBackend {
fn drop(&mut self) { if !self.raw.is_null() { let _ = self.stop(); } }
}
}
pub use inner::TgcallsBackend;
  • Step 2: Extend crates/safe/tests/contract.rs to run the same contract against TgcallsBackend under the feature, plus the leak test:
#[cfg(feature = "ffi-stub")]
#[test]
fn tgcalls_backend_satisfies_contract() {
use rstgcalls::{InitConfig, TgcallsBackend};
run_backend_contract(|| TgcallsBackend::create(&InitConfig::default()).expect("stub create ok"));
}
#[cfg(feature = "ffi-stub")]
#[test]
fn tgcalls_backend_does_not_leak_on_drop() {
use rstgcalls::{InitConfig, TgcallsBackend};
// SAFETY: stub-only test hook declared extern by bridge_stub.cpp.
extern "C" { fn rstgcalls_stub_live_count() -> i32; }
let before = unsafe { rstgcalls_stub_live_count() };
{
let b = TgcallsBackend::create(&InitConfig::default()).expect("create");
assert_eq!(unsafe { rstgcalls_stub_live_count() }, before + 1);
drop(b); // no explicit stop → Drop must release
}
assert_eq!(unsafe { rstgcalls_stub_live_count() }, before);
}

The unsafe { … } in the test file is acceptable (tests are not the shipped safe surface), but keep it minimal and #[cfg]-gated. If the workspace deny(unsafe_code) blocks it in tests, add #[allow(unsafe_code)] on the test fn with a comment.

  • Step 3: Run the gated tests to verify they fail, then pass

Run: cargo test --offline -p rstgcalls --features ffi-stub Expected: FAIL first (backend undefined / link error), then PASS — contract runs against both impls; leak test asserts live count returns to baseline. This is Review Focus #5 and #7.

  • Step 4: Verify default-feature build is still green (the gated module must not compile in by default)

Run: cargo test --offline (no features) → PASS, TgcallsBackend absent.

  • Step 5: Verify clippy pedantic across both feature sets

Run: cargo clippy --offline --all-targets and cargo clippy --offline --all-targets --features ffi-stub Expected: both clean.

  • Step 6: Commit
Terminal window
git add crates/safe/src/ffi_backend.rs crates/safe/src/lib.rs crates/safe/tests/contract.rs
git commit -m "feat(safe): gated TgcallsBackend over C ABI + contract & leak tests
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 7: CallManager<Idle|Active> typestate (ADR-015)

Section titled “Task 7: CallManager<Idle|Active> typestate (ADR-015)”

Files:

  • Create: crates/safe/src/manager.rs
  • Modify: crates/safe/src/lib.rs (add mod manager; + pub use manager::{CallManager, Idle, Active};)

Interfaces:

  • Consumes: CallBackend, MockBackend (Task 5), gated TgcallsBackend (Task 6), error, types.

  • Produces:

    • Sealed state marker traits: pub trait State sealed {}; pub struct Idle; pub struct Active; implementing it.
    • pub struct CallManager<S: State> { backend: Box<dyn CallBackend>, _state: PhantomData<S> }.
    • CallManager<Idle>: pub fn new_mock() -> Self; #[cfg(gated)] pub fn new_tgcalls(cfg: &InitConfig) -> Result<Self>; pub fn join(self) -> Result<CallManager<Active>> (calls a start/handshake — for the scaffold, join validates the backend is usable and transitions; real signaling wiring is future work, documented).
    • CallManager<Active>: pub fn push_audio(&mut self, frame: &AudioFrame<'_>) -> Result<()>, pub fn receive_signaling(&mut self, data: &[u8]) -> Result<()>, pub fn set_mute(&mut self, muted: bool) -> Result<()>, pub fn set_network_type(&mut self, nt: NetworkType) -> Result<()>, pub fn state(&self) -> ConnectionState, pub fn leave(self) -> Result<FinalState> (stops backend, returns to nothing / consumes).
    • push_audio and friends are only defined on CallManager<Active> → calling them on Idle is a compile error (Review Focus #1, pinned by Task 8).
  • Step 1: Write crates/safe/src/manager.rs with the sealed typestate. join on Idle consumes self and returns Result<CallManager<Active>>; leave on Active consumes self, calls backend.stop(), returns Result<FinalState>. Document that join/leave are the lifecycle edges and that Active gates all media ops.

  • Step 2: Write runtime transition tests in crates/safe/src/manager.rs (#[cfg(test)]):

#[test] fn mock_join_push_leave() {
let mut m = CallManager::new_mock().join().expect("join ok");
let f = AudioFrame::new(&[0i16; 960], 2, 48000).expect("valid");
assert!(m.push_audio(&f).is_ok());
assert!(m.set_mute(true).is_ok());
let _fs = m.leave().expect("leave ok");
}
#[test] fn state_is_connection_state_enum() {
let m = CallManager::new_mock().join().expect("join");
assert!(matches!(m.state(), ConnectionState::WaitInit | ConnectionState::Established));
}
  • Step 3: Run tests to verify they fail, then pass

Run: cargo test --offline -p rstgcalls --lib Expected: FAIL then PASS.

  • Step 4: Verify clippy — cargo clippy --offline --all-targets.

  • Step 5: Commit

Terminal window
git add crates/safe/src/manager.rs crates/safe/src/lib.rs
git commit -m "feat(safe): CallManager<Idle|Active> typestate (ADR-015)
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 8: Compile-fail tests for the typestate (trybuild)

Section titled “Task 8: Compile-fail tests for the typestate (trybuild)”

Files:

  • Create: crates/safe/tests/ui.rs, crates/safe/tests/ui/push_before_join.rs, crates/safe/tests/ui/push_before_join.stderr, crates/safe/tests/ui/leave_then_push.rs, crates/safe/tests/ui/leave_then_push.stderr
  • Modify: crates/safe/Cargo.toml (add [dev-dependencies] trybuild = "1.0")

Interfaces:

  • Consumes: CallManager, Idle, Active (Task 7).

  • Produces: a compile-fail suite proving invalid lifecycle use does not typecheck (Review Focus #1).

  • Step 1: Add trybuild dev-dependency to crates/safe/Cargo.toml.

  • Step 2: Write crates/safe/tests/ui.rs

#[test]
fn typestate_compile_fail() {
let t = trybuild::TestCases::new();
t.compile_fail("tests/ui/*.rs");
}
  • Step 3: Write crates/safe/tests/ui/push_before_join.rs (push_audio on Idle must not compile)
fn main() {
let mut m = rstgcalls::CallManager::new_mock(); // CallManager<Idle>
let f = rstgcalls::AudioFrame::new(&[0i16; 960], 2, 48000).unwrap();
m.push_audio(&f).unwrap(); // ERROR: no method `push_audio` on CallManager<Idle>
}
  • Step 4: Write crates/safe/tests/ui/leave_then_push.rs (after leave, the Active handle is consumed → push is unreachable/doesn’t compile)
fn main() {
let mut m = rstgcalls::CallManager::new_mock().join().unwrap();
let _fs = m.leave().unwrap(); // consumes m
let f = rstgcalls::AudioFrame::new(&[0i16; 960], 2, 48000).unwrap();
m.push_audio(&f).unwrap(); // ERROR: use of moved value `m`
}
  • Step 5: Generate the .stderr snapshots

Run: TRYBUILD=overwrite cargo test --offline -p rstgcalls --test ui Expected: trybuild writes tests/ui/*.stderr matching the actual rustc diagnostics (method-not-found on CallManager<Idle>; use-after-move). Inspect the generated files — they must reference push_audio/moved value, not an unrelated error.

  • Step 6: Re-run clean to confirm the suite passes

Run: cargo test --offline -p rstgcalls --test ui Expected: PASS (1 passed), no mismatch.

  • Step 7: Commit
Terminal window
git add crates/safe/Cargo.toml crates/safe/tests/ui.rs crates/safe/tests/ui/
git commit -m "test(safe): trybuild compile-fail proofs for CallManager typestate
Co-Authored-By: Claude Fable 5 <[email protected]>"

Files:

  • Create: TODO.md
  • Modify: README.md (replace the “pre-code” status with real build docs; keep the ecosystem context)

Interfaces:

  • Consumes: the finished crate surface (all prior tasks).

  • Produces: developer-facing build/feature documentation and the blocking-dependency record.

  • Step 1: Write README.md covering: what the crate is (link ADR-018, re:call deferral); the two-crate layout; the feature matrix — default (MockBackend, no C++, fully offline), ffi-stub (links the in-memory C++ stub, runs the full FFI + contract + leak tests offline), link-tgcalls (real backend; blocked, see TODO.md); build/test commands (cargo test --offline, cargo test --offline --features ffi-stub); the unsafe-isolation guarantee; system deps present vs required.

  • Step 2: Write TODO.md with:

    • Done (this scaffold): workspace, sys bindgen bridge, ffi-stub, safe types/backend/manager/typestate, contract + compile-fail + leak tests, all green offline.
    • Blocking for link-tgcalls (record, do NOT install): libwebrtc source tree + build (tgcalls has ~250 api/,rtc_base/,modules/,media/,call/,system_wrappers/,common_audio/ includes across 63 files; no bundled WebRTC, no CMake — upstream is consumed via SwiftPM/tdesktop against an external webrtc/build/src); libyuv (video paths; not present); libtgvoip (tgcalls/legacy/InstanceImplLegacy — only needed for legacy codec support, can be excluded initially). Present already: openssl 3.6.5, ffmpeg 63.1.102, opus 1.6.1, clang 23, cmake 4.4.4.
    • Next steps toward a real backend: write src/bridge_tgcalls.cpp implementing the bridge over tgcalls::Meta::Create / GroupInstanceInterface; obtain/build libwebrtc (pin a commit); wire cc + link flags (-lwebrtc, opus, ffmpeg, ssl, crypto, yuv) under link-tgcalls; drive join/leave with real MTProto signaling via grmrs; audio device module (FakeAudioDeviceModule for headless, real ADM for desktop); group-call (GroupInstanceDescriptor) support; nylon-ring cdylib packaging as calls-tgcalls (TECHSPEC §4).
    • Design notes: bridge return-code table; why bindgen targets the C bridge not the C++ classes; the deny+scoped-allow unsafe strategy and why forbid was rejected.
  • Step 3: Ensure crate/module docs are complete — run cargo doc --offline --no-deps and confirm no missing-docs warnings for public items.

  • Step 4: Verify README commands actually work — copy-paste the build/test invocations and run them; they must succeed offline.

  • Step 5: Commit

Terminal window
git add README.md TODO.md
git commit -m "docs: README build/feature matrix + TODO with blocking C++ deps
Co-Authored-By: Claude Fable 5 <[email protected]>"

Task 10: Final integration gate + unsafe-confinement proof

Section titled “Task 10: Final integration gate + unsafe-confinement proof”

Files: none created (verification task; may add .cargo/config.toml note to .gitignore already done).

Interfaces:

  • Consumes: the whole workspace.

  • Produces: a verified green feature matrix and a proof that unsafe is confined.

  • Step 1: Full feature-matrix verification (offline)

Run each and confirm zero warnings/errors:

cargo check --offline
cargo check --offline --features ffi-stub
cargo clippy --offline --all-targets
cargo clippy --offline --all-targets --features ffi-stub
cargo test --offline
cargo test --offline --features ffi-stub
cargo fmt --all -- --check

Expected: all green. (link-tgcalls is documented as not-yet-buildable; do not run it — it will fail at C++ compile for missing webrtc, which is expected and recorded in TODO.md.)

  • Step 2: Prove unsafe confinement (Review Focus #6)

Run: grep -rn "unsafe" crates/safe/src/ | grep -v "ffi_backend.rs" Expected: only doc-comment mentions in lib.rs (the crate docs describing the policy) — no unsafe code outside ffi_backend.rs. Then confirm crates/safe/src/lib.rs root carries the inherited deny(unsafe_code) and ffi_backend.rs’s inner module is the only #[allow(unsafe_code)].

Temporarily append a stray pub fn leak(){ unsafe { std::ptr::null::<u8>().read() } } to crates/safe/src/backend.rs, run cargo check --offline --features ffi-stub, confirm it FAILS with usage of an 'unsafe' block (E0453/deny), then revert. This proves the guardrail is live.

  • Step 3: Confirm the FFI round-trip end-to-end under ffi-stub

Run: cargo test --offline --features ffi-stub -- --nocapture and confirm the contract suite ran against both MockBackend and TgcallsBackend, and the leak test passed.

  • Step 4: Tag the milestone in a final commit if any fixups were needed
Terminal window
git add -A
git commit -m "chore: rstgcalls FFI scaffold green across feature matrix (offline)
Co-Authored-By: Claude Fable 5 <[email protected]>"

(If Steps 1–3 required no changes, skip this commit — do not create an empty one.)


1. Spec coverage:

  • ADR-018 (tgcalls bindings, C++ isolated) → whole design; isolation via sys crate + gated backend. ✓
  • ADR-014 (contract tests per trait with >1 impl) → Task 5 defines run_backend_contract, Tasks 5+6 run it against both impls. ✓
  • ADR-015 (typestate/enum over booleans) → Task 7 CallManager<Idle|Active> + Task 4 ConnectionState/NetworkType enums; Task 8 compile-fail proofs. ✓
  • TECHSPEC §4/§10-8 (calls-tgcalls nylon-ring module, isolated) → documented as next step in TODO.md (Task 9); scaffold is the binding-crate foundation the module builds on. ✓ (Full nylon-ring cdylib packaging is explicitly out of scope for this “compilable foundation” milestone and recorded in TODO.)
  • QA cross-cutting (multi-state as enum; contract suite before 2nd impl merges) → Tasks 4, 5, 6. ✓
  • Workspace gates (edition 2024, MSRV 1.100, clippy all+pedantic deny, conventional commits, never push) → Global Constraints + every commit step. ✓

2. Step scan: Each step is one action with a checkable result (write file / run command / expect output). No “TBD” or “handle edge cases” placeholders. Test steps carry exact assertions with the spec’s values; code steps carry exact signatures/names. The size_of assertions in Task 2 Step 2 are explicitly flagged as “correct to the actual generated value” — that is a real decision the implementer makes against bindgen output, not a gap.

3. Type consistency: CallBackend method set is identical in Tasks 5 (trait + Mock) and 6 (Tgcalls impl); run_backend_contract<B: CallBackend> is defined in Task 5 and reused in Task 6. AudioFrame<'a>, InitConfig, ConnectionState, NetworkType, FinalState, Error, Result names are fixed in Task 4 and used verbatim in Tasks 5–8. TgcallsBackend::create(&InitConfig) (Task 6) matches CallManager::new_tgcalls(cfg) usage (Task 7). Idle/Active/CallManager (Task 7) match the trybuild files (Task 8). Return-code constants (RSTGCALLS_*) are defined once (Task 2 header) and mapped once (Error::from_code, Task 4).

4. Review Focus: all 7 lines have an owning task’s test — #1→Task 8, #2→Tasks 4+6, #3→Task 4, #4→Tasks 1/5/7/10, #5→Tasks 5+6, #6→Task 10 Step 2, #7→Task 6 leak test. ✓

5. Proportion: the plan is longer than a trivial feature because it fixes exact ABI names, return-code tables, lint policy, and two non-obvious build gotchas (bindgen --target assert; clang++ include discovery) that an implementer cannot guess and that cost me several spike iterations to find. Code blocks are signatures + the two build files whose exact content matters (build.rs discovery loop, bridge header ABI); trait/impl bodies are signatures with SAFETY-comment skeletons, not full transcripts. This is proportionate to a cross-language FFI scaffold where the build mechanics are the hard part.

Validation status: every mechanical assumption in this plan was proven in throwaway spikes before writing — bindgen offline against real tgcalls headers (15633 lines, tgcalls_AudioFrame = 56 bytes matching C++), portable clang++ include discovery, cc compiling+linking a C++ extern "C" stub, the full FFI round-trip test through the linked stub (3 passed), deny+scoped-allow unsafe pattern with stray-unsafe rejection, and trybuild compile-fail with TRYBUILD=overwrite. All dependency crates (bindgen 0.72.1, cc 1.2.x, thiserror 2.0.21, trybuild 1.0.122) are present in the local cargo registry cache.