use std::path::{Path, PathBuf};
use crate::project::Project;
use anyhow::{Context, Result};
use async_trait::async_trait;
#[macro_export]
macro_rules! impl_projects_hashmap_accessors {
() => {
fn projects(&self) -> ::std::vec::Vec<&$crate::Project> {
self.projects.values().collect::<::std::vec::Vec<_>>()
}
fn projects_mut(&mut self) -> ::std::vec::Vec<&mut $crate::Project> {
self.projects.values_mut().collect::<::std::vec::Vec<_>>()
}
fn project_count(&self) -> ::std::primitive::usize {
self.projects.len()
}
fn extend_projects<'a>(&'a self, out: &mut ::std::vec::Vec<&'a $crate::Project>) {
out.extend(self.projects.values());
}
fn extend_projects_mut<'a>(
&'a mut self,
out: &mut ::std::vec::Vec<&'a mut $crate::Project>,
) {
out.extend(self.projects.values_mut());
}
fn contains_project(&self, path: &::std::path::Path) -> ::std::primitive::bool {
self.projects.contains_key(path)
}
};
}
#[macro_export]
macro_rules! impl_dependencies_accessors {
() => {
fn dependencies(&self) -> &::std::collections::HashSet<::std::string::String> {
&self.dependencies
}
fn add_dependency(&mut self, dependency: &str) {
if !self.dependencies.contains(dependency) {
self.dependencies
.insert(::std::string::ToString::to_string(dependency));
}
}
};
}
#[macro_export]
macro_rules! impl_publishable_by_default {
() => {
fn is_publishable_by_default(&self) -> ::std::primitive::bool {
self.publishable_by_default
}
};
}
#[macro_export]
macro_rules! impl_const_publish_commands {
($publish:path, $dry_run:path) => {
fn default_publish_command(&self) -> ::std::string::String {
$publish.to_string()
}
fn default_dry_run_publish_command(&self) -> ::std::option::Option<::std::string::String> {
::std::option::Option::Some($dry_run.to_string())
}
};
($publish:path) => {
fn default_publish_command(&self) -> ::std::string::String {
$publish.to_string()
}
fn default_dry_run_publish_command(&self) -> ::std::option::Option<::std::string::String> {
::std::option::Option::None
}
};
}
#[macro_export]
macro_rules! impl_publish_command_resolvers {
() => {
fn get_publish_command(&self, config: &$crate::Config) -> ::std::string::String {
$crate::publish::resolve_publish_command(
self.relative_path(),
self.language(),
|| self.default_publish_command(),
config,
)
}
fn get_dry_run_publish_command(
&self,
config: &$crate::Config,
) -> ::std::option::Option<::std::string::String> {
$crate::publish::resolve_dry_run_publish_command(
self.relative_path(),
self.language(),
|| self.default_dry_run_publish_command(),
config,
)
}
};
}
#[macro_export]
macro_rules! impl_publish_flows {
($dir_not_found:path) => {
fn publish<'life0, 'life1, 'async_trait>(
&'life0 self,
config: &'life1 $crate::Config,
) -> ::core::pin::Pin<
::std::boxed::Box<
dyn ::core::future::Future<
Output = ::anyhow::Result<$crate::publish::PublishOutput>,
> + ::core::marker::Send
+ 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
::std::boxed::Box::pin(async move {
let command = self.get_publish_command(config);
$crate::publish::run_publish_flow(&command, self.path(), &[], $dir_not_found).await
})
}
fn dry_run_publish<'life0, 'life1, 'async_trait>(
&'life0 self,
config: &'life1 $crate::Config,
) -> ::core::pin::Pin<
::std::boxed::Box<
dyn ::core::future::Future<
Output = ::anyhow::Result<
::std::option::Option<$crate::publish::PublishOutput>,
>,
> + ::core::marker::Send
+ 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: 'async_trait,
{
::std::boxed::Box::pin(async move {
let command = self.get_dry_run_publish_command(config);
$crate::publish::run_dry_run_publish_flow(
command.as_deref(),
self.path(),
&[],
$dir_not_found,
)
.await
})
}
};
}
#[macro_export]
macro_rules! impl_shared_project_defaults {
() => {
fn check_changed(&mut self, path: &::std::path::Path) -> ::anyhow::Result<()> {
if self.is_changed() {
return ::core::result::Result::Ok(());
}
if $crate::change_detection::should_mark_changed(path, self.path())? {
self.set_changed(true);
}
::core::result::Result::Ok(())
}
fn is_publishable_by_default(&self) -> ::std::primitive::bool {
true
}
fn is_dry_run_publishable_by_default(&self) -> ::std::primitive::bool {
self.is_publishable_by_default()
}
};
}
#[macro_export]
macro_rules! impl_basic_accessors {
() => {
fn name(&self) -> ::std::option::Option<&::std::primitive::str> {
self.name.as_deref()
}
fn version(&self) -> ::std::option::Option<&::std::primitive::str> {
self.version.as_deref()
}
fn path(&self) -> &::std::path::Path {
&self.path
}
fn relative_path(&self) -> &::std::path::Path {
&self.relative_path
}
fn is_changed(&self) -> ::std::primitive::bool {
self.is_changed
}
fn set_changed(&mut self, changed: ::std::primitive::bool) {
self.is_changed = changed;
}
fn set_name(&mut self, name: ::std::string::String) {
self.name = ::std::option::Option::Some(name);
}
};
}
#[macro_export]
macro_rules! assert_set_name_roundtrip {
($project:expr) => {{
let mut project = $project;
::std::assert_eq!(
project.name(),
::std::option::Option::None,
"a project constructed with no name must report None"
);
project.set_name(::std::string::ToString::to_string("my-project"));
::std::assert_eq!(
project.name(),
::std::option::Option::Some("my-project"),
"set_name must be observable through name()"
);
}};
}
#[macro_export]
macro_rules! assert_set_changed_roundtrip {
($project:expr) => {{
let mut project = $project;
::std::assert!(
!project.is_changed(),
"a freshly constructed project must start unchanged"
);
project.set_changed(true);
::std::assert!(
project.is_changed(),
"set_changed(true) must be observable through is_changed()"
);
project.set_changed(false);
::std::assert!(
!project.is_changed(),
"set_changed(false) must clear is_changed() again"
);
}};
}
#[macro_export]
macro_rules! assert_dependencies_roundtrip {
($project:expr, $first:expr, $second:expr) => {{
let mut project = $project;
::std::assert!(
project.dependencies().is_empty(),
"a freshly constructed project must start with no dependencies"
);
project.add_dependency($first);
project.add_dependency($second);
let deps = project.dependencies();
::std::assert_eq!(deps.len(), 2, "both added dependencies must be recorded");
::std::assert!(
deps.contains($first),
"add_dependency must be observable through dependencies()"
);
::std::assert!(
deps.contains($second),
"add_dependency must be observable through dependencies()"
);
project.add_dependency($first);
::std::assert_eq!(
project.dependencies().len(),
2,
"re-adding an existing dependency must not grow the set"
);
}};
}
#[macro_export]
macro_rules! impl_discovered_new {
() => {
#[must_use]
pub fn new(
name: ::std::option::Option<::std::string::String>,
version: ::std::option::Option<::std::string::String>,
path: ::std::path::PathBuf,
relative_path: ::std::path::PathBuf,
) -> Self {
Self::new_discovered(name, version, path, relative_path, true)
}
#[must_use]
pub(crate) fn new_discovered(
name: ::std::option::Option<::std::string::String>,
version: ::std::option::Option<::std::string::String>,
path: ::std::path::PathBuf,
relative_path: ::std::path::PathBuf,
publishable_by_default: ::std::primitive::bool,
) -> Self {
Self {
name,
version,
path,
relative_path,
is_changed: false,
publishable_by_default,
dependencies: ::std::collections::HashSet::new(),
}
}
};
}
#[macro_export]
macro_rules! declare_discovered_project {
($(#[$meta:meta])* pub struct $name:ident) => {
$(#[$meta])*
#[derive(::std::fmt::Debug)]
pub struct $name {
name: ::std::option::Option<::std::string::String>,
version: ::std::option::Option<::std::string::String>,
path: ::std::path::PathBuf,
relative_path: ::std::path::PathBuf,
is_changed: ::std::primitive::bool,
publishable_by_default: ::std::primitive::bool,
dependencies: ::std::collections::HashSet<::std::string::String>,
}
impl $name {
$crate::impl_discovered_new!();
}
};
}
#[macro_export]
macro_rules! discovered_project {
($is_workspace:expr, $ws:path, $pkg:path, $($arg:expr),* $(,)?) => {
if $is_workspace {
$crate::Project::Workspace(::std::boxed::Box::new($ws($($arg),*)))
} else {
$crate::Project::Package(::std::boxed::Box::new($pkg($($arg),*)))
}
};
}
#[macro_export]
macro_rules! impl_language {
($lang:expr) => {
fn language(&self) -> $crate::Language {
$lang
}
};
}
#[must_use]
pub fn has_extension_ignore_ascii_case(path: &Path, ext: &str) -> bool {
path.extension()
.and_then(|e| e.to_str())
.is_some_and(|e| e.eq_ignore_ascii_case(ext))
}
pub async fn is_regular_file(path: &Path) -> Result<bool> {
Ok(regular_file_metadata(path).await?.is_some())
}
pub async fn regular_file_metadata(path: &Path) -> Result<Option<std::fs::Metadata>> {
match tokio::fs::metadata(path).await {
Ok(metadata) if metadata.is_file() => Ok(Some(metadata)),
Ok(_) => Ok(None),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(error) => {
Err(error).with_context(|| format!("Failed to read metadata for {}", path.display()))
}
}
}
#[async_trait]
pub trait ProjectFinder: std::fmt::Debug + Send + Sync {
fn projects(&self) -> Vec<&Project>;
fn projects_mut(&mut self) -> Vec<&mut Project>;
fn project_count(&self) -> usize;
fn extend_projects<'a>(&'a self, out: &mut Vec<&'a Project>) {
out.extend(self.projects());
}
fn extend_projects_mut<'a>(&'a mut self, out: &mut Vec<&'a mut Project>) {
out.extend(self.projects_mut());
}
fn contains_project(&self, path: &Path) -> bool {
self.projects()
.into_iter()
.any(|project| project.path() == path)
}
fn should_visit_manifest<'life0, 'life1, 'async_trait>(
&'life0 self,
path: &'life1 Path,
) -> ::core::pin::Pin<
::std::boxed::Box<
dyn ::core::future::Future<Output = Result<bool>> + ::core::marker::Send + 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: ::core::marker::Sync + 'async_trait,
{
::std::boxed::Box::pin(async move {
Ok(self.matches_project_file(path).await? && !self.contains_project(path))
})
}
fn project_files(&self) -> &[&str];
async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()>;
fn matches_project_file<'life0, 'life1, 'async_trait>(
&'life0 self,
path: &'life1 Path,
) -> ::core::pin::Pin<
::std::boxed::Box<
dyn ::core::future::Future<Output = Result<bool>> + ::core::marker::Send + 'async_trait,
>,
>
where
'life0: 'async_trait,
'life1: 'async_trait,
Self: ::core::marker::Sync + 'async_trait,
{
::std::boxed::Box::pin(async move {
let Some(name_os) = path.file_name() else {
return Ok(false);
};
let Some(name) = name_os.to_str() else {
return Ok(false);
};
if !self.project_files().contains(&name) {
return Ok(false);
}
is_regular_file(path).await
})
}
fn check_changed_many(&mut self, paths: &[PathBuf]) -> Result<()> {
for project in self.projects_mut() {
for path in paths {
project.check_changed(path)?;
if project.is_changed() {
break;
}
}
}
Ok(())
}
fn finalize<'life0, 'async_trait>(
&'life0 mut self,
) -> ::core::pin::Pin<
::std::boxed::Box<
dyn ::core::future::Future<Output = Result<()>> + ::core::marker::Send + 'async_trait,
>,
>
where
'life0: 'async_trait,
Self: ::core::marker::Send + 'async_trait,
{
::std::boxed::Box::pin(async move { Ok(()) })
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_support::{MockPackage, MockWorkspace};
use crate::{Package, Workspace};
use async_trait::async_trait;
use rstest::rstest;
use std::path::PathBuf;
#[rstest]
#[case("foo.json", "json", true)]
#[case("foo.JSON", "json", true)]
#[case("foo.Json", "json", true)]
#[case("foo", "json", false)]
#[case(".json", "json", false)]
#[case("foo.jsonx", "json", false)]
fn test_has_extension_ignore_ascii_case(
#[case] file: &str,
#[case] ext: &str,
#[case] expected: bool,
) {
assert_eq!(
has_extension_ignore_ascii_case(Path::new(file), ext),
expected,
"has_extension_ignore_ascii_case(Path::new({file:?}), {ext:?})"
);
}
#[test]
fn test_add_dependency_deduplicates_repeated_names() {
let mut package = MockPackage::same_path("pkg", "/project/package.json");
package.add_dependency("serde");
package.add_dependency("serde");
package.add_dependency("serde");
assert_eq!(
package.dependencies().len(),
1,
"repeated add_dependency must keep exactly one entry"
);
assert!(package.dependencies().contains("serde"));
package.add_dependency("tokio");
package.add_dependency("anyhow");
package.add_dependency("tokio");
let mut names = package.dependencies().iter().cloned().collect::<Vec<_>>();
names.sort();
assert_eq!(names, vec!["anyhow", "serde", "tokio"]);
}
#[test]
fn test_add_dependency_deduplicates_on_workspace_too() {
let mut workspace = MockWorkspace::same_path("root", "/project/package.json");
workspace.add_dependency("left-pad");
workspace.add_dependency("left-pad");
assert_eq!(workspace.dependencies().len(), 1);
assert!(workspace.dependencies().contains("left-pad"));
}
#[derive(Debug)]
struct MockProjectFinder {
projects: Vec<Project>,
}
impl MockProjectFinder {
fn new() -> Self {
Self { projects: vec![] }
}
fn with_package(mut self, package: MockPackage) -> Self {
self.projects.push(Project::Package(Box::new(package)));
self
}
fn with_workspace(mut self, workspace: MockWorkspace) -> Self {
self.projects.push(Project::Workspace(Box::new(workspace)));
self
}
}
#[async_trait]
impl ProjectFinder for MockProjectFinder {
fn projects(&self) -> Vec<&Project> {
self.projects.iter().collect()
}
fn projects_mut(&mut self) -> Vec<&mut Project> {
self.projects.iter_mut().collect()
}
fn project_count(&self) -> usize {
self.projects.len()
}
fn project_files(&self) -> &[&str] {
&["package.json"]
}
async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
Ok(())
}
}
#[derive(Debug)]
struct HashMapProjectFinder {
projects: std::collections::HashMap<PathBuf, Project>,
}
impl HashMapProjectFinder {
fn with_packages(names: &[(&str, &str)]) -> Self {
let mut projects = std::collections::HashMap::new();
for (name, path) in names {
projects.insert(
PathBuf::from(*path),
Project::Package(Box::new(MockPackage::same_path(name, path))),
);
}
Self { projects }
}
}
#[async_trait]
impl ProjectFinder for HashMapProjectFinder {
crate::impl_projects_hashmap_accessors!();
fn project_files(&self) -> &[&str] {
&["package.json"]
}
async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
Ok(())
}
}
fn project_names<P: std::borrow::Borrow<Project>>(projects: &[P]) -> Vec<String> {
projects
.iter()
.map(|project| project.borrow().name().unwrap_or_default().to_string())
.collect()
}
#[test]
fn test_extend_projects_default_matches_projects_and_preserves_buffer() {
let finder = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
.with_workspace(MockWorkspace::same_path("root", "/project2/package.json"))
.with_package(MockPackage::same_path("pkg2", "/project3/package.json"));
let seed = MockProjectFinder::new()
.with_package(MockPackage::same_path("seed", "/seed/package.json"));
let mut out = seed.projects();
finder.extend_projects(&mut out);
let mut expected = vec!["seed".to_string()];
expected.extend(project_names(&finder.projects()));
assert_eq!(project_names(&out), expected);
}
#[test]
fn test_extend_projects_default_on_empty_finder_is_a_no_op() {
let finder = MockProjectFinder::new();
let mut out: Vec<&Project> = Vec::new();
finder.extend_projects(&mut out);
assert!(out.is_empty());
}
#[test]
fn test_extend_projects_mut_default_matches_projects_and_preserves_buffer() {
let mut finder = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
.with_workspace(MockWorkspace::same_path("root", "/project2/package.json"))
.with_package(MockPackage::same_path("pkg2", "/project3/package.json"));
let mut seed = MockProjectFinder::new()
.with_package(MockPackage::same_path("seed", "/seed/package.json"));
let mut out = seed.projects_mut();
finder.extend_projects_mut(&mut out);
let names = project_names(&out);
drop(out);
let mut expected = vec!["seed".to_string()];
expected.extend(project_names(&finder.projects()));
assert_eq!(names, expected);
}
#[test]
fn test_extend_projects_mut_default_on_empty_finder_is_a_no_op() {
let mut finder = MockProjectFinder::new();
let mut out: Vec<&mut Project> = Vec::new();
finder.extend_projects_mut(&mut out);
assert!(out.is_empty());
}
#[test]
fn test_extend_projects_macro_override_matches_projects_order() {
let finder = HashMapProjectFinder::with_packages(&[
("pkg1", "/project1/package.json"),
("pkg2", "/project2/package.json"),
("pkg3", "/project3/package.json"),
]);
let mut out: Vec<&Project> = Vec::new();
finder.extend_projects(&mut out);
assert_eq!(out.len(), finder.project_count());
assert_eq!(project_names(&out), project_names(&finder.projects()));
}
#[test]
fn test_extend_projects_mut_macro_override_matches_projects_order() {
let mut finder = HashMapProjectFinder::with_packages(&[
("pkg1", "/project1/package.json"),
("pkg2", "/project2/package.json"),
("pkg3", "/project3/package.json"),
]);
let mut out: Vec<&mut Project> = Vec::new();
finder.extend_projects_mut(&mut out);
let out_len = out.len();
let names = project_names(&out);
drop(out);
assert_eq!(out_len, finder.project_count());
assert_eq!(names, project_names(&finder.projects_mut()));
}
#[test]
fn test_extend_projects_mut_yields_borrows_that_mutate_the_finder() {
let mut finder = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
let mut out: Vec<&mut Project> = Vec::new();
finder.extend_projects_mut(&mut out);
for project in &mut out {
project.set_name("renamed".to_string());
}
drop(out);
assert_eq!(project_names(&finder.projects()), vec!["renamed"]);
}
#[test]
fn test_contains_project_default_scans_projects_by_path() {
let finder = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
.with_workspace(MockWorkspace::same_path("root", "/project2/package.json"));
assert!(finder.contains_project(Path::new("/project1/package.json")));
assert!(finder.contains_project(Path::new("/project2/package.json")));
assert!(!finder.contains_project(Path::new("/project3/package.json")));
assert!(!finder.contains_project(Path::new("/project1")));
}
#[test]
fn test_contains_project_default_on_empty_finder_is_always_false() {
let finder = MockProjectFinder::new();
assert!(!finder.contains_project(Path::new("/project1/package.json")));
}
#[test]
fn test_contains_project_macro_override_matches_default_answers() {
let entries = [
("pkg1", "/project1/package.json"),
("pkg2", "/project2/package.json"),
];
let hashed = HashMapProjectFinder::with_packages(&entries);
let mut scanned = MockProjectFinder::new();
for (name, path) in entries {
scanned = scanned.with_package(MockPackage::same_path(name, path));
}
for probe in [
"/project1/package.json",
"/project2/package.json",
"/project3/package.json",
"/project1",
"",
] {
assert_eq!(
hashed.contains_project(Path::new(probe)),
scanned.contains_project(Path::new(probe)),
"hashed and scanned answers diverged for {probe:?}"
);
}
}
#[tokio::test]
async fn test_should_visit_manifest_accepts_new_recognized_manifest() {
let temp_dir = tempfile::TempDir::new().unwrap();
let manifest = temp_dir.path().join("package.json");
std::fs::write(&manifest, "{}").unwrap();
let finder = MockProjectFinder::new();
assert!(finder.should_visit_manifest(&manifest).await.unwrap());
}
#[tokio::test]
async fn test_should_visit_manifest_rejects_already_discovered_manifest() {
let temp_dir = tempfile::TempDir::new().unwrap();
let manifest = temp_dir.path().join("package.json");
std::fs::write(&manifest, "{}").unwrap();
let finder = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg", manifest.to_str().unwrap()));
assert!(
!finder.should_visit_manifest(&manifest).await.unwrap(),
"a manifest already in the finder must not be visited twice"
);
}
#[tokio::test]
async fn test_should_visit_manifest_rejects_non_manifest_and_directory() {
let temp_dir = tempfile::TempDir::new().unwrap();
let other = temp_dir.path().join("Cargo.toml");
std::fs::write(&other, "[package]\n").unwrap();
let dir_path = temp_dir.path().join("package.json");
std::fs::create_dir(&dir_path).unwrap();
let finder = MockProjectFinder::new();
assert!(!finder.should_visit_manifest(&other).await.unwrap());
assert!(!finder.should_visit_manifest(&dir_path).await.unwrap());
}
#[tokio::test]
async fn test_should_visit_manifest_equals_the_open_coded_two_guard_prelude() {
let temp_dir = tempfile::TempDir::new().unwrap();
let manifest = temp_dir.path().join("package.json");
std::fs::write(&manifest, "{}").unwrap();
let unrecognized = temp_dir.path().join("Cargo.toml");
std::fs::write(&unrecognized, "[package]\n").unwrap();
let missing = temp_dir.path().join("nested").join("package.json");
let known = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg", manifest.to_str().unwrap()));
let empty = MockProjectFinder::new();
for finder in [&known, &empty] {
for probe in [&manifest, &unrecognized, &missing] {
let open_coded = finder.matches_project_file(probe).await.unwrap()
&& !finder.contains_project(probe);
assert_eq!(
finder.should_visit_manifest(probe).await.unwrap(),
open_coded,
"consolidated gate diverged from the open-coded prelude for {}",
probe.display()
);
}
}
}
#[test]
fn test_extend_projects_macro_override_preserves_existing_buffer_contents() {
let first = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
let second = HashMapProjectFinder::with_packages(&[("pkg2", "/project2/package.json")]);
let mut out: Vec<&Project> = Vec::new();
first.extend_projects(&mut out);
second.extend_projects(&mut out);
assert_eq!(project_names(&out), vec!["pkg1", "pkg2"]);
}
#[test]
fn test_extend_projects_mut_macro_override_preserves_existing_buffer_contents() {
let mut first = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
let mut second = HashMapProjectFinder::with_packages(&[("pkg2", "/project2/package.json")]);
let mut out: Vec<&mut Project> = Vec::new();
first.extend_projects_mut(&mut out);
second.extend_projects_mut(&mut out);
assert_eq!(project_names(&out), vec!["pkg1", "pkg2"]);
}
#[test]
fn test_project_finder_check_changed() {
let package = MockPackage::same_path("test", "/project/package.json");
let mut finder = MockProjectFinder::new().with_package(package);
finder
.check_changed_many(&[PathBuf::from("/project/src/index.js")])
.unwrap();
assert!(finder.projects()[0].is_changed());
}
#[test]
fn test_project_finder_check_changed_multiple_projects() {
let package1 = MockPackage::same_path("pkg1", "/project1/package.json");
let package2 = MockPackage::same_path("pkg2", "/project2/package.json");
let mut finder = MockProjectFinder::new()
.with_package(package1)
.with_package(package2);
finder
.check_changed_many(&[PathBuf::from("/project1/src/index.js")])
.unwrap();
assert!(finder.projects()[0].is_changed());
assert!(!finder.projects()[1].is_changed());
}
#[test]
fn test_project_finder_check_changed_many() {
let package1 = MockPackage::same_path("pkg1", "/project1/package.json");
let package2 = MockPackage::same_path("pkg2", "/project2/package.json");
let workspace = MockWorkspace::same_path("root", "/project3/package.json");
let mut finder = MockProjectFinder::new()
.with_package(package1)
.with_package(package2)
.with_workspace(workspace);
let paths = [
PathBuf::from("/project1/src/index.js"),
PathBuf::from("/project3/lib/mod.rs"),
];
finder.check_changed_many(&paths).unwrap();
assert!(finder.projects()[0].is_changed());
assert!(!finder.projects()[1].is_changed());
assert!(finder.projects()[2].is_changed());
}
#[test]
fn test_project_finder_check_changed_many_matches_per_file_traversal() {
let paths = [
PathBuf::from("/project1/src/index.js"),
PathBuf::from("/project2/README.md"),
];
let mut per_path = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
.with_package(MockPackage::same_path("pkg2", "/project2/package.json"));
for path in &paths {
per_path
.check_changed_many(std::slice::from_ref(path))
.unwrap();
}
let mut batched = MockProjectFinder::new()
.with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
.with_package(MockPackage::same_path("pkg2", "/project2/package.json"));
batched.check_changed_many(&paths).unwrap();
assert_eq!(
per_path.projects()[0].is_changed(),
batched.projects()[0].is_changed()
);
assert_eq!(
per_path.projects()[1].is_changed(),
batched.projects()[1].is_changed()
);
assert!(batched.projects()[0].is_changed());
assert!(batched.projects()[1].is_changed());
}
#[test]
fn test_project_finder_with_workspace() {
let workspace = MockWorkspace::same_path("root", "/project/package.json");
let mut finder = MockProjectFinder::new().with_workspace(workspace);
finder
.check_changed_many(&[PathBuf::from("/project/src/index.js")])
.unwrap();
assert!(finder.projects()[0].is_changed());
}
#[test]
fn test_project_finder_check_changed_many_propagates_check_changed_error() {
let mut finder = MockProjectFinder::new().with_package(MockPackage::same_path("root", "/"));
let error = finder
.check_changed_many(&[PathBuf::from("/src/index.js")])
.expect_err("a manifest without a parent directory must fail the batch");
let chain = format!("{error:#}");
assert!(
chain.contains("Parent not found"),
"error chain should carry the missing-parent context, got: {chain}"
);
assert!(
!finder.projects()[0].is_changed(),
"a project whose check_changed failed must not be reported as changed"
);
}
#[test]
fn project_finder_entry_points_are_included_in_coverage() {
assert!(
!include_str!("project_finder.rs")
.contains(concat!("#[cfg(not(", "tarpaulin_include))]"))
);
}
#[tokio::test]
async fn test_default_project_finder_finalize_is_covered_no_op() {
let mut finder = MockProjectFinder::new();
let result = finder.finalize().await;
assert!(result.is_ok());
}
#[tokio::test]
async fn test_is_regular_file_with_existing_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let result = is_regular_file(&file_path).await;
assert!(result.unwrap());
}
#[tokio::test]
async fn test_is_regular_file_with_directory() {
let temp_dir = tempfile::TempDir::new().unwrap();
let dir_path = temp_dir.path().join("subdir");
std::fs::create_dir(&dir_path).unwrap();
let result = is_regular_file(&dir_path).await;
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_is_regular_file_with_missing_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let missing_path = temp_dir.path().join("nonexistent.txt");
let result = is_regular_file(&missing_path).await;
assert!(!result.unwrap());
}
#[tokio::test]
async fn test_is_regular_file_propagates_metadata_error_with_path_context() {
let temp_dir = tempfile::TempDir::new().unwrap();
#[cfg(windows)]
let invalid_path = temp_dir.path().join("invalid\0path");
#[cfg(unix)]
let invalid_path = {
use std::os::unix::fs::symlink;
let path = temp_dir.path().join("metadata-loop");
symlink(&path, &path).unwrap();
path
};
let error = is_regular_file(&invalid_path)
.await
.expect_err("metadata errors other than NotFound must be propagated");
let chain = format!("{error:#}");
assert!(
chain.contains(&invalid_path.display().to_string()),
"error chain should name the path whose metadata failed, got: {chain}"
);
}
#[tokio::test]
async fn test_regular_file_metadata_returns_metadata_for_existing_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.txt");
std::fs::write(&file_path, "test content").unwrap();
let metadata = regular_file_metadata(&file_path)
.await
.unwrap()
.expect("an existing regular file must yield its metadata");
assert!(metadata.is_file());
assert_eq!(metadata.len(), "test content".len() as u64);
}
#[tokio::test]
async fn test_regular_file_metadata_returns_none_for_directory_and_missing_path() {
let temp_dir = tempfile::TempDir::new().unwrap();
let dir_path = temp_dir.path().join("subdir");
std::fs::create_dir(&dir_path).unwrap();
assert!(regular_file_metadata(&dir_path).await.unwrap().is_none());
assert!(
regular_file_metadata(&temp_dir.path().join("nonexistent.txt"))
.await
.unwrap()
.is_none()
);
}
#[tokio::test]
async fn test_regular_file_metadata_propagates_error_with_path_context() {
let temp_dir = tempfile::TempDir::new().unwrap();
#[cfg(windows)]
let invalid_path = temp_dir.path().join("invalid\0path");
#[cfg(unix)]
let invalid_path = {
use std::os::unix::fs::symlink;
let path = temp_dir.path().join("metadata-loop");
symlink(&path, &path).unwrap();
path
};
let error = regular_file_metadata(&invalid_path)
.await
.expect_err("metadata errors other than NotFound must be propagated");
let chain = format!("{error:#}");
assert!(
chain.contains(&format!(
"Failed to read metadata for {}",
invalid_path.display()
)),
"error chain should carry the shared metadata context, got: {chain}"
);
}
#[tokio::test]
async fn test_matches_project_file_accepts_recognized_regular_file() {
let temp_dir = tempfile::TempDir::new().unwrap();
let manifest = temp_dir.path().join("package.json");
std::fs::write(&manifest, "{}").unwrap();
let finder = MockProjectFinder::new();
assert!(
finder.matches_project_file(&manifest).await.unwrap(),
"a real file named package.json must be recognized"
);
}
#[tokio::test]
async fn test_matches_project_file_rejects_directory_with_recognized_name() {
let temp_dir = tempfile::TempDir::new().unwrap();
let dir_path = temp_dir.path().join("package.json");
std::fs::create_dir(&dir_path).unwrap();
let finder = MockProjectFinder::new();
assert!(
!finder.matches_project_file(&dir_path).await.unwrap(),
"a directory named package.json must not be treated as a manifest"
);
}
#[tokio::test]
async fn test_matches_project_file_rejects_unrecognized_name() {
let temp_dir = tempfile::TempDir::new().unwrap();
let other = temp_dir.path().join("Cargo.toml");
std::fs::write(&other, "[package]\n").unwrap();
let finder = MockProjectFinder::new();
assert!(
!finder.matches_project_file(&other).await.unwrap(),
"Cargo.toml is not in this finder's project_files()"
);
}
#[tokio::test]
async fn test_matches_project_file_rejects_path_without_file_name() {
let temp_dir = tempfile::TempDir::new().unwrap();
let parent_ref = temp_dir.path().join("..");
assert!(parent_ref.file_name().is_none());
let finder = MockProjectFinder::new();
assert!(
!finder.matches_project_file(&parent_ref).await.unwrap(),
"a path with no file name cannot match a manifest name"
);
}
#[tokio::test]
async fn test_matches_project_file_rejects_non_utf8_file_name() {
#[cfg(unix)]
let name: std::ffi::OsString = {
use std::os::unix::ffi::OsStrExt;
std::ffi::OsStr::from_bytes(b"\xFF\xFEpackage.json").to_os_string()
};
#[cfg(windows)]
let name: std::ffi::OsString = {
use std::os::windows::ffi::OsStringExt;
std::ffi::OsString::from_wide(&[0xD800, u16::from(b'x')])
};
assert!(
Path::new(&name)
.file_name()
.and_then(std::ffi::OsStr::to_str)
.is_none(),
"fixture must really be a non-UTF-8 file name"
);
let temp_dir = tempfile::TempDir::new().unwrap();
let path = temp_dir.path().join(&name);
let finder = MockProjectFinder::new();
assert!(
!finder.matches_project_file(&path).await.unwrap(),
"a non-UTF-8 file name cannot match an ASCII manifest name"
);
}
#[derive(Debug)]
struct ExtensionProjectFinder;
#[async_trait]
impl ProjectFinder for ExtensionProjectFinder {
fn projects(&self) -> Vec<&Project> {
vec![]
}
fn projects_mut(&mut self) -> Vec<&mut Project> {
vec![]
}
fn project_count(&self) -> usize {
0
}
fn project_files(&self) -> &[&str] {
&[".csproj"]
}
async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
Ok(())
}
}
#[tokio::test]
async fn test_matches_project_file_never_matches_an_extension_entry() {
let temp_dir = tempfile::TempDir::new().unwrap();
let finder = ExtensionProjectFinder;
for name in ["App.csproj", "App.CSPROJ"] {
let manifest = temp_dir.path().join(name);
std::fs::write(&manifest, "<Project/>").unwrap();
assert!(
!finder.matches_project_file(&manifest).await.unwrap(),
"{name} must not match through the file-name-only gate"
);
assert!(has_extension_ignore_ascii_case(&manifest, "csproj"));
assert!(is_regular_file(&manifest).await.unwrap());
}
}
#[tokio::test]
async fn test_matches_project_file_rejects_missing_manifest() {
let temp_dir = tempfile::TempDir::new().unwrap();
let missing = temp_dir.path().join("package.json");
let finder = MockProjectFinder::new();
assert!(
!finder.matches_project_file(&missing).await.unwrap(),
"a package.json that does not exist must not match"
);
}
}