diff --git a/src/config.rs b/src/config.rs index 9027607..c554563 100644 --- a/src/config.rs +++ b/src/config.rs @@ -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 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 to run recipes"), ) - .arg( - Arg::with_name(cmd::SHOW) - .short("s") - .long("show") - .takes_value(true) - .value_name("RECIPE") - .help("Show information about "), - ) - .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 "), + ) + .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); + } } diff --git a/src/config_error.rs b/src/config_error.rs index bc79bd5..052dc35 100644 --- a/src/config_error.rs +++ b/src/config_error.rs @@ -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 { diff --git a/src/search.rs b/src/search.rs index 4d6ead7..34cbb49 100644 --- a/src/search.rs +++ b/src/search.rs @@ -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 { @@ -60,33 +61,83 @@ impl Search { } } - fn justfile(directory: &Path) -> SearchResult { - let mut candidates = Vec::new(); + pub(crate) fn init( + search_config: &SearchConfig, + invocation_directory: &Path, + ) -> SearchResult { + 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 { + 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 { + 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 { 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); diff --git a/src/subcommand.rs b/src/subcommand.rs index 5ea1f1d..9147cce 100644 --- a/src/subcommand.rs +++ b/src/subcommand.rs @@ -7,11 +7,12 @@ pub(crate) enum Subcommand { Evaluate { overrides: BTreeMap, }, + Init, + List, Run { overrides: BTreeMap, arguments: Vec, }, - List, Show { name: String, }, diff --git a/test-utilities/src/lib.rs b/test-utilities/src/lib.rs index 567beb9..f177d45 100644 --- a/test-utilities/src/lib.rs +++ b/test-utilities/src/lib.rs @@ -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 { { diff --git a/tests/init.rs b/tests/init.rs new file mode 100644 index 0000000..655165d --- /dev/null +++ b/tests/init.rs @@ -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 + ); +}