Add the --choose subcommand (#680)

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>`.
This commit is contained in:
Casey Rodarmor 2020-09-17 19:43:04 -07:00 committed by GitHub
parent 55985aa242
commit 9d0246998d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 550 additions and 203 deletions

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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`'

View File

@ -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'

View File

@ -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`')

View File

@ -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`]' \

View File

@ -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},

View File

@ -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"],

View File

@ -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 {

View File

@ -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,
},

130
tests/choose.rs Normal file
View File

@ -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");
}

14
tests/common.rs Normal file
View File

@ -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;

View File

@ -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.";

View File

@ -1,3 +1,9 @@
#[macro_use]
mod test;
mod common;
mod choose;
mod completions;
mod dotenv;
mod edit;

View File

@ -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,

172
tests/test.rs Normal file
View File

@ -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");
}