Add --init subcommand (#541)

When `--init` is passed on the command line, search upward for the
project root, identified by the presence of a VCS directory like `.git`,
falling back to the current directory, and create a default justfile in
that directory.
This commit is contained in:
Casey Rodarmor 2019-11-20 01:07:44 -06:00 committed by GitHub
parent c4e9857ebd
commit e948f11784
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
6 changed files with 387 additions and 65 deletions

View file

@ -4,6 +4,7 @@ use clap::{App, AppSettings, Arg, ArgGroup, ArgMatches};
use unicode_width::UnicodeWidthStr;
pub(crate) const DEFAULT_SHELL: &str = "sh";
pub(crate) const INIT_JUSTFILE: &str = "default:\n\techo 'Hello, world!'\n";
#[derive(Debug, PartialEq)]
pub(crate) struct Config {
@ -22,12 +23,13 @@ mod cmd {
pub(crate) const DUMP: &str = "DUMP";
pub(crate) const EDIT: &str = "EDIT";
pub(crate) const EVALUATE: &str = "EVALUATE";
pub(crate) const INIT: &str = "INIT";
pub(crate) const LIST: &str = "LIST";
pub(crate) const SHOW: &str = "SHOW";
pub(crate) const SUMMARY: &str = "SUMMARY";
pub(crate) const ALL: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY, EVALUATE];
pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY];
pub(crate) const ALL: &[&str] = &[DUMP, EDIT, INIT, EVALUATE, LIST, SHOW, SUMMARY];
pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, INIT, LIST, SHOW, SUMMARY];
}
mod arg {
@ -70,22 +72,6 @@ impl Config {
.help("Print what just would do without doing it")
.conflicts_with(arg::QUIET),
)
.arg(
Arg::with_name(cmd::DUMP)
.long("dump")
.help("Print entire justfile"),
)
.arg(
Arg::with_name(cmd::EDIT)
.short("e")
.long("edit")
.help("Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`"),
)
.arg(
Arg::with_name(cmd::EVALUATE)
.long("evaluate")
.help("Print evaluated variables"),
)
.arg(
Arg::with_name(arg::HIGHLIGHT)
.long("highlight")
@ -105,12 +91,6 @@ impl Config {
.takes_value(true)
.help("Use <JUSTFILE> as justfile."),
)
.arg(
Arg::with_name(cmd::LIST)
.short("l")
.long("list")
.help("List available recipes and their arguments"),
)
.arg(
Arg::with_name(arg::QUIET)
.short("q")
@ -134,19 +114,6 @@ impl Config {
.default_value(DEFAULT_SHELL)
.help("Invoke <SHELL> to run recipes"),
)
.arg(
Arg::with_name(cmd::SHOW)
.short("s")
.long("show")
.takes_value(true)
.value_name("RECIPE")
.help("Show information about <RECIPE>"),
)
.arg(
Arg::with_name(cmd::SUMMARY)
.long("summary")
.help("List names of available recipes"),
)
.arg(
Arg::with_name(arg::VERBOSE)
.short("v")
@ -167,6 +134,46 @@ impl Config {
.multiple(true)
.help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"),
)
.arg(
Arg::with_name(cmd::DUMP)
.long("dump")
.help("Print entire justfile"),
)
.arg(
Arg::with_name(cmd::EDIT)
.short("e")
.long("edit")
.help("Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`"),
)
.arg(
Arg::with_name(cmd::EVALUATE)
.long("evaluate")
.help("Print evaluated variables"),
)
.arg(
Arg::with_name(cmd::INIT)
.long("init")
.help("Initialize new justfile in project root"),
)
.arg(
Arg::with_name(cmd::LIST)
.short("l")
.long("list")
.help("List available recipes and their arguments"),
)
.arg(
Arg::with_name(cmd::SHOW)
.short("s")
.long("show")
.takes_value(true)
.value_name("RECIPE")
.help("Show information about <RECIPE>"),
)
.arg(
Arg::with_name(cmd::SUMMARY)
.long("summary")
.help("List names of available recipes"),
)
.group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL));
if cfg!(feature = "help4help2man") {
@ -288,6 +295,8 @@ impl Config {
Subcommand::Summary
} else if matches.is_present(cmd::DUMP) {
Subcommand::Dump
} else if matches.is_present(cmd::INIT) {
Subcommand::Init
} else if matches.is_present(cmd::LIST) {
Subcommand::List
} else if let Some(name) = matches.value_of(cmd::SHOW) {
@ -295,6 +304,12 @@ impl Config {
name: name.to_owned(),
}
} else if matches.is_present(cmd::EVALUATE) {
if !positional.arguments.is_empty() {
return Err(ConfigError::SubcommandArguments {
subcommand: format!("--{}", cmd::EVALUATE.to_lowercase()),
arguments: positional.arguments,
});
}
Subcommand::Evaluate { overrides }
} else {
Subcommand::Run {
@ -319,8 +334,12 @@ impl Config {
pub(crate) fn run_subcommand(self) -> Result<(), i32> {
use Subcommand::*;
if self.subcommand == Init {
return self.init();
}
let search =
Search::search(&self.search_config, &self.invocation_directory).eprint(self.color)?;
Search::find(&self.search_config, &self.invocation_directory).eprint(self.color)?;
if self.subcommand == Edit {
return self.edit(&search);
@ -355,7 +374,7 @@ impl Config {
List => self.list(justfile),
Show { ref name } => self.show(&name, justfile),
Summary => self.summary(justfile),
Edit => unreachable!(),
Edit | Init => unreachable!(),
}
}
@ -394,6 +413,26 @@ impl Config {
}
}
pub(crate) fn init(&self) -> Result<(), i32> {
let search =
Search::init(&self.search_config, &self.invocation_directory).eprint(self.color)?;
if search.justfile.exists() {
eprintln!("Justfile `{}` already exists", search.justfile.display());
Err(EXIT_FAILURE)
} else if let Err(err) = fs::write(&search.justfile, INIT_JUSTFILE) {
eprintln!(
"Failed to write justfile to `{}`: {}",
search.justfile.display(),
err
);
Err(EXIT_FAILURE)
} else {
eprintln!("Wrote justfile to `{}`", search.justfile.display());
Ok(())
}
}
fn list(&self, justfile: Justfile) -> Result<(), i32> {
// Construct a target to alias map.
let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
@ -561,6 +600,7 @@ FLAGS:
Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`
--evaluate Print evaluated variables
--highlight Highlight echoed recipe lines in bold
--init Initialize new justfile in project root
-l, --list List available recipes and their arguments
--no-highlight Don't highlight echoed recipe lines in bold
-q, --quiet Suppress all output
@ -922,6 +962,14 @@ ARGS:
},
}
test! {
name: subcommand_evaluate_overrides,
args: ["--evaluate", "x=y"],
subcommand: Subcommand::Evaluate {
overrides: map!{"x": "y"},
},
}
test! {
name: subcommand_list_long,
args: ["--list"],
@ -1097,6 +1145,16 @@ ARGS:
},
}
error! {
name: evaluate_arguments,
args: ["--evaluate", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--evaluate");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: dump_arguments,
args: ["--dump", "bar"],
@ -1117,6 +1175,16 @@ ARGS:
},
}
error! {
name: init_arguments,
args: ["--init", "bar"],
error: ConfigError::SubcommandArguments { subcommand, arguments },
check: {
assert_eq!(subcommand, "--init");
assert_eq!(arguments, &["bar"]);
},
}
error! {
name: show_arguments,
args: ["--show", "foo", "bar"],
@ -1157,4 +1225,9 @@ ARGS:
assert_eq!(overrides, map!{"bar": "baz"});
},
}
#[test]
fn init_justfile() {
testing::compile(INIT_JUSTFILE);
}
}

View file

@ -16,8 +16,9 @@ pub(crate) enum ConfigError {
))]
SearchDirConflict,
#[snafu(display(
"`{}` used with unexpected arguments: {}",
"`{}` used with unexpected {}: {}",
subcommand,
Count("argument", arguments.len()),
List::and_ticked(arguments)
))]
SubcommandArguments {

View file

@ -3,6 +3,7 @@ use crate::common::*;
use std::path::Component;
const FILENAME: &str = "justfile";
const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
pub(crate) struct Search {
pub(crate) justfile: PathBuf,
@ -10,7 +11,7 @@ pub(crate) struct Search {
}
impl Search {
pub(crate) fn search(
pub(crate) fn find(
search_config: &SearchConfig,
invocation_directory: &Path,
) -> SearchResult<Search> {
@ -60,33 +61,83 @@ impl Search {
}
}
fn justfile(directory: &Path) -> SearchResult<PathBuf> {
let mut candidates = Vec::new();
pub(crate) fn init(
search_config: &SearchConfig,
invocation_directory: &Path,
) -> SearchResult<Search> {
match search_config {
SearchConfig::FromInvocationDirectory => {
let working_directory = Self::project_root(&invocation_directory)?;
let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
io_error,
directory: directory.to_owned(),
})?;
for entry in entries {
let entry = entry.map_err(|io_error| SearchError::Io {
let justfile = working_directory.join(FILENAME);
Ok(Search {
justfile,
working_directory,
})
}
SearchConfig::FromSearchDirectory { search_directory } => {
let search_directory = Self::clean(invocation_directory, search_directory);
let working_directory = Self::project_root(&search_directory)?;
let justfile = working_directory.join(FILENAME);
Ok(Search {
justfile,
working_directory,
})
}
SearchConfig::WithJustfile { justfile } => {
let justfile = Self::clean(invocation_directory, justfile);
let working_directory = Self::working_directory_from_justfile(&justfile)?;
Ok(Search {
justfile,
working_directory,
})
}
SearchConfig::WithJustfileAndWorkingDirectory {
justfile,
working_directory,
} => Ok(Search {
justfile: Self::clean(invocation_directory, justfile),
working_directory: Self::clean(invocation_directory, working_directory),
}),
}
}
fn justfile(directory: &Path) -> SearchResult<PathBuf> {
for directory in directory.ancestors() {
let mut candidates = Vec::new();
let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
io_error,
directory: directory.to_owned(),
})?;
if let Some(name) = entry.file_name().to_str() {
if name.eq_ignore_ascii_case(FILENAME) {
candidates.push(entry.path());
for entry in entries {
let entry = entry.map_err(|io_error| SearchError::Io {
io_error,
directory: directory.to_owned(),
})?;
if let Some(name) = entry.file_name().to_str() {
if name.eq_ignore_ascii_case(FILENAME) {
candidates.push(entry.path());
}
}
}
if candidates.len() == 1 {
return Ok(candidates.pop().unwrap());
} else if candidates.len() > 1 {
return Err(SearchError::MultipleCandidates { candidates });
}
}
if candidates.len() == 1 {
Ok(candidates.pop().unwrap())
} else if candidates.len() > 1 {
Err(SearchError::MultipleCandidates { candidates })
} else if let Some(parent) = directory.parent() {
Self::justfile(parent)
} else {
Err(SearchError::NotFound)
}
Err(SearchError::NotFound)
}
fn clean(invocation_directory: &Path, path: &Path) -> PathBuf {
@ -107,6 +158,29 @@ impl Search {
clean.into_iter().collect()
}
fn project_root(directory: &Path) -> SearchResult<PathBuf> {
for directory in directory.ancestors() {
let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
io_error,
directory: directory.to_owned(),
})?;
for entry in entries {
let entry = entry.map_err(|io_error| SearchError::Io {
io_error,
directory: directory.to_owned(),
})?;
for project_root_child in PROJECT_ROOT_CHILDREN.iter().cloned() {
if entry.file_name() == project_root_child {
return Ok(directory.to_owned());
}
}
}
}
Ok(directory.to_owned())
}
fn working_directory_from_justfile(justfile: &Path) -> SearchResult<PathBuf> {
Ok(
justfile
@ -260,7 +334,7 @@ mod tests {
let search_config = SearchConfig::FromInvocationDirectory;
let search = Search::search(&search_config, &sub).unwrap();
let search = Search::find(&search_config, &sub).unwrap();
assert_eq!(search.justfile, justfile);
assert_eq!(search.working_directory, sub);

View file

@ -7,11 +7,12 @@ pub(crate) enum Subcommand {
Evaluate {
overrides: BTreeMap<String, String>,
},
Init,
List,
Run {
overrides: BTreeMap<String, String>,
arguments: Vec<String>,
},
List,
Show {
name: String,
},

View file

@ -134,13 +134,13 @@ macro_rules! entries {
std::collections::HashMap::new()
};
{
$($name:ident : $contents:tt,)*
$($name:tt : $contents:tt,)*
} => {
{
let mut entries: std::collections::HashMap<&'static str, $crate::Entry> = std::collections::HashMap::new();
$(
entries.insert(stringify!($name), $crate::entry!($contents));
entries.insert($crate::name!($name), $crate::entry!($contents));
)*
entries
@ -148,6 +148,20 @@ macro_rules! entries {
}
}
#[macro_export]
macro_rules! name {
{
$name:ident
} => {
stringify!($name)
};
{
$name:literal
} => {
$name
};
}
#[macro_export]
macro_rules! tmptree {
{

159
tests/init.rs Normal file
View file

@ -0,0 +1,159 @@
use std::{fs, process::Command};
use executable_path::executable_path;
use test_utilities::{tempdir, tmptree};
const EXPECTED: &str = "default:\n\techo 'Hello, world!'\n";
#[test]
fn current_dir() {
let tmp = tempdir();
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--init")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
fs::read_to_string(tmp.path().join("justfile")).unwrap(),
EXPECTED
);
}
#[test]
fn exists() {
let tmp = tempdir();
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--init")
.output()
.unwrap();
assert!(output.status.success());
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--init")
.output()
.unwrap();
assert!(!output.status.success());
}
#[test]
fn invocation_directory() {
let tmp = tmptree! {
".git": {},
};
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--init")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
fs::read_to_string(tmp.path().join("justfile")).unwrap(),
EXPECTED
);
}
#[test]
fn alternate_marker() {
let tmp = tmptree! {
"_darcs": {},
};
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--init")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
fs::read_to_string(tmp.path().join("justfile")).unwrap(),
EXPECTED
);
}
#[test]
fn search_directory() {
let tmp = tmptree! {
sub: {
".git": {},
},
};
let output = Command::new(executable_path("just"))
.current_dir(tmp.path())
.arg("--init")
.arg("sub/")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
fs::read_to_string(tmp.path().join("sub/justfile")).unwrap(),
EXPECTED
);
}
#[test]
fn justfile() {
let tmp = tmptree! {
sub: {
".git": {},
},
};
let output = Command::new(executable_path("just"))
.current_dir(tmp.path().join("sub"))
.arg("--init")
.arg("--justfile")
.arg(tmp.path().join("justfile"))
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
fs::read_to_string(tmp.path().join("justfile")).unwrap(),
EXPECTED
);
}
#[test]
fn justfile_and_working_directory() {
let tmp = tmptree! {
sub: {
".git": {},
},
};
let output = Command::new(executable_path("just"))
.current_dir(tmp.path().join("sub"))
.arg("--init")
.arg("--justfile")
.arg(tmp.path().join("justfile"))
.arg("--working-directory")
.arg("/")
.output()
.unwrap();
assert!(output.status.success());
assert_eq!(
fs::read_to_string(tmp.path().join("justfile")).unwrap(),
EXPECTED
);
}