Repository: casey/just
Pull request #931 (2021-07-31 07:35:48)

Find justfiles named `.justfile`


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index 07888d8a..f455ae2d 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -549,9 +549,9 @@ dependencies = [
 
 [[package]]
 name = "temptree"
-version = "0.1.0"
+version = "0.2.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "13f60523942b252a93f18dd6c8ba53488929d59f7b106be23a29bc9cbc466461"
+checksum = "8fda94d8251b40088cb769576f436da19ac1d1ae792c97d0afe1cadc890c8630"
 dependencies = [
  "tempfile",
 ]
diff --git a/Cargo.toml b/Cargo.toml
index 8cb1b365..7a957e97 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -49,7 +49,7 @@ cradle            = "0.0.13"
 executable-path   = "1.0.0"
 pretty_assertions = "0.7.0"
 regex             = "1.5.4"
-temptree          = "0.1.0"
+temptree          = "0.2.0"
 which             = "4.0.0"
 yaml-rust         = "0.4.5"
 
diff --git a/README.adoc b/README.adoc
index fbfc9741..00b72cf0 100644
--- a/README.adoc
+++ b/README.adoc
@@ -231,7 +231,7 @@ another-recipe:
 
 When you invoke `just` it looks for file `justfile` in the current directory and upwards, so you can invoke it from any subdirectory of your project.
 
-The search for a `justfile` is case insensitive, so any case, like `Justfile`, `JUSTFILE`, or `JuStFiLe`, will work.
+The search for a `justfile` is case insensitive, so any case, like `Justfile`, `JUSTFILE`, or `JuStFiLe`, will work. `just` will also look for files with the name `.justfile`, in case you'd like to hide a `justfile`.
 
 Running `just` with no arguments runs the first recipe in the `justfile`:
 
@@ -1542,6 +1542,10 @@ $ just foo/build
 $ just foo/
 ```
 
+=== Hiding Justfiles
+
+`just` looks for justfiles named `justfile` and `.justfile`, which can be used to keep a `justfile` hidden.
+
 === Just Scripts
 
 By adding a shebang line to the top of a justfile and making it executable, `just` can be used as an interpreter for scripts:
diff --git a/src/search.rs b/src/search.rs
index 1b95a52f..c1ae20d9 100644
--- a/src/search.rs
+++ b/src/search.rs
@@ -2,7 +2,8 @@ use crate::common::*;
 
 use std::path::Component;
 
-pub(crate) const FILENAME: &str = "justfile";
+const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0];
+const JUSTFILE_NAMES: &[&str] = &["justfile", ".justfile"];
 const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
 
 pub(crate) struct Search {
@@ -69,7 +70,7 @@ impl Search {
       SearchConfig::FromInvocationDirectory => {
         let working_directory = Self::project_root(&invocation_directory)?;
 
-        let justfile = working_directory.join(FILENAME);
+        let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
 
         Ok(Self {
           justfile,
@@ -82,7 +83,7 @@ impl Search {
 
         let working_directory = Self::project_root(&search_directory)?;
 
-        let justfile = working_directory.join(FILENAME);
+        let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
 
         Ok(Self {
           justfile,
@@ -113,7 +114,7 @@ impl Search {
 
   fn justfile(directory: &Path) -> SearchResult<PathBuf> {
     for directory in directory.ancestors() {
-      let mut candidates = Vec::new();
+      let mut candidates = BTreeSet::new();
 
       let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
         io_error,
@@ -125,14 +126,16 @@ impl Search {
           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 justfile_name in JUSTFILE_NAMES {
+            if name.eq_ignore_ascii_case(justfile_name) {
+              candidates.insert(entry.path());
+            }
           }
         }
       }
 
       if candidates.len() == 1 {
-        return Ok(candidates.pop().unwrap());
+        return Ok(candidates.into_iter().next().unwrap());
       } else if candidates.len() > 1 {
         return Err(SearchError::MultipleCandidates { candidates });
       }
@@ -212,10 +215,10 @@ mod tests {
   fn multiple_candidates() {
     let tmp = testing::tempdir();
     let mut path = tmp.path().to_path_buf();
-    path.push(FILENAME);
+    path.push(DEFAULT_JUSTFILE_NAME);
     fs::write(&path, "default:\n\techo ok").unwrap();
     path.pop();
-    path.push(FILENAME.to_uppercase());
+    path.push(DEFAULT_JUSTFILE_NAME.to_uppercase());
     if fs::File::open(path.as_path()).is_ok() {
       // We are in case-insensitive file system
       return;
@@ -232,7 +235,7 @@ mod tests {
   fn found() {
     let tmp = testing::tempdir();
     let mut path = tmp.path().to_path_buf();
-    path.push(FILENAME);
+    path.push(DEFAULT_JUSTFILE_NAME);
     fs::write(&path, "default:\n\techo ok").unwrap();
     path.pop();
     if let Err(err) = Search::justfile(path.as_path()) {
@@ -244,7 +247,7 @@ mod tests {
   fn found_spongebob_case() {
     let tmp = testing::tempdir();
     let mut path = tmp.path().to_path_buf();
-    let spongebob_case = FILENAME
+    let spongebob_case = DEFAULT_JUSTFILE_NAME
       .chars()
       .enumerate()
       .map(|(i, c)| {
@@ -267,7 +270,7 @@ mod tests {
   fn found_from_inner_dir() {
     let tmp = testing::tempdir();
     let mut path = tmp.path().to_path_buf();
-    path.push(FILENAME);
+    path.push(DEFAULT_JUSTFILE_NAME);
     fs::write(&path, "default:\n\techo ok").unwrap();
     path.pop();
     path.push("a");
@@ -283,12 +286,12 @@ mod tests {
   fn found_and_stopped_at_first_justfile() {
     let tmp = testing::tempdir();
     let mut path = tmp.path().to_path_buf();
-    path.push(FILENAME);
+    path.push(DEFAULT_JUSTFILE_NAME);
     fs::write(&path, "default:\n\techo ok").unwrap();
     path.pop();
     path.push("a");
     fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory");
-    path.push(FILENAME);
+    path.push(DEFAULT_JUSTFILE_NAME);
     fs::write(&path, "default:\n\techo ok").unwrap();
     path.pop();
     path.push("b");
@@ -296,7 +299,7 @@ mod tests {
     match Search::justfile(path.as_path()) {
       Ok(found_path) => {
         path.pop();
-        path.push(FILENAME);
+        path.push(DEFAULT_JUSTFILE_NAME);
         assert_eq!(found_path, path);
       },
       Err(err) => panic!("No errors were expected: {}", err),
diff --git a/src/search_error.rs b/src/search_error.rs
index 6a8e708b..cea46cf6 100644
--- a/src/search_error.rs
+++ b/src/search_error.rs
@@ -16,14 +16,14 @@ pub(crate) enum SearchError {
   JustfileHadNoParent { path: PathBuf },
   #[snafu(display(
     "Multiple candidate justfiles found in `{}`: {}",
-    candidates[0].parent().unwrap().display(),
+    candidates.iter().next().unwrap().parent().unwrap().display(),
     List::and_ticked(
       candidates
         .iter()
         .map(|candidate| candidate.file_name().unwrap().to_string_lossy())
     ),
   ))]
-  MultipleCandidates { candidates: Vec<PathBuf> },
+  MultipleCandidates { candidates: BTreeSet<PathBuf> },
   #[snafu(display("No justfile found"))]
   NotFound,
 }
@@ -35,15 +35,15 @@ mod tests {
   #[test]
   fn multiple_candidates_formatting() {
     let error = SearchError::MultipleCandidates {
-      candidates: vec![
-        PathBuf::from("/foo/justfile"),
-        PathBuf::from("/foo/JUSTFILE"),
-      ],
+      candidates: [Path::new("/foo/justfile"), Path::new("/foo/JUSTFILE")]
+        .iter()
+        .map(|path| path.to_path_buf())
+        .collect(),
     };
 
     assert_eq!(
       error.to_string(),
-      "Multiple candidate justfiles found in `/foo`: `justfile` and `JUSTFILE`"
+      "Multiple candidate justfiles found in `/foo`: `JUSTFILE` and `justfile`"
     );
   }
 }


Test patch:
diff --git a/src/testing.rs b/src/testing.rs
index a69dcc40..d9e4ea40 100644
--- a/src/testing.rs
+++ b/src/testing.rs
@@ -23,7 +23,7 @@ pub(crate) fn config(args: &[&str]) -> Config {
 
 pub(crate) fn search(config: &Config) -> Search {
   let working_directory = config.invocation_directory.clone();
-  let justfile = working_directory.join(crate::search::FILENAME);
+  let justfile = working_directory.join("justfile");
 
   Search {
     justfile,
diff --git a/tests/common.rs b/tests/common.rs
index f874084e..881afad6 100644
--- a/tests/common.rs
+++ b/tests/common.rs
@@ -18,7 +18,7 @@ pub(crate) use libc::{EXIT_FAILURE, EXIT_SUCCESS};
 pub(crate) use pretty_assertions::Comparison;
 pub(crate) use regex::Regex;
 pub(crate) use tempfile::TempDir;
-pub(crate) use temptree::temptree;
+pub(crate) use temptree::{temptree, tree, Tree};
 pub(crate) use which::which;
 pub(crate) use yaml_rust::YamlLoader;
 
diff --git a/tests/search.rs b/tests/search.rs
index eb4a5f66..425ba8d1 100644
--- a/tests/search.rs
+++ b/tests/search.rs
@@ -142,3 +142,44 @@ fn single_upwards() {
 
   search_test(&path, &["../"]);
 }
+
+#[test]
+fn find_dot_justfile() {
+  Test::new()
+    .justfile(
+      "
+      foo:
+        echo bad
+    ",
+    )
+    .tree(tree! {
+      dir: {
+        ".justfile": "
+          foo:
+            echo ok
+        "
+      }
+    })
+    .current_dir("dir")
+    .stderr("echo ok\n")
+    .stdout("ok\n")
+    .run();
+}
+
+#[test]
+fn dot_justfile_conflicts_with_justfile() {
+  Test::new()
+    .justfile(
+      "
+        foo:
+    ",
+    )
+    .tree(tree! {
+      ".justfile": "
+        foo:
+      ",
+    })
+    .stderr_regex("error: Multiple candidate justfiles found in `.*`: `.justfile` and `justfile`\n")
+    .status(EXIT_FAILURE)
+    .run();
+}
diff --git a/tests/test.rs b/tests/test.rs
index df0dd617..b998dd58 100644
--- a/tests/test.rs
+++ b/tests/test.rs
@@ -33,17 +33,18 @@ macro_rules! test {
 }
 
 pub(crate) struct Test {
-  pub(crate) tempdir: TempDir,
-  pub(crate) justfile: Option<String>,
   pub(crate) args: Vec<String>,
+  pub(crate) current_dir: PathBuf,
   pub(crate) env: BTreeMap<String, String>,
-  pub(crate) stdin: String,
-  pub(crate) stdout: String,
+  pub(crate) justfile: Option<String>,
+  pub(crate) shell: bool,
+  pub(crate) status: i32,
   pub(crate) stderr: String,
   pub(crate) stderr_regex: Option<Regex>,
-  pub(crate) status: i32,
-  pub(crate) shell: bool,
+  pub(crate) stdin: String,
+  pub(crate) stdout: String,
   pub(crate) suppress_dotenv_load_warning: bool,
+  pub(crate) tempdir: TempDir,
 }
 
 impl Test {
@@ -54,6 +55,7 @@ impl Test {
   pub(crate) fn with_tempdir(tempdir: TempDir) -> Self {
     Self {
       args: Vec::new(),
+      current_dir: PathBuf::new(),
       env: BTreeMap::new(),
       justfile: Some(String::new()),
       shell: true,
@@ -79,6 +81,11 @@ impl Test {
     self
   }
 
+  pub(crate) fn current_dir(mut self, path: impl AsRef<Path>) -> Self {
+    self.current_dir = path.as_ref().to_owned();
+    self
+  }
+
   pub(crate) fn env(mut self, key: &str, val: &str) -> Self {
     self.env.insert(key.to_string(), val.to_string());
     self
@@ -132,6 +139,12 @@ impl Test {
     self.suppress_dotenv_load_warning = suppress_dotenv_load_warning;
     self
   }
+
+  pub(crate) fn tree(self, mut tree: Tree) -> Self {
+    tree.map(|_name, content| unindent(content));
+    tree.instantiate(&self.tempdir.path()).unwrap();
+    self
+  }
 }
 
 impl Test {
@@ -165,7 +178,7 @@ impl Test {
           "0"
         },
       )
-      .current_dir(self.tempdir.path())
+      .current_dir(self.tempdir.path().join(self.current_dir))
       .stdin(Stdio::piped())
       .stdout(Stdio::piped())
       .stderr(Stdio::piped())