Scarlet Industries

Mordant

Lints for Rust that find the rules your code relies on but your types do not enforce.

A rule like "always build a Port through Port::new" usually lives in a comment or in somebody's head. Mordant finds rules like that and points at the type change that would make the compiler enforce them. It runs next to Clippy, not instead of it.

Run it

Mordant is built with a pinned nightly, and it lints projects on stable Rust without changing their toolchain. Install the nightly and Mordant once on each machine:

rustup toolchain install nightly-2026-09-01 --component rustc-dev --component llvm-tools-preview
cargo +nightly-2026-09-01 install --locked --git https://github.com/scarletindustries/mordant

Then run the lints from the workspace root:

cargo mordant --workspace --all-targets   # report
cargo mordant --workspace --fix           # also rewrite wildcard arms

Read a finding

Here Port::new checks that a number fits in 16 bits, but the crate also builds a Port by hand and skips the check:

Flagged
mod port {
    pub struct Port {
        pub(crate) n: u16,
    }

    impl Port {
        pub fn new(n: u32) -> Result<Port, ()> {
            if n <= u16::MAX as u32 {
                Ok(Port { n: n as u16 })
            } else {
                Err(())
            }
        }
    }
}

use port::Port;

fn bypass() -> Port {
    Port { n: 0 }
}
warning: `port::Port` is built by hand here, skipping the check on `n` that `Port::new` makes
  --> src/lib.rs:24:5
   |
24 |     Port { n: 0 }
   |     ^^^^^^^^^^^^^
   |
note: the check in `Port::new`
  --> src/lib.rs:12:16
   |
12 |             if n <= u16::MAX as u32 {
   |                ^^^^^^^^^^^^^^^^^^^^
   = help: build it with `Port::new(..)`, or make `n` private so a literal like this only compiles inside `Port`'s module
   = note: `#[warn(unchecked_construction)]` on by default

The first line is the claim, the note is the evidence and the help is the fix. Here the fix makes n private, so nothing outside the module can skip Port::new:

Fixed
mod port {
    pub struct Port {
        n: u16,
    }

    impl Port {
        pub fn new(n: u32) -> Result<Port, ()> {
            if n <= u16::MAX as u32 {
                Ok(Port { n: n as u16 })
            } else {
                Err(())
            }
        }
    }
}

use port::Port;

fn build(n: u32) -> Result<Port, ()> {
    Port::new(n)
}

When the code is right, silence that one finding with #[allow(unchecked_construction)] on the item.

Run it in CI

Mordant Action runs the lints on every pull request and fails the job on any finding.

.github/workflows/mordant.yml
name: mordant
on: [pull_request]

jobs:
  mordant:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v7
      - uses: scarletindustries/mordant-action@v1

Findings show up as annotations on the pull request. Set fail-on: never to report without failing, and ref to pin a Mordant commit.

Configuration

Settings live in the [mordant] table of a mordant.toml at the workspace root. Without the file every default lint runs, and a misspelt key stops the run with an error rather than being ignored.

mordant.toml
[mordant]
disabled = ["unused_pub", "group:naming"]   # never run these
bool-cluster-enabled = true                 # turn on an opt-in lint

The lints

Each family is also a lint group, named in brackets, so #![allow(mordant_naming)] covers a whole family. Opt-in lints only run once mordant.toml turns them on.

State that should be a type (mordant_state)
options_as_enum Option fields that are never Some together, an enum in disguise
parallel_bools Bools that are always assigned together, so they are one state
bool_cluster A struct with so many bools that most combinations mean nothing (opt-in)
runtime_typestate A ready flag that several methods check before they do anything
always_unwrapped_option An Option field that every reader unwraps
derived_field A field that always has the same value for a given value of another
field_valid_only_when A field that only means something while a sibling has one value
bool_beside_option A bool that is always the is_some() of the Option beside it
parallel_vecs Vecs that grow together and are read at the same index
parallel_params Parameters that always travel together, a value with no type (opt-in)
stringly_state A string that only ever holds one of a few literals
tuple_wants_struct A tuple that every caller unpacks under the same names
some_still_unchecked Some(x) if x.ready(), where a Some that fails counts as None (opt-in)
Checks that some path skips (mordant_checks)
unchecked_construction A checked type built or changed without its check
defaulted_failure Rejected input replaced by a default, so processing carries on
unchecked_input_len A length checked on one path and trusted on another (opt-in)
guard_blind_to_action if self.can_x() guarding changes that can_x never looks at
stale_across_reentry A length or pointer read from self, trusted after a callback
error_collapsed_to_bool An error turned into false, and then the false thrown away
narrowed_two_ways One value narrowed with try_from in one place and as in another
cast_bypasses_from A transmute or pointer cast around a checked conversion
sentinel_integer A MAX that means none, used unchecked as an index
Errors that lose their type (mordant_errors)
stringly_error An exported function whose error is a String
stringified_error A typed error turned into a string in map_err
discarded_error something().ok();, which throws the error away
unread_error_variant A private enum variant that is built but never matched
Enums wider than their use (mordant_enums)
wildcard_over_own_enum A _ => arm that will swallow the next variant
param_wider_than_callers A panic on a variant no caller passes
return_wider_than_body A panic on a variant the function never returns
Code written twice (mordant_duplication)
same_match_twice The same match over one enum, written in two places
reimplemented_helper Two functions with the same signature and body
generic_body_not_generic Generic code that ignores its type parameters, compiled once per type (opt-in)
Names doing a type's job (mordant_naming)
bare_bool_args f(x, true, false), where nothing says which flag is which
arg_named_like_other_param resize(height, width) against fn resize(width, height)
interchangeable_aliases Two aliases of one integer, used in place of each other
index_of_other_kind An index of one kind used on a table of another
unit_mismatch timeout_ms + deadline_ns
Map keys and locks (mordant_keys_locks)
key_not_identity A map keyed on something that does not identify its value
insert_then_unwrap insert(k, v) followed by get(&k).unwrap()
lock_order Two locks taken in both orders, the shape of a deadlock
Comments that have gone stale (mordant_comments)
stale_safety_comment A SAFETY: comment naming something that no longer exists (opt-in)
stale_panic_message A panic message naming something that no longer exists
Public code nobody uses (mordant_unused)
unused_pub A pub item that nothing in the workspace uses
Your own rules (mordant_custom)
forbidden_reach A call path your config bans