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.

Bringing the random_color Rust Crate to Version 1.0.0

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 and Luminosity enums to their own files.
  • Move Seed to its own file.
  • Rename Color to Gamut 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.

Accurate representation of the moment I realized my mistake.

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 the RandomColor struct; we can store the chosen Gamut and update the ColorDictionary to retrieve ColorInformation when needed.
  • Similarly, there’s no need to store ColorDictionary as an Option<>. 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 ❤️.


Features - The Cargo Book

Explanation of how cargo features work.

random_color - Rust
A library for generating attractive random colors with a variety of options. Inspired by randomColor.

Documentation for random_color.