Repository: casey/just
Pull request #680 (2020-09-18 01:01:00)

Add the `--choose` subcommand
The `--choose` subcommand runs a chooser to select a recipe to run. The
chooser should read lines containing recipe names from standard input,
and write one of those names to standard output.

The chooser defaults to `fzf`, a popular fuzzy finder, but can be
overridden by setting $JUST_CHOOSER or passing `--chooser <CHOOSER>`.


Patch:
diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml
index 89d4b4a0..1f0d4239 100644
--- a/.github/workflows/build.yaml
+++ b/.github/workflows/build.yaml
@@ -10,6 +10,12 @@ on:
     branches:
     - master
 
+env:
+  # Increment to invalidate github actions caches if they become corrupt.
+  # Errors of the form "can't find crate for `snafu_derive` which `snafu` depends on"
+  # can usually be fixed by incrementing this value.
+  CACHE_KEY_PREFIX: 1
+
 jobs:
   all:
     name: All
@@ -41,19 +47,19 @@ jobs:
       uses: actions/cache@v1
       with:
         path: ~/.cargo/registry
-        key: 0-${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
+        key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }}
 
     - name: Cache cargo index
       uses: actions/cache@v1
       with:
         path: ~/.cargo/git
-        key: 0-${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
+        key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-cargo-index-${{ hashFiles('**/Cargo.lock') }}
 
     - name: Cache cargo build
       uses: actions/cache@v1
       with:
         path: target
-        key: 0-${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
+        key: ${{ env.CACHE_KEY_PREFIX }}-${{ runner.os }}-cargo-build-target-${{ hashFiles('**/Cargo.lock') }}
 
     - name: Install Main Toolchain
       uses: actions-rs/toolchain@v1
diff --git a/README.adoc b/README.adoc
index 050cfa37..111b78e7 100644
--- a/README.adoc
+++ b/README.adoc
@@ -266,6 +266,14 @@ $ just --summary --unsorted
 test build
 ```
 
+If you'd like `just` to default to listing the recipes in the justfile, you can
+use this as your default recipe:
+
+```make
+default:
+  @just --list
+```
+
 === Aliases
 
 Aliases allow recipes to be invoked with alternative names:
@@ -958,6 +966,35 @@ echo 'Bar!'
 Bar!
 ```
 
+=== Selecting a Recipe to Run With an Interactive Chooser
+
+The `--choose` subcommand makes just invoke a chooser to select which recipe to
+run. Choosers should read lines containing recipe names from standard input and
+print one of those names to standard output.
+
+Because there is currenly no way to run a recipe that requires arguments with
+`--choose`, such recipes will not be given to the chooser. Private recipes and
+aliases are also skipped.
+
+The chooser can be overridden with the `--chooser` flag. If `--chooser` is not
+given, then `just` first checks if `$JUST_CHOOSER` is set. If it isn't, then
+the chooser defaults to `fzf`, a popular fuzzy finder.
+
+Arguments can be included in the chooser, i.e. `fzf --exact`.
+
+The chooser is invoked in the same way as recipe lines. For example, if the
+chooser is `fzf`, it will be invoked with `sh -cu 'fzf'`, and if the shell, or
+the shell arguments are overridden, the chooser invocation will respect those
+overrides.
+
+If you'd like `just` to default to selecting a recipe with a chooser, you can
+use this as your default recipe:
+
+```make
+default:
+  @just --choose
+```
+
 === Invoking Justfiles in Other Directories
 
 If the first argument passed to `just` contains a `/`, then the following occurs:
diff --git a/completions/just.bash b/completions/just.bash
index 7d867fa7..eed0234e 100644
--- a/completions/just.bash
+++ b/completions/just.bash
@@ -20,13 +20,17 @@ _just() {
 
     case "${cmd}" in
         just)
-            opts=" -q -u -v -e -l -h -V -f -d -s  --dry-run --highlight --no-dotenv --no-highlight --quiet --clear-shell-args --unsorted --verbose --dump --edit --evaluate --init --list --summary --variables --help --version --color --justfile --set --shell --shell-arg --working-directory --completions --show  <ARGUMENTS>... "
+            opts=" -q -u -v -e -l -h -V -f -d -s  --dry-run --highlight --no-dotenv --no-highlight --quiet --clear-shell-args --unsorted --verbose --choose --dump --edit --evaluate --init --list --summary --variables --help --version --chooser --color --justfile --set --shell --shell-arg --working-directory --completions --show  <ARGUMENTS>... "
             if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then
                 COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") )
                 return 0
             fi
             case "${prev}" in
                 
+                --chooser)
+                    COMPREPLY=($(compgen -f "${cur}"))
+                    return 0
+                    ;;
                 --color)
                     COMPREPLY=($(compgen -W "auto always never" -- "${cur}"))
                     return 0
diff --git a/completions/just.elvish b/completions/just.elvish
index a823aac1..dbc86ce1 100644
--- a/completions/just.elvish
+++ b/completions/just.elvish
@@ -14,6 +14,7 @@ edit:completion:arg-completer[just] = [@words]{
     }
     completions = [
         &'just'= {
+            cand --chooser 'Override binary invoked by `--choose`'
             cand --color 'Print colorful output'
             cand -f 'Use <JUSTFILE> as justfile.'
             cand --justfile 'Use <JUSTFILE> as justfile.'
@@ -36,6 +37,7 @@ edit:completion:arg-completer[just] = [@words]{
             cand --unsorted 'Return list and summary entries in source order'
             cand -v 'Use verbose output'
             cand --verbose 'Use verbose output'
+            cand --choose 'Select a recipe to run using a binary. If `--chooser` is not passed the chooser defaults to the value of $JUST_CHOOSER, falling back to `fzf`'
             cand --dump 'Print entire justfile'
             cand -e 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
             cand --edit 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
diff --git a/completions/just.fish b/completions/just.fish
index 61d2e13b..4ee53e10 100644
--- a/completions/just.fish
+++ b/completions/just.fish
@@ -9,6 +9,7 @@ complete -c just -n "__fish_is_first_arg" --no-files
 complete -c just -a '(__fish_just_complete_recipes)'
 
 # autogenerated completions
+complete -c just -n "__fish_use_subcommand" -l chooser -d 'Override binary invoked by `--choose`'
 complete -c just -n "__fish_use_subcommand" -l color -d 'Print colorful output' -r -f -a "auto always never"
 complete -c just -n "__fish_use_subcommand" -s f -l justfile -d 'Use <JUSTFILE> as justfile.'
 complete -c just -n "__fish_use_subcommand" -l set -d 'Override <VARIABLE> with <VALUE>'
@@ -25,6 +26,7 @@ complete -c just -n "__fish_use_subcommand" -s q -l quiet -d 'Suppress all outpu
 complete -c just -n "__fish_use_subcommand" -l clear-shell-args -d 'Clear shell arguments'
 complete -c just -n "__fish_use_subcommand" -s u -l unsorted -d 'Return list and summary entries in source order'
 complete -c just -n "__fish_use_subcommand" -s v -l verbose -d 'Use verbose output'
+complete -c just -n "__fish_use_subcommand" -l choose -d 'Select a recipe to run using a binary. If `--chooser` is not passed the chooser defaults to the value of $JUST_CHOOSER, falling back to `fzf`'
 complete -c just -n "__fish_use_subcommand" -l dump -d 'Print entire justfile'
 complete -c just -n "__fish_use_subcommand" -s e -l edit -d 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`'
 complete -c just -n "__fish_use_subcommand" -l evaluate -d 'Print evaluated variables'
diff --git a/completions/just.powershell b/completions/just.powershell
index 262c702a..aba5992c 100644
--- a/completions/just.powershell
+++ b/completions/just.powershell
@@ -19,6 +19,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
 
     $completions = @(switch ($command) {
         'just' {
+            [CompletionResult]::new('--chooser', 'chooser', [CompletionResultType]::ParameterName, 'Override binary invoked by `--choose`')
             [CompletionResult]::new('--color', 'color', [CompletionResultType]::ParameterName, 'Print colorful output')
             [CompletionResult]::new('-f', 'f', [CompletionResultType]::ParameterName, 'Use <JUSTFILE> as justfile.')
             [CompletionResult]::new('--justfile', 'justfile', [CompletionResultType]::ParameterName, 'Use <JUSTFILE> as justfile.')
@@ -41,6 +42,7 @@ Register-ArgumentCompleter -Native -CommandName 'just' -ScriptBlock {
             [CompletionResult]::new('--unsorted', 'unsorted', [CompletionResultType]::ParameterName, 'Return list and summary entries in source order')
             [CompletionResult]::new('-v', 'v', [CompletionResultType]::ParameterName, 'Use verbose output')
             [CompletionResult]::new('--verbose', 'verbose', [CompletionResultType]::ParameterName, 'Use verbose output')
+            [CompletionResult]::new('--choose', 'choose', [CompletionResultType]::ParameterName, 'Select a recipe to run using a binary. If `--chooser` is not passed the chooser defaults to the value of $JUST_CHOOSER, falling back to `fzf`')
             [CompletionResult]::new('--dump', 'dump', [CompletionResultType]::ParameterName, 'Print entire justfile')
             [CompletionResult]::new('-e', 'e', [CompletionResultType]::ParameterName, 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`')
             [CompletionResult]::new('--edit', 'edit', [CompletionResultType]::ParameterName, 'Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`')
diff --git a/completions/just.zsh b/completions/just.zsh
index c7197861..86140370 100644
--- a/completions/just.zsh
+++ b/completions/just.zsh
@@ -15,6 +15,7 @@ _just() {
 
     local context curcontext="$curcontext" state line
     local common=(
+'--chooser=[Override binary invoked by `--choose`]' \
 '--color=[Print colorful output]: :(auto always never)' \
 '-f+[Use <JUSTFILE> as justfile.]' \
 '--justfile=[Use <JUSTFILE> as justfile.]' \
@@ -37,6 +38,7 @@ _just() {
 '--unsorted[Return list and summary entries in source order]' \
 '*-v[Use verbose output]' \
 '*--verbose[Use verbose output]' \
+'--choose[Select a recipe to run using a binary. If `--chooser` is not passed the chooser defaults to the value of $JUST_CHOOSER, falling back to `fzf`]' \
 '--dump[Print entire justfile]' \
 '-e[Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`]' \
 '--edit[Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`]' \
diff --git a/src/common.rs b/src/common.rs
index 1c688c57..c31f54bc 100644
--- a/src/common.rs
+++ b/src/common.rs
@@ -4,13 +4,14 @@ pub(crate) use std::{
   cmp,
   collections::{BTreeMap, BTreeSet},
   env,
+  ffi::OsString,
   fmt::{self, Debug, Display, Formatter},
   fs,
   io::{self, Cursor, Write},
   iter::{self, FromIterator},
   ops::{Index, Range, RangeInclusive},
   path::{Path, PathBuf},
-  process::{self, Command},
+  process::{self, Command, Stdio},
   rc::Rc,
   str::{self, Chars},
   sync::{Mutex, MutexGuard},
diff --git a/src/config.rs b/src/config.rs
index 50fc1abb..11437c56 100644
--- a/src/config.rs
+++ b/src/config.rs
@@ -2,6 +2,13 @@ use crate::common::*;
 
 use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches, ArgSettings};
 
+// These three strings should be kept in sync:
+pub(crate) const CHOOSER_DEFAULT: &str = "fzf";
+pub(crate) const CHOOSER_ENVIRONMENT_KEY: &str = "JUST_CHOOSER";
+pub(crate) const CHOOSE_HELP: &str = "Select a recipe to run using a binary. If `--chooser` is \
+                                      not passed the chooser defaults to the value of \
+                                      $JUST_CHOOSER, falling back to `fzf`";
+
 pub(crate) const DEFAULT_SHELL: &str = "sh";
 pub(crate) const DEFAULT_SHELL_ARG: &str = "-cu";
 pub(crate) const INIT_JUSTFILE: &str = "default:\n\techo 'Hello, world!'\n";
@@ -24,6 +31,7 @@ pub(crate) struct Config {
 }
 
 mod cmd {
+  pub(crate) const CHOOSE: &str = "CHOOSE";
   pub(crate) const COMPLETIONS: &str = "COMPLETIONS";
   pub(crate) const DUMP: &str = "DUMP";
   pub(crate) const EDIT: &str = "EDIT";
@@ -35,6 +43,7 @@ mod cmd {
   pub(crate) const VARIABLES: &str = "VARIABLES";
 
   pub(crate) const ALL: &[&str] = &[
+    CHOOSE,
     COMPLETIONS,
     DUMP,
     EDIT,
@@ -60,6 +69,7 @@ mod cmd {
 
 mod arg {
   pub(crate) const ARGUMENTS: &str = "ARGUMENTS";
+  pub(crate) const CHOOSER: &str = "CHOOSER";
   pub(crate) const CLEAR_SHELL_ARGS: &str = "CLEAR-SHELL-ARGS";
   pub(crate) const COLOR: &str = "COLOR";
   pub(crate) const DRY_RUN: &str = "DRY-RUN";
@@ -88,6 +98,12 @@ impl Config {
       .version_message("Print version information")
       .setting(AppSettings::ColoredHelp)
       .setting(AppSettings::TrailingVarArg)
+      .arg(
+        Arg::with_name(arg::CHOOSER)
+          .long("chooser")
+          .takes_value(true)
+          .help("Override binary invoked by `--choose`"),
+      )
       .arg(
         Arg::with_name(arg::COLOR)
           .long("color")
@@ -192,6 +208,7 @@ impl Config {
           .multiple(true)
           .help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
       )
+      .arg(Arg::with_name(cmd::CHOOSE).long("choose").help(CHOOSE_HELP))
       .arg(
         Arg::with_name(cmd::COMPLETIONS)
           .long("completions")
@@ -359,7 +376,12 @@ impl Config {
       }
     }
 
-    let subcommand = if let Some(shell) = matches.value_of(cmd::COMPLETIONS) {
+    let subcommand = if matches.is_present(cmd::CHOOSE) {
+      Subcommand::Choose {
+        chooser: matches.value_of(arg::CHOOSER).map(str::to_owned),
+        overrides,
+      }
+    } else if let Some(shell) = matches.value_of(cmd::COMPLETIONS) {
       Subcommand::Completions {
         shell: shell.to_owned(),
       }
@@ -461,8 +483,10 @@ impl Config {
     }
 
     match &self.subcommand {
+      Choose { overrides, chooser } =>
+        self.choose(justfile, &search, overrides, chooser.as_deref()),
       Dump => Self::dump(justfile),
-      Evaluate { overrides } => self.run(justfile, &search, overrides, &Vec::new()),
+      Evaluate { overrides } => self.run(justfile, &search, overrides, &[]),
       List => self.list(justfile),
       Run {
         arguments,
@@ -475,6 +499,93 @@ impl Config {
     }
   }
 
+  fn choose(
+    &self,
+    justfile: Justfile,
+    search: &Search,
+    overrides: &BTreeMap<String, String>,
+    chooser: Option<&str>,
+  ) -> Result<(), i32> {
+    let recipes = justfile
+      .public_recipes(self.unsorted)
+      .iter()
+      .filter(|recipe| recipe.min_arguments() == 0)
+      .cloned()
+      .collect::<Vec<&Recipe<Dependency>>>();
+
+    if recipes.is_empty() {
+      eprintln!("Justfile contains no choosable recipes.");
+      return Err(EXIT_FAILURE);
+    }
+
+    let chooser = chooser
+      .map(OsString::from)
+      .or_else(|| env::var_os(CHOOSER_ENVIRONMENT_KEY))
+      .unwrap_or_else(|| OsString::from(CHOOSER_DEFAULT));
+
+    let result = justfile
+      .settings
+      .shell_command(self)
+      .arg(&chooser)
+      .current_dir(&search.working_directory)
+      .stdin(Stdio::piped())
+      .stdout(Stdio::piped())
+      .spawn();
+
+    let mut child = match result {
+      Ok(child) => child,
+      Err(error) => {
+        eprintln!(
+          "Chooser `{}` invocation failed: {}",
+          chooser.to_string_lossy(),
+          error
+        );
+        return Err(EXIT_FAILURE);
+      },
+    };
+
+    for recipe in recipes {
+      if let Err(error) = child
+        .stdin
+        .as_mut()
+        .expect("Child was created with piped stdio")
+        .write_all(format!("{}\n", recipe.name).as_bytes())
+      {
+        eprintln!(
+          "Failed to write to chooser `{}`: {}",
+          chooser.to_string_lossy(),
+          error
+        );
+        return Err(EXIT_FAILURE);
+      }
+    }
+
+    let output = match child.wait_with_output() {
+      Ok(output) => output,
+      Err(error) => {
+        eprintln!(
+          "Failed to read output from chooser `{}`: {}",
+          chooser.to_string_lossy(),
+          error
+        );
+        return Err(EXIT_FAILURE);
+      },
+    };
+
+    if !output.status.success() {
+      eprintln!(
+        "Chooser `{}` returned error: {}",
+        chooser.to_string_lossy(),
+        output.status
+      );
+      return Err(output.status.code().unwrap_or(EXIT_FAILURE));
+    }
+
+    let stdout = String::from_utf8_lossy(&output.stdout);
+
+    self.run(justfile, search, overrides, &[stdout.trim().to_string()])
+  }
+
   fn dump(justfile: Justfile) -> Result<(), i32> {
     println!("{}", justfile);
     Ok(())
@@ -570,13 +681,9 @@ impl Config {
     let doc_color = self.color.stdout().doc();
     println!("Available recipes:");
 
-    for recipe in justfile.recipes(self.unsorted) {
+    for recipe in justfile.public_recipes(self.unsorted) {
       let name = recipe.name();
 
-      if recipe.private {
-        continue;
-      }
-
       for (i, name) in iter::once(&name)
         .chain(recipe_aliases.get(name).unwrap_or(&Vec::new()))
         .enumerate()
@@ -662,9 +769,8 @@ impl Config {
       eprintln!("Justfile contains no recipes.");
     } else {
       let summary = justfile
-        .recipes(self.unsorted)
+        .public_recipes(self.unsorted)
         .iter()
-        .filter(|recipe| recipe.public())
         .map(|recipe| recipe.name())
         .collect::<Vec<&str>>()
         .join(" ");
@@ -704,6 +810,9 @@ USAGE:
     just [FLAGS] [OPTIONS] [--] [ARGUMENTS]...
 
 FLAGS:
+        --choose              Select a recipe to run using a binary. If `--chooser` is not passed \
+                                 the chooser defaults
+                              to the value of $JUST_CHOOSER, falling back to `fzf`
         --clear-shell-args    Clear shell arguments
         --dry-run             Print what just would do without doing it
         --dump                Print entire justfile
@@ -722,6 +831,7 @@ FLAGS:
     -v, --verbose             Use verbose output
 
 OPTIONS:
+        --chooser <CHOOSER>                        Override binary invoked by `--choose`
         --color <COLOR>
             Print colorful output [default: auto]  [possible values: auto, always, never]
 
@@ -1089,6 +1199,46 @@ ARGS:
     },
   }
 
+  error! {
+    name: subcommand_conflict_summary,
+    args: ["--list", "--summary"],
+  }
+
+  error! {
+    name: subcommand_conflict_dump,
+    args: ["--list", "--dump"],
+  }
+
+  error! {
+    name: subcommand_conflict_init,
+    args: ["--list", "--init"],
+  }
+
+  error! {
+    name: subcommand_conflict_evaluate,
+    args: ["--list", "--evaluate"],
+  }
+
+  error! {
+    name: subcommand_conflict_show,
+    args: ["--list", "--show"],
+  }
+
+  error! {
+    name: subcommand_conflict_completions,
+    args: ["--list", "--completions"],
+  }
+
+  error! {
+    name: subcommand_conflict_variables,
+    args: ["--list", "--variables"],
+  }
+
+  error! {
+    name: subcommand_conflict_choose,
+    args: ["--list", "--choose"],
+  }
+
   test! {
     name: subcommand_completions,
     args: ["--completions", "bash"],
diff --git a/src/justfile.rs b/src/justfile.rs
index 6eefe603..6bcfa9b5 100644
--- a/src/justfile.rs
+++ b/src/justfile.rs
@@ -263,11 +263,12 @@ impl<'src> Justfile<'src> {
     Ok(())
   }
 
-  pub(crate) fn recipes(&self, source_order: bool) -> Vec<&Recipe<Dependency>> {
+  pub(crate) fn public_recipes(&self, source_order: bool) -> Vec<&Recipe<Dependency>> {
     let mut recipes = self
       .recipes
       .values()
       .map(AsRef::as_ref)
+      .filter(|recipe| recipe.public())
       .collect::<Vec<&Recipe<Dependency>>>();
 
     if source_order {
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 673d4d22..c2b090d7 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -2,6 +2,10 @@ use crate::common::*;
 
 #[derive(PartialEq, Clone, Debug)]
 pub(crate) enum Subcommand {
+  Choose {
+    overrides: BTreeMap<String, String>,
+    chooser:   Option<String>,
+  },
   Completions {
     shell: String,
   },


Test patch:
diff --git a/tests/choose.rs b/tests/choose.rs
new file mode 100644
index 00000000..32496529
--- /dev/null
+++ b/tests/choose.rs
@@ -0,0 +1,130 @@
+use crate::common::*;
+
+test! {
+  name: env,
+  justfile: "
+    foo:
+      echo foo
+
+    bar:
+      echo bar
+  ",
+  args: ("--choose"),
+  env: {
+    "JUST_CHOOSER": "head -n1",
+  },
+  stdout: "bar\n",
+  stderr: "echo bar\n",
+}
+
+test! {
+  name: chooser,
+  justfile: "
+    foo:
+      echo foo
+
+    bar:
+      echo bar
+  ",
+  args: ("--choose", "--chooser", "head -n1"),
+  stdout: "bar\n",
+  stderr: "echo bar\n",
+}
+
+test! {
+  name: override_variable,
+  justfile: "
+    baz := 'A'
+
+    foo:
+      echo foo
+
+    bar:
+      echo {{baz}}
+  ",
+  args: ("--choose", "baz=B"),
+  env: {
+    "JUST_CHOOSER": "head -n1",
+  },
+  stdout: "B\n",
+  stderr: "echo B\n",
+}
+
+test! {
+  name: skip_private_recipes,
+  justfile: "
+    foo:
+      echo foo
+
+    _bar:
+      echo bar
+  ",
+  args: ("--choose"),
+  env: {
+    "JUST_CHOOSER": "head -n1",
+  },
+  stdout: "foo\n",
+  stderr: "echo foo\n",
+}
+
+test! {
+  name: skip_recipes_that_require_arguments,
+  justfile: "
+    foo:
+      echo foo
+
+    bar BAR:
+      echo {{BAR}}
+  ",
+  args: ("--choose"),
+  env: {
+    "JUST_CHOOSER": "head -n1",
+  },
+  stdout: "foo\n",
+  stderr: "echo foo\n",
+}
+
+test! {
+  name: no_choosable_recipes,
+  justfile: "
+    _foo:
+      echo foo
+
+    bar BAR:
+      echo {{BAR}}
+  ",
+  args: ("--choose"),
+  stdout: "",
+  stderr: "Justfile contains no choosable recipes.\n",
+  status: EXIT_FAILURE,
+}
+
+#[test]
+fn default() {
+  let tmp = tmptree! {
+    justfile: "foo:\n echo foo\n",
+  };
+
+  let cat = which("cat").unwrap();
+  let fzf = tmp.path().join(format!("fzf{}", env::consts::EXE_SUFFIX));
+
+  #[cfg(unix)]
+  std::os::unix::fs::symlink(cat, fzf).unwrap();
+
+  #[cfg(windows)]
+  std::os::windows::fs::symlink_file(cat, fzf).unwrap();
+
+  let path = env::join_paths(
+    iter::once(tmp.path().to_owned()).chain(env::split_paths(&env::var_os("PATH").unwrap())),
+  )
+  .unwrap();
+
+  let output = Command::new(executable_path("just"))
+    .arg("--choose")
+    .current_dir(tmp.path())
+    .env("PATH", path)
+    .output()
+    .unwrap();
+
+  assert_stdout(&output, "foo\n");
+}
diff --git a/tests/common.rs b/tests/common.rs
new file mode 100644
index 00000000..4f8048a9
--- /dev/null
+++ b/tests/common.rs
@@ -0,0 +1,14 @@
+pub(crate) use std::{
+  collections::BTreeMap,
+  env, fs,
+  io::Write,
+  iter,
+  path::Path,
+  process::{Command, Stdio},
+  str,
+};
+
+pub(crate) use executable_path::executable_path;
+pub(crate) use libc::{EXIT_FAILURE, EXIT_SUCCESS};
+pub(crate) use test_utilities::{assert_stdout, tempdir, tmptree, unindent};
+pub(crate) use which::which;
diff --git a/tests/edit.rs b/tests/edit.rs
index ff25685a..9dcbc7b1 100644
--- a/tests/edit.rs
+++ b/tests/edit.rs
@@ -1,9 +1,4 @@
-use std::{env, iter, process::Command, str};
-
-use executable_path::executable_path;
-use which::which;
-
-use test_utilities::{assert_stdout, tmptree};
+use crate::common::*;
 
 const JUSTFILE: &str = "Yooooooo, hopefully this never becomes valid syntax.";
 
diff --git a/tests/lib.rs b/tests/lib.rs
index 9b575575..17095d26 100644
--- a/tests/lib.rs
+++ b/tests/lib.rs
@@ -1,3 +1,9 @@
+#[macro_use]
+mod test;
+
+mod common;
+
+mod choose;
 mod completions;
 mod dotenv;
 mod edit;
diff --git a/tests/misc.rs b/tests/misc.rs
index a402ce2c..2a025739 100644
--- a/tests/misc.rs
+++ b/tests/misc.rs
@@ -1,185 +1,4 @@
-use std::{
-  collections::BTreeMap,
-  env, fs,
-  io::Write,
-  path::Path,
-  process::{Command, Stdio},
-  str,
-};
-
-use executable_path::executable_path;
-use libc::{EXIT_FAILURE, EXIT_SUCCESS};
-use pretty_assertions::assert_eq;
-use test_utilities::{tempdir, unindent};
-
-macro_rules! test {
-  (
-    name:     $name:ident,
-    justfile: $justfile:expr,
-    $(args:     ($($arg:tt)*),)?
-    $(env:      {
-      $($env_key:literal : $env_value:literal,)*
-    },)?
-    $(stdin:    $stdin:expr,)?
-    $(stdout:   $stdout:expr,)?
-    $(stderr:   $stderr:expr,)?
-    $(status:   $status:expr,)?
-    $(shell:    $shell:expr,)?
-  ) => {
-    #[test]
-    fn $name() {
-      #[allow(unused_mut)]
-      let mut env = BTreeMap::new();
-
-      $($(env.insert($env_key.to_string(), $env_value.to_string());)*)?
-
-      Test {
-        justfile: $justfile,
-        $(args: &[$($arg)*],)?
-        $(stdin: $stdin,)?
-        $(stdout: $stdout,)?
-        $(stderr: $stderr,)?
-        $(status: $status,)?
-        $(shell: $shell,)?
-        env,
-        ..Test::default()
-      }.run();
-    }
-  }
-}
-
-struct Test<'a> {
-  justfile: &'a str,
-  args:     &'a [&'a str],
-  env:      BTreeMap<String, String>,
-  stdin:    &'a str,
-  stdout:   &'a str,
-  stderr:   &'a str,
-  status:   i32,
-  shell:    bool,
-}
-
-impl<'a> Default for Test<'a> {
-  fn default() -> Test<'a> {
-    Test {
-      justfile: "",
-      args:     &[],
-      env:      BTreeMap::new(),
-      stdin:    "",
-      stdout:   "",
-      stderr:   "",
-      status:   EXIT_SUCCESS,
-      shell:    true,
-    }
-  }
-}
-
-impl<'a> Test<'a> {
-  fn run(self) {
-    let tmp = tempdir();
-
-    let justfile = unindent(self.justfile);
-    let stdout = unindent(self.stdout);
-    let stderr = unindent(self.stderr);
-
-    let mut justfile_path = tmp.path().to_path_buf();
-    justfile_path.push("justfile");
-    fs::write(justfile_path, justfile).unwrap();
-
-    let mut dotenv_path = tmp.path().to_path_buf();
-    dotenv_path.push(".env");
-    fs::write(dotenv_path, "DOTENV_KEY=dotenv-value").unwrap();
-
-    let mut command = Command::new(&executable_path("just"));
-
-    if self.shell {
-      command.args(&["--shell", "bash"]);
-    }
-
-    let mut child = command
-      .args(self.args)
-      .envs(self.env)
-      .current_dir(tmp.path())
-      .stdin(Stdio::piped())
-      .stdout(Stdio::piped())
-      .stderr(Stdio::piped())
-      .spawn()
-      .expect("just invocation failed");
-
-    {
-      let mut stdin_handle = child.stdin.take().expect("failed to unwrap stdin handle");
-
-      stdin_handle
-        .write_all(self.stdin.as_bytes())
-        .expect("failed to write stdin to just process");
-    }
-
-    let output = child
-      .wait_with_output()
-      .expect("failed to wait for just process");
-
-    let have = Output {
-      status: output.status.code().unwrap(),
-      stdout: str::from_utf8(&output.stdout).unwrap(),
-      stderr: str::from_utf8(&output.stderr).unwrap(),
-    };
-
-    let want = Output {
-      status: self.status,
-      stdout: &stdout,
-      stderr: &stderr,
-    };
-
-    assert_eq!(have, want, "bad output");
-
-    if self.status == EXIT_SUCCESS {
-      test_round_trip(tmp.path());
-    }
-  }
-}
-
-#[derive(PartialEq, Debug)]
-struct Output<'a> {
-  stdout: &'a str,
-  stderr: &'a str,
-  status: i32,
-}
-
-fn test_round_trip(tmpdir: &Path) {
-  println!("Reparsing...");
-
-  let output = Command::new(&executable_path("just"))
-    .current_dir(tmpdir)
-    .arg("--dump")
-    .output()
-    .expect("just invocation failed");
-
-  if !output.status.success() {
-    panic!("dump failed: {}", output.status);
-  }
-
-  let dumped = String::from_utf8(output.stdout).unwrap();
-
-  let reparsed_path = tmpdir.join("reparsed.just");
-
-  fs::write(&reparsed_path, &dumped).unwrap();
-
-  let output = Command::new(&executable_path("just"))
-    .current_dir(tmpdir)
-    .arg("--justfile")
-    .arg(&reparsed_path)
-    .arg("--dump")
-    .output()
-    .expect("just invocation failed");
-
-  if !output.status.success() {
-    panic!("reparse failed: {}", output.status);
-  }
-
-  let reparsed = String::from_utf8(output.stdout).unwrap();
-
-  assert_eq!(reparsed, dumped, "reparse mismatch");
-}
+use crate::common::*;
 
 test! {
   name: alias_listing,
diff --git a/tests/test.rs b/tests/test.rs
new file mode 100644
index 00000000..7b50de5f
--- /dev/null
+++ b/tests/test.rs
@@ -0,0 +1,172 @@
+use crate::common::*;
+
+pub(crate) use pretty_assertions::assert_eq;
+
+macro_rules! test {
+  (
+    name:     $name:ident,
+    justfile: $justfile:expr,
+    $(args:     ($($arg:tt)*),)?
+    $(env:      {
+      $($env_key:literal : $env_value:literal,)*
+    },)?
+    $(stdin:    $stdin:expr,)?
+    $(stdout:   $stdout:expr,)?
+    $(stderr:   $stderr:expr,)?
+    $(status:   $status:expr,)?
+    $(shell:    $shell:expr,)?
+  ) => {
+    #[test]
+    fn $name() {
+      #[allow(unused_mut)]
+      let mut env = std::collections::BTreeMap::new();
+
+      $($(env.insert($env_key.to_string(), $env_value.to_string());)*)?
+
+      crate::test::Test {
+        justfile: $justfile,
+        $(args: &[$($arg)*],)?
+        $(stdin: $stdin,)?
+        $(stdout: $stdout,)?
+        $(stderr: $stderr,)?
+        $(status: $status,)?
+        $(shell: $shell,)?
+        env,
+        ..crate::test::Test::default()
+      }.run();
+    }
+  }
+}
+
+pub(crate) struct Test<'a> {
+  pub(crate) justfile: &'a str,
+  pub(crate) args:     &'a [&'a str],
+  pub(crate) env:      BTreeMap<String, String>,
+  pub(crate) stdin:    &'a str,
+  pub(crate) stdout:   &'a str,
+  pub(crate) stderr:   &'a str,
+  pub(crate) status:   i32,
+  pub(crate) shell:    bool,
+}
+
+impl<'a> Default for Test<'a> {
+  fn default() -> Test<'a> {
+    Test {
+      justfile: "",
+      args:     &[],
+      env:      BTreeMap::new(),
+      stdin:    "",
+      stdout:   "",
+      stderr:   "",
+      status:   EXIT_SUCCESS,
+      shell:    true,
+    }
+  }
+}
+
+impl<'a> Test<'a> {
+  pub(crate) fn run(self) {
+    let tmp = tempdir();
+
+    let justfile = unindent(self.justfile);
+    let stdout = unindent(self.stdout);
+    let stderr = unindent(self.stderr);
+
+    let mut justfile_path = tmp.path().to_path_buf();
+    justfile_path.push("justfile");
+    fs::write(justfile_path, justfile).unwrap();
+
+    let mut dotenv_path = tmp.path().to_path_buf();
+    dotenv_path.push(".env");
+    fs::write(dotenv_path, "DOTENV_KEY=dotenv-value").unwrap();
+
+    let mut command = Command::new(&executable_path("just"));
+
+    if self.shell {
+      command.args(&["--shell", "bash"]);
+    }
+
+    let mut child = command
+      .args(self.args)
+      .envs(self.env)
+      .current_dir(tmp.path())
+      .stdin(Stdio::piped())
+      .stdout(Stdio::piped())
+      .stderr(Stdio::piped())
+      .spawn()
+      .expect("just invocation failed");
+
+    {
+      let mut stdin_handle = child.stdin.take().expect("failed to unwrap stdin handle");
+
+      stdin_handle
+        .write_all(self.stdin.as_bytes())
+        .expect("failed to write stdin to just process");
+    }
+
+    let output = child
+      .wait_with_output()
+      .expect("failed to wait for just process");
+
+    let have = Output {
+      status: output.status.code().unwrap(),
+      stdout: str::from_utf8(&output.stdout).unwrap(),
+      stderr: str::from_utf8(&output.stderr).unwrap(),
+    };
+
+    let want = Output {
+      status: self.status,
+      stdout: &stdout,
+      stderr: &stderr,
+    };
+
+    assert_eq!(have, want, "bad output");
+
+    if self.status == EXIT_SUCCESS {
+      test_round_trip(tmp.path());
+    }
+  }
+}
+
+#[derive(PartialEq, Debug)]
+struct Output<'a> {
+  stdout: &'a str,
+  stderr: &'a str,
+  status: i32,
+}
+
+fn test_round_trip(tmpdir: &Path) {
+  println!("Reparsing...");
+
+  let output = Command::new(&executable_path("just"))
+    .current_dir(tmpdir)
+    .arg("--dump")
+    .output()
+    .expect("just invocation failed");
+
+  if !output.status.success() {
+    panic!("dump failed: {}", output.status);
+  }
+
+  let dumped = String::from_utf8(output.stdout).unwrap();
+
+  let reparsed_path = tmpdir.join("reparsed.just");
+
+  fs::write(&reparsed_path, &dumped).unwrap();
+
+  let output = Command::new(&executable_path("just"))
+    .current_dir(tmpdir)
+    .arg("--justfile")
+    .arg(&reparsed_path)
+    .arg("--dump")
+    .output()
+    .expect("just invocation failed");
+
+  if !output.status.success() {
+    panic!("reparse failed: {}", output.status);
+  }
+
+  let reparsed = String::from_utf8(output.stdout).unwrap();
+
+  assert_eq!(reparsed, dumped, "reparse mismatch");
+}