diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml index 89d4b4a..1f0d423 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 050cfa3..111b78e 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 7d867fa..eed0234 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 ... " + 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 ... " 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 a823aac..dbc86ce 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 as justfile.' cand --justfile 'Use 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 61d2e13..4ee53e1 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 as justfile.' complete -c just -n "__fish_use_subcommand" -l set -d 'Override with ' @@ -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 262c702..aba5992 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 as justfile.') [CompletionResult]::new('--justfile', 'justfile', [CompletionResultType]::ParameterName, 'Use 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 c719786..8614037 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 as justfile.]' \ '--justfile=[Use 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 1c688c5..c31f54b 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 50fc1ab..11437c5 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, + chooser: Option<&str>, + ) -> Result<(), i32> { + let recipes = justfile + .public_recipes(self.unsorted) + .iter() + .filter(|recipe| recipe.min_arguments() == 0) + .cloned() + .collect::>>(); + + 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::>() .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 Override binary invoked by `--choose` --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 6eefe60..6bcfa9b 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> { + pub(crate) fn public_recipes(&self, source_order: bool) -> Vec<&Recipe> { let mut recipes = self .recipes .values() .map(AsRef::as_ref) + .filter(|recipe| recipe.public()) .collect::>>(); if source_order { diff --git a/src/subcommand.rs b/src/subcommand.rs index 673d4d2..c2b090d 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, + chooser: Option, + }, Completions { shell: String, }, diff --git a/tests/choose.rs b/tests/choose.rs new file mode 100644 index 0000000..3249652 --- /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 0000000..4f8048a --- /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 ff25685..9dcbc7b 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 9b57557..17095d2 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 a402ce2..2a02573 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, - 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 0000000..7b50de5 --- /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, + 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"); +}