SKILL.md
Rust Coding Skill
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
Error handling
The SDK's public error type is crate::Error (rust/src/errors.rs). Add new variants to crate::ErrorKind 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.
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 #![cfgattr(test, allow(clippy::unwrapused))] 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.
Async and concurrency
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::spawnblocking, 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; parkinglot::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 parkinglot. 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.
Traits and conversions
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 (onpermissionrequest,onuserinput,onexternaltool,onelicitation,onexitplanmode,onautomodeswitch,onsessionevent) each with a default impl. The dispatcheronevent(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 tocreatesession/resumesession.SystemMessageTransform(rust/src/systemmessage.rs) — declaresectionids()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 toinfo(&self) or MyType::fromrecord(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.
Concurrency primitives
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][ctoken] · [tonic example][tonic-cancel] · [withoutboats: async clean-up][wb-cleanup] · [Cybernetist: cancellation patterns][cybernetist].
[ctoken]: https://docs.rs/tokio-util/latest/tokio_util/sync/struct.CancellationToken.html [tonic-cancel]: https://github.com/hyperium/tonic/blob/master/examples/src/cancellation/server.rs [wb-cleanup]: https://without.boats/blog/asynchronous-clean-up/ [cybernetist]: https://cybernetist.com/2024/04/19/rust-tokio-task-cancellation-patterns/
Optional fields and serde
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(renameall = "camelCase")] at the type level, per-field #[serde(rename = "…")] for outliers, #[serde(skipserializingif = "Option::isnone")] for output, #[serde(default)] for input tolerance. Reach for serde_with only for non-trivial transforms (durations, base64, numeric-as-string keys).
Tracing — #[tracing::instrument] is banned
Banned via clippy.toml. Use manual spans with error_span!:
- Almost always use
errorspan!, notinfospan!. Span level controls
the minimum filter at which the span appears. An infospan disappears when the filter is warn or error — taking all child events with it, even errors. errorspan! 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.
Idioms that don't port from other languages
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 towerlsp::LanguageServer / rmcp::ServerHandler / notify::EventHandler shape) — and (2) plumbing context.Context / CancellationToken through every call site — drop-cancel for caller-owned futures, tokioutil::sync::CancellationToken for SDK-internal tasks.
Code organization
- Public API: every
pubitem in the crate is part of the SDK's contract.
Adding a field to a pub struct is a breaking change unless the struct is #[non_exhaustive] or constructors hide field-by-field literals. Prefer Default + ..Default::default() patterns and document new fields with rustdoc.
- Generated code lives in
rust/src/generated/and must not be
hand-edited. Regenerate with cd scripts/codegen && npm run generate:rust. When a generated type lacks a field the schema doesn't yet describe (e.g. Tool::overridesbuiltin_tool), hand-author the user-facing type in rust/src/types.rs and stop re-exporting the generated one.
#[expect(deadcode)]instead of#[allow(deadcode)]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 nightly cargo fmt via rust/.rustfmt.nightly.toml.
pub(crate)vspub— most modules inlib.rsare private (mod), so
pub items inside them are already crate-private. Use pub(crate) only when you want to be explicit that an item must not become part of the public API.
Testing
- No mock testing. Depend on real implementations, spin up lightweight
versions (e.g. MockServer in 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 — see rust/src/handler.rs:174.
Cross-platform
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 tostringlossy() only when you need a String.
Process spawning needs care. The SDK applies CREATENOWINDOW 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.
Build speed
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.
Comments
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.
Toolchain
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-features
Match those exact commands locally before pushing.
Codegen
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:rust
Never 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.