Repository: casey/just
Pull request #541 (2019-11-19 12:54:43)

Add `--init` subcommand
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.


Patch:
diff --git a/src/config.rs b/src/config.rs
index 9027607e..c554563f 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 <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);
+  }
 }
diff --git a/src/config_error.rs b/src/config_error.rs
index bc79bd57..052dc352 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 4d6ead7c..34cbb496 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<Search> {
@@ -60,33 +61,83 @@ 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> {
-    let mut candidates = Vec::new();
-
-    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 {
+    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);
diff --git a/src/subcommand.rs b/src/subcommand.rs
index 5ea1f1db..9147ccef 100644
--- a/src/subcommand.rs
+++ b/src/subcommand.rs
@@ -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,
   },


Test patch:
diff --git a/test-utilities/src/lib.rs b/test-utilities/src/lib.rs
index 567beb9d..f177d45a 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 00000000..655165db
--- /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
+  );
+}