Rust Command Line Tools
2019-02-09
Project Page
Continuing on from yesterday, I built a feature equivalent version of my lost directory profile tool in rust. What follows were the steps and tools I used in the process.
Previous Version Structure
The decompiled source we got from yesterday gave us the basic structure of the original version. The tool has two main executable paths. Either it is run without any arguments, or it is passed the prefix of a command to run.
In the first case, it searches for the .ok
profile and lists the command names
to the console.
> ScriptProfileManager
use
build
clean
In the second case it finds the longest match in the profile commands for the passed in prefix and prints the associated command to the console.
> ScriptProfileManager b
cargo build
The original version of the tool assumed that the output list of commands would be post-processed by some form of pretty printer. I think this puts too much burden on the script side of things, so my new version will print the command list in one line.
Similarly the old version assumed that the script environment contained a pushd
and popd command. In the new version I am relaxing that requirement by adding
prefix and suffix arguments. They take a string which can optionally have a {}
hole which will be filled with the directory path to the .ok
file. This lets
the user of okeydokey specify how they would like to wrap the command.
Clap
I decided to use the popular Clap crate for command line argument parsing. The library allows to declaritively describe the command line interface and then query the parsed arguments in a simple way. Clap gives you many options for ways to describe the interface, but he one I picked was a yaml file which gets parsed at compile time. This lets me separate the interface out of the rust code and move on with the actual command logic.
The yaml file was pretty simple, but I did have to do some guesswork in order to
get everything right. For example it wasn't clear what value_name
did.
Eventually I landed on this structure:
name: Okeydokey
version: "0.1"
author: Kaylee Simmons
about: .ok file manager
args:
- COMMAND:
help: The command in the profile to run
- prefix:
short: p
long: prefix
value_name: PREFIX
help: Prepends argument to the returned command replacing {} with the full path to the found .ok file.
- suffix:
short: s
long: suffix
value_name: SUFFIX
help: Appends argument to the returned command replacing {} with the full path to the found .ok file.
Then in the main function I was able to build up the matches and query them like so:
let yaml = load_yaml!("cli.yml");
let matches = App::from_yaml(yaml).get_matches();
println!("{}", matches.value_of("COMMAND"));
Walking Up the Directory Tree
Both of the execution paths of the tool require a parsed .ok
file. To find it
the tool needs to walk up the directory tree searching for a parent directory
which contains the .ok
file. Rust has safe and complete file system apis, but
as with most things in the Rust standard library, it does some gymnastics to
make sure everything is above board with regards to memory safety. Similar to
the relationship between &str
and String
, Rust has Path
and PathBuf
where Path
is an immutable filesystem path and PathBuf
is an owned mutable
variant. The confusing part is that PathBuf
implements the trait Deref
to
Path
which as far as I can understand it means that the compiler is allowed to
dereference the PathBuf
implicitly. So any place a function can take a Path
you can also pass in a PathBuf
and things should work out. For example,
although PathBuf
does not contain a parent
function directly, you can still
call parent
on it since it gets dereferenced into a Path
which does.
On top of being immutable, str
and Path
are unsized types meaning that you
can't store variables to them without a pointer or similar without the compiler
yelling at you. Given my C# background, I'm still a little fuzzy about the
idiomatic way to use these types, but in practice I have found that using the
PathBuf and String versions of the types is maybe not the most efficient method,
but gets the job done, allows you the most flexibility, and keeps our friend the
compiler happy.
Frustratingly, there is a fair amount of syntactic overhead for ensuring that
you are using the correct type. The previously mentioned parent
function does
not return a PathBuf
but instead the more frustrating Path
, so whenever I
use the function, I needed to call to_path_buf
just adding to the visual
noise. I get that it is for my own good, but its an example of the Rust making
easy things harder.
After stumbling through understanding the above, the actual task of finding the
.ok
file and parsing it into command name, command pairs was pretty trivial.
fn find_profile(current_path: PathBuf) -> Option<Profile> {
let possible_profile = current_path.join(".ok");
if possible_profile.exists() {
Some(read_profile(possible_profile)?)
} else {
Some(find_profile(current_path.parent()?.to_path_buf())?)
}
}
fn read_profile(profile_path: PathBuf) -> Option<Profile> {
match File::open(profile_path.clone()) {
Ok(ref mut file) => {
let mut commands = HashMap::new();
for line in BufReader::new(file).lines() {
let (name, command) = split_on_colon(line.unwrap())?;
commands.insert(name, command);
}
Some(Profile { commands, path: profile_path })
},
Err(_) => None
}
}
fn split_on_colon(line: String) -> Option<(String, String)> {
let mut splitter = line.splitn(2, ':');
let name = splitter.next()?;
let command = splitter.next()?;
Some((name.to_string(), command.to_string()))
}
String Manipulation
The argumentless version of okeydokey which prints the commands to the console
was deceptively difficult to get right. The problem stems from the fact that I
store the commands in a HashMap<String, String>
where the key is the command
name and the value is the command. The naive solution would be to pull the keys
out into a collection, and use the String
utilities to join them into a single
String
. In practice though, we run into ownership problems. HashMap.keys
returns an iterator of &String
, not String
. This prevents us from using
join
which must operate on values not references. Eventually I landed
on a call to fold
passing in String
accumulator but it wasn't my first choice.
The query execution path went relatively smoothly after the above. The query
function finds the best match in the commands list using filter_map
and max_by_key
and then passes the found command as well as the passed in prefix and suffix options on to get formatted into the final output.
fn list(profile: Profile) {
let list = profile.commands
.keys()
.fold(String::new(), |acc, next| {
acc + " " + next
});
println!("{}", list.trim());
}
fn query(profile: Profile, command: &str, prefix: Option<&str>, suffix: Option<&str>) {
let best_option = profile
.commands
.keys()
.filter_map(|possible_command| shared_prefix(possible_command, command))
.max_by_key(|&(shared_chars, _)| shared_chars);
match best_option {
Some((_, actual_command)) => print_decorated_command(profile, actual_command, prefix, suffix),
None => ()
}
}
fn shared_prefix(possible_command: &str, command: &str) -> Option<(usize, String)> {
match possible_command.starts_with(command) {
true => Some((command.len(), possible_command.to_string())),
false => None
}
}
fn print_decorated_command(profile: Profile, command_name: String, prefix: Option<&str>, suffix: Option<&str>) {
let prefix = fill_in_profile_directory(&profile, prefix);
let suffix = fill_in_profile_directory(&profile, suffix);
let command = profile.commands.get(&command_name).unwrap();
println!("{}", vec![prefix, command.to_string(), suffix].concat())
}
fn fill_in_profile_directory(profile: &Profile, pattern: Option<&str>) -> String {
let profile_directory = profile.path.parent().unwrap().to_str().unwrap();
pattern.unwrap_or_default().replace("{}", profile_directory)
}
The Script Wrapper
At this stage, the tool is feature complete for the first version, but it
requires a couple of modifications to my script profile to work properly. I have
a function defined in my PowerShell profile called ok
which gets called in my
prompt function so that ever time I change directories the command list is
printed if it exists. If the function is given any arguments, they are fed into
the profile utility and the output is executed directly.
Previously, I did special formatting of the command list in PowerShell, which has since been pushed to the tool side. Similarly the command execution assumed that the directory management was handled in the tool, but I have pushed that to the command arguments instead to be more flexible. So the execution of okeydokey needed to be modified as well. I landed on this:
function ok
{
Param($command = $null)
if ($command -eq $null) {
$fore = $host.UI.RawUI.ForegroundColor
$host.UI.RawUI.ForegroundColor = 'Blue'
okeydokey
$host.UI.RawUI.ForegroundColor = $fore
} else {
$script = okeydokey $command -p "pushd {};" -s "; popd"
if ($script -ne $null) {
iex $script
}
}
}
Simple and straight forward.
Summary
I have pushed the current version of the tool to github so anyone can use it if they like. Depending on how much I've got left in me (this took many hours again... I need to dial this back if I want to do it every day), I may build a simple home page for the tool describing its usage shortly. I've used a version of this tool pretty much daily for the past 6 months, and I have some ideas for how to make it even better. Among them is argument support, profile references, and a better UI. For now though, I will probably do some site styling and call it a day.
Till tomorrow,
Kaylee