Skip to content
mutastic

Rust edition

Library methods

Operators that swap a method from std for its counterpart, or change a string or a pattern the code holds. They ask whether your tests would notice the wrong end, the wrong direction, or the wrong text.

OperatorChangesSet
library/swap-stringstarts_with and ends_with, to_uppercase and to_lowercase, trim_start and trim_end, strip_prefix and strip_suffixdefaults
library/swap-mathmin and max, floor and ceildefaults
library/swap-index-offind and rfind, position and rposition, split_once and rsplit_oncestronger
library/reverse-orderinga.cmp(&b) to b.cmp(&a); .reverse() left outstronger
mutastic/string-constanta string to "", and "" to "mutastic"stronger
mutastic/regex-constanta regular expression to one that differs and still compilesextended
bigdecimal/remove-stepround_dp(2) and the like, left outextended

The first five cards come from mutastic run fixtures/operators with these operators, on src/library.rs.

library/swap-string, in the defaults. A str method becomes its counterpart: starts_with and ends_with, to_uppercase and to_lowercase (and their ASCII forms), trim_start and trim_end (and their _matches forms), strip_prefix and strip_suffix.

The bug it stands for: the method next to the one you meant. They take the same arguments, the compiler can’t tell them apart, and on the text tests usually pick they give the same answer. It costs in parsing and normalising: a prefix check that’s really a suffix check.

pub fn is_comment(line: &str) -> bool {
line.starts_with('#')
}
#[test]
fn plain_lines_are_not_comments() {
assert!(!is_comment("hello"));
}
Terminal window
✗ MISSED tests pass even when starts_with becomes ends_with in is_comment()
┌─ src/library.rs:4
│
3 │ pub fn is_comment(line: &str) -> bool {
4 │ line.starts_with('#')
│ ^^^^^^^^^^^ starts_with changed to ends_with
5 │ }
│
= mutant - line.starts_with('#')
+ line.ends_with('#')
= note: covered by library::tests::plain_lines_are_not_comments (its 1 covering test still passes)
= help: use inputs where starts_with and ends_with give different results, and assert what is_comment() does
done when mutastic verify m739423e6 fixtures/operators passes
reproduce mutastic run --only=m739423e6 fixtures/operators

The test that catches it: text with what’s looked for at one end only.

#[test]
fn a_comment_starts_with_the_hash() {
assert!(is_comment("# a note"));
}

Edition notes: the Java edition has this operator too, library/swap-string, for startsWith and endsWith and the like.

library/swap-math, in the defaults. a.min(b) and a.max(b) swap, as do cmp::min and cmp::max, min_by and max_by (and their _key forms), and floor and ceil.

The bug it stands for: min where max was meant, a limit that’s a floor instead of a ceiling, or rounding the wrong way. Each reads right at a glance, and tests whose values are all on one side of the limit can’t tell.

/// Never more than half off.
pub fn capped_discount(percent: u32) -> u32 {
percent.min(50)
}
#[test]
fn half_off_at_most() {
assert_eq!(capped_discount(50), 50);
}

At exactly the cap, min and max agree:

Terminal window
✗ MISSED tests pass even when min becomes max in capped_discount()
┌─ src/library.rs:9
│
8 │ pub fn capped_discount(percent: u32) -> u32 {
9 │ percent.min(50)
│ ^^^ min changed to max
10 │ }
│
= mutant - percent.min(50)
+ percent.max(50)
= note: inputs seen: percent = 50
covered by library::tests::half_off_at_most (its 1 covering test still passes)
= help: use inputs where min and max give different results, and assert what capped_discount() does
// in mod library::tests
#[test]
fn capped_discount_with_percent_49() {
assert_eq!(capped_discount(49), 49); // today 49 · with this mutant 50
}
check today's value is the one you intend: the test pins what the code does now
done when mutastic verify m5bb85f22 fixtures/operators passes
reproduce mutastic run --only=m5bb85f22 fixtures/operators

The test that catches it: one on the side the code guards against.

#[test]
fn more_than_half_is_capped() {
assert_eq!(capped_discount(80), 50);
}

Edition notes: the Java edition has this operator too, library/swap-math, for Math.min and Math.max, floor and ceil.

library/swap-index-of, in the stronger set. A search from one end becomes the search from the other: find and rfind, position and rposition, split_once and rsplit_once, match_indices and rmatch_indices.

The bug it stands for: the first match where the last was meant. On text with one match they agree, and that’s the text tests usually have. It costs in taking text apart: an extension taken from the first dot.

pub fn extension(file: &str) -> Option<&str> {
file.find('.').map(|dot| &file[dot + 1..])
}
#[test]
fn a_text_file() {
assert_eq!(extension("notes.txt"), Some("txt"));
}

With one dot in the name, both ends find the same one. A test of archive.tar.gz would tell them apart:

Terminal window
✗ MISSED tests pass even when find becomes rfind in extension()
┌─ src/library.rs:13
│
12 │ pub fn extension(file: &str) -> Option<&str> {
13 │ file.find('.').map(|dot| &file[dot + 1..])
│ ^^^^ find changed to rfind
14 │ }
│
= mutant - file.find('.').map(|dot| &file[dot + 1..])
+ file.rfind('.').map(|dot| &file[dot + 1..])
= note: covered by library::tests::a_text_file (its 1 covering test still passes)
= help: use inputs where find and rfind give different results, and assert what extension() does
done when mutastic verify m1c9a050a fixtures/operators passes
reproduce mutastic run --only=m1c9a050a fixtures/operators

The test that catches it: one where what’s searched for appears twice.

#[test]
fn an_archive() {
assert_eq!(extension("backup.tar.gz"), Some("tar.gz"));
}

Edition notes: the Java edition has this operator too, library/swap-index-of, for indexOf and lastIndexOf.

library/reverse-ordering, in the stronger set, and only in Rust. A comparison of two values turns round, a.cmp(&b) becoming b.cmp(&a) (and partial_cmp too), and a .reverse() on an ordering is left out. It goes unnoticed when no test checks the order something is sorted in.

The bug it stands for: a sort the wrong way round: newest first where oldest was meant, a ranking upside down. Tests with input already in order, or that only count what comes back, can’t tell.

pub fn sorted(mut names: Vec<String>) -> Vec<String> {
names.sort_by(|a, b| a.cmp(b));
names
}
#[test]
fn sorting_keeps_every_name() {
let names = vec!["cy".to_string(), "al".to_string(), "bo".to_string()];
assert_eq!(sorted(names).len(), 3);
}
Terminal window
✗ MISSED tests pass even when a.cmp(b) becomes b.cmp(a) in sorted()
┌─ src/library.rs:17
│
16 │ pub fn sorted(mut names: Vec<String>) -> Vec<String> {
17 │ names.sort_by(|a, b| a.cmp(b));
│ ^^^ changed to b.cmp(a)
18 │ names
│
= mutant - names.sort_by(|a, b| a.cmp(b));
+ names.sort_by(|a, b| b.cmp(a));
= note: covered by library::tests::sorting_keeps_every_name (its 1 covering test still passes)
= help: use inputs where a.cmp(b) and b.cmp(a) differ, and assert what sorted() does
done when mutastic verify m6a7d7d94 fixtures/operators passes
reproduce mutastic run --only=m6a7d7d94 fixtures/operators

The test that catches it: one with input out of order, asserting the order that comes out.

#[test]
fn names_come_out_in_order() {
let names = vec!["cy".to_string(), "al".to_string(), "bo".to_string()];
assert_eq!(sorted(names), vec!["al", "bo", "cy"]);
}

Edition notes: Rust only.

mutastic/string-constant, in the stronger set. A string in the code becomes "", and "" becomes "mutastic". Not a string given straight to Err, expect, context, with_context or wrap_err, which is a message for people; not a returned one, which is return value’s; and not one inside a macro such as format!, whose tokens mutastic leaves alone.

The bug it stands for: the wrong key, name or format: a typo in a header, a separator someone changed in one place and not the other. The compiler can’t check any of it.

pub fn header(columns: &[&str]) -> String {
columns.join(", ")
}
#[test]
fn one_column() {
assert_eq!(header(&["name"]), "name");
}

With one column there’s nothing to separate:

Terminal window
✗ MISSED tests pass even when ", " becomes "" in header()
┌─ src/library.rs:22
│
21 │ pub fn header(columns: &[&str]) -> String {
22 │ columns.join(", ")
│ ^^^^ changed to ""
23 │ }
│
= mutant - columns.join(", ")
+ columns.join("")
= note: covered by library::tests::one_column (its 1 covering test still passes)
= help: use inputs where ", " and "" differ, and assert what header() does
done when mutastic verify m55b8a89c fixtures/operators passes
reproduce mutastic run --only=m55b8a89c fixtures/operators

The test that catches it: one whose result depends on the text.

#[test]
fn columns_are_separated_by_commas() {
assert_eq!(header(&["name", "email"]), "name, email");
}

Edition notes: the Java edition has this operator too, mutastic/string-constant, and skips as arid what goes into a log or an exception’s message.

mutastic/regex-constant, in the extended set. The pattern given to Regex::new becomes ones that differ and still compile: an anchor dropped, a class turned round. It goes unnoticed when no test gives the regular expression text it should reject.

The bug it stands for: an expression that accepts more than it should, or less: a missing anchor, a class the wrong way round. Tests of a regular expression tend to try text that should match, and an unanchored \d+ finds abc123 too.

The fixtures depend on no crates, so there’s no card here from the regex crate itself. The truth fixture has a stand-in Regex that mutastic knows by its path, and mutastic mutants shows what it makes:

let re = Regex::new(r"^\d+$").unwrap();
Terminal window
md8834f6e src/lib.rs:535:25 mutastic/regex-constant r"^\d+$" becomes r"\d+$" in patterns
mf7d51f60 src/lib.rs:535:25 mutastic/regex-constant r"^\d+$" becomes r"^\d+" in patterns
mf617b484 src/lib.rs:535:25 mutastic/regex-constant r"^\d+$" becomes r"^\D+$" in patterns
3 mutants

The test that catches it: text that only one of the two matches, at each end: abc123 and 123abc as well as 123.

Edition notes: the Java edition has this operator too, mutastic/regex-constant, for Pattern.compile, matches, split and the replace methods.

bigdecimal/remove-step, in the extended set: the Java edition’s BigDecimal operator, for rust_decimal. A rounding or scaling step is left out: x.round_dp(2), round_sf, normalize() and trunc_with_scale become x. It goes unnoticed when no test uses a value the step would change.

The bug it stands for: a rounding step left out, so an amount keeps more decimal places than it should, and a total adds up from values nobody rounded.

As with regular expressions, the truth fixture has a stand-in Decimal, known by its method’s name:

let price = Decimal(x as i64 * 137).round_dp(1);
Terminal window
ma0a93565 src/lib.rs:536:41 bigdecimal/remove-step .round_dp(1) is left out in patterns
1 mutants

The test that catches it: an amount with more places than the step keeps, asserting what comes out.

Edition notes: the Java edition has this operator too, bigdecimal/remove-step, in its BigDecimal plugin, for BigDecimal’s setScale, round, stripTrailingZeros, abs and negate.