changepacks_core/project_finder.rs
1use std::path::{Path, PathBuf};
2
3use crate::project::Project;
4use anyhow::{Context, Result};
5use async_trait::async_trait;
6
7/// Generates `projects()`, `projects_mut()`, `project_count()`,
8/// `extend_projects()`, `extend_projects_mut()`, and `contains_project()` for
9/// finders backed by a `projects: HashMap<PathBuf, Project>` field.
10///
11/// The `extend_projects` / `extend_projects_mut` bodies drain
12/// `self.projects.values()` / `self.projects.values_mut()` straight into the
13/// caller's buffer, so the intermediate `Vec` that `projects()` /
14/// `projects_mut()` has to materialize is never built. Yield order is
15/// `HashMap::values()` / `HashMap::values_mut()` in both bodies, so overriding
16/// is order-preserving with respect to the defaulted
17/// [`ProjectFinder::extend_projects`] and
18/// [`ProjectFinder::extend_projects_mut`].
19///
20/// The `contains_project` body is the O(1) hashed probe the map already
21/// offers, replacing the defaulted linear scan over `projects()` — see
22/// [`ProjectFinder::contains_project`]. `HashMap<PathBuf, Project>` borrows
23/// its keys as `Path`, so the probe neither allocates nor clones.
24#[macro_export]
25macro_rules! impl_projects_hashmap_accessors {
26 () => {
27 fn projects(&self) -> ::std::vec::Vec<&$crate::Project> {
28 self.projects.values().collect::<::std::vec::Vec<_>>()
29 }
30 fn projects_mut(&mut self) -> ::std::vec::Vec<&mut $crate::Project> {
31 self.projects.values_mut().collect::<::std::vec::Vec<_>>()
32 }
33 fn project_count(&self) -> ::std::primitive::usize {
34 self.projects.len()
35 }
36 fn extend_projects<'a>(&'a self, out: &mut ::std::vec::Vec<&'a $crate::Project>) {
37 out.extend(self.projects.values());
38 }
39 fn extend_projects_mut<'a>(
40 &'a mut self,
41 out: &mut ::std::vec::Vec<&'a mut $crate::Project>,
42 ) {
43 out.extend(self.projects.values_mut());
44 }
45 fn contains_project(&self, path: &::std::path::Path) -> ::std::primitive::bool {
46 self.projects.contains_key(path)
47 }
48 };
49}
50
51/// Generates `dependencies()` and `add_dependency()` for types with a
52/// `dependencies: HashSet<String>` field.
53///
54/// `add_dependency` probes membership with `contains` before allocating.
55/// `HashSet<String>` borrows its keys as `str`, so the probe is
56/// allocation-free, while the previous unconditional
57/// `insert(dependency.to_string())` heap-allocated a fresh `String` that
58/// `insert` immediately dropped whenever the name was already present.
59/// Callers hit that duplicate path routinely: a manifest that lists the same
60/// package in more than one dependency section (e.g. `dependencies` and
61/// `peerDependencies` in `package.json`) is walked section by section, and
62/// every section after the first re-adds a name already in the set. The set
63/// contents are unchanged either way — a `HashSet` keeps its existing key on
64/// a duplicate insert — so this is purely allocation elision.
65#[macro_export]
66macro_rules! impl_dependencies_accessors {
67 () => {
68 fn dependencies(&self) -> &::std::collections::HashSet<::std::string::String> {
69 &self.dependencies
70 }
71 fn add_dependency(&mut self, dependency: &str) {
72 if !self.dependencies.contains(dependency) {
73 self.dependencies
74 .insert(::std::string::ToString::to_string(dependency));
75 }
76 }
77 };
78}
79
80/// Generates `is_publishable_by_default()` for package/workspace structs with a
81/// `publishable_by_default: bool` field.
82///
83/// Contract: the implementing struct MUST own a field named exactly
84/// `publishable_by_default` of type `bool` — the same field
85/// [`impl_discovered_new!`] initializes. Language crates whose publishability
86/// is derived from a differently named field (e.g. Java's `has_publish_task`)
87/// must keep their hand-rolled body instead.
88#[macro_export]
89macro_rules! impl_publishable_by_default {
90 () => {
91 fn is_publishable_by_default(&self) -> ::std::primitive::bool {
92 self.publishable_by_default
93 }
94 };
95}
96
97/// Generates const-backed publish command defaults.
98///
99/// Two arguments return `Some($dry_run.to_string())`; one argument returns
100/// `None` for ecosystems without a built-in dry-run command.
101#[macro_export]
102macro_rules! impl_const_publish_commands {
103 ($publish:path, $dry_run:path) => {
104 fn default_publish_command(&self) -> ::std::string::String {
105 $publish.to_string()
106 }
107 fn default_dry_run_publish_command(&self) -> ::std::option::Option<::std::string::String> {
108 ::std::option::Option::Some($dry_run.to_string())
109 }
110 };
111 // CSharp variant: `dotnet nuget push` has no built-in `--dry-run`
112 // mode, so the default returns `None`. The actual dry-run flow
113 // lives in `CSharpPackage::dry_run_publish` (see
114 // `crates/csharp/src/dry_run.rs::resolve_and_run_dry_run`), which
115 // honors `config.publishDryRun` overrides first and falls back to a
116 // managed `dotnet pack` + `dotnet nuget push` against ephemeral
117 // `tempfile::TempDir` directories when no override is set.
118 ($publish:path) => {
119 fn default_publish_command(&self) -> ::std::string::String {
120 $publish.to_string()
121 }
122 fn default_dry_run_publish_command(&self) -> ::std::option::Option<::std::string::String> {
123 ::std::option::Option::None
124 }
125 };
126}
127
128/// Generates the `get_publish_command` / `get_dry_run_publish_command`
129/// trait defaults shared by [`Package`](crate::Package) and
130/// [`Workspace`](crate::Workspace).
131///
132/// Both traits resolve their publish commands through the exact same
133/// [`crate::publish`] ladder — only the surrounding doc prose used to
134/// differ — so the bodies live here once instead of being kept
135/// byte-identical by hand in two files.
136///
137/// Contract: the invoking trait MUST already declare `relative_path()`,
138/// `language()`, `default_publish_command()`, and
139/// `default_dry_run_publish_command()`.
140///
141/// The sibling `publish` / `dry_run_publish` defaults live in
142/// [`impl_publish_flows!`], which takes the differing missing-directory
143/// message constant as an argument.
144#[macro_export]
145macro_rules! impl_publish_command_resolvers {
146 () => {
147 /// Get the publish command for this project, checking config first.
148 ///
149 /// The `default_publish_command()` closure is `FnOnce`, so the
150 /// project's language-specific default (e.g. Node's
151 /// `detect_package_manager_recursive`, which walks the ancestor chain
152 /// with sync filesystem stats) is only invoked when config supplies
153 /// neither a per-path nor a per-language override — the common case
154 /// where the user configures a custom publish command in
155 /// `.changepacks/config.json` now avoids one `String` allocation and,
156 /// for Node, the ancestor-walking probe.
157 fn get_publish_command(&self, config: &$crate::Config) -> ::std::string::String {
158 $crate::publish::resolve_publish_command(
159 self.relative_path(),
160 self.language(),
161 || self.default_publish_command(),
162 config,
163 )
164 }
165
166 /// Get the dry-run publish command for this project, checking config
167 /// first, then falling back to the project's
168 /// `default_dry_run_publish_command`.
169 ///
170 /// Mirrors `get_publish_command` — the default closure is `FnOnce` so
171 /// it is only invoked on the cache-miss path.
172 fn get_dry_run_publish_command(
173 &self,
174 config: &$crate::Config,
175 ) -> ::std::option::Option<::std::string::String> {
176 $crate::publish::resolve_dry_run_publish_command(
177 self.relative_path(),
178 self.language(),
179 || self.default_dry_run_publish_command(),
180 config,
181 )
182 }
183 };
184}
185
186/// Generates the `publish` / `dry_run_publish` trait defaults shared by
187/// [`Package`](crate::Package) and [`Workspace`](crate::Workspace).
188///
189/// `$dir_not_found` is the only thing that differed between the two hand-kept
190/// copies: [`crate::publish::PACKAGE_DIR_NOT_FOUND`] for `Package`,
191/// [`crate::publish::WORKSPACE_DIR_NOT_FOUND`] for `Workspace`.
192///
193/// Contract: the invoking trait MUST already declare `path()`,
194/// `get_publish_command()`, and `get_dry_run_publish_command()` — the latter
195/// two come from [`impl_publish_command_resolvers!`].
196///
197/// The two methods are emitted in the shape `#[async_trait]` would produce
198/// rather than as `async fn`, because an attribute macro cannot see through a
199/// `macro_rules!` invocation in a trait body: `#[async_trait]` runs BEFORE
200/// this macro expands, so an `async fn` emitted here would survive as a
201/// native RPITIT method and make `Package` / `Workspace` dyn-incompatible,
202/// breaking `Box<dyn Package>` in `crate::Project`. Emitting the boxed-future
203/// signature keeps the defaults object safe and keeps them overridable by
204/// `#[async_trait]` impls in the language crates (Node, Java, C#), whose
205/// generated signatures this shape matches.
206#[macro_export]
207macro_rules! impl_publish_flows {
208 ($dir_not_found:path) => {
209 /// Publish this project using the configured command or default.
210 ///
211 /// # Errors
212 /// Returns error if the publish command fails to spawn or the project
213 /// directory is missing. A non-zero exit code is reported via
214 /// `PublishOutput::success = false`.
215 fn publish<'life0, 'life1, 'async_trait>(
216 &'life0 self,
217 config: &'life1 $crate::Config,
218 ) -> ::core::pin::Pin<
219 ::std::boxed::Box<
220 dyn ::core::future::Future<
221 Output = ::anyhow::Result<$crate::publish::PublishOutput>,
222 > + ::core::marker::Send
223 + 'async_trait,
224 >,
225 >
226 where
227 'life0: 'async_trait,
228 'life1: 'async_trait,
229 Self: 'async_trait,
230 {
231 ::std::boxed::Box::pin(async move {
232 let command = self.get_publish_command(config);
233 $crate::publish::run_publish_flow(&command, self.path(), &[], $dir_not_found).await
234 })
235 }
236
237 /// Run the publish command in dry-run mode to verify the pre-release
238 /// flow works without actually publishing.
239 ///
240 /// Returns `Ok(Some(output))` with the captured command output, or
241 /// `Ok(None)` when the language does not support a dry-run mode and
242 /// the user has not provided an override in `config.publish_dry_run`.
243 ///
244 /// # Errors
245 /// Returns error if the dry-run command fails to spawn or the project
246 /// directory is missing. A non-zero exit code is reported via
247 /// `PublishOutput::success = false`.
248 fn dry_run_publish<'life0, 'life1, 'async_trait>(
249 &'life0 self,
250 config: &'life1 $crate::Config,
251 ) -> ::core::pin::Pin<
252 ::std::boxed::Box<
253 dyn ::core::future::Future<
254 Output = ::anyhow::Result<
255 ::std::option::Option<$crate::publish::PublishOutput>,
256 >,
257 > + ::core::marker::Send
258 + 'async_trait,
259 >,
260 >
261 where
262 'life0: 'async_trait,
263 'life1: 'async_trait,
264 Self: 'async_trait,
265 {
266 ::std::boxed::Box::pin(async move {
267 let command = self.get_dry_run_publish_command(config);
268 $crate::publish::run_dry_run_publish_flow(
269 command.as_deref(),
270 self.path(),
271 &[],
272 $dir_not_found,
273 )
274 .await
275 })
276 }
277 };
278}
279
280/// Generates the `check_changed`, `is_publishable_by_default`, and
281/// `is_dry_run_publishable_by_default` trait defaults shared by
282/// [`Package`](crate::Package) and [`Workspace`](crate::Workspace).
283///
284/// These three bodies were byte-identical hand-kept copies in `package.rs`
285/// and `workspace.rs`; they live here once for the same reason
286/// [`impl_publish_flows!`] and [`impl_publish_command_resolvers!`] do.
287///
288/// Contract: the invoking trait MUST already declare `is_changed()`,
289/// `set_changed()`, and `path()` (the manifest path — its parent is the
290/// project directory). Like its two publish siblings, this macro emits trait
291/// *default methods*, so it is only meaningful inside the `Package` /
292/// `Workspace` trait definitions in this crate.
293///
294/// `check_changed` is a sync default, so unlike [`impl_publish_flows!`] it
295/// needs no `Pin<Box<dyn Future>>` desugaring and does not interact with
296/// `#[async_trait]`.
297///
298/// The emitted `check_changed` is monotonic: it early-returns once the
299/// project is already changed and only ever flips `changed` to `true` via the
300/// pure, stateless `should_mark_changed`. That is the invariant
301/// [`ProjectFinder::check_changed_many`] relies on for its project-major loop
302/// order and early `break`.
303#[macro_export]
304macro_rules! impl_shared_project_defaults {
305 () => {
306 /// # Errors
307 /// Returns error if the parent path cannot be determined.
308 fn check_changed(&mut self, path: &::std::path::Path) -> ::anyhow::Result<()> {
309 if self.is_changed() {
310 return ::core::result::Result::Ok(());
311 }
312 if $crate::change_detection::should_mark_changed(path, self.path())? {
313 self.set_changed(true);
314 }
315 ::core::result::Result::Ok(())
316 }
317
318 /// Whether this project should be included in publish runs when no
319 /// project-path or language command override is configured.
320 fn is_publishable_by_default(&self) -> ::std::primitive::bool {
321 true
322 }
323
324 /// Whether this project should be included in dry-run publish runs when no
325 /// project-path or language command override is configured.
326 fn is_dry_run_publishable_by_default(&self) -> ::std::primitive::bool {
327 self.is_publishable_by_default()
328 }
329 };
330}
331
332/// Generates the shared basic accessors for package/workspace structs with
333/// `name`, `version`, `path`, `relative_path`, and `is_changed` fields.
334#[macro_export]
335macro_rules! impl_basic_accessors {
336 () => {
337 fn name(&self) -> ::std::option::Option<&::std::primitive::str> {
338 self.name.as_deref()
339 }
340 fn version(&self) -> ::std::option::Option<&::std::primitive::str> {
341 self.version.as_deref()
342 }
343 fn path(&self) -> &::std::path::Path {
344 &self.path
345 }
346 fn relative_path(&self) -> &::std::path::Path {
347 &self.relative_path
348 }
349 fn is_changed(&self) -> ::std::primitive::bool {
350 self.is_changed
351 }
352 fn set_changed(&mut self, changed: ::std::primitive::bool) {
353 self.is_changed = changed;
354 }
355 fn set_name(&mut self, name: ::std::string::String) {
356 self.name = ::std::option::Option::Some(name);
357 }
358 };
359}
360
361/// Asserts the `name()` / `set_name()` round trip generated by
362/// [`impl_basic_accessors!`] for the project value produced by `$project`.
363///
364/// Every language crate had a byte-identical `test_set_name` body — build a
365/// name-less package/workspace, assert `name()` is `None`, `set_name` it, then
366/// assert `name()` observes the new value — differing only in the concrete type
367/// and its manifest path. All eleven exercise the exact same
368/// [`impl_basic_accessors!`] expansion, so the assertions live here once and
369/// each site supplies only its own constructor.
370///
371/// Contract: `$project` must evaluate to a value whose `name` starts as `None`,
372/// and the `Package` or `Workspace` trait supplying `name()` / `set_name()`
373/// must be in scope at the call site.
374#[macro_export]
375macro_rules! assert_set_name_roundtrip {
376 ($project:expr) => {{
377 let mut project = $project;
378 ::std::assert_eq!(
379 project.name(),
380 ::std::option::Option::None,
381 "a project constructed with no name must report None"
382 );
383 project.set_name(::std::string::ToString::to_string("my-project"));
384 ::std::assert_eq!(
385 project.name(),
386 ::std::option::Option::Some("my-project"),
387 "set_name must be observable through name()"
388 );
389 }};
390}
391
392/// Asserts the `is_changed()` / `set_changed()` round trip generated by
393/// [`impl_basic_accessors!`] for the project value produced by `$project`.
394///
395/// Every language crate had a byte-identical `set_changed` body — build a
396/// project, assert it starts unchanged, flip it to `true`, assert, flip it back
397/// to `false`, assert — differing only in the concrete type and its manifest
398/// path. All eleven exercise the exact same [`impl_basic_accessors!`]
399/// expansion, so the assertions live here once and each site supplies only its
400/// own constructor.
401///
402/// Contract: `$project` must evaluate to a freshly constructed value whose
403/// `is_changed` starts as `false`, and the `Package` or `Workspace` trait
404/// supplying `is_changed()` / `set_changed()` must be in scope at the call site.
405#[macro_export]
406macro_rules! assert_set_changed_roundtrip {
407 ($project:expr) => {{
408 let mut project = $project;
409 ::std::assert!(
410 !project.is_changed(),
411 "a freshly constructed project must start unchanged"
412 );
413 project.set_changed(true);
414 ::std::assert!(
415 project.is_changed(),
416 "set_changed(true) must be observable through is_changed()"
417 );
418 project.set_changed(false);
419 ::std::assert!(
420 !project.is_changed(),
421 "set_changed(false) must clear is_changed() again"
422 );
423 }};
424}
425
426/// Asserts the `dependencies()` / `add_dependency()` round trip generated by
427/// [`impl_dependencies_accessors!`] for the project value produced by
428/// `$project`.
429///
430/// Every language crate had a byte-identical dependencies body — build a
431/// project, assert `dependencies()` starts empty, add two names, assert the set
432/// has both and a length of two, re-add the first, assert the length is still
433/// two — differing only in the concrete type and the two dependency-name
434/// literals. All ten exercise the exact same [`impl_dependencies_accessors!`]
435/// expansion, including its `contains`-before-`insert` duplicate path, so the
436/// assertions live here once and each site supplies only its own constructor
437/// and names.
438///
439/// Contract: `$project` must evaluate to a freshly constructed value whose
440/// `dependencies` set starts empty, `$first` and `$second` must be distinct
441/// `&str` names, and the `Package` or `Workspace` trait supplying
442/// `dependencies()` / `add_dependency()` must be in scope at the call site.
443#[macro_export]
444macro_rules! assert_dependencies_roundtrip {
445 ($project:expr, $first:expr, $second:expr) => {{
446 let mut project = $project;
447 ::std::assert!(
448 project.dependencies().is_empty(),
449 "a freshly constructed project must start with no dependencies"
450 );
451 project.add_dependency($first);
452 project.add_dependency($second);
453 let deps = project.dependencies();
454 ::std::assert_eq!(deps.len(), 2, "both added dependencies must be recorded");
455 ::std::assert!(
456 deps.contains($first),
457 "add_dependency must be observable through dependencies()"
458 );
459 ::std::assert!(
460 deps.contains($second),
461 "add_dependency must be observable through dependencies()"
462 );
463 project.add_dependency($first);
464 ::std::assert_eq!(
465 project.dependencies().len(),
466 2,
467 "re-adding an existing dependency must not grow the set"
468 );
469 }};
470}
471
472/// Generates constructors for discovered package/workspace structs with a
473/// `publishable_by_default` field.
474#[macro_export]
475macro_rules! impl_discovered_new {
476 () => {
477 #[must_use]
478 pub fn new(
479 name: ::std::option::Option<::std::string::String>,
480 version: ::std::option::Option<::std::string::String>,
481 path: ::std::path::PathBuf,
482 relative_path: ::std::path::PathBuf,
483 ) -> Self {
484 Self::new_discovered(name, version, path, relative_path, true)
485 }
486
487 #[must_use]
488 pub(crate) fn new_discovered(
489 name: ::std::option::Option<::std::string::String>,
490 version: ::std::option::Option<::std::string::String>,
491 path: ::std::path::PathBuf,
492 relative_path: ::std::path::PathBuf,
493 publishable_by_default: ::std::primitive::bool,
494 ) -> Self {
495 Self {
496 name,
497 version,
498 path,
499 relative_path,
500 is_changed: false,
501 publishable_by_default,
502 dependencies: ::std::collections::HashSet::new(),
503 }
504 }
505 };
506}
507
508/// Declares a discovered package/workspace struct plus its constructors.
509///
510/// Five language types — `PythonPackage`, `PythonWorkspace`, `DartPackage`,
511/// `DartWorkspace` and `CSharpPackage` — declared the exact same seven private
512/// fields (`name`, `version`, `path`, `relative_path`, `is_changed`,
513/// `publishable_by_default`, `dependencies`) and each immediately followed the
514/// declaration with an inherent impl containing
515/// [`impl_discovered_new!`](crate::impl_discovered_new). Those field names are
516/// already hard-coded by that constructor macro, so the declarations were not
517/// independently variable: this macro makes the coupling explicit and keeps the
518/// layout in one place.
519///
520/// Node, Rust and Java are intentionally NOT expressible here — they carry
521/// extra fields (`package_manager`, the workspace-inheritance trio) or lack
522/// `publishable_by_default` entirely — and keep their hand-written
523/// declarations.
524///
525/// Additional inherent methods stay in a separate `impl` block beside the
526/// invocation (see `CSharpPackage`'s command-runner helpers).
527///
528/// ```ignore
529/// changepacks_core::declare_discovered_project!(
530/// /// Doc comments and other outer attributes pass through.
531/// pub struct PythonPackage
532/// );
533/// ```
534#[macro_export]
535macro_rules! declare_discovered_project {
536 ($(#[$meta:meta])* pub struct $name:ident) => {
537 $(#[$meta])*
538 #[derive(::std::fmt::Debug)]
539 pub struct $name {
540 name: ::std::option::Option<::std::string::String>,
541 version: ::std::option::Option<::std::string::String>,
542 path: ::std::path::PathBuf,
543 relative_path: ::std::path::PathBuf,
544 is_changed: ::std::primitive::bool,
545 publishable_by_default: ::std::primitive::bool,
546 dependencies: ::std::collections::HashSet<::std::string::String>,
547 }
548
549 impl $name {
550 $crate::impl_discovered_new!();
551 }
552 };
553}
554
555/// Builds the [`Project`](crate::Project) a finder just discovered, choosing
556/// the `Workspace` or `Package` variant from `$is_workspace`.
557///
558/// Four finders — Node, Python, Dart and Java — ended their `visit()` with the
559/// same ten-line `if is_workspace { Project::Workspace(Box::new(WsCtor(..))) }
560/// else { Project::Package(Box::new(PkgCtor(..))) }`, and in every copy the
561/// argument list was byte-identical in both arms because the finder had
562/// already hoisted the shared `name` / `version` / `path_key` bindings above
563/// the branch. Only the two constructor paths and that argument list actually
564/// vary, so they are what this macro takes.
565///
566/// Evaluation is unchanged from the hand-written shape: `$is_workspace` is
567/// evaluated once, and each `$arg` is evaluated exactly once and only inside
568/// the branch that is taken. A `path_key.clone()` argument therefore still
569/// performs exactly one `PathBuf` allocation per visit, not two, and the
570/// moved-once `name` / `version` bindings keep type-checking because only one
571/// arm ever runs.
572///
573/// Deliberately NOT used by two finders:
574/// - `crates/csharp/src/finder.rs` has no workspace variant — it always
575/// constructs a `Project::Package`, so there is no branch to factor out.
576/// - `crates/rust/src/finder.rs` builds its two variants from separate
577/// control-flow blocks with different argument lists, not from one `if/else`
578/// over a shared list.
579///
580/// ```ignore
581/// let mut project = changepacks_core::discovered_project!(
582/// is_workspace,
583/// NodeWorkspace::new_discovered,
584/// NodePackage::new_discovered,
585/// name,
586/// version,
587/// path_key.clone(),
588/// relative_path_key,
589/// package_manager,
590/// publishable_by_default,
591/// );
592/// ```
593#[macro_export]
594macro_rules! discovered_project {
595 ($is_workspace:expr, $ws:path, $pkg:path, $($arg:expr),* $(,)?) => {
596 if $is_workspace {
597 $crate::Project::Workspace(::std::boxed::Box::new($ws($($arg),*)))
598 } else {
599 $crate::Project::Package(::std::boxed::Box::new($pkg($($arg),*)))
600 }
601 };
602}
603
604/// Generates `fn language(&self) -> Language` for a fixed language variant.
605#[macro_export]
606macro_rules! impl_language {
607 ($lang:expr) => {
608 fn language(&self) -> $crate::Language {
609 $lang
610 }
611 };
612}
613
614/// Returns `true` when `path`'s extension matches `ext` case-insensitively
615/// (ASCII only).
616///
617/// Mirrors the `path.extension().and_then(|e| e.to_str()).is_some_and(|e|
618/// e.eq_ignore_ascii_case(ext))` idiom used across language crates so the
619/// predicate lives in exactly one place. Returns `false` when the path has
620/// no extension (including dotfiles such as `.json`, where
621/// [`std::path::Path::extension`] returns `None`).
622///
623/// Public so cross-crate callers (e.g. `changepacks-csharp`,
624/// `changepacks-java`, `changepacks-utils`) can reuse it via the re-export
625/// from `changepacks_core::lib.rs`.
626#[must_use]
627pub fn has_extension_ignore_ascii_case(path: &Path, ext: &str) -> bool {
628 path.extension()
629 .and_then(|e| e.to_str())
630 .is_some_and(|e| e.eq_ignore_ascii_case(ext))
631}
632
633/// Returns `Ok(true)` when `path` refers to an existing regular file.
634///
635/// Boolean shorthand over [`regular_file_metadata`], which owns the triage:
636/// a missing path or directory returns `Ok(false)`, and other metadata errors
637/// are propagated with the failing path in their context.
638///
639/// Shared between `ProjectFinder::matches_project_file` (name-based match
640/// used by every language) and `CSharpProjectFinder::visit` (extension-based
641/// match) so the byte-identical stat + `is_file()` fallthrough lives in ONE
642/// place. Public so cross-crate callers (e.g. `changepacks-csharp`) can
643/// reuse it via the re-export from `changepacks_core::lib.rs`.
644///
645/// # Errors
646/// Propagates any [`regular_file_metadata`] error, i.e. a metadata read that
647/// fails for a reason other than the path being absent.
648pub async fn is_regular_file(path: &Path) -> Result<bool> {
649 Ok(regular_file_metadata(path).await?.is_some())
650}
651
652/// Returns `Ok(Some(metadata))` when `path` refers to an existing regular
653/// file, and `Ok(None)` when it is missing or is not a regular file.
654///
655/// This is the single owner of the metadata triage ladder shared by every
656/// language crate: `is_file()` decides regular-vs-other,
657/// [`std::io::ErrorKind::NotFound`] is normalized to "absent", and any other
658/// metadata error is propagated with the failing path in its context.
659///
660/// [`is_regular_file`] is the boolean shorthand over this function. Callers
661/// that additionally need something *from* the same
662/// [`std::fs::Metadata`] — e.g. `changepacks-java` reading the Unix
663/// permission bits of a `java` candidate — must use this function rather
664/// than `is_regular_file` followed by their own `metadata` call, because a
665/// second stat is both a wasted syscall and a TOCTOU window.
666///
667/// AGENTS.md rule: never blocking I/O in async — uses `tokio::fs::metadata`.
668///
669/// # Errors
670/// Returns the underlying `io::Error`, annotated with the failing path, when
671/// the metadata read fails for any reason other than
672/// [`std::io::ErrorKind::NotFound`].
673pub async fn regular_file_metadata(path: &Path) -> Result<Option<std::fs::Metadata>> {
674 match tokio::fs::metadata(path).await {
675 Ok(metadata) if metadata.is_file() => Ok(Some(metadata)),
676 Ok(_) => Ok(None),
677 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(None),
678 Err(error) => {
679 Err(error).with_context(|| format!("Failed to read metadata for {}", path.display()))
680 }
681 }
682}
683
684/// Visitor pattern for discovering projects by walking the git tree.
685///
686/// Each language implements this trait to detect its project files (package.json, Cargo.toml, etc.)
687/// and build a collection of projects. The `visit` method is called for each file in the git tree.
688#[async_trait]
689pub trait ProjectFinder: std::fmt::Debug + Send + Sync {
690 fn projects(&self) -> Vec<&Project>;
691 fn projects_mut(&mut self) -> Vec<&mut Project>;
692 /// Number of projects held by this finder.
693 ///
694 /// Required rather than defaulted: a `self.projects().len()` default would
695 /// allocate and immediately drop a whole `Vec<&Project>` just to read a
696 /// length. Every implementor already owns an O(1), allocation-free count
697 /// (the language finders get one from
698 /// [`impl_projects_hashmap_accessors!`]), so the trait demands it instead
699 /// of offering a lossy shortcut.
700 fn project_count(&self) -> usize;
701 /// Append every project held by this finder onto `out`.
702 ///
703 /// Exists for the same reason [`ProjectFinder::project_count`] does:
704 /// callers that merge several finders into one buffer (the CLI's
705 /// `collect_projects`) would otherwise pay one throwaway `Vec<&Project>`
706 /// per finder — allocated by `projects()` and dropped one line later —
707 /// on every `check`, `update`, `publish`, and default-changepack run.
708 /// Pushing into the caller's buffer removes that per-finder allocation.
709 ///
710 /// The default body is the compatibility path for external implementors:
711 /// it forwards to `projects()`, so an implementor that only supplies the
712 /// required accessors keeps compiling and keeps identical behaviour, and
713 /// merely forfeits the allocation elision. Implementors backed by a
714 /// `HashMap<PathBuf, Project>` get the elided override for free from
715 /// [`impl_projects_hashmap_accessors!`].
716 ///
717 /// Contract for overrides: append in exactly `projects()` order and never
718 /// clear or reorder what `out` already holds — callers rely on the merged
719 /// order for their output (e.g. `changepacks check`).
720 fn extend_projects<'a>(&'a self, out: &mut Vec<&'a Project>) {
721 out.extend(self.projects());
722 }
723 /// Mutable counterpart of [`ProjectFinder::extend_projects`]: append every
724 /// project held by this finder onto `out` as `&mut Project`.
725 ///
726 /// Exists for the same allocation reason: `find_project_dirs`'s no-name
727 /// fallback merges every finder's projects into one buffer, and
728 /// `flat_map(|f| f.projects_mut())` paid one throwaway `Vec<&mut Project>`
729 /// per finder — six of them on every CLI run — allocated by `projects_mut()`
730 /// and dropped as soon as the flattening consumed it. Pushing into the
731 /// caller's buffer removes that per-finder allocation.
732 ///
733 /// The default body is the compatibility path for external implementors:
734 /// it forwards to `projects_mut()`, so an implementor that only supplies
735 /// the required accessors keeps compiling and keeps identical behaviour,
736 /// and merely forfeits the allocation elision. Implementors backed by a
737 /// `HashMap<PathBuf, Project>` get the elided override for free from
738 /// [`impl_projects_hashmap_accessors!`].
739 ///
740 /// Contract for overrides: append in exactly `projects_mut()` order and
741 /// never clear or reorder what `out` already holds.
742 fn extend_projects_mut<'a>(&'a mut self, out: &mut Vec<&'a mut Project>) {
743 out.extend(self.projects_mut());
744 }
745 /// Whether a project keyed by exactly `path` has already been discovered
746 /// by this finder.
747 ///
748 /// `path` is the manifest path a `visit()` call was handed — the same
749 /// value every finder uses as its storage key — so this is the
750 /// "already visited, do not re-parse" probe. It exists so the six
751 /// language finders stop reaching into their private `projects` field
752 /// from inside `visit()`; the gate now belongs to the trait that defines
753 /// the visit protocol.
754 ///
755 /// The default body is the compatibility path for external implementors,
756 /// exactly like [`ProjectFinder::extend_projects`]: it linearly scans
757 /// `projects()` for a project whose [`Project::path`] equals `path`, so
758 /// an implementor that only supplies the required accessors keeps
759 /// compiling and keeps identical behaviour, and merely forfeits the
760 /// hashed lookup. Implementors backed by a `HashMap<PathBuf, Project>`
761 /// get the O(1) override for free from
762 /// [`impl_projects_hashmap_accessors!`].
763 fn contains_project(&self, path: &Path) -> bool {
764 self.projects()
765 .into_iter()
766 .any(|project| project.path() == path)
767 }
768 /// Whether `visit()` should parse `path`, or bail out early.
769 ///
770 /// This is the two-guard prelude every language finder open-coded at the
771 /// top of its `visit()`: `path` must be a manifest this finder claims AND
772 /// must not have been discovered already.
773 ///
774 /// Guard order is deliberate and preserved from the hand-rolled copies:
775 /// the name/stat gate ([`ProjectFinder::matches_project_file`]) runs
776 /// FIRST and the map probe ([`ProjectFinder::contains_project`]) second.
777 /// `&&` short-circuits, so a path that is not a manifest never pays for
778 /// the second probe — and, more importantly, the ordering keeps the
779 /// error surface unchanged: a metadata error on a recognized manifest
780 /// name still propagates even when that path is already known.
781 ///
782 /// Only finders whose [`ProjectFinder::project_files`] uses the bare
783 /// file-name form may gate on this. An extension-based finder (the
784 /// `".csproj"` form) must keep its own
785 /// [`has_extension_ignore_ascii_case`] + [`is_regular_file`] check and
786 /// call [`ProjectFinder::contains_project`] directly, for the reason
787 /// spelled out on [`ProjectFinder::matches_project_file`].
788 ///
789 /// # Errors
790 /// Propagates whatever [`ProjectFinder::matches_project_file`] returns.
791 ///
792 /// Written in the boxed-future shape `#[async_trait]` would produce rather
793 /// than as a defaulted `async fn`, for the same reason
794 /// [`impl_publish_flows!`] is: the spans `#[async_trait]` puts on a
795 /// *defaulted* body are not attributed back to this file by `llvm-cov`, so
796 /// such a default reads as permanently unexecuted in coverage even while
797 /// tests drive every branch of it. This shape is object safe, stays
798 /// overridable by the `#[async_trait]` impls in the language crates, and is
799 /// measurable. The three defaults below follow the same rule.
800 fn should_visit_manifest<'life0, 'life1, 'async_trait>(
801 &'life0 self,
802 path: &'life1 Path,
803 ) -> ::core::pin::Pin<
804 ::std::boxed::Box<
805 dyn ::core::future::Future<Output = Result<bool>> + ::core::marker::Send + 'async_trait,
806 >,
807 >
808 where
809 'life0: 'async_trait,
810 'life1: 'async_trait,
811 Self: ::core::marker::Sync + 'async_trait,
812 {
813 ::std::boxed::Box::pin(async move {
814 Ok(self.matches_project_file(path).await? && !self.contains_project(path))
815 })
816 }
817 /// The manifest patterns this finder claims, in one of TWO forms.
818 ///
819 /// 1. A bare **file name** (`"package.json"`, `"Cargo.toml"`,
820 /// `"pyproject.toml"`, `"pubspec.yaml"`, `"build.gradle.kts"`). Five of
821 /// the six language finders use only this form.
822 /// 2. A leading-dot **extension** (`".csproj"`), used when the manifest
823 /// name varies per project. `CSharpProjectFinder` is the only
824 /// in-tree implementor of this form.
825 ///
826 /// Only the discovery walk understands both: `find_project_dirs`'s
827 /// `project_files_can_visit_path` (in `changepacks-utils`) first compares
828 /// the entry to `path.file_name()` and, on a miss, retries any entry that
829 /// starts with `.` as a case-insensitive extension match.
830 ///
831 /// The defaulted [`ProjectFinder::matches_project_file`] deliberately
832 /// implements ONLY form 1 — it compares `path.file_name()` against this
833 /// list — so an extension entry such as `".csproj"` can never match there
834 /// (`Path::new("App.csproj").file_name()` is `"App.csproj"`, never
835 /// `".csproj"`). An implementor that returns extension entries therefore
836 /// MUST NOT gate `visit()` on `matches_project_file`; it must do its own
837 /// extension check with [`has_extension_ignore_ascii_case`] plus
838 /// [`is_regular_file`], exactly as `CSharpProjectFinder::visit` does.
839 ///
840 /// The two forms also differ in case sensitivity: a file-name entry is
841 /// compared byte-for-byte, while an extension entry matches
842 /// case-insensitively (`App.CSPROJ` is accepted).
843 fn project_files(&self) -> &[&str];
844 /// # Errors
845 /// Returns error if the file visitation fails.
846 async fn visit(&mut self, path: &Path, relative_path: &Path) -> Result<()>;
847 /// Whether `path` is a project manifest file recognized by this finder.
848 ///
849 /// Returns `false` for directories and files whose name is not in
850 /// `project_files()`. Used by language-specific `visit()` implementations
851 /// to gate manifest parsing on file-name matching.
852 ///
853 /// This gate implements ONLY the bare-file-name form of
854 /// [`ProjectFinder::project_files`]. A leading-dot extension entry such as
855 /// `".csproj"` can never match here, because `path.file_name()` yields the
856 /// whole name (`"App.csproj"`) and never the extension alone. That is why
857 /// `CSharpProjectFinder` gates `visit()` on
858 /// [`has_extension_ignore_ascii_case`] + [`is_regular_file`] instead of
859 /// calling this method — any future extension-based finder must do the
860 /// same.
861 ///
862 /// Check order is name-first, stat-last: on a monorepo with N tracked
863 /// files where only K match any recognized manifest name (typically
864 /// K ≪ N), the previous stat-then-name shape issued 5 × N async
865 /// `tokio::fs::metadata` syscalls across every non-CSharp language
866 /// finder invocation. Reversing the order collapses that to ~K stats
867 /// total. Missing/non-UTF-8 file names cannot possibly match ASCII
868 /// manifest names anyway, so returning `Ok(false)` early is
869 /// semantically identical to the previous `with_context` error paths
870 /// — which were unreachable for git-index-derived paths.
871 ///
872 /// # Errors
873 /// Returns an error when metadata for a recognized manifest path cannot be
874 /// read for a reason other than the path not existing.
875 fn matches_project_file<'life0, 'life1, 'async_trait>(
876 &'life0 self,
877 path: &'life1 Path,
878 ) -> ::core::pin::Pin<
879 ::std::boxed::Box<
880 dyn ::core::future::Future<Output = Result<bool>> + ::core::marker::Send + 'async_trait,
881 >,
882 >
883 where
884 'life0: 'async_trait,
885 'life1: 'async_trait,
886 Self: ::core::marker::Sync + 'async_trait,
887 {
888 ::std::boxed::Box::pin(async move {
889 let Some(name_os) = path.file_name() else {
890 return Ok(false);
891 };
892 let Some(name) = name_os.to_str() else {
893 return Ok(false);
894 };
895 if !self.project_files().contains(&name) {
896 return Ok(false);
897 }
898 is_regular_file(path).await
899 })
900 }
901 /// Mark every project against every path in `paths` from ONE
902 /// `projects_mut()` call.
903 ///
904 /// The driver dispatches every changed file to every finder. Rebuilding
905 /// the `Vec<&mut Project>` via `projects_mut()` once per file would cost
906 /// `F` changed files × `M` finders fresh Vec allocations. Collecting the
907 /// paths once and looping project-major here collapses that to one Vec per
908 /// finder (`M` total).
909 ///
910 /// The project-major / path-major order flip is behavior-preserving:
911 /// [`Project::check_changed`] is monotonic — it early-returns once the
912 /// project is already changed and only ever sets `changed = true` via the
913 /// pure, stateless `should_mark_changed`. A project ends up changed iff
914 /// *any* path matches, an order-independent logical OR, so visiting all
915 /// paths for one project before moving to the next yields an identical
916 /// result to a path-major traversal.
917 ///
918 /// # Errors
919 /// Returns error if checking changed status fails for any project.
920 fn check_changed_many(&mut self, paths: &[PathBuf]) -> Result<()> {
921 for project in self.projects_mut() {
922 for path in paths {
923 project.check_changed(path)?;
924 // Early break: check_changed is monotonic, so once changed, remaining paths are redundant.
925 if project.is_changed() {
926 break;
927 }
928 }
929 }
930 Ok(())
931 }
932 /// Manifests this finder knows about that carry version references to
933 /// discovered packages, but are NOT themselves managed projects.
934 ///
935 /// The motivating case is a Cargo workspace root excluded by
936 /// `config.ignore`: it has no version of its own to bump — so it must not
937 /// become a `Project` and start showing up in `check` / `publish` — yet its
938 /// `[workspace.dependencies]` table pins the very members being bumped, and
939 /// Cargo refuses to resolve the workspace the moment those pins go stale.
940 /// A member manifest that pins a sibling directly, rather than through
941 /// `dep = { workspace = true }`, has the same problem.
942 ///
943 /// The update transaction snapshots these paths alongside the project
944 /// manifests, so a failure anywhere in the transaction still restores every
945 /// file [`ProjectFinder::sync_dependency_references`] could have written.
946 ///
947 /// The default is empty: only `changepacks-rust` has manifests of this kind.
948 fn dependency_reference_manifests(&self) -> Vec<PathBuf> {
949 Vec::new()
950 }
951
952 /// Retarget the version references inside
953 /// [`ProjectFinder::dependency_reference_manifests`] at the bumped
954 /// `packages`.
955 ///
956 /// Runs after every project version write has completed, so each package's
957 /// `version()` already reports its post-bump value. Implementors MUST NOT
958 /// write any manifest owned by a discovered [`Project`] whose own
959 /// `Workspace::update_workspace_dependencies` already covers it — the two
960 /// steps run concurrently, and two writers on one path is a lost update.
961 ///
962 /// # Errors
963 /// Returns an error if a reference manifest cannot be read, parsed, or
964 /// written.
965 ///
966 /// Boxed-future shape rather than a defaulted `async fn`, for the reason
967 /// spelled out on [`ProjectFinder::should_visit_manifest`].
968 fn sync_dependency_references<'life0, 'life1, 'life2, 'async_trait>(
969 &'life0 self,
970 _packages: &'life1 [&'life2 dyn crate::Package],
971 ) -> ::core::pin::Pin<
972 ::std::boxed::Box<
973 dyn ::core::future::Future<Output = Result<()>> + ::core::marker::Send + 'async_trait,
974 >,
975 >
976 where
977 'life0: 'async_trait,
978 'life1: 'async_trait,
979 'life2: 'async_trait,
980 Self: ::core::marker::Sync + 'async_trait,
981 {
982 ::std::boxed::Box::pin(async move { Ok(()) })
983 }
984 /// Post-visit processing hook for resolving deferred state (e.g., workspace-inherited versions).
985 /// Called once after all `visit()` calls complete.
986 /// # Errors
987 /// Returns error if finalization fails.
988 fn finalize<'life0, 'async_trait>(
989 &'life0 mut self,
990 ) -> ::core::pin::Pin<
991 ::std::boxed::Box<
992 dyn ::core::future::Future<Output = Result<()>> + ::core::marker::Send + 'async_trait,
993 >,
994 >
995 where
996 'life0: 'async_trait,
997 Self: ::core::marker::Send + 'async_trait,
998 {
999 ::std::boxed::Box::pin(async move { Ok(()) })
1000 }
1001}
1002
1003#[cfg(test)]
1004mod tests {
1005 use super::*;
1006 use crate::test_support::{MockPackage, MockWorkspace};
1007 use crate::{Package, Workspace};
1008 use async_trait::async_trait;
1009 use rstest::rstest;
1010 use std::path::PathBuf;
1011
1012 // `Path::new(".json").extension()` returns `None` in Rust — dotfiles have
1013 // no extension — so `has_extension_ignore_ascii_case(Path::new(".json"), "json")`
1014 // is `false`. This matches the behaviour of every call site that wraps a
1015 // bare filename with `Path::new(file_name)`.
1016 #[rstest]
1017 #[case("foo.json", "json", true)]
1018 #[case("foo.JSON", "json", true)]
1019 #[case("foo.Json", "json", true)]
1020 #[case("foo", "json", false)]
1021 #[case(".json", "json", false)]
1022 #[case("foo.jsonx", "json", false)]
1023 fn test_has_extension_ignore_ascii_case(
1024 #[case] file: &str,
1025 #[case] ext: &str,
1026 #[case] expected: bool,
1027 ) {
1028 assert_eq!(
1029 has_extension_ignore_ascii_case(Path::new(file), ext),
1030 expected,
1031 "has_extension_ignore_ascii_case(Path::new({file:?}), {ext:?})"
1032 );
1033 }
1034
1035 // `add_dependency` now probes `contains` before allocating. These cases
1036 // lock the observable contract the probe must not change: a repeated name
1037 // is still stored exactly once, and distinct names all still land.
1038 #[test]
1039 fn test_add_dependency_deduplicates_repeated_names() {
1040 let mut package = MockPackage::same_path("pkg", "/project/package.json");
1041
1042 // The duplicate path: the same name arrives from two manifest sections.
1043 package.add_dependency("serde");
1044 package.add_dependency("serde");
1045 package.add_dependency("serde");
1046
1047 assert_eq!(
1048 package.dependencies().len(),
1049 1,
1050 "repeated add_dependency must keep exactly one entry"
1051 );
1052 assert!(package.dependencies().contains("serde"));
1053
1054 // Distinct names still insert normally (the miss path is unchanged).
1055 package.add_dependency("tokio");
1056 package.add_dependency("anyhow");
1057 package.add_dependency("tokio");
1058
1059 let mut names = package.dependencies().iter().cloned().collect::<Vec<_>>();
1060 names.sort();
1061 assert_eq!(names, vec!["anyhow", "serde", "tokio"]);
1062 }
1063
1064 #[test]
1065 fn test_add_dependency_deduplicates_on_workspace_too() {
1066 // The macro backs both the Package and the Workspace impls of all six
1067 // language crates, so pin the behaviour at the Workspace surface as well.
1068 let mut workspace = MockWorkspace::same_path("root", "/project/package.json");
1069
1070 workspace.add_dependency("left-pad");
1071 workspace.add_dependency("left-pad");
1072
1073 assert_eq!(workspace.dependencies().len(), 1);
1074 assert!(workspace.dependencies().contains("left-pad"));
1075 }
1076
1077 #[derive(Debug)]
1078 struct MockProjectFinder {
1079 projects: Vec<Project>,
1080 }
1081
1082 impl MockProjectFinder {
1083 fn new() -> Self {
1084 Self { projects: vec![] }
1085 }
1086
1087 fn with_package(mut self, package: MockPackage) -> Self {
1088 self.projects.push(Project::Package(Box::new(package)));
1089 self
1090 }
1091
1092 fn with_workspace(mut self, workspace: MockWorkspace) -> Self {
1093 self.projects.push(Project::Workspace(Box::new(workspace)));
1094 self
1095 }
1096 }
1097
1098 #[async_trait]
1099 impl ProjectFinder for MockProjectFinder {
1100 fn projects(&self) -> Vec<&Project> {
1101 self.projects.iter().collect()
1102 }
1103
1104 fn projects_mut(&mut self) -> Vec<&mut Project> {
1105 self.projects.iter_mut().collect()
1106 }
1107
1108 fn project_count(&self) -> usize {
1109 self.projects.len()
1110 }
1111
1112 fn project_files(&self) -> &[&str] {
1113 &["package.json"]
1114 }
1115
1116 async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
1117 Ok(())
1118 }
1119 }
1120
1121 /// HashMap-backed finder that takes its accessors from
1122 /// [`impl_projects_hashmap_accessors!`], so the macro's `extend_projects`
1123 /// override is exercised inside `core` (the six language finders use the
1124 /// exact same expansion).
1125 #[derive(Debug)]
1126 struct HashMapProjectFinder {
1127 projects: std::collections::HashMap<PathBuf, Project>,
1128 }
1129
1130 impl HashMapProjectFinder {
1131 fn with_packages(names: &[(&str, &str)]) -> Self {
1132 let mut projects = std::collections::HashMap::new();
1133 for (name, path) in names {
1134 projects.insert(
1135 PathBuf::from(*path),
1136 Project::Package(Box::new(MockPackage::same_path(name, path))),
1137 );
1138 }
1139 Self { projects }
1140 }
1141 }
1142
1143 #[async_trait]
1144 impl ProjectFinder for HashMapProjectFinder {
1145 crate::impl_projects_hashmap_accessors!();
1146
1147 fn project_files(&self) -> &[&str] {
1148 &["package.json"]
1149 }
1150
1151 async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
1152 Ok(())
1153 }
1154 }
1155
1156 // Accepts both `&[&Project]` and `&[&mut Project]` buffers: std provides
1157 // `Borrow<T>` for `&T` and `&mut T` alike, so the shared/mutable
1158 // `extend_projects` twins can assert against one helper.
1159 fn project_names<P: std::borrow::Borrow<Project>>(projects: &[P]) -> Vec<String> {
1160 projects
1161 .iter()
1162 .map(|project| project.borrow().name().unwrap_or_default().to_string())
1163 .collect()
1164 }
1165
1166 // The defaulted `extend_projects` body is the compatibility path for
1167 // external implementors: `MockProjectFinder` does NOT override it, so this
1168 // pins that the default appends exactly `projects()`, in `projects()`
1169 // order, without disturbing what the buffer already holds.
1170 #[test]
1171 fn test_extend_projects_default_matches_projects_and_preserves_buffer() {
1172 let finder = MockProjectFinder::new()
1173 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1174 .with_workspace(MockWorkspace::same_path("root", "/project2/package.json"))
1175 .with_package(MockPackage::same_path("pkg2", "/project3/package.json"));
1176
1177 let seed = MockProjectFinder::new()
1178 .with_package(MockPackage::same_path("seed", "/seed/package.json"));
1179 let mut out = seed.projects();
1180 finder.extend_projects(&mut out);
1181
1182 let mut expected = vec!["seed".to_string()];
1183 expected.extend(project_names(&finder.projects()));
1184 assert_eq!(project_names(&out), expected);
1185 }
1186
1187 #[test]
1188 fn test_extend_projects_default_on_empty_finder_is_a_no_op() {
1189 let finder = MockProjectFinder::new();
1190 let mut out: Vec<&Project> = Vec::new();
1191 finder.extend_projects(&mut out);
1192 assert!(out.is_empty());
1193 }
1194
1195 // Mutable twin of the test above: `MockProjectFinder` does NOT override
1196 // `extend_projects_mut`, so this pins that the default appends exactly
1197 // `projects_mut()`, in `projects_mut()` order, without disturbing what the
1198 // buffer already holds — the contract `find_project_dirs`'s no-name
1199 // fallback relies on when it merges every finder into one buffer.
1200 #[test]
1201 fn test_extend_projects_mut_default_matches_projects_and_preserves_buffer() {
1202 let mut finder = MockProjectFinder::new()
1203 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1204 .with_workspace(MockWorkspace::same_path("root", "/project2/package.json"))
1205 .with_package(MockPackage::same_path("pkg2", "/project3/package.json"));
1206
1207 let mut seed = MockProjectFinder::new()
1208 .with_package(MockPackage::same_path("seed", "/seed/package.json"));
1209 let mut out = seed.projects_mut();
1210 finder.extend_projects_mut(&mut out);
1211 let names = project_names(&out);
1212 drop(out);
1213
1214 let mut expected = vec!["seed".to_string()];
1215 expected.extend(project_names(&finder.projects()));
1216 assert_eq!(names, expected);
1217 }
1218
1219 #[test]
1220 fn test_extend_projects_mut_default_on_empty_finder_is_a_no_op() {
1221 let mut finder = MockProjectFinder::new();
1222 let mut out: Vec<&mut Project> = Vec::new();
1223 finder.extend_projects_mut(&mut out);
1224 assert!(out.is_empty());
1225 }
1226
1227 // The macro override skips the intermediate Vec that `projects()` builds;
1228 // both must still yield the same projects in the same `HashMap::values()`
1229 // order, so a caller can swap one for the other without reordering output.
1230 #[test]
1231 fn test_extend_projects_macro_override_matches_projects_order() {
1232 let finder = HashMapProjectFinder::with_packages(&[
1233 ("pkg1", "/project1/package.json"),
1234 ("pkg2", "/project2/package.json"),
1235 ("pkg3", "/project3/package.json"),
1236 ]);
1237
1238 let mut out: Vec<&Project> = Vec::new();
1239 finder.extend_projects(&mut out);
1240
1241 assert_eq!(out.len(), finder.project_count());
1242 assert_eq!(project_names(&out), project_names(&finder.projects()));
1243 }
1244
1245 // Same equivalence for the mutable override: `HashMap::values_mut()` and
1246 // `HashMap::values()` walk one unmodified map in the same order, so the
1247 // elided body must yield exactly what `projects_mut()` would have.
1248 #[test]
1249 fn test_extend_projects_mut_macro_override_matches_projects_order() {
1250 let mut finder = HashMapProjectFinder::with_packages(&[
1251 ("pkg1", "/project1/package.json"),
1252 ("pkg2", "/project2/package.json"),
1253 ("pkg3", "/project3/package.json"),
1254 ]);
1255
1256 let mut out: Vec<&mut Project> = Vec::new();
1257 finder.extend_projects_mut(&mut out);
1258 let out_len = out.len();
1259 let names = project_names(&out);
1260 drop(out);
1261
1262 assert_eq!(out_len, finder.project_count());
1263 assert_eq!(names, project_names(&finder.projects_mut()));
1264 }
1265
1266 // The borrows handed out must alias the finder's own storage: mutating a
1267 // project through the merged buffer has to be visible on the finder
1268 // afterwards, which is exactly what the no-name `set_name` fallback does.
1269 #[test]
1270 fn test_extend_projects_mut_yields_borrows_that_mutate_the_finder() {
1271 let mut finder = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
1272
1273 let mut out: Vec<&mut Project> = Vec::new();
1274 finder.extend_projects_mut(&mut out);
1275 for project in &mut out {
1276 project.set_name("renamed".to_string());
1277 }
1278 drop(out);
1279
1280 assert_eq!(project_names(&finder.projects()), vec!["renamed"]);
1281 }
1282
1283 // `contains_project` has the same two-body shape as `extend_projects`: a
1284 // defaulted linear scan for external implementors and a hashed override
1285 // from the macro. `MockProjectFinder` does NOT override it, so this pins
1286 // the compatibility path — hit on the exact stored manifest path, miss on
1287 // an unknown one and on a merely-similar one.
1288 #[test]
1289 fn test_contains_project_default_scans_projects_by_path() {
1290 let finder = MockProjectFinder::new()
1291 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1292 .with_workspace(MockWorkspace::same_path("root", "/project2/package.json"));
1293
1294 assert!(finder.contains_project(Path::new("/project1/package.json")));
1295 // Workspace projects count too, not just packages.
1296 assert!(finder.contains_project(Path::new("/project2/package.json")));
1297 assert!(!finder.contains_project(Path::new("/project3/package.json")));
1298 // The probe keys on the whole manifest path, never on the directory.
1299 assert!(!finder.contains_project(Path::new("/project1")));
1300 }
1301
1302 #[test]
1303 fn test_contains_project_default_on_empty_finder_is_always_false() {
1304 let finder = MockProjectFinder::new();
1305 assert!(!finder.contains_project(Path::new("/project1/package.json")));
1306 }
1307
1308 // The macro override must answer identically to the default for every
1309 // probe — that equivalence is what lets the six language finders swap
1310 // their open-coded `self.projects.contains_key(path)` for the trait
1311 // method without changing behaviour.
1312 #[test]
1313 fn test_contains_project_macro_override_matches_default_answers() {
1314 let entries = [
1315 ("pkg1", "/project1/package.json"),
1316 ("pkg2", "/project2/package.json"),
1317 ];
1318 let hashed = HashMapProjectFinder::with_packages(&entries);
1319 let mut scanned = MockProjectFinder::new();
1320 for (name, path) in entries {
1321 scanned = scanned.with_package(MockPackage::same_path(name, path));
1322 }
1323
1324 for probe in [
1325 "/project1/package.json",
1326 "/project2/package.json",
1327 "/project3/package.json",
1328 "/project1",
1329 "",
1330 ] {
1331 assert_eq!(
1332 hashed.contains_project(Path::new(probe)),
1333 scanned.contains_project(Path::new(probe)),
1334 "hashed and scanned answers diverged for {probe:?}"
1335 );
1336 }
1337 }
1338
1339 // `should_visit_manifest` is the consolidated two-guard prelude. Its
1340 // documented order is name/stat gate FIRST, already-discovered probe
1341 // SECOND, and it returns `true` only when both agree the manifest is new.
1342 #[tokio::test]
1343 async fn test_should_visit_manifest_accepts_new_recognized_manifest() {
1344 let temp_dir = tempfile::TempDir::new().unwrap();
1345 let manifest = temp_dir.path().join("package.json");
1346 std::fs::write(&manifest, "{}").unwrap();
1347
1348 let finder = MockProjectFinder::new();
1349 assert!(finder.should_visit_manifest(&manifest).await.unwrap());
1350 }
1351
1352 // The duplicate-visit half: same manifest, but already discovered.
1353 #[tokio::test]
1354 async fn test_should_visit_manifest_rejects_already_discovered_manifest() {
1355 let temp_dir = tempfile::TempDir::new().unwrap();
1356 let manifest = temp_dir.path().join("package.json");
1357 std::fs::write(&manifest, "{}").unwrap();
1358
1359 let finder = MockProjectFinder::new()
1360 .with_package(MockPackage::same_path("pkg", manifest.to_str().unwrap()));
1361 assert!(
1362 !finder.should_visit_manifest(&manifest).await.unwrap(),
1363 "a manifest already in the finder must not be visited twice"
1364 );
1365 }
1366
1367 // The name/stat half: an unrecognized name and a directory that merely
1368 // shares a manifest name are both rejected before anything is parsed.
1369 #[tokio::test]
1370 async fn test_should_visit_manifest_rejects_non_manifest_and_directory() {
1371 let temp_dir = tempfile::TempDir::new().unwrap();
1372 let other = temp_dir.path().join("Cargo.toml");
1373 std::fs::write(&other, "[package]\n").unwrap();
1374 let dir_path = temp_dir.path().join("package.json");
1375 std::fs::create_dir(&dir_path).unwrap();
1376
1377 let finder = MockProjectFinder::new();
1378 assert!(!finder.should_visit_manifest(&other).await.unwrap());
1379 assert!(!finder.should_visit_manifest(&dir_path).await.unwrap());
1380 }
1381
1382 // Equivalence with the hand-rolled prelude the language finders used to
1383 // open-code: `matches_project_file(path)? && !contains_project(path)`.
1384 #[tokio::test]
1385 async fn test_should_visit_manifest_equals_the_open_coded_two_guard_prelude() {
1386 let temp_dir = tempfile::TempDir::new().unwrap();
1387 let manifest = temp_dir.path().join("package.json");
1388 std::fs::write(&manifest, "{}").unwrap();
1389 let unrecognized = temp_dir.path().join("Cargo.toml");
1390 std::fs::write(&unrecognized, "[package]\n").unwrap();
1391 let missing = temp_dir.path().join("nested").join("package.json");
1392
1393 let known = MockProjectFinder::new()
1394 .with_package(MockPackage::same_path("pkg", manifest.to_str().unwrap()));
1395 let empty = MockProjectFinder::new();
1396
1397 for finder in [&known, &empty] {
1398 for probe in [&manifest, &unrecognized, &missing] {
1399 let open_coded = finder.matches_project_file(probe).await.unwrap()
1400 && !finder.contains_project(probe);
1401 assert_eq!(
1402 finder.should_visit_manifest(probe).await.unwrap(),
1403 open_coded,
1404 "consolidated gate diverged from the open-coded prelude for {}",
1405 probe.display()
1406 );
1407 }
1408 }
1409 }
1410
1411 #[test]
1412 fn test_extend_projects_macro_override_preserves_existing_buffer_contents() {
1413 let first = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
1414 let second = HashMapProjectFinder::with_packages(&[("pkg2", "/project2/package.json")]);
1415
1416 // Mirrors the CLI's `collect_projects`: one buffer, several finders.
1417 let mut out: Vec<&Project> = Vec::new();
1418 first.extend_projects(&mut out);
1419 second.extend_projects(&mut out);
1420
1421 assert_eq!(project_names(&out), vec!["pkg1", "pkg2"]);
1422 }
1423
1424 #[test]
1425 fn test_extend_projects_mut_macro_override_preserves_existing_buffer_contents() {
1426 let mut first = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
1427 let mut second = HashMapProjectFinder::with_packages(&[("pkg2", "/project2/package.json")]);
1428
1429 // Mirrors `find_project_dirs`'s no-name fallback: one buffer, several finders.
1430 let mut out: Vec<&mut Project> = Vec::new();
1431 first.extend_projects_mut(&mut out);
1432 second.extend_projects_mut(&mut out);
1433
1434 assert_eq!(project_names(&out), vec!["pkg1", "pkg2"]);
1435 }
1436
1437 #[test]
1438 fn test_project_finder_check_changed() {
1439 let package = MockPackage::same_path("test", "/project/package.json");
1440 let mut finder = MockProjectFinder::new().with_package(package);
1441
1442 // Check a file that's in the project directory
1443 finder
1444 .check_changed_many(&[PathBuf::from("/project/src/index.js")])
1445 .unwrap();
1446
1447 // The project should be marked as changed
1448 assert!(finder.projects()[0].is_changed());
1449 }
1450
1451 #[test]
1452 fn test_project_finder_check_changed_multiple_projects() {
1453 let package1 = MockPackage::same_path("pkg1", "/project1/package.json");
1454 let package2 = MockPackage::same_path("pkg2", "/project2/package.json");
1455 let mut finder = MockProjectFinder::new()
1456 .with_package(package1)
1457 .with_package(package2);
1458
1459 // Check a file in project1 only
1460 finder
1461 .check_changed_many(&[PathBuf::from("/project1/src/index.js")])
1462 .unwrap();
1463
1464 // Only project1 should be changed
1465 assert!(finder.projects()[0].is_changed());
1466 assert!(!finder.projects()[1].is_changed());
1467 }
1468
1469 #[test]
1470 fn test_project_finder_check_changed_many() {
1471 let package1 = MockPackage::same_path("pkg1", "/project1/package.json");
1472 let package2 = MockPackage::same_path("pkg2", "/project2/package.json");
1473 let workspace = MockWorkspace::same_path("root", "/project3/package.json");
1474 let mut finder = MockProjectFinder::new()
1475 .with_package(package1)
1476 .with_package(package2)
1477 .with_workspace(workspace);
1478
1479 // One batch: a file under project1 and a file under project3 (the
1480 // workspace); nothing under project2. `check_changed_many` must mark
1481 // exactly project1 and project3 — a project is marked changed iff any
1482 // path matches it, proving the project-major loop order is
1483 // behavior-preserving across both Package and Workspace variants.
1484 let paths = [
1485 PathBuf::from("/project1/src/index.js"),
1486 PathBuf::from("/project3/lib/mod.rs"),
1487 ];
1488 finder.check_changed_many(&paths).unwrap();
1489
1490 assert!(finder.projects()[0].is_changed());
1491 assert!(!finder.projects()[1].is_changed());
1492 assert!(finder.projects()[2].is_changed());
1493 }
1494
1495 #[test]
1496 fn test_project_finder_check_changed_many_matches_per_file_traversal() {
1497 // The same inputs fed one-at-a-time (each path its own single-element
1498 // batch, mirroring a per-file traversal) and fed together in ONE batch
1499 // must land the two finders in an identical changed-state, locking the
1500 // order/batch equivalence the driver relies on.
1501 let paths = [
1502 PathBuf::from("/project1/src/index.js"),
1503 PathBuf::from("/project2/README.md"),
1504 ];
1505
1506 let mut per_path = MockProjectFinder::new()
1507 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1508 .with_package(MockPackage::same_path("pkg2", "/project2/package.json"));
1509 for path in &paths {
1510 per_path
1511 .check_changed_many(std::slice::from_ref(path))
1512 .unwrap();
1513 }
1514
1515 let mut batched = MockProjectFinder::new()
1516 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1517 .with_package(MockPackage::same_path("pkg2", "/project2/package.json"));
1518 batched.check_changed_many(&paths).unwrap();
1519
1520 assert_eq!(
1521 per_path.projects()[0].is_changed(),
1522 batched.projects()[0].is_changed()
1523 );
1524 assert_eq!(
1525 per_path.projects()[1].is_changed(),
1526 batched.projects()[1].is_changed()
1527 );
1528 assert!(batched.projects()[0].is_changed());
1529 assert!(batched.projects()[1].is_changed());
1530 }
1531
1532 #[test]
1533 fn test_project_finder_with_workspace() {
1534 let workspace = MockWorkspace::same_path("root", "/project/package.json");
1535 let mut finder = MockProjectFinder::new().with_workspace(workspace);
1536
1537 finder
1538 .check_changed_many(&[PathBuf::from("/project/src/index.js")])
1539 .unwrap();
1540
1541 assert!(finder.projects()[0].is_changed());
1542 }
1543
1544 // Every other `check_changed_many` test ends in `unwrap`, so the `?` on
1545 // `project.check_changed(path)` inside the defaulted body was never
1546 // exercised on its failing branch. The only way that call can fail is
1547 // `should_mark_changed` finding no parent directory for the project
1548 // manifest, so a project rooted at `/` — whose `Path::parent()` is `None`
1549 // on both Windows and Unix — reaches it. The batch loop must surface that
1550 // error instead of swallowing it and reporting the project as unchanged.
1551 #[test]
1552 fn test_project_finder_check_changed_many_propagates_check_changed_error() {
1553 let mut finder = MockProjectFinder::new().with_package(MockPackage::same_path("root", "/"));
1554
1555 let error = finder
1556 .check_changed_many(&[PathBuf::from("/src/index.js")])
1557 .expect_err("a manifest without a parent directory must fail the batch");
1558
1559 let chain = format!("{error:#}");
1560 assert!(
1561 chain.contains("Parent not found"),
1562 "error chain should carry the missing-parent context, got: {chain}"
1563 );
1564 assert!(
1565 !finder.projects()[0].is_changed(),
1566 "a project whose check_changed failed must not be reported as changed"
1567 );
1568 }
1569
1570 #[test]
1571 fn project_finder_entry_points_are_included_in_coverage() {
1572 assert!(
1573 !include_str!("project_finder.rs")
1574 .contains(concat!("#[cfg(not(", "tarpaulin_include))]"))
1575 );
1576 }
1577
1578 #[tokio::test]
1579 async fn test_default_project_finder_finalize_is_covered_no_op() {
1580 let mut finder = MockProjectFinder::new();
1581 let result = finder.finalize().await;
1582 assert!(result.is_ok());
1583 }
1584
1585 #[tokio::test]
1586 async fn test_is_regular_file_with_existing_file() {
1587 let temp_dir = tempfile::TempDir::new().unwrap();
1588 let file_path = temp_dir.path().join("test.txt");
1589 std::fs::write(&file_path, "test content").unwrap();
1590
1591 let result = is_regular_file(&file_path).await;
1592 assert!(result.unwrap());
1593 }
1594
1595 #[tokio::test]
1596 async fn test_is_regular_file_with_directory() {
1597 let temp_dir = tempfile::TempDir::new().unwrap();
1598 let dir_path = temp_dir.path().join("subdir");
1599 std::fs::create_dir(&dir_path).unwrap();
1600
1601 let result = is_regular_file(&dir_path).await;
1602 assert!(!result.unwrap());
1603 }
1604
1605 #[tokio::test]
1606 async fn test_is_regular_file_with_missing_path() {
1607 let temp_dir = tempfile::TempDir::new().unwrap();
1608 let missing_path = temp_dir.path().join("nonexistent.txt");
1609
1610 let result = is_regular_file(&missing_path).await;
1611 assert!(!result.unwrap());
1612 }
1613
1614 #[tokio::test]
1615 async fn test_is_regular_file_propagates_metadata_error_with_path_context() {
1616 let temp_dir = tempfile::TempDir::new().unwrap();
1617 #[cfg(windows)]
1618 let invalid_path = temp_dir.path().join("invalid\0path");
1619 #[cfg(unix)]
1620 let invalid_path = {
1621 use std::os::unix::fs::symlink;
1622
1623 let path = temp_dir.path().join("metadata-loop");
1624 symlink(&path, &path).unwrap();
1625 path
1626 };
1627
1628 let error = is_regular_file(&invalid_path)
1629 .await
1630 .expect_err("metadata errors other than NotFound must be propagated");
1631 let chain = format!("{error:#}");
1632 assert!(
1633 chain.contains(&invalid_path.display().to_string()),
1634 "error chain should name the path whose metadata failed, got: {chain}"
1635 );
1636 }
1637
1638 // `regular_file_metadata` is the ladder `is_regular_file` and the Java
1639 // executable probe both sit on, and it is the only one of the two that
1640 // hands the caller the stat'ed `Metadata`. These cases pin that extra
1641 // guarantee: the returned metadata must describe the file itself, and the
1642 // non-file exits must stay indistinguishable `None`s.
1643 #[tokio::test]
1644 async fn test_regular_file_metadata_returns_metadata_for_existing_file() {
1645 let temp_dir = tempfile::TempDir::new().unwrap();
1646 let file_path = temp_dir.path().join("test.txt");
1647 std::fs::write(&file_path, "test content").unwrap();
1648
1649 let metadata = regular_file_metadata(&file_path)
1650 .await
1651 .unwrap()
1652 .expect("an existing regular file must yield its metadata");
1653 assert!(metadata.is_file());
1654 assert_eq!(metadata.len(), "test content".len() as u64);
1655 }
1656
1657 #[tokio::test]
1658 async fn test_regular_file_metadata_returns_none_for_directory_and_missing_path() {
1659 let temp_dir = tempfile::TempDir::new().unwrap();
1660 let dir_path = temp_dir.path().join("subdir");
1661 std::fs::create_dir(&dir_path).unwrap();
1662
1663 assert!(regular_file_metadata(&dir_path).await.unwrap().is_none());
1664 assert!(
1665 regular_file_metadata(&temp_dir.path().join("nonexistent.txt"))
1666 .await
1667 .unwrap()
1668 .is_none()
1669 );
1670 }
1671
1672 #[tokio::test]
1673 async fn test_regular_file_metadata_propagates_error_with_path_context() {
1674 let temp_dir = tempfile::TempDir::new().unwrap();
1675 #[cfg(windows)]
1676 let invalid_path = temp_dir.path().join("invalid\0path");
1677 #[cfg(unix)]
1678 let invalid_path = {
1679 use std::os::unix::fs::symlink;
1680
1681 let path = temp_dir.path().join("metadata-loop");
1682 symlink(&path, &path).unwrap();
1683 path
1684 };
1685
1686 let error = regular_file_metadata(&invalid_path)
1687 .await
1688 .expect_err("metadata errors other than NotFound must be propagated");
1689 let chain = format!("{error:#}");
1690 assert!(
1691 chain.contains(&format!(
1692 "Failed to read metadata for {}",
1693 invalid_path.display()
1694 )),
1695 "error chain should carry the shared metadata context, got: {chain}"
1696 );
1697 }
1698
1699 // `matches_project_file` is the defaulted gate every non-CSharp language
1700 // finder calls before parsing a manifest. `MockProjectFinder::project_files`
1701 // returns exactly `["package.json"]`, so these cases pin all four exits of
1702 // its documented name-first / stat-last order.
1703
1704 // Exit 4 (the only `true`): recognized name AND a real regular file.
1705 #[tokio::test]
1706 async fn test_matches_project_file_accepts_recognized_regular_file() {
1707 let temp_dir = tempfile::TempDir::new().unwrap();
1708 let manifest = temp_dir.path().join("package.json");
1709 std::fs::write(&manifest, "{}").unwrap();
1710
1711 let finder = MockProjectFinder::new();
1712 assert!(
1713 finder.matches_project_file(&manifest).await.unwrap(),
1714 "a real file named package.json must be recognized"
1715 );
1716 }
1717
1718 // Exit 4 again, negative half: the name matches but the entry is a
1719 // DIRECTORY, so the stat must veto it. This is why the stat cannot simply
1720 // be dropped once the name check is in place.
1721 #[tokio::test]
1722 async fn test_matches_project_file_rejects_directory_with_recognized_name() {
1723 let temp_dir = tempfile::TempDir::new().unwrap();
1724 let dir_path = temp_dir.path().join("package.json");
1725 std::fs::create_dir(&dir_path).unwrap();
1726
1727 let finder = MockProjectFinder::new();
1728 assert!(
1729 !finder.matches_project_file(&dir_path).await.unwrap(),
1730 "a directory named package.json must not be treated as a manifest"
1731 );
1732 }
1733
1734 // Exit 3: an unrecognized name is rejected even though the file really
1735 // exists — the name guard, not the stat, is what filters it out.
1736 #[tokio::test]
1737 async fn test_matches_project_file_rejects_unrecognized_name() {
1738 let temp_dir = tempfile::TempDir::new().unwrap();
1739 let other = temp_dir.path().join("Cargo.toml");
1740 std::fs::write(&other, "[package]\n").unwrap();
1741
1742 let finder = MockProjectFinder::new();
1743 assert!(
1744 !finder.matches_project_file(&other).await.unwrap(),
1745 "Cargo.toml is not in this finder's project_files()"
1746 );
1747 }
1748
1749 // Exit 1: `file_name()` is `None` for a path ending in `..`, even though
1750 // that path resolves to an existing directory. The early return must fire
1751 // before any stat.
1752 #[tokio::test]
1753 async fn test_matches_project_file_rejects_path_without_file_name() {
1754 let temp_dir = tempfile::TempDir::new().unwrap();
1755 let parent_ref = temp_dir.path().join("..");
1756 assert!(parent_ref.file_name().is_none());
1757
1758 let finder = MockProjectFinder::new();
1759 assert!(
1760 !finder.matches_project_file(&parent_ref).await.unwrap(),
1761 "a path with no file name cannot match a manifest name"
1762 );
1763 }
1764
1765 // Exit 2: `to_str()` is `None` for a non-UTF-8 file name. Such a name
1766 // cannot equal any ASCII manifest name, so the guard must short-circuit to
1767 // `Ok(false)` before any stat — exactly what the method doc reasons about.
1768 // The path deliberately does not exist: the early return fires first.
1769 #[tokio::test]
1770 async fn test_matches_project_file_rejects_non_utf8_file_name() {
1771 #[cfg(unix)]
1772 let name: std::ffi::OsString = {
1773 use std::os::unix::ffi::OsStrExt;
1774 std::ffi::OsStr::from_bytes(b"\xFF\xFEpackage.json").to_os_string()
1775 };
1776 #[cfg(windows)]
1777 let name: std::ffi::OsString = {
1778 use std::os::windows::ffi::OsStringExt;
1779 // Unpaired high surrogate — unrepresentable in UTF-8.
1780 std::ffi::OsString::from_wide(&[0xD800, u16::from(b'x')])
1781 };
1782
1783 // Stay honest if a platform ever normalizes the name away.
1784 assert!(
1785 Path::new(&name)
1786 .file_name()
1787 .and_then(std::ffi::OsStr::to_str)
1788 .is_none(),
1789 "fixture must really be a non-UTF-8 file name"
1790 );
1791
1792 let temp_dir = tempfile::TempDir::new().unwrap();
1793 let path = temp_dir.path().join(&name);
1794
1795 let finder = MockProjectFinder::new();
1796 assert!(
1797 !finder.matches_project_file(&path).await.unwrap(),
1798 "a non-UTF-8 file name cannot match an ASCII manifest name"
1799 );
1800 }
1801
1802 /// Finder that returns the leading-dot EXTENSION form of
1803 /// [`ProjectFinder::project_files`], the same shape `CSharpProjectFinder`
1804 /// uses.
1805 #[derive(Debug)]
1806 struct ExtensionProjectFinder;
1807
1808 #[async_trait]
1809 impl ProjectFinder for ExtensionProjectFinder {
1810 fn projects(&self) -> Vec<&Project> {
1811 vec![]
1812 }
1813
1814 fn projects_mut(&mut self) -> Vec<&mut Project> {
1815 vec![]
1816 }
1817
1818 fn project_count(&self) -> usize {
1819 0
1820 }
1821
1822 fn project_files(&self) -> &[&str] {
1823 &[".csproj"]
1824 }
1825
1826 async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
1827 Ok(())
1828 }
1829 }
1830
1831 // Pins the documented half of the dual contract: the defaulted
1832 // `matches_project_file` implements ONLY the bare-file-name form, so an
1833 // extension entry can never match through it — not even for a real
1834 // `.csproj` file on disk, in any casing. This is exactly why
1835 // `CSharpProjectFinder::visit` gates on `has_extension_ignore_ascii_case`
1836 // + `is_regular_file` instead of calling this method; if that ever
1837 // changed, C# discovery would silently stop finding projects.
1838 #[tokio::test]
1839 async fn test_matches_project_file_never_matches_an_extension_entry() {
1840 let temp_dir = tempfile::TempDir::new().unwrap();
1841 let finder = ExtensionProjectFinder;
1842
1843 for name in ["App.csproj", "App.CSPROJ"] {
1844 let manifest = temp_dir.path().join(name);
1845 std::fs::write(&manifest, "<Project/>").unwrap();
1846
1847 assert!(
1848 !finder.matches_project_file(&manifest).await.unwrap(),
1849 "{name} must not match through the file-name-only gate"
1850 );
1851 // The extension-aware check is the one that accepts it.
1852 assert!(has_extension_ignore_ascii_case(&manifest, "csproj"));
1853 assert!(is_regular_file(&manifest).await.unwrap());
1854 }
1855 }
1856
1857 // Recognized name, nothing on disk: `is_regular_file` maps NotFound to
1858 // `Ok(false)` rather than an error, so the gate stays quiet for deleted
1859 // manifests still listed in the git index.
1860 #[tokio::test]
1861 async fn test_matches_project_file_rejects_missing_manifest() {
1862 let temp_dir = tempfile::TempDir::new().unwrap();
1863 let missing = temp_dir.path().join("package.json");
1864
1865 let finder = MockProjectFinder::new();
1866 assert!(
1867 !finder.matches_project_file(&missing).await.unwrap(),
1868 "a package.json that does not exist must not match"
1869 );
1870 }
1871}