use {super::*, std::path::Component}; const DEFAULT_JUSTFILE_NAME: &str = JUSTFILE_NAMES[0]; pub(crate) const JUSTFILE_NAMES: [&str; 2] = ["justfile", ".justfile"]; const PROJECT_ROOT_CHILDREN: &[&str] = &[".bzr", ".git", ".hg", ".svn", "_darcs"]; pub(crate) struct Search { pub(crate) justfile: PathBuf, pub(crate) working_directory: PathBuf, } impl Search { fn global_justfile_paths() -> Vec { let mut paths = Vec::new(); if let Some(config_dir) = dirs::config_dir() { paths.push(config_dir.join("just").join(DEFAULT_JUSTFILE_NAME)); } if let Some(home_dir) = dirs::home_dir() { paths.push( home_dir .join(".config") .join("just") .join(DEFAULT_JUSTFILE_NAME), ); for justfile_name in JUSTFILE_NAMES { paths.push(home_dir.join(justfile_name)); } } paths } pub(crate) fn find( search_config: &SearchConfig, invocation_directory: &Path, ) -> SearchResult { match search_config { SearchConfig::FromInvocationDirectory => Self::find_next(invocation_directory), SearchConfig::FromSearchDirectory { search_directory } => { let search_directory = Self::clean(invocation_directory, search_directory); let justfile = Self::justfile(&search_directory)?; let working_directory = Self::working_directory_from_justfile(&justfile)?; Ok(Self { justfile, working_directory, }) } SearchConfig::GlobalJustfile => Ok(Self { justfile: Self::global_justfile_paths() .iter() .find(|path| path.exists()) .cloned() .ok_or(SearchError::GlobalJustfileNotFound)?, working_directory: Self::project_root(invocation_directory)?, }), SearchConfig::WithJustfile { justfile } => { let justfile = Self::clean(invocation_directory, justfile); let working_directory = Self::working_directory_from_justfile(&justfile)?; Ok(Self { justfile, working_directory, }) } SearchConfig::WithJustfileAndWorkingDirectory { justfile, working_directory, } => Ok(Self { justfile: Self::clean(invocation_directory, justfile), working_directory: Self::clean(invocation_directory, working_directory), }), } } pub(crate) fn find_next(starting_dir: &Path) -> SearchResult { let justfile = Self::justfile(starting_dir)?; let working_directory = Self::working_directory_from_justfile(&justfile)?; Ok(Self { justfile, working_directory, }) } 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 justfile = working_directory.join(DEFAULT_JUSTFILE_NAME); Ok(Self { 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(DEFAULT_JUSTFILE_NAME); Ok(Self { justfile, working_directory, }) } SearchConfig::GlobalJustfile => Err(SearchError::GlobalJustfileInit), SearchConfig::WithJustfile { justfile } => { let justfile = Self::clean(invocation_directory, justfile); let working_directory = Self::working_directory_from_justfile(&justfile)?; Ok(Self { justfile, working_directory, }) } SearchConfig::WithJustfileAndWorkingDirectory { justfile, working_directory, } => Ok(Self { justfile: Self::clean(invocation_directory, justfile), working_directory: Self::clean(invocation_directory, working_directory), }), } } pub(crate) fn justfile(directory: &Path) -> SearchResult { for directory in directory.ancestors() { let mut candidates = BTreeSet::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 { io_error, directory: directory.to_owned(), })?; if let Some(name) = entry.file_name().to_str() { for justfile_name in JUSTFILE_NAMES { if name.eq_ignore_ascii_case(justfile_name) { candidates.insert(entry.path()); } } } } match candidates.len() { 0 => {} 1 => return Ok(candidates.into_iter().next().unwrap()), _ => return Err(SearchError::MultipleCandidates { candidates }), } } Err(SearchError::NotFound) } fn clean(invocation_directory: &Path, path: &Path) -> PathBuf { let path = invocation_directory.join(path); let mut clean = Vec::new(); for component in path.components() { if component == Component::ParentDir { if let Some(Component::Normal(_)) = clean.last() { clean.pop(); } } else { clean.push(component); } } 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().copied() { 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 .parent() .ok_or_else(|| SearchError::JustfileHadNoParent { path: justfile.to_path_buf(), })? .to_owned(), ) } } #[cfg(test)] mod tests { use super::*; use temptree::temptree; #[test] fn not_found() { let tmp = testing::tempdir(); match Search::justfile(tmp.path()) { Err(SearchError::NotFound) => {} _ => panic!("No justfile found error was expected"), } } #[test] fn multiple_candidates() { let tmp = testing::tempdir(); let mut path = tmp.path().to_path_buf(); path.push(DEFAULT_JUSTFILE_NAME); fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); path.push(DEFAULT_JUSTFILE_NAME.to_uppercase()); if fs::File::open(path.as_path()).is_ok() { // We are in case-insensitive file system return; } fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); match Search::justfile(path.as_path()) { Err(SearchError::MultipleCandidates { .. }) => {} _ => panic!("Multiple candidates error was expected"), } } #[test] fn found() { let tmp = testing::tempdir(); let mut path = tmp.path().to_path_buf(); path.push(DEFAULT_JUSTFILE_NAME); fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); if let Err(err) = Search::justfile(path.as_path()) { panic!("No errors were expected: {err}"); } } #[test] fn found_spongebob_case() { let tmp = testing::tempdir(); let mut path = tmp.path().to_path_buf(); let spongebob_case = DEFAULT_JUSTFILE_NAME .chars() .enumerate() .map(|(i, c)| { if i % 2 == 0 { c.to_ascii_uppercase() } else { c } }) .collect::(); path.push(spongebob_case); fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); if let Err(err) = Search::justfile(path.as_path()) { panic!("No errors were expected: {err}"); } } #[test] fn found_from_inner_dir() { let tmp = testing::tempdir(); let mut path = tmp.path().to_path_buf(); 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("b"); fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); if let Err(err) = Search::justfile(path.as_path()) { panic!("No errors were expected: {err}"); } } #[test] fn found_and_stopped_at_first_justfile() { let tmp = testing::tempdir(); let mut path = tmp.path().to_path_buf(); 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(DEFAULT_JUSTFILE_NAME); fs::write(&path, "default:\n\techo ok").unwrap(); path.pop(); path.push("b"); fs::create_dir(&path).expect("test justfile search: failed to create intermediary directory"); match Search::justfile(path.as_path()) { Ok(found_path) => { path.pop(); path.push(DEFAULT_JUSTFILE_NAME); assert_eq!(found_path, path); } Err(err) => panic!("No errors were expected: {err}"), } } #[test] fn justfile_symlink_parent() { let tmp = temptree! { src: "", sub: {}, }; let src = tmp.path().join("src"); let sub = tmp.path().join("sub"); let justfile = sub.join("justfile"); #[cfg(unix)] std::os::unix::fs::symlink(src, &justfile).unwrap(); #[cfg(windows)] std::os::windows::fs::symlink_file(&src, &justfile).unwrap(); let search_config = SearchConfig::FromInvocationDirectory; let search = Search::find(&search_config, &sub).unwrap(); assert_eq!(search.justfile, justfile); assert_eq!(search.working_directory, sub); } #[test] fn clean() { let cases = &[ ("/", "foo", "/foo"), ("/bar", "/foo", "/foo"), #[cfg(windows)] ("//foo", "bar//baz", "//foo\\bar\\baz"), #[cfg(not(windows))] ("/", "..", "/"), ("/", "/..", "/"), ("/..", "", "/"), ("/../../../..", "../../../", "/"), ("/.", "./", "/"), ("/foo/../", "bar", "/bar"), ("/foo/bar", "..", "/foo"), ("/foo/bar/", "..", "/foo"), ]; for (prefix, suffix, want) in cases { let have = Search::clean(Path::new(prefix), Path::new(suffix)); assert_eq!(have, Path::new(want)); } } }