Lessons learned along “The Way”

A tool to store, search and copy snippets of code from your terminal

The Way

Two weeks ago I found myself, yet again, trawling through my repositories in the university GitLab looking for a function that maps a species name to its lineage. This, along with various file format parsers, protein related functions, and other tiny alleged “one-off” functions keep popping up in unrelated projects. Figured a code snippets manager would help and thus The Way was born.

the_way_demo.gif

Figure 1: A short self-referential demo

the_way_help.png

Figure 2: The help page

I’ve sort of documented the purpose of all the dependencies in Cargo.toml. It’s essentially a much more polished rewrite of quoth, so I won’t go into code examples of the crates used Though, if you’re interested, the Git repository has a snippets JSON file with these examples that you can add and use with the-way import . Instead, I’ll document the process of publishing an application like this, specifically some aspects I found non-trivial. Bioinformatics research doesn’t necessarily force you to learn things like testing, git and CI so it’s been great suddenly diving into this stuff; I’ve only recently graduated from the occasional git-commit-git-push to issues and pull requests for projects at work too.

Tests

For now I’ve tried to test common fail paths and success paths that a user may face.

Integration Tests

[dev-dependencies]
assert_cmd = "1.0.1"
predicates = "1.0.4"
tempdir = "0.3.7"

assert-cmd along with predicates, as recommended by the pretty wonderful Command Line Applications in Rust book (a.k.a the CLI book), covered most of the test cases I had in mind. Here’s a test for setting a syntax highlighting theme:

use tempdir::TempDir;
use predicates::prelude::*;
use assert_cmd::Command;

fn make_config_file(tempdir: &TempDir) -> color_eyre::Result<PathBuf> {
    let db_dir = tempdir.path().join("db");
    let themes_dir = tempdir.path().join("themes");
    let config_contents = format!(
	"theme = 'base16-ocean.dark'\n\
db_dir = \"{}\"\n\
themes_dir = \"{}\"",
	db_dir.to_str().unwrap(),
	themes_dir.to_str().unwrap()
    );
    let config_file = tempdir.path().join("the-way.toml");
    fs::write(&config_file, config_contents)?;
    Ok(config_file.to_path_buf())
}

#[test]
fn change_theme() -> color_eyre::Result<()> {
    let temp_dir = TempDir::new("change_theme")?;
    let config_file = make_config_file(&temp_dir)?;
    // Test nonexistent theme
    let theme = "no-such-theme";
    let mut cmd = Command::cargo_bin("the-way")?;
    cmd.env("THE_WAY_CONFIG", &config_file)
	.arg("themes")
	.arg("set")
	.arg(theme)
	.assert()
	.failure();

    // Test changing theme
    let theme = "base16-mocha.dark";
    let mut cmd = Command::cargo_bin("the-way")?;
    cmd.env("THE_WAY_CONFIG", &config_file)
	.arg("themes")
	.arg("set")
	.arg(theme)
	.assert()
	.success();
    let mut cmd = Command::cargo_bin("the-way")?;
    cmd.env("THE_WAY_CONFIG", &config_file)
	.arg("themes")
	.arg("get")
	.assert()
	.stdout(predicate::str::contains(theme));
    Ok(())
}

Snippets are stored in a sled database and the location of this database, along with the location of highlighting themes, and the currently set theme are stored in a configuration file, with confy‘s help. Configuration files and application data are stored in different locations according to the OS, which is why confy uses directories to figure this out. Of course, not everyone falls under the general case - sudo rights, permissions, multiple users etc. could mean a user wants to store The Way’s data somewhere else. So the $THE_WAY_CONFIG environment variable can be set to point to a custom configuration file and the-way config default can be used to populate it. This is also super handy for testing, since it means we can make a separate data directory for each test and not worry about polluting a user’s (or my) snippets while testing.

So each test starts by making a new configuration file and temporary directories for test snippets. assert_cmd lets you pass in an environment variable so we’re sure that the application has access to these directories via the config file. The test then just checks that the command fails if you try to set a theme that doesn’t exist and successfully changes the theme otherwise.

Interactive Tests

[dev-dependencies]
# ... continued
rexpect = "0.3"

Some commands, the-way new and the-way edit in particular, are interactive, i.e. they use dialoguer to take input from the user with a series of prompts. assert_cmd doesn’t work for testing interactive behavior, but rexpect does!

#[test]
fn add_snippet() -> color_eyre::Result<()> {
    let temp_dir = TempDir::new("add_snippet")??;
    let config_file = make_config_file(&temp_dir)?;
    let target_dir = std::env::var("TARGET").ok();
    let executable_dir = match target_dir {
	Some(t) => format!("target/{}/release", t),
	None => "target/release".into(),
    };
    assert!(add_snippet_rexpect(config_file, &executable_dir).is_ok());
    temp_dir.close()?;
    Ok(())
}

fn add_snippet_rexpect(
    config_file: PathBuf,
    executable_dir: &str,
) -> rexpect::errors::Result<()> {
    let mut p = rexpect::spawn_bash(Some(300_000))?;
    p.send_line(&format!(
	"export THE_WAY_CONFIG={}",
	config_file.to_string_lossy()
    ))?;
    p.wait_for_prompt()?;
    p.send_line(&format!("{}/the-way config get", executable_dir))?;
    p.exp_regex(config_file.to_string_lossy().as_ref())?;
    p.wait_for_prompt()?;
    p.execute(&format!("{}/the-way new", executable_dir), "Description:")?;
    p.send_line("test description 1")?;
    p.exp_string("Language:")?;
    p.send_line("rust")?;
    p.exp_regex("Tags \\(.*\\):")?;
    p.send_line("tag1 tag2")?;
    p.exp_regex("Code snippet \\(.*\\):")?;
    p.send_line("code")?;
    p.exp_regex("Added snippet #1")?;
    p.wait_for_prompt()?;
    Ok(())
}

Let’s ignore the executable_dir bit for now, that’s explained in the Binaries section. rexpect lets you start a bash session and interact with it by sending input (send_line) and checking if the output has what you expect (exp_string and exp_regex).

Platform-specific Tests

[target.'cfg(target_os = "macos")'.dev-dependencies]
clipboard = "0.5.0"

Since command-line applications are often cross-platform, it’s plausible that some functionality works differently on different platforms. In this case, the copy to clipboard functionality ended up needing to be different for Linux and OSX targets because the clipboard crate doesn’t keep the contents on Linux after the program quits (see here for the issue and the xclip/pbcopy workaround I ended up using) and it refused to build on Linux even to test if the copy command works (see here). So, the clipboard crate and the corresponding copy test are only imported and run on OSX targets, using conditional compilation via cfg.

#[cfg(target_os = "macos")]
use clipboard::{ClipboardContext, ClipboardProvider};

#[cfg(target_os = "macos")]
#[test]
fn copy() -> color_eyre::Result<()> {
    // ...
    Ok(())
}

I’m hoping this won’t be necessary later on but it’s good to know that workarounds like this are possible.

Packaging and distribution

This chapter in the CLI book was pretty thorough and easy to follow. Still ran into some specific issues though.

With cargo

cargo publish worked like a charm. For some reason it took me a bunch of digging around to find out how to get the crates.io metadata badge though, probably since I barely even knew it was called a badge. Anyway, here’s how:

# replace the-way with the crate name and add this line to README.md
[![Crates.io](https://img.shields.io/crates/v/the-way.svg)](https://crates.io/crates/the-way)

Binaries

Next up was figuring out how to use trust and Travis CI to make binaries for Linux and OSX (and maybe even Windows later, if skim adds support for it). This took longer than it should’ve since I’d never worked with Travis before and documentation seemed to make some knowledge assumptions I didn’t satisfy. The general workflow was as stated in the trust README:

  1. Get a Travis account (and add the-way to the tracked repositories)
  2. Clone the trust repository
  3. Copy the ci/ folder and .travis.yml to the-ways repository
  4. Go through and address all the TODOs in all the files.

At one point it says to run travis encrypt to encrypt a secret token, this needs the Travis CLI tool (from here).

Travis makes separate target directories for each target though, this tripped me up in the rexpect tests - this is why there’s an executable_dir that uses the $TARGET environment variable in the testing code (1) Another thing I needed was to make sure tests were run on a single thread since I was changing the config environment variable - this was easy enough, added -- --test-threads=1 in ci/script.sh.

I wanted a CI badge that showed the status of the Travis build, so added this to README.md:

[![Build Status](https://travis-ci.org/out-of-cheese-error/the-way.svg?branch=master)](https://travis-ci.org/out-of-cheese-error/the-way)

I think it doesn’t update very often since it sometimes shows “build: error” when the build is perfectly fine.

You build releases by pushing an annotated tag. I suppose it’s not mentioned anywhere because it’s obvious but trust looks for pushes to branches that look like this:

branches:
  only:
    # release tags
    - /^v\d+\.\d+\.\d+.*$/
    - master

This means it only builds releases for tags starting with a v and then a version number (like v0.0.1), took me a try or two to figure this out.

Finally, I’m keeping a changelog, using the format from keep a changelog. It’s been good.

TODO: System package managers

I’ll look into this only if it makes sense to package The Way with brew / apt, i.e. if people are interested. It’s not a super smooth installation process now without cargo though, since with Mac Catalina you’d need to open System Preferences and allow the-way explicitly or it keeps trying to trash it, and with Linux you’d need to give it permission with chmod +x.



For comments, click the arrow at the top right corner.