| name | rust-coding-skill |
|---|---|
| description | Use this skill whenever editing `*.rs` files in the `rust/` SDK in order to write idiomatic, efficient, well-structured Rust code |
Opinionated Rust rules for the Copilot Rust SDK (rust/). Priority order:
- Readable code — every line should earn its place
- Correct code — especially in concurrent/async contexts
- Performant code — think about allocations, data structures, hot paths
The SDK's public error type is crate::Error (rust/src/error.rs). Add new
variants there rather than introducing parallel error enums per module — every
public failure mode is part of the API contract and should be expressible in one
type. Internal modules can use thiserror enums when a richer local taxonomy
helps; convert at the boundary.
anyhow is reserved for binaries and example code. Library code never returns
anyhow::Result — callers can't pattern-match on anyhow::Error, so it would
prevent them from handling specific failures.
In production code, prefer ?, let-else, and if let. Reach for expect("…")
when an invariant cannot fail and the message would help debug a future
regression. unwrap() belongs in tests only — Clippy enforces this in the SDK
via #![cfg_attr(test, allow(clippy::unwrap_used))] in lib.rs.
When you need to log on the way through, prefer
.inspect_err(|e| warn!(error = ?e, "context"))? over a match that logs and
re-wraps. It reads top-to-bottom and keeps the happy path uncluttered.
The default for request-scoped I/O is async fn plus .await — futures
inherit cancellation from their parent task and can borrow local references.
Reach for tokio::spawn only when you genuinely need background work (an event
loop, a long-lived watcher) and track the JoinHandle so you can cancel or join
it on shutdown. Fire-and-forget spawns silently swallow panics and outlive the
session; don't.
Blocking calls (filesystem, subprocess wait) belong in
tokio::task::spawn_blocking, not on the async runtime. The blocking pool is
bounded, so for genuinely long-lived workers (think: file watchers that run for
the lifetime of a session) prefer std::thread::spawn with a channel back into
async land.
Lock choice matters. tokio::sync::Mutex is correct when you must hold the
guard across .await; parking_lot::Mutex (or RwLock) is faster on hot
synchronous paths and is what session.rs uses for capability state.
std::sync::Mutex is rarely the right answer in this crate — its poisoning
semantics buy us nothing and it's slower than parking_lot. Never hold a
std::sync::Mutex guard across an .await; Clippy will catch this, but the
fix is to move the await out, not silence the lint.
For lazy statics use std::sync::LazyLock. The once_cell crate is no longer
needed.
Plain functions on a type beat traits for navigability. Use them as the default.
Trait-based extension points are different. When a consumer must plug behaviour into the SDK, prefer one trait with one default-impl method per event over per-event Box<dyn Fn> callback fields. This is what tower_lsp::LanguageServer, rmcp::ServerHandler, and notify::EventHandler do — the dominant idiom in async Rust for "wire-protocol handler" traits. Callback fields fight Send + Sync + 'static, fragment consumer state across closures, and skip exhaustiveness checks.
The four extension-point traits in this crate:
SessionHandler(rust/src/handler.rs) — per-event methods (on_permission_request,on_user_input,on_external_tool,on_elicitation,on_exit_plan_mode,on_auto_mode_switch,on_session_event) each with a default impl. The dispatcheron_event(HandlerEvent)is itself a default method that fans out to them; override per-event methods in normal use, overrideon_eventonly when you want a single exhaustive match. Concurrent invocations are possible (notification-triggered events run on spawned tasks), soSend + Sync + 'staticis required on the trait.SessionHooks(rust/src/hooks.rs) — optional lifecycle callbacks. The SDK auto-enables hooks when an impl is supplied tocreate_session/resume_session.SystemMessageTransform(rust/src/system_message.rs) — declaresection_ids()and return content fromtransform_section().ToolHandler(rust/src/tool.rs) — client-side tool implementations, dispatched by name viaToolHandlerRouter.
ApproveAllHandler is the standard test handler for SessionHandler.
Don't add traits without a clear extension story. Don't implement From/Into for SDK-internal conversions: they can't take extra parameters, can't return Result, and hide which conversion is happening at call sites. Prefer named methods like to_info(&self) or MyType::from_record(record, ctx).
Trivial field re-shaping is best inlined. Closures should stay short (under ~10 lines); extract to named functions when they grow. Visitor patterns are a closure-fest — expose iter() and let the consumer drive.
Channels, not callback closures, for event flow. Closures fight Send + Sync + 'static and don't compose with select!. Channel choice by semantics:
| Use case | Primitive |
|---|---|
| One producer → one consumer with backpressure | tokio::sync::mpsc (cap 1) or tokio::sync::oneshot for single value |
| Many producers → one consumer | tokio::sync::mpsc |
| One producer → many consumers, every event delivered (pub/sub) | tokio::sync::broadcast |
| One producer → many consumers, only the latest value matters | tokio::sync::watch |
For the public API, prefer returning impl Stream<Item = Event> (wrap a broadcast::Receiver in tokio_stream::wrappers::BroadcastStream). Stream composes with select!, take, map, filter, timeout. See EventSubscription and LifecycleSubscription.
Cancellation: drop is the primitive; tokio_util::sync::CancellationToken for SDK-internal task coordination.
- Caller-owned futures (
send_message, subscription streams): drop /select!/tokio::time::timeout. Don't accept a token parameter — it duplicates whatselect!already provides. Document cancel-safety on every.awaitin the hot path. - SDK-internal tasks (event loops, subprocess readers, anything
tokio::spawned by the SDK): useCancellationTokenstored on the long-lived handle.Dropcallscancel().Session::cancellation_token()exposes a child token so callers can bind external work to the session lifetime.
Refs: CancellationToken · tonic example · withoutboats: async clean-up · Cybernetist: cancellation patterns.
Use Option<T> for optional fields, not nullable references or sentinel values. Defaults come from Default impls. Pair with #[non_exhaustive] on public config structs and a builder so adding fields stays non-breaking.
For required builder fields: prefer build() -> Result<Self, BuildError> over typestate unless required-field count is tiny (1-2).
JSON: #[serde(rename_all = "camelCase")] at the type level, per-field #[serde(rename = "…")] for outliers, #[serde(skip_serializing_if = "Option::is_none")] for output, #[serde(default)] for input tolerance. Reach for serde_with only for non-trivial transforms (durations, base64, numeric-as-string keys).
Banned via clippy.toml. Use manual spans with error_span!:
- Almost always use
error_span!, notinfo_span!. Span level controls the minimum filter at which the span appears. Aninfo_spandisappears when the filter iswarnorerror— taking all child events with it, even errors.error_span!ensures the span is always present. - Spawned tasks lose parent context. Attach a span with
.instrument()or events inside won't correlate. - Never hold
span.enter()guards across.await— use.instrument(span)instead (also enforced by Clippy).
use tracing::Instrument;
async fn send_message(&self, session_id: &str, prompt: &str) -> Result<(), Error> {
let span = tracing::error_span!("send_message", session_id = %session_id);
async { /* body */ }.instrument(span).await
}
let span = tracing::error_span!("event_loop", session_id = %id);
tokio::spawn(async move { run_loop().await }.instrument(span));Log with structured fields: info!(session_id = %id, "Session created").
Static messages stay greppable; dynamic data goes in named fields, not
interpolated into the message string.
When porting from Node, Python, Go, or .NET: see the Concurrency primitives and Traits and conversions sections above. The two patterns that most reliably translate poorly are (1) per-event Box<dyn Fn> callback fields — use a trait with default-impl methods (the tower_lsp::LanguageServer / rmcp::ServerHandler / notify::EventHandler shape) — and (2) plumbing context.Context / CancellationToken through every call site — drop-cancel for caller-owned futures, tokio_util::sync::CancellationToken for SDK-internal tasks.
- Public API: every
pubitem in the crate is part of the SDK's contract. Adding a field to apub structis a breaking change unless the struct is#[non_exhaustive]or constructors hide field-by-field literals. PreferDefault + ..Default::default()patterns and document new fields with rustdoc. - Generated code lives in
rust/src/generated/and must not be hand-edited. Regenerate withcd scripts/codegen && npm run generate:rust. When a generated type lacks a field the schema doesn't yet describe (e.g.Tool::overrides_built_in_tool), hand-author the user-facing type inrust/src/types.rsand stop re-exporting the generated one. #[expect(dead_code)]instead of#[allow(dead_code)]on individual fields — it forces a cleanup once the field gets used...Default::default()— avoid in production code (be explicit about which fields you're setting); prefer it in tests and doc examples to keep the focus on the values that matter for the test.- Import grouping — three blocks separated by blank lines:
(1)
std/core/alloc, (2) external crates, (3)crate::/super::/self::. Enforced by nightlycargo fmtviarust/.rustfmt.nightly.toml. pub(crate)vspub— most modules inlib.rsare private (mod), sopubitems inside them are already crate-private. Usepub(crate)only when you want to be explicit that an item must not become part of the public API.
- No mock testing. Depend on real implementations, spin up lightweight
versions (e.g.
MockServerin tests), or restructure code so the logic under test takes its dependency's output as input. assert_eq!(actual, expected)— actual first, for readable diffs.- Tests at end of file:
#[cfg(test)] mod tests. Never place production code after the test module. - Keep tests concurrent-safe — unique temp dirs (
tempfile::tempdir()), unique data, no global state. ApproveAllHandleris the standard test handler for sessions that don't exercise permission logic — seerust/src/handler.rs:174.
The SDK ships on macOS, Windows, and Linux; CI exercises all three. Construct
paths with Path::join rather than string concatenation — / and \ are not
interchangeable, and string equality breaks on Windows UNC paths. Log paths
with path.display(); serialize with to_string_lossy() only when you need a
String.
Process spawning needs care. The SDK applies CREATE_NO_WINDOW on Windows
when launching the CLI (see Client::build_command); preserve that if you
touch process spawning. Subprocess stdout often contains \r on Windows — strip
or split on \r?\n rather than assuming \n.
Tests must use tempfile::tempdir(), never hardcoded /tmp/, and any test
that asserts on a path string needs to normalize separators or use
std::path::MAIN_SEPARATOR.
Specify Tokio features explicitly — never features = ["full"]. Iterate with
cargo check; reach for cargo build only when you need the binary. Audit
new dependency feature flags with cargo tree before committing.
Explain why, never what. No comments that restate code. No decorative
banners (// ── Section ────────).
Never compare to other SDKs in code comments or rustdoc. Don't write
"Mirrors Node's Foo", "Like Go's Bar", "Unlike Python's Baz", or include
file/line citations into other SDKs (nodejs/src/types.ts:1592, go/types.go:14).
The Rust SDK seeks parity with the Node, Python, Go, and .NET SDKs, and that
fact is stated once at the top of rust/README.md. Intentional divergences
live in the README's "Differences From Other SDKs" section. Repeating the
relationship per-symbol is unscalable, drifts as the other SDKs evolve, and
adds noise to consumer-facing rustdoc — Rust users care about the Rust API,
not its lineage. Self-references within the Rust crate (e.g. "Mirrors
[from_streams] but adds…") are fine.
The SDK is pinned to rust 1.94.0 via rust/rust-toolchain.toml. Formatting
uses nightly (nightly-2026-04-14) so unstable rustfmt options like grouped
imports work — see rust/.rustfmt.nightly.toml. CI runs:
cd rust
cargo +nightly-2026-04-14 fmt --check
cargo clippy --all-features --all-targets -- -D warnings
cargo test --all-featuresMatch those exact commands locally before pushing.
JSON-RPC and session-event types are generated from the Copilot CLI schema:
| Source | Output |
|---|---|
nodejs/node_modules/@github/copilot/schemas/api.schema.json |
rust/src/generated/api_types.rs |
nodejs/node_modules/@github/copilot/schemas/session-events.schema.json |
rust/src/generated/session_events.rs |
Regenerate with:
cd scripts/codegen && npm run generate:rustNever hand-edit files under rust/src/generated/. If a generated type needs a
field the schema lacks, hand-author the user-facing type in rust/src/types.rs
and stop re-exporting the generated one.