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; use unicode_width::UnicodeWidthStr;
pub(crate) const DEFAULT_SHELL: &str = "sh"; pub(crate) const DEFAULT_SHELL: &str = "sh";
pub(crate) const INIT_JUSTFILE: &str = "default:\n\techo 'Hello, world!'\n";
#[derive(Debug, PartialEq)] #[derive(Debug, PartialEq)]
pub(crate) struct Config { pub(crate) struct Config {
@ -22,12 +23,13 @@ mod cmd {
pub(crate) const DUMP: &str = "DUMP"; pub(crate) const DUMP: &str = "DUMP";
pub(crate) const EDIT: &str = "EDIT"; pub(crate) const EDIT: &str = "EDIT";
pub(crate) const EVALUATE: &str = "EVALUATE"; pub(crate) const EVALUATE: &str = "EVALUATE";
pub(crate) const INIT: &str = "INIT";
pub(crate) const LIST: &str = "LIST"; pub(crate) const LIST: &str = "LIST";
pub(crate) const SHOW: &str = "SHOW"; pub(crate) const SHOW: &str = "SHOW";
pub(crate) const SUMMARY: &str = "SUMMARY"; pub(crate) const SUMMARY: &str = "SUMMARY";
pub(crate) const ALL: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY, EVALUATE]; pub(crate) const ALL: &[&str] = &[DUMP, EDIT, INIT, EVALUATE, LIST, SHOW, SUMMARY];
pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, LIST, SHOW, SUMMARY]; pub(crate) const ARGLESS: &[&str] = &[DUMP, EDIT, INIT, LIST, SHOW, SUMMARY];
} }
mod arg { mod arg {
@ -70,22 +72,6 @@ impl Config {
.help("Print what just would do without doing it") .help("Print what just would do without doing it")
.conflicts_with(arg::QUIET), .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(
Arg::with_name(arg::HIGHLIGHT) Arg::with_name(arg::HIGHLIGHT)
.long("highlight") .long("highlight")
@ -105,12 +91,6 @@ impl Config {
.takes_value(true) .takes_value(true)
.help("Use <JUSTFILE> as justfile."), .help("Use <JUSTFILE> as justfile."),
) )
.arg(
Arg::with_name(cmd::LIST)
.short("l")
.long("list")
.help("List available recipes and their arguments"),
)
.arg( .arg(
Arg::with_name(arg::QUIET) Arg::with_name(arg::QUIET)
.short("q") .short("q")
@ -134,19 +114,6 @@ impl Config {
.default_value(DEFAULT_SHELL) .default_value(DEFAULT_SHELL)
.help("Invoke <SHELL> to run recipes"), .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(
Arg::with_name(arg::VERBOSE) Arg::with_name(arg::VERBOSE)
.short("v") .short("v")
@ -167,6 +134,46 @@ impl Config {
.multiple(true) .multiple(true)
.help("Overrides and recipe(s) to run, defaulting to the first recipe in the justfile"), .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)); .group(ArgGroup::with_name("SUBCOMMAND").args(cmd::ALL));
if cfg!(feature = "help4help2man") { if cfg!(feature = "help4help2man") {
@ -288,6 +295,8 @@ impl Config {
Subcommand::Summary Subcommand::Summary
} else if matches.is_present(cmd::DUMP) { } else if matches.is_present(cmd::DUMP) {
Subcommand::Dump Subcommand::Dump
} else if matches.is_present(cmd::INIT) {
Subcommand::Init
} else if matches.is_present(cmd::LIST) { } else if matches.is_present(cmd::LIST) {
Subcommand::List Subcommand::List
} else if let Some(name) = matches.value_of(cmd::SHOW) { } else if let Some(name) = matches.value_of(cmd::SHOW) {
@ -295,6 +304,12 @@ impl Config {
name: name.to_owned(), name: name.to_owned(),
} }
} else if matches.is_present(cmd::EVALUATE) { } 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 } Subcommand::Evaluate { overrides }
} else { } else {
Subcommand::Run { Subcommand::Run {
@ -319,8 +334,12 @@ impl Config {
pub(crate) fn run_subcommand(self) -> Result<(), i32> { pub(crate) fn run_subcommand(self) -> Result<(), i32> {
use Subcommand::*; use Subcommand::*;
if self.subcommand == Init {
return self.init();
}
let search = 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 { if self.subcommand == Edit {
return self.edit(&search); return self.edit(&search);
@ -355,7 +374,7 @@ impl Config {
List => self.list(justfile), List => self.list(justfile),
Show { ref name } => self.show(&name, justfile), Show { ref name } => self.show(&name, justfile),
Summary => self.summary(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> { fn list(&self, justfile: Justfile) -> Result<(), i32> {
// Construct a target to alias map. // Construct a target to alias map.
let mut recipe_aliases: BTreeMap<&str, Vec<&str>> = BTreeMap::new(); 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` Edit justfile with editor given by $VISUAL or $EDITOR, falling back to `vim`
--evaluate Print evaluated variables --evaluate Print evaluated variables
--highlight Highlight echoed recipe lines in bold --highlight Highlight echoed recipe lines in bold
--init Initialize new justfile in project root
-l, --list List available recipes and their arguments -l, --list List available recipes and their arguments
--no-highlight Don't highlight echoed recipe lines in bold --no-highlight Don't highlight echoed recipe lines in bold
-q, --quiet Suppress all output -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! { test! {
name: subcommand_list_long, name: subcommand_list_long,
args: ["--list"], 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! { error! {
name: dump_arguments, name: dump_arguments,
args: ["--dump", "bar"], 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! { error! {
name: show_arguments, name: show_arguments,
args: ["--show", "foo", "bar"], args: ["--show", "foo", "bar"],
@ -1157,4 +1225,9 @@ ARGS:
assert_eq!(overrides, map!{"bar": "baz"}); 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, SearchDirConflict,
#[snafu(display( #[snafu(display(
"`{}` used with unexpected arguments: {}", "`{}` used with unexpected {}: {}",
subcommand, subcommand,
Count("argument", arguments.len()),
List::and_ticked(arguments) List::and_ticked(arguments)
))] ))]
SubcommandArguments { SubcommandArguments {

View File

@ -3,6 +3,7 @@ use crate::common::*;
use std::path::Component; use std::path::Component;
const FILENAME: &str = "justfile"; const FILENAME: &str = "justfile";
const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
pub(crate) struct Search { pub(crate) struct Search {
pub(crate) justfile: PathBuf, pub(crate) justfile: PathBuf,
@ -10,7 +11,7 @@ pub(crate) struct Search {
} }
impl Search { impl Search {
pub(crate) fn search( pub(crate) fn find(
search_config: &SearchConfig, search_config: &SearchConfig,
invocation_directory: &Path, invocation_directory: &Path,
) -> SearchResult<Search> { ) -> SearchResult<Search> {
@ -60,7 +61,58 @@ impl Search {
} }
} }
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 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> { fn justfile(directory: &Path) -> SearchResult<PathBuf> {
for directory in directory.ancestors() {
let mut candidates = Vec::new(); let mut candidates = Vec::new();
let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io { let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
@ -79,16 +131,15 @@ impl Search {
} }
} }
if candidates.len() == 1 { if candidates.len() == 1 {
Ok(candidates.pop().unwrap()) return Ok(candidates.pop().unwrap());
} else if candidates.len() > 1 { } else if candidates.len() > 1 {
Err(SearchError::MultipleCandidates { candidates }) return 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 { fn clean(invocation_directory: &Path, path: &Path) -> PathBuf {
let path = invocation_directory.join(path); let path = invocation_directory.join(path);
@ -107,6 +158,29 @@ impl Search {
clean.into_iter().collect() 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> { fn working_directory_from_justfile(justfile: &Path) -> SearchResult<PathBuf> {
Ok( Ok(
justfile justfile
@ -260,7 +334,7 @@ mod tests {
let search_config = SearchConfig::FromInvocationDirectory; 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.justfile, justfile);
assert_eq!(search.working_directory, sub); assert_eq!(search.working_directory, sub);

View File

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

View File

@ -134,13 +134,13 @@ macro_rules! entries {
std::collections::HashMap::new() 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(); 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 entries
@ -148,6 +148,20 @@ macro_rules! entries {
} }
} }
#[macro_export]
macro_rules! name {
{
$name:ident
} => {
stringify!($name)
};
{
$name:literal
} => {
$name
};
}
#[macro_export] #[macro_export]
macro_rules! tmptree { 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
);
}