just/src/search.rs
2024-05-19 09:29:13 +00:00

370 lines
11 KiB
Rust

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<PathBuf> {
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<Self> {
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<Self> {
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<Self> {
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<PathBuf> {
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<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().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<PathBuf> {
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::<String>();
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));
}
}
}