Rust Development at Sentry

This is a document that contains a bunch of useful resources for getting started with Rust and adhering to our coding principles.

Getting Started

Coding Principles

Iterators

Prefer explicit iterator types over impl Iterator in stable, public interfaces of published crates. This allows to name the type in places like associated types or globals. The type name should end with Iter per naming convention.

In addition to the standard Iterator trait, always consider implementing additional traits from std::iter:

  • FusedIterator in all cases unless there is a strong reason not to
  • DoubleEndedIterator if reverse iteration is possible
  • ExactSizeIterator if the size is known beforehand

If it is exceptionally hard to write a custom iterator, then it can also be a private newtype around a boxed iterator:

Copied
pub struct FooIter(Box<dyn Iterator<Item = Foo>>);

impl Iterator for FooIter {
    type Item = Foo;

    fn next(&mut self) -> Option<Self::Item> {
        self.0.next()
    }
}

Async Traits

In traits you can not yet use async fn (see this blog post). In this case, functions should return -> Pin<Box<dyn Future<Output = ...> + Send>>:

Copied
trait Database {
    fn get_user(&self) -> Pin<Box<dyn Future<Output = User> + Send + '_>>;
}

impl Database for MyDB {
  fn get_user(&self) -> Pin<Box<dyn Future<Output = User> + Send + '_>> {
    Box::pin(async {
      // ...
    })
  }
}

Note that the returned future type is Send, to ensure that it can run on a multi-threaded runtime.

This corresponds to what the async-trait crate does.

Avoid .unwrap()

This may seem obvious, but almost always avoid the use of .unwrap(). Even if a piece of code is guaranteed not to panic in its current form (because some precondition is met), it might be reused or refactored in a future situation where the precondition does not hold anymore.

Instead, refactor your code and function signatures to use match, etc.

Use .get() Instead of Slice Syntax

Slice syntax (&foo[a..b]) will panic if the indices are out of bounds or out of order. Especially when dealing with untrusted input, it is better to use .get(a..b) and an if let Some(...) = expression.

Checked Math

Arithmetic under/overflows will panic in debug builds, but will lead to wrong results or panics in other parts of the code (like slice syntax mentioned above) in release builds. It is a good idea to always use checked math, such as checked_sub or saturating_sub. The saturating variants will be capped at the appropriate MIN/MAX of the underlying data type.

Field visibility

By default, fields of structs (including tuple-structs) should be fully private. The only exception to this are:

  • Newtypes (1-tuple structs) where direct access to the inner type is desired. This is to annotate semantics of a type where accessing the inner type is required.
  • Plain data types with a very stable signature. For example, schema definitions such as the Sentry Event protocol.

Mixed visibility is never allowed, not even between private and pub(crate) or pub(super). Instead, provide accessors:

  • foo(), foo_mut() and set_foo() for exposing the values
  • pub(crate) or pub(super) accessor functions in corner cases
  • A Default implementation and builder when incremental construction is required

Style Guidelines

In general, we follow the official Rust Style Guidelines.

Import Order

Imports must be declared before any other code in the file, and can only be preceded by module doc comments and module attributes (#![...]). We bundle imports in groups based on their origin, each separated by an empty line. The order of imports is:

  1. Rust standard library, comprising std, core and alloc
  2. External & workspace dependencies, including pub use
  3. Crate modules, comprising self, super, and crate

This is equivalent to the following rustfmt configuration, which requires nightly:

Copied
imports_granularity = "Module"
group_imports = "StdExternalCrate"  # nightly only

Example:

Copied
use std::borrow::Cow;
use std::collections::HashMap;
use std::io::{self, Seek, Write};

use fnv::{FnvHashMap, FnvHashSet};
use num::FromPrimitive;
use symbolic_common::{Arch, DebugId, Language};
use symbolic_debuginfo::{DebugSession, Function, LineInfo};

use crate::error::{SymCacheError, SymCacheErrorKind, ValueKind};
use crate::format;

pub use gimli::RunTimeEndian as Endian;

Declaration order

Within a file, the order of components should always be roughly the same:

  1. Module-level documentation
  2. Imports
  3. Public re-exports
  4. Modules and public modules
  5. Constants
  6. Error types
  7. All other functions and structs
  8. Unit tests

There is no hard rule on declaration order, but we suggest to place the more significant item first. For example, for types that expose an iterator, declare the type and its impl block (including fn iter) first, then below define the corresponding iterator.

When declaring a structure with an implementation, always ensure that the struct and its impl blocks are consecutive. Use the following order for the blocks:

  1. The struct or enum definition
  2. impl block
  3. impl block with further constraints
  4. Trait implementations of std traits
  5. Trait implementations of other traits
  6. Trait implementations of own traits

Inside an impl block, follow roughly this order (public before private):

  1. Associated constants
  2. Associated non-instance functions
  3. Constructors
  4. Getters / setters
  5. Anything else

Default lints

We use clippy with the clippy::all preset for linting. See The Configuring CI section for how to invoke it.

In addition to that, every crate enable the following warnings in its top-level file after the doc block before imports (see the list of all available lints):

Copied
#![warn(missing_docs)]
#![warn(missing_debug_implementations)]

Note that we do not enable unsafe_code, since that should already stand out in code review. There are a few legitimate cases for unsafe code, and the unsafe keyword alone already marks them sufficiently.

Naming

We are strictly following the Rust Naming Conventions. The entire conventions apply, although here are a few highlighted sections:

Writing Doc Comments

We follow conventions from RFC 505 and RFC 1574 for writing doc comments. See the full conventions text. Particularly, this includes:

  • A single-line short summary sentence written in American English using third-person.
  • Use the default headers where applicable, but avoid custom sections outside of module-level docs.
  • Link between types and methods where possible, especially within the crate.
  • Write doc tests

Writing Doc Tests

For crate-public utilities and SDKs, prefer writing at least one doctest for the critical path of your methods or structs. Of course, this requires the interface to be public. This should even take precedence over an equal unit test, since it both tests and documents the API.

To format code in doc comments, you can temporarily configure rustfmt to do so. We might add this to our default configuration at some point:

Copied
format_code_in_doc_comments = true

Writing Tests

All test functions should contain the function name + a simple condition in their name. The test name should be as concise as possible and avoid redundant words (such as "should", "that"). Examples:

  • tests::parse_empty
  • tests::parse_null

Place unit tests in a tests submodule annotated with #[cfg(test)]. This way, imports and helper functions for tests are only compiled before running tests. Depending on the editor, rust-analyzer will auto-expand tmod to an appropriate snippet.

Copied
fn foo() {}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn foo_works() { .. }
}

Integration tests should go into the tests/ folder, ideally into a file based on the functionality they test. Such tests can only use the public interface. See this blog post for tips on how to structure integration tests.

For libraries, consider providing examples in examples/.

Development Environment

We use VSCode for development. Each repository contains settings files configuring code style, linters, and useful features. When opening the project for the first time, make sure to "Install the Recommended Extensions", as they will allow editor assist during coding. The probably most up-to-date example can always be found in the Relay repository:

  • Rust Analyzer: A fast and feature-packed language server integration.
  • CodeLLDB: Debugger based on LLDB, required to debug with the Rust Analyzer extension
  • Better TOML and Crates: Support for Cargo.toml files and bumping dependency versions

Recommended Rust Analyzer settings in your project's .vscode/settings.json:

Copied
{
  // Enable all features
  "rust-analyzer.cargo.features": "all",

  // If you don't like inline type annotations, this disables it unless Ctrl + Alt is pressed.
  "editor.inlayHints.enabled": "offUnlessPressed",

  // Import rules
  "rust-analyzer.imports.granularity.group": "module",
  "rust-analyzer.imports.prefix": "crate"
}

For testing, we often use the snapshot library [insta](https://github.com/mitsuhiko/insta). By default, when running tests with Cargo, insta will compare snapshots and pretty-print diffs on standard out. However, to get most out of insta, install cargo-insta and use the cargo command instead:

Copied
# Install insta. This will upgrade outdated versions.
$ cargo install cargo-insta

# To review snapshot diffs interactively:
$ cargo insta review --all

# To run all tests skipping failures:
$ cargo insta test --review

# To quickly reject all pending diffs:
$ cargo insta reject --all

Makefiles

We use Makefiles to collect the most standard actions. A full Makefile for a workspace comprising multiple crates should roughly look like this (can be simplified):

Copied
all: check test
.PHONY: all

check: style lint
.PHONY: check

test: test-default test-all
.PHONY: test

test-default:
	cargo test --all
.PHONY: test-default

test-all:
	cargo test --all --all-features
.PHONY: test-all

style:
	@rustup component add rustfmt --toolchain stable 2> /dev/null
	cargo +stable fmt --all -- --check
.PHONY: style

lint:
	@rustup component add clippy --toolchain stable 2> /dev/null
	cargo +stable clippy --all-features --all --tests --examples -- -D clippy::all
.PHONY: lint

format:
	@rustup component add rustfmt --toolchain stable 2> /dev/null
	cargo +stable fmt --all
.PHONY: format

doc:
	cargo doc --workspace --all-features --no-deps
.PHONY: doc

Cargo Version Numbers

Cargo follows Semantic Versioning, which uses X.Y.Z which are respectively called major, minor and patch version numbers. There are two major cases:

After 1.0

Once a 1.0 release is reached following SemVer is easy:

  • Breaking changes require bumping the major version.
  • New features bump the minor version.
  • Bugfix-only releases bump the patch version.

Before 1.0

Before a 1.0 release is reached anything goes according to the spec. However Cargo has strictly defined update rules for caret requirements (caret requirements are the default if plain version numbers are used). So in order to make cargo update behave well we adopt the following:

  • Breaking changes require bumping the minor (Y) version.
  • New features bump the patch (Z) version.
  • Bugfix-only releases bump the patch (Z) version.

Further Reading

You can edit this page on GitHub.