use std::path::{Component, Path, PathBuf, Prefix};
use crate::detect::heuristics::{
Ecosystem, Excludes, ManifestKind, basename, is_vendored, manifest_kind,
};
#[derive(Debug, Clone)]
pub(crate) struct DiscoverOptions {
pub(crate) hidden: bool,
pub(crate) respect_ignore: bool,
pub(crate) exclude: Vec<String>,
pub(crate) ecosystems: Vec<Ecosystem>,
}
impl Default for DiscoverOptions {
fn default() -> Self {
Self {
hidden: false,
respect_ignore: true,
exclude: Vec::new(),
ecosystems: Vec::new(),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Found {
pub(crate) path: PathBuf,
pub(crate) kind: ManifestKind,
}
pub(crate) fn discover(root: &Path, options: &DiscoverOptions) -> Result<Vec<Found>, String> {
let metadata =
std::fs::metadata(root).map_err(|error| format!("{}: {error}", root.display()))?;
if metadata.is_file() {
let kind = manifest_kind(&normalise(root))
.ok_or_else(|| format!("{}: not a manifest this tool reads", root.display()))?;
return Ok(vec![Found {
path: root.to_path_buf(),
kind,
}]);
}
let mut builder = ignore::WalkBuilder::new(root);
builder
.hidden(false)
.git_ignore(options.respect_ignore)
.git_global(options.respect_ignore)
.git_exclude(options.respect_ignore)
.ignore(options.respect_ignore)
.parents(options.respect_ignore)
.follow_links(false)
.filter_entry({
let descend_hidden = options.hidden;
move |entry| {
let is_dir = entry.file_type().is_some_and(|kind| kind.is_dir());
!is_dir || descend(entry.path(), descend_hidden)
}
});
let excluded = Excludes::new(&options.exclude);
let mut found = Vec::new();
for entry in builder.build() {
let entry = entry.map_err(|error| format!("{}: {error}", root.display()))?;
if !entry.file_type().is_some_and(|kind| kind.is_file()) {
continue;
}
let path = entry.into_path();
let relative = label(&path, root, false);
let Some(kind) = manifest_kind(&relative) else {
continue;
};
if excluded.matches(&relative) || !wanted(kind, &options.ecosystems) {
continue;
}
found.push(Found { path, kind });
}
found.sort_by(|left, right| left.path.cmp(&right.path));
found.dedup();
Ok(found)
}
fn wanted(kind: ManifestKind, ecosystems: &[Ecosystem]) -> bool {
ecosystems.is_empty() || ecosystems.contains(&kind.ecosystem())
}
fn descend(path: &Path, hidden: bool) -> bool {
let name = path
.file_name()
.map_or_else(String::new, |name| name.to_string_lossy().into_owned());
if is_vendored(&name) {
return false;
}
hidden || name == ".github" || !name.starts_with('.')
}
const BYTE_ORDER_MARK: char = '\u{feff}';
pub(crate) fn read(path: &Path) -> Result<String, String> {
let bytes = std::fs::read(path).map_err(|error| format!("{}: {error}", path.display()))?;
let mut text =
String::from_utf8(bytes).map_err(|_| format!("{}: not UTF-8 text", path.display()))?;
if text.starts_with(BYTE_ORDER_MARK) {
text.replace_range(..BYTE_ORDER_MARK.len_utf8(), "");
}
Ok(text)
}
pub(crate) fn label(path: &Path, root: &Path, qualify: bool) -> String {
if root.is_file() {
return normalise(root);
}
let relative = path
.strip_prefix(root)
.map_or_else(|_| normalise(path), normalise);
let root = normalise(root);
if !qualify || root == "." || root.is_empty() {
return relative;
}
format!("{}/{relative}", root.trim_end_matches('/'))
}
pub(crate) fn normalise(path: &Path) -> String {
let mut out = String::new();
for component in path.components() {
match component {
Component::Prefix(prefix) => out.push_str(&designator(prefix.kind())),
Component::RootDir => {
if !out.ends_with('/') {
out.push('/');
}
}
Component::CurDir | Component::ParentDir | Component::Normal(_) => {
if !out.is_empty() && !out.ends_with('/') {
out.push('/');
}
out.push_str(&component.as_os_str().to_string_lossy());
}
}
}
out
}
fn designator(prefix: Prefix<'_>) -> String {
match prefix {
Prefix::Disk(letter) | Prefix::VerbatimDisk(letter) => {
format!("{}:", letter.to_ascii_uppercase() as char)
}
Prefix::UNC(server, share) | Prefix::VerbatimUNC(server, share) => {
format!("//{}/{}", server.to_string_lossy(), share.to_string_lossy())
}
Prefix::DeviceNS(name) => format!("//./{}", name.to_string_lossy()),
Prefix::Verbatim(name) => format!("//?/{}", name.to_string_lossy()),
}
}
pub(crate) fn name_of(path: &Path) -> String {
basename(&normalise(path)).to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::testing::TempTree;
fn labels(found: &[Found], root: &Path) -> Vec<String> {
found
.iter()
.map(|item| label(&item.path, root, false))
.collect()
}
#[test]
fn the_manifests_are_found_and_nothing_else_is() {
let tree = TempTree::new("discover-basic");
tree.write("package.json", "{}");
tree.write("Cargo.toml", "");
tree.write("crates/api/Cargo.toml", "");
tree.write("README.md", "not a manifest");
tree.write("Cargo.lock", "");
let found = discover(tree.path(), &DiscoverOptions::default()).expect("walks");
assert_eq!(
labels(&found, tree.path()),
["Cargo.toml", "crates/api/Cargo.toml", "package.json"]
);
}
#[test]
fn the_workflow_directory_is_walked_without_asking() {
let tree = TempTree::new("discover-workflows");
tree.write(".github/workflows/ci.yml", "jobs:\n");
tree.write(".github/dependabot.yml", "version: 2\n");
let found = discover(tree.path(), &DiscoverOptions::default()).expect("walks");
assert_eq!(labels(&found, tree.path()), [".github/workflows/ci.yml"]);
}
#[test]
fn other_hidden_directories_are_skipped_unless_asked_for() {
let tree = TempTree::new("discover-hidden");
tree.write("Cargo.toml", "");
tree.write(".cache/Cargo.toml", "");
assert_eq!(
discover(tree.path(), &DiscoverOptions::default())
.expect("walks")
.len(),
1
);
assert_eq!(
discover(
tree.path(),
&DiscoverOptions {
hidden: true,
..DiscoverOptions::default()
}
)
.expect("walks")
.len(),
2
);
}
#[test]
fn node_modules_is_never_walked() {
let tree = TempTree::new("discover-vendored");
tree.write("package.json", "{}");
tree.write("node_modules/left-pad/package.json", "{}");
let found = discover(tree.path(), &DiscoverOptions::default()).expect("walks");
assert_eq!(labels(&found, tree.path()), ["package.json"]);
}
#[test]
fn an_ecosystem_filter_narrows_the_walk() {
let tree = TempTree::new("discover-filter");
tree.write("package.json", "{}");
tree.write("Cargo.toml", "");
let found = discover(
tree.path(),
&DiscoverOptions {
ecosystems: vec![Ecosystem::Cargo],
..DiscoverOptions::default()
},
)
.expect("walks");
assert_eq!(labels(&found, tree.path()), ["Cargo.toml"]);
}
#[test]
fn ignored_files_are_skipped() {
let tree = TempTree::new("discover-ignore");
tree.mkdir(".git");
tree.write(".gitignore", "generated/\n");
tree.write("Cargo.toml", "");
tree.write("generated/Cargo.toml", "");
let found = discover(tree.path(), &DiscoverOptions::default()).expect("walks");
assert_eq!(labels(&found, tree.path()), ["Cargo.toml"]);
}
#[test]
fn an_exclude_pattern_hides_a_manifest() {
let tree = TempTree::new("discover-exclude");
tree.write("Cargo.toml", "");
tree.write("examples/demo/Cargo.toml", "");
let found = discover(
tree.path(),
&DiscoverOptions {
exclude: vec!["examples/**".to_string()],
..DiscoverOptions::default()
},
)
.expect("walks");
assert_eq!(labels(&found, tree.path()), ["Cargo.toml"]);
}
#[test]
fn a_named_file_is_the_whole_walk() {
let tree = TempTree::new("discover-one");
let file = tree.write("Cargo.toml", "");
let found = discover(&file, &DiscoverOptions::default()).expect("walks");
assert_eq!(found.len(), 1);
assert_eq!(found[0].kind, ManifestKind::CargoToml);
}
#[test]
fn a_named_file_that_is_not_a_manifest_is_refused_by_name() {
let tree = TempTree::new("discover-not-a-manifest");
let file = tree.write("README.md", "");
let error = discover(&file, &DiscoverOptions::default()).expect_err("a refusal");
assert!(error.contains("README.md"), "{error}");
}
#[test]
fn a_missing_root_is_refused_by_name() {
let tree = TempTree::new("discover-missing");
let error = discover(&tree.path().join("nope"), &DiscoverOptions::default())
.expect_err("a refusal");
assert!(error.contains("nope"), "{error}");
}
#[test]
fn the_order_is_stable_between_runs() {
let tree = TempTree::new("discover-order");
for name in ["z/Cargo.toml", "a/Cargo.toml", "m/package.json"] {
tree.write(name, "");
}
let first = discover(tree.path(), &DiscoverOptions::default()).expect("walks");
let again = discover(tree.path(), &DiscoverOptions::default()).expect("walks");
assert_eq!(first, again);
assert_eq!(
labels(&first, tree.path()),
["a/Cargo.toml", "m/package.json", "z/Cargo.toml"]
);
}
#[test]
fn more_than_one_root_qualifies_the_label() {
let tree = TempTree::new("discover-label");
let file = tree.write("api/Cargo.toml", "");
let root = tree.path().join("api");
assert_eq!(label(&file, &root, false), "Cargo.toml");
assert!(label(&file, &root, true).ends_with("api/Cargo.toml"));
assert!(!label(&file, &root, true).contains('\\'));
}
#[test]
fn an_absolute_file_root_is_labelled_with_one_separator_run() {
let tree = TempTree::new("discover-absolute");
let file = tree.write("Cargo.toml", "");
let labelled = label(&file, &file, false);
assert!(!labelled.contains('\\'), "{labelled}");
assert!(!labelled.contains("//"), "{labelled}");
assert!(labelled.ends_with("/Cargo.toml"), "{labelled}");
let rooted = labelled.starts_with('/');
let drive = labelled.split_once(":/").is_some_and(|(head, _)| {
head.len() == 1 && head.starts_with(|c: char| c.is_ascii_uppercase())
});
assert!(rooted || drive, "{labelled}");
}
#[test]
fn every_windows_prefix_shape_is_one_forward_slashed_designator() {
use std::ffi::OsStr;
let server = OsStr::new("server");
let share = OsStr::new("share");
for (prefix, expected) in [
(Prefix::Disk(b'C'), "C:"),
(Prefix::Disk(b'c'), "C:"),
(Prefix::VerbatimDisk(b'C'), "C:"),
(Prefix::UNC(server, share), "//server/share"),
(Prefix::VerbatimUNC(server, share), "//server/share"),
(Prefix::DeviceNS(OsStr::new("COM1")), "//./COM1"),
(Prefix::Verbatim(OsStr::new("Volume{0}")), "//?/Volume{0}"),
] {
let designated = designator(prefix);
assert_eq!(designated, expected, "{prefix:?}");
assert!(!designated.contains('\\'), "{prefix:?} kept a backslash");
}
}
#[test]
fn a_verbatim_drive_and_a_plain_drive_are_the_same_identity() {
assert_eq!(
designator(Prefix::VerbatimDisk(b'C')),
designator(Prefix::Disk(b'C'))
);
assert_eq!(
designator(Prefix::VerbatimUNC(
std::ffi::OsStr::new("server"),
std::ffi::OsStr::new("share")
)),
designator(Prefix::UNC(
std::ffi::OsStr::new("server"),
std::ffi::OsStr::new("share")
))
);
}
#[test]
fn two_unc_shares_keep_two_identities() {
use std::ffi::OsStr;
let one = designator(Prefix::UNC(OsStr::new("alpha"), OsStr::new("share")));
let other = designator(Prefix::UNC(OsStr::new("beta"), OsStr::new("share")));
assert_ne!(one, other);
assert_ne!(
one,
designator(Prefix::UNC(OsStr::new("alpha"), OsStr::new("other")))
);
}
#[cfg(windows)]
#[test]
fn windows_paths_normalise_to_one_label_per_file() {
for (raw, expected) in [
(r"\\?\C:\a\Cargo.toml", "C:/a/Cargo.toml"),
(r"C:\a\Cargo.toml", "C:/a/Cargo.toml"),
(r"c:\a\Cargo.toml", "C:/a/Cargo.toml"),
(
r"\\server\share\a\Cargo.toml",
"//server/share/a/Cargo.toml",
),
(
r"\\?\UNC\server\share\a\Cargo.toml",
"//server/share/a/Cargo.toml",
),
(r"a\b\Cargo.toml", "a/b/Cargo.toml"),
(r".github\workflows\ci.yml", ".github/workflows/ci.yml"),
] {
assert_eq!(normalise(Path::new(raw)), expected, "{raw}");
}
}
#[cfg(not(windows))]
#[test]
fn windows_paths_normalise_to_one_label_per_file() {
eprintln!("SKIPPED windows_paths_normalise_to_one_label_per_file: not a Windows target");
}
#[test]
fn prefix_free_paths_normalise_to_what_the_caller_typed() {
for (raw, expected) in [
("a/b/Cargo.toml", "a/b/Cargo.toml"),
("./a/Cargo.toml", "./a/Cargo.toml"),
("Cargo.toml", "Cargo.toml"),
("/a/b/Cargo.toml", "/a/b/Cargo.toml"),
("/Cargo.toml", "/Cargo.toml"),
("a//b/Cargo.toml", "a/b/Cargo.toml"),
("../a/Cargo.toml", "../a/Cargo.toml"),
] {
assert_eq!(normalise(Path::new(raw)), expected, "{raw}");
}
}
#[test]
fn a_byte_order_mark_is_not_part_of_the_document() {
let tree = TempTree::new("discover-bom");
let file = tree.write("package.json", "\u{feff}{ \"name\": \"a\" }");
let text = read(&file).expect("reads");
assert!(text.starts_with('{'), "{text:?}");
}
#[test]
fn a_file_that_is_not_text_is_refused_by_name() {
let tree = TempTree::new("discover-binary");
let file = tree.path().join("package.json");
std::fs::write(&file, [0xff, 0xfe, 0x00]).expect("a file");
let error = read(&file).expect_err("a refusal");
assert!(error.contains("not UTF-8"), "{error}");
}
}