pub_just/
search.rs

1use {super::*, std::path::Component};
2
3const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0];
4pub const JUSTFILE_NAMES: [&str; 2] = ["justfile", ".justfile"];
5const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"];
6
7#[derive(Debug)]
8pub struct Search {
9  pub justfile: PathBuf,
10  pub working_directory: PathBuf,
11}
12
13impl Search {
14  fn global_justfile_paths() -> Vec<PathBuf> {
15    let mut paths = Vec::new();
16
17    if let Some(config_dir) = dirs::config_dir() {
18      paths.push(config_dir.join("just").join(DEFAULT_JUSTFILE_NAME));
19    }
20
21    if let Some(home_dir) = dirs::home_dir() {
22      paths.push(
23        home_dir
24          .join(".config")
25          .join("just")
26          .join(DEFAULT_JUSTFILE_NAME),
27      );
28
29      for justfile_name in JUSTFILE_NAMES {
30        paths.push(home_dir.join(justfile_name));
31      }
32    }
33
34    paths
35  }
36
37  /// Find justfile given search configuration and invocation directory
38  pub fn find(
39    search_config: &SearchConfig,
40    invocation_directory: &Path,
41  ) -> SearchResult<Self> {
42    match search_config {
43      SearchConfig::FromInvocationDirectory => Self::find_in_directory(invocation_directory),
44      SearchConfig::FromSearchDirectory { search_directory } => {
45        let search_directory = Self::clean(invocation_directory, search_directory);
46        let justfile = Self::justfile(&search_directory)?;
47        let working_directory = Self::working_directory_from_justfile(&justfile)?;
48        Ok(Self {
49          justfile,
50          working_directory,
51        })
52      }
53      SearchConfig::GlobalJustfile => Ok(Self {
54        justfile: Self::global_justfile_paths()
55          .iter()
56          .find(|path| path.exists())
57          .cloned()
58          .ok_or(SearchError::GlobalJustfileNotFound)?,
59        working_directory: Self::project_root(invocation_directory)?,
60      }),
61      SearchConfig::WithJustfile { justfile } => {
62        let justfile = Self::clean(invocation_directory, justfile);
63        let working_directory = Self::working_directory_from_justfile(&justfile)?;
64        Ok(Self {
65          justfile,
66          working_directory,
67        })
68      }
69      SearchConfig::WithJustfileAndWorkingDirectory {
70        justfile,
71        working_directory,
72      } => Ok(Self {
73        justfile: Self::clean(invocation_directory, justfile),
74        working_directory: Self::clean(invocation_directory, working_directory),
75      }),
76    }
77  }
78
79  /// Find justfile starting from parent directory of current justfile
80  pub fn search_parent_directory(&self) -> SearchResult<Self> {
81    let parent = self
82      .justfile
83      .parent()
84      .and_then(|path| path.parent())
85      .ok_or_else(|| SearchError::JustfileHadNoParent {
86        path: self.justfile.clone(),
87      })?;
88    Self::find_in_directory(parent)
89  }
90
91  /// Find justfile starting in given directory searching upwards in directory tree
92  fn find_in_directory(starting_dir: &Path) -> SearchResult<Self> {
93    let justfile = Self::justfile(starting_dir)?;
94    let working_directory = Self::working_directory_from_justfile(&justfile)?;
95    Ok(Self {
96      justfile,
97      working_directory,
98    })
99  }
100
101  /// Get working directory and justfile path for newly-initialized justfile
102  pub fn init(
103    search_config: &SearchConfig,
104    invocation_directory: &Path,
105  ) -> SearchResult<Self> {
106    match search_config {
107      SearchConfig::FromInvocationDirectory => {
108        let working_directory = Self::project_root(invocation_directory)?;
109        let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
110        Ok(Self {
111          justfile,
112          working_directory,
113        })
114      }
115      SearchConfig::FromSearchDirectory { search_directory } => {
116        let search_directory = Self::clean(invocation_directory, search_directory);
117        let working_directory = Self::project_root(&search_directory)?;
118        let justfile = working_directory.join(DEFAULT_JUSTFILE_NAME);
119        Ok(Self {
120          justfile,
121          working_directory,
122        })
123      }
124      SearchConfig::GlobalJustfile => Err(SearchError::GlobalJustfileInit),
125      SearchConfig::WithJustfile { justfile } => {
126        let justfile = Self::clean(invocation_directory, justfile);
127        let working_directory = Self::working_directory_from_justfile(&justfile)?;
128        Ok(Self {
129          justfile,
130          working_directory,
131        })
132      }
133      SearchConfig::WithJustfileAndWorkingDirectory {
134        justfile,
135        working_directory,
136      } => Ok(Self {
137        justfile: Self::clean(invocation_directory, justfile),
138        working_directory: Self::clean(invocation_directory, working_directory),
139      }),
140    }
141  }
142
143  /// Search upwards from `directory` for a file whose name matches one of
144  /// `JUSTFILE_NAMES`
145  fn justfile(directory: &Path) -> SearchResult<PathBuf> {
146    for directory in directory.ancestors() {
147      let mut candidates = BTreeSet::new();
148
149      let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
150        io_error,
151        directory: directory.to_owned(),
152      })?;
153      for entry in entries {
154        let entry = entry.map_err(|io_error| SearchError::Io {
155          io_error,
156          directory: directory.to_owned(),
157        })?;
158        if let Some(name) = entry.file_name().to_str() {
159          for justfile_name in JUSTFILE_NAMES {
160            if name.eq_ignore_ascii_case(justfile_name) {
161              candidates.insert(entry.path());
162            }
163          }
164        }
165      }
166
167      match candidates.len() {
168        0 => {}
169        1 => return Ok(candidates.into_iter().next().unwrap()),
170        _ => return Err(SearchError::MultipleCandidates { candidates }),
171      }
172    }
173
174    Err(SearchError::NotFound)
175  }
176
177  fn clean(invocation_directory: &Path, path: &Path) -> PathBuf {
178    let path = invocation_directory.join(path);
179
180    let mut clean = Vec::new();
181
182    for component in path.components() {
183      if component == Component::ParentDir {
184        if let Some(Component::Normal(_)) = clean.last() {
185          clean.pop();
186        }
187      } else {
188        clean.push(component);
189      }
190    }
191
192    clean.into_iter().collect()
193  }
194
195  /// Search upwards from `directory` for the root directory of a software
196  /// project, as determined by the presence of one of the version control
197  /// system directories given in `PROJECT_ROOT_CHILDREN`
198  fn project_root(directory: &Path) -> SearchResult<PathBuf> {
199    for directory in directory.ancestors() {
200      let entries = fs::read_dir(directory).map_err(|io_error| SearchError::Io {
201        io_error,
202        directory: directory.to_owned(),
203      })?;
204
205      for entry in entries {
206        let entry = entry.map_err(|io_error| SearchError::Io {
207          io_error,
208          directory: directory.to_owned(),
209        })?;
210        for project_root_child in PROJECT_ROOT_CHILDREN.iter().copied() {
211          if entry.file_name() == project_root_child {
212            return Ok(directory.to_owned());
213          }
214        }
215      }
216    }
217
218    Ok(directory.to_owned())
219  }
220
221  fn working_directory_from_justfile(justfile: &Path) -> SearchResult<PathBuf> {
222    Ok(
223      justfile
224        .parent()
225        .ok_or_else(|| SearchError::JustfileHadNoParent {
226          path: justfile.to_path_buf(),
227        })?
228        .to_owned(),
229    )
230  }
231}
232
233#[cfg(test)]
234mod tests {
235  use super::*;
236  use temptree::temptree;
237
238  #[test]
239  fn not_found() {
240    let tmp = testing::tempdir();
241    match Search::justfile(tmp.path()) {
242      Err(SearchError::NotFound) => {}
243      _ => panic!("No justfile found error was expected"),
244    }
245  }
246
247  #[test]
248  fn multiple_candidates() {
249    let tmp = testing::tempdir();
250    let mut path = tmp.path().to_path_buf();
251    path.push(DEFAULT_JUSTFILE_NAME);
252    fs::write(&path, "default:\n\techo ok").unwrap();
253    path.pop();
254    path.push(DEFAULT_JUSTFILE_NAME.to_uppercase());
255    if fs::File::open(path.as_path()).is_ok() {
256      // We are in case-insensitive file system
257      return;
258    }
259    fs::write(&path, "default:\n\techo ok").unwrap();
260    path.pop();
261    match Search::justfile(path.as_path()) {
262      Err(SearchError::MultipleCandidates { .. }) => {}
263      _ => panic!("Multiple candidates error was expected"),
264    }
265  }
266
267  #[test]
268  fn found() {
269    let tmp = testing::tempdir();
270    let mut path = tmp.path().to_path_buf();
271    path.push(DEFAULT_JUSTFILE_NAME);
272    fs::write(&path, "default:\n\techo ok").unwrap();
273    path.pop();
274    if let Err(err) = Search::justfile(path.as_path()) {
275      panic!("No errors were expected: {err}");
276    }
277  }
278
279  #[test]
280  fn found_spongebob_case() {
281    let tmp = testing::tempdir();
282    let mut path = tmp.path().to_path_buf();
283    let spongebob_case = DEFAULT_JUSTFILE_NAME
284      .chars()
285      .enumerate()
286      .map(|(i, c)| {
287        if i % 2 == 0 {
288          c.to_ascii_uppercase()
289        } else {
290          c
291        }
292      })
293      .collect::<String>();
294    path.push(spongebob_case);
295    fs::write(&path, "default:\n\techo ok").unwrap();
296    path.pop();
297    if let Err(err) = Search::justfile(path.as_path()) {
298      panic!("No errors were expected: {err}");
299    }
300  }
301
302  #[test]
303  fn found_from_inner_dir() {
304    let tmp = testing::tempdir();
305    let mut path = tmp.path().to_path_buf();
306    path.push(DEFAULT_JUSTFILE_NAME);
307    fs::write(&path, "default:\n\techo ok").unwrap();
308    path.pop();
309    path.push("a");
310    fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory");
311    path.push("b");
312    fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory");
313    if let Err(err) = Search::justfile(path.as_path()) {
314      panic!("No errors were expected: {err}");
315    }
316  }
317
318  #[test]
319  fn found_and_stopped_at_first_justfile() {
320    let tmp = testing::tempdir();
321    let mut path = tmp.path().to_path_buf();
322    path.push(DEFAULT_JUSTFILE_NAME);
323    fs::write(&path, "default:\n\techo ok").unwrap();
324    path.pop();
325    path.push("a");
326    fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory");
327    path.push(DEFAULT_JUSTFILE_NAME);
328    fs::write(&path, "default:\n\techo ok").unwrap();
329    path.pop();
330    path.push("b");
331    fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory");
332    match Search::justfile(path.as_path()) {
333      Ok(found_path) => {
334        path.pop();
335        path.push(DEFAULT_JUSTFILE_NAME);
336        assert_eq!(found_path, path);
337      }
338      Err(err) => panic!("No errors were expected: {err}"),
339    }
340  }
341
342  #[test]
343  fn justfile_symlink_parent() {
344    let tmp = temptree! {
345      src: "",
346      sub: {},
347    };
348
349    let src = tmp.path().join("src");
350    let sub = tmp.path().join("sub");
351    let justfile = sub.join("justfile");
352
353    #[cfg(unix)]
354    std::os::unix::fs::symlink(src, &justfile).unwrap();
355
356    #[cfg(windows)]
357    std::os::windows::fs::symlink_file(&src, &justfile).unwrap();
358
359    let search_config = SearchConfig::FromInvocationDirectory;
360
361    let search = Search::find(&search_config, &sub).unwrap();
362
363    assert_eq!(search.justfile, justfile);
364    assert_eq!(search.working_directory, sub);
365  }
366
367  #[test]
368  fn clean() {
369    let cases = &[
370      ("/", "foo", "/foo"),
371      ("/bar", "/foo", "/foo"),
372      #[cfg(windows)]
373      ("//foo", "bar//baz", "//foo\\bar\\baz"),
374      #[cfg(not(windows))]
375      ("/", "..", "/"),
376      ("/", "/..", "/"),
377      ("/..", "", "/"),
378      ("/../../../..", "../../../", "/"),
379      ("/.", "./", "/"),
380      ("/foo/../", "bar", "/bar"),
381      ("/foo/bar", "..", "/foo"),
382      ("/foo/bar/", "..", "/foo"),
383    ];
384
385    for (prefix, suffix, want) in cases {
386      let have = Search::clean(Path::new(prefix), Path::new(suffix));
387      assert_eq!(have, Path::new(want));
388    }
389  }
390}