Bringing the random_color Rust Crate to Version 1.0.0
In this post, I will outline the process of upgrading the random_color Rust crate, with over 185K downloads, to version 1.0.0, covering the key changes and improvements made, along with practical usage examples.
On February 14, 2017, feeling single and bored, I decided to dive into Rust. What better way to learn a new programming language than through a small project?
I decided to port David Merfield's famous randomColor
library as a way to immerse myself in Rust while also contributing something useful to the community. Since then, I've updated the library multiple times and accepted contributions from fellow developers to fix bugs, add small features, and improve documentation. However, I’ve never quite gotten the library to a point where I’d feel comfortable labeling it as v1.0.0
.
For example, in the library current state, the following test would fail:
#[test]
fn should_produce_different_colors() {
let mut color = RandomColor::new();
color.seed("42");
let s1 = color.to_hsl_string();
let s2 = color.to_hsl_string();
assert_ne!(s1, s2);
}
And it shouldn't — this is clearly an oversight on my part, a result of flawed implementation.
Enough is enough: today, we will tackle what I believe is missing to bring random_color
to version 1.0.0
.
- A way to produce more than one
RandomColor
given the initial input parameters. - Optional support for transforming a
RandomColor
into color structs from the most popular libraries (e.g.,rgb
).
The list is short, but the tasks are complex. Most likely, I will have to introduce breaking changes, especially regarding the first requirement.
A little housecleaning to kick-start the process
The lib.rs
file is a bit crowded right now, containing not only the implementation for the RandomColor
struct but also the Seed
trait and the Color
and Luminosty
enums.
Improving this is straightforward. We'll:
- Move
Color
andLuminosity
enums to their own files. - Move
Seed
to its own file. - Rename
Color
toGamut
for better representation of its purpose.
The new structure looks like this:
src
├── ./options
│ ├── ./options/gamut.rs
│ ├── ./options/luminosity.rs
│ ├── ./options/mod.rs
│ └── ./options/seed.rs
├── ./color_dictionary.rs
└── ./lib.rs
You'll notice a mod.rs
file. This file defines how the module is structured and ultimately determines the public interface of our inner "options" library. The implementation of the mod.rs
file is simple:
mod gamut;
mod luminosity;
mod seed;
pub use self::gamut::Gamut;
pub use self::luminosity::Luminosity;
pub use self::seed::Seed;
This will also allow the structs and traits to be exposed by adding the following line in code:
use random_color::options::{Color, Luminosity, Seed};
Check out all the changes in the associated pull request.
Generating new colors given the same instance
As @RReverser pointed out in this issue, an existing instance of RandomColor
should be able to produce different colors every time that a new color is requested.
The problem resides in the random_within
method:
fn random_within(&self, mut min: i64, mut max: i64) -> i64 {
if min > max {
std::mem::swap(&mut min, &mut max);
}
if min == max {
max += 1;
}
match self.seed {
None => SmallRng::from_entropy().gen_range(min..max),
Some(seed) => SmallRng::seed_from_u64(seed).gen_range(min..max),
}
}
As you can see in line 233, we are creating a new SmallRng
every time with the same seed. You can't see me, but I'm face-palming in real life.
Anyway, the improvement is obvious: we need to update the RandomColor
struct to store not just the seed as u64
, but a seeded instance of SmallRng
. We'll also need to update almost every method to use &mut self
instead of &self
.
The new random_within
method looks like this:
fn random_within(&mut self, mut min: i64, mut max: i64) -> i64 {
if min > max {
std::mem::swap(&mut min, &mut max);
}
if min == max {
max += 1;
}
self.seed.gen_range(min..max)
}
But we can do even better. A quick examination of the ColorDictionary
struct and its usage reveals some areas for improvement:
- There's no need to store the
ColorInformation
in theRandomColor
struct; we can store the chosenGamut
and update theColorDictionary
to retrieveColorInformation
when needed. - Similarly, there’s no need to store
ColorDictionary
as anOption<>
. Since it's always required, we can instantiate it during::new()
.
The updated RandomColor
struct looks like this:
#[derive(Debug, PartialEq, Clone)]
pub struct RandomColor {
/// The hue of the color to generate.
pub hue: Option<Gamut>,
/// The luminosity of the color to generate.
pub luminosity: Option<Luminosity>,
/// The seed for the random number generator.
pub seed: SmallRng,
/// The alpha value of the color to generate.
pub alpha: Option<f32>,
/// The color dictionary to use.
pub color_dictionary: ColorDictionary,
}
And its ::new
implementation:
pub fn new() -> Self {
RandomColor {
hue: None,
luminosity: None,
seed: SmallRng::from_entropy(),
alpha: Some(1.0),
color_dictionary: ColorDictionary::new(),
}
}
Additionally, I've updated the documentation for the whole module.
Check out all the changes in the associated pull request.
Adding support for other color crates
Searching crates.io for popular crates that work with colors yields three notable options:
Wouldn't it be great if you could generate a random color and easily transform it into the native representation of each crate?
We can achieve this in two ways: by implementing the From
trait or by using crate-specific to_STRUCT
methods. Additionally, this is a good opportunity to work with cargo features
. If you don't need support for a specific crate, you shouldn't carry the associated implementations in your code.
Let's start by adding the features in the Cargo.toml
file:
[features]
rgb_support = ["dep:rgb"]
palette_support = ["dep:palette"]
ecolor_support = ["dep:ecolor"]
[dependencies]
rand = { version = "0.8.5", features = ["small_rng"] }
rgb = { version = "0.8.50", optional = true}
palette = { version = "0.7.6", optional = true}
ecolor = { version = "0.28.1", optional = true}
Lines 14 to 16 define the different features our crate supports and their respective dependencies.
Lines 19 to 22 specify the optional dependencies to be used by these features.
But what about the code? How can we ensure that certain methods or crates are only used when specific features are enabled?
You can achieve this by using the #[cfg(feature = "YOUR_FEATURE")]
attribute before any method, extern crate
declaration, use
declaration, trait implementation, or even tests.
A case study: rgb
The process for integrating support for the three libraries mentioned earlier is quite similar. Let's focus on one specific case: the rgb
crate.
The dependency has already been specified in Cargo.toml
, so the next step is to declare the usage of the extern crate
in lib.rs
:
#[cfg(feature = "rgb_support")]
extern crate rgb;
Next, we can start implementing the From
traits to simplify the process of converting our colors to rgb::Rgb<u8>
and rgb::Rgba<u8>
#[cfg(feature = "rgb_support")]
impl From<RandomColor> for Rgb<u8> {
fn from(value: RandomColor) -> Self {
let rgb = value.into_rgb_array();
Rgb {
r: rgb[0],
g: rgb[1],
b: rgb[2],
}
}
}
#[cfg(feature = "rgb_support")]
impl From<&mut RandomColor> for Rgb<u8> {
fn from(value: &mut RandomColor) -> Self {
let rgb = value.to_rgb_array();
Rgb {
r: rgb[0],
g: rgb[1],
b: rgb[2],
}
}
}
#[cfg(feature = "rgb_support")]
impl From<RandomColor> for rgb::Rgba<u8>
{
fn from(value: RandomColor) -> Self {
let rgba = value.into_rgba_array();
rgb::Rgba {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
}
}
}
#[cfg(feature = "rgb_support")]
impl From<&mut RandomColor> for rgb::Rgba<u8> {
fn from(value: &mut RandomColor) -> Self {
let rgba = value.to_rgba_array();
rgb::Rgba {
r: rgba[0],
g: rgba[1],
b: rgba[2],
a: rgba[3],
}
}
}
With the code above, we can effortlessly implement new convenient methods:
#[cfg(feature = "rgb_support")]
pub fn to_rgb(&mut self) -> Rgb<u8> {
Rgb::from(self)
}
#[cfg(feature = "rgb_support")]
pub fn to_rgba(&mut self) -> rgb::Rgba<u8> {
rgb::Rgba::from(self)
}
Finally, let's add some tests:
#[test]
#[cfg(feature = "rgb_support")]
fn generates_color_as_rgb_from_rgb_crate() {
let test_case = RandomColor::new()
.hue(Gamut::Blue)
.luminosity(Luminosity::Light)
.seed(42)
.alpha(1.0)
.to_rgb();
assert_eq!(test_case, Rgb::new(174, 236, 249));
}
#[test]
#[cfg(feature = "rgb_support")]
fn generates_color_as_rgba_from_rgb_crate() {
let test_case = RandomColor::new()
.hue(Gamut::Blue)
.luminosity(Luminosity::Light)
.seed(42)
.alpha(0.69)
.to_rgba();
assert_eq!(test_case, rgb::Rgba::new(174, 236, 249, 175));
}
As you can see, adding feature support in Rust code is straightforward. However, we’re not done yet. If we want docs.rs to show the documentation for our feature-enabled code, we need to update Cargo.toml
:
[package.metadata.docs.rs]
all-features = true
rustdoc-args = ["--cfg", "docsrs"]
With these additions, when docs.rs compiles the crate documentation, it will enable all the features. Without this, since random_color
doesn't specify any default features, the documentation would not include any of the optionally enabled methods and implementations.
Check out all the changes in the associated pull request.
Anything else?
I’d love to introduce support for the iterator pattern in this release, but I need more time to consider how it will interact with the optional features. Since that requires careful thought, I'll save it for a future update.
In the meantime, feel free to install random_color
in your Rust project!
cargo add random_color@=1.0.0
Wrapping up
The random_color
crate has been upgraded to v1.0.0
and I couldn't be happier. I'm grateful for being able to contribute to the open-source ecosystem, and for the incredible packages and projects that now use my crate — ranging from the amazing fake-rs
to mediasup
, with dozens of repositories in between.
Don't forget to check out the project's source code!
If you have any suggestions, spot any errors, or simply want to share your thoughts, feel free to let me know. Your feedback is always welcome as I continue to refine and enhance my approach.
Thank you for reading Hardcoded ❤️.