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 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 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 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 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 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 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 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}