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 /// Post-visit processing hook for resolving deferred state (e.g., workspace-inherited versions).
933 /// Called once after all `visit()` calls complete.
934 /// # Errors
935 /// Returns error if finalization fails.
936 fn finalize<'life0, 'async_trait>(
937 &'life0 mut self,
938 ) -> ::core::pin::Pin<
939 ::std::boxed::Box<
940 dyn ::core::future::Future<Output = Result<()>> + ::core::marker::Send + 'async_trait,
941 >,
942 >
943 where
944 'life0: 'async_trait,
945 Self: ::core::marker::Send + 'async_trait,
946 {
947 ::std::boxed::Box::pin(async move { Ok(()) })
948 }
949}
950
951#[cfg(test)]
952mod tests {
953 use super::*;
954 use crate::test_support::{MockPackage, MockWorkspace};
955 use crate::{Package, Workspace};
956 use async_trait::async_trait;
957 use rstest::rstest;
958 use std::path::PathBuf;
959
960 // `Path::new(".json").extension()` returns `None` in Rust — dotfiles have
961 // no extension — so `has_extension_ignore_ascii_case(Path::new(".json"), "json")`
962 // is `false`. This matches the behaviour of every call site that wraps a
963 // bare filename with `Path::new(file_name)`.
964 #[rstest]
965 #[case("foo.json", "json", true)]
966 #[case("foo.JSON", "json", true)]
967 #[case("foo.Json", "json", true)]
968 #[case("foo", "json", false)]
969 #[case(".json", "json", false)]
970 #[case("foo.jsonx", "json", false)]
971 fn test_has_extension_ignore_ascii_case(
972 #[case] file: &str,
973 #[case] ext: &str,
974 #[case] expected: bool,
975 ) {
976 assert_eq!(
977 has_extension_ignore_ascii_case(Path::new(file), ext),
978 expected,
979 "has_extension_ignore_ascii_case(Path::new({file:?}), {ext:?})"
980 );
981 }
982
983 // `add_dependency` now probes `contains` before allocating. These cases
984 // lock the observable contract the probe must not change: a repeated name
985 // is still stored exactly once, and distinct names all still land.
986 #[test]
987 fn test_add_dependency_deduplicates_repeated_names() {
988 let mut package = MockPackage::same_path("pkg", "/project/package.json");
989
990 // The duplicate path: the same name arrives from two manifest sections.
991 package.add_dependency("serde");
992 package.add_dependency("serde");
993 package.add_dependency("serde");
994
995 assert_eq!(
996 package.dependencies().len(),
997 1,
998 "repeated add_dependency must keep exactly one entry"
999 );
1000 assert!(package.dependencies().contains("serde"));
1001
1002 // Distinct names still insert normally (the miss path is unchanged).
1003 package.add_dependency("tokio");
1004 package.add_dependency("anyhow");
1005 package.add_dependency("tokio");
1006
1007 let mut names = package.dependencies().iter().cloned().collect::<Vec<_>>();
1008 names.sort();
1009 assert_eq!(names, vec!["anyhow", "serde", "tokio"]);
1010 }
1011
1012 #[test]
1013 fn test_add_dependency_deduplicates_on_workspace_too() {
1014 // The macro backs both the Package and the Workspace impls of all six
1015 // language crates, so pin the behaviour at the Workspace surface as well.
1016 let mut workspace = MockWorkspace::same_path("root", "/project/package.json");
1017
1018 workspace.add_dependency("left-pad");
1019 workspace.add_dependency("left-pad");
1020
1021 assert_eq!(workspace.dependencies().len(), 1);
1022 assert!(workspace.dependencies().contains("left-pad"));
1023 }
1024
1025 #[derive(Debug)]
1026 struct MockProjectFinder {
1027 projects: Vec<Project>,
1028 }
1029
1030 impl MockProjectFinder {
1031 fn new() -> Self {
1032 Self { projects: vec![] }
1033 }
1034
1035 fn with_package(mut self, package: MockPackage) -> Self {
1036 self.projects.push(Project::Package(Box::new(package)));
1037 self
1038 }
1039
1040 fn with_workspace(mut self, workspace: MockWorkspace) -> Self {
1041 self.projects.push(Project::Workspace(Box::new(workspace)));
1042 self
1043 }
1044 }
1045
1046 #[async_trait]
1047 impl ProjectFinder for MockProjectFinder {
1048 fn projects(&self) -> Vec<&Project> {
1049 self.projects.iter().collect()
1050 }
1051
1052 fn projects_mut(&mut self) -> Vec<&mut Project> {
1053 self.projects.iter_mut().collect()
1054 }
1055
1056 fn project_count(&self) -> usize {
1057 self.projects.len()
1058 }
1059
1060 fn project_files(&self) -> &[&str] {
1061 &["package.json"]
1062 }
1063
1064 async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
1065 Ok(())
1066 }
1067 }
1068
1069 /// HashMap-backed finder that takes its accessors from
1070 /// [`impl_projects_hashmap_accessors!`], so the macro's `extend_projects`
1071 /// override is exercised inside `core` (the six language finders use the
1072 /// exact same expansion).
1073 #[derive(Debug)]
1074 struct HashMapProjectFinder {
1075 projects: std::collections::HashMap<PathBuf, Project>,
1076 }
1077
1078 impl HashMapProjectFinder {
1079 fn with_packages(names: &[(&str, &str)]) -> Self {
1080 let mut projects = std::collections::HashMap::new();
1081 for (name, path) in names {
1082 projects.insert(
1083 PathBuf::from(*path),
1084 Project::Package(Box::new(MockPackage::same_path(name, path))),
1085 );
1086 }
1087 Self { projects }
1088 }
1089 }
1090
1091 #[async_trait]
1092 impl ProjectFinder for HashMapProjectFinder {
1093 crate::impl_projects_hashmap_accessors!();
1094
1095 fn project_files(&self) -> &[&str] {
1096 &["package.json"]
1097 }
1098
1099 async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
1100 Ok(())
1101 }
1102 }
1103
1104 // Accepts both `&[&Project]` and `&[&mut Project]` buffers: std provides
1105 // `Borrow<T>` for `&T` and `&mut T` alike, so the shared/mutable
1106 // `extend_projects` twins can assert against one helper.
1107 fn project_names<P: std::borrow::Borrow<Project>>(projects: &[P]) -> Vec<String> {
1108 projects
1109 .iter()
1110 .map(|project| project.borrow().name().unwrap_or_default().to_string())
1111 .collect()
1112 }
1113
1114 // The defaulted `extend_projects` body is the compatibility path for
1115 // external implementors: `MockProjectFinder` does NOT override it, so this
1116 // pins that the default appends exactly `projects()`, in `projects()`
1117 // order, without disturbing what the buffer already holds.
1118 #[test]
1119 fn test_extend_projects_default_matches_projects_and_preserves_buffer() {
1120 let finder = MockProjectFinder::new()
1121 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1122 .with_workspace(MockWorkspace::same_path("root", "/project2/package.json"))
1123 .with_package(MockPackage::same_path("pkg2", "/project3/package.json"));
1124
1125 let seed = MockProjectFinder::new()
1126 .with_package(MockPackage::same_path("seed", "/seed/package.json"));
1127 let mut out = seed.projects();
1128 finder.extend_projects(&mut out);
1129
1130 let mut expected = vec!["seed".to_string()];
1131 expected.extend(project_names(&finder.projects()));
1132 assert_eq!(project_names(&out), expected);
1133 }
1134
1135 #[test]
1136 fn test_extend_projects_default_on_empty_finder_is_a_no_op() {
1137 let finder = MockProjectFinder::new();
1138 let mut out: Vec<&Project> = Vec::new();
1139 finder.extend_projects(&mut out);
1140 assert!(out.is_empty());
1141 }
1142
1143 // Mutable twin of the test above: `MockProjectFinder` does NOT override
1144 // `extend_projects_mut`, so this pins that the default appends exactly
1145 // `projects_mut()`, in `projects_mut()` order, without disturbing what the
1146 // buffer already holds — the contract `find_project_dirs`'s no-name
1147 // fallback relies on when it merges every finder into one buffer.
1148 #[test]
1149 fn test_extend_projects_mut_default_matches_projects_and_preserves_buffer() {
1150 let mut finder = MockProjectFinder::new()
1151 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1152 .with_workspace(MockWorkspace::same_path("root", "/project2/package.json"))
1153 .with_package(MockPackage::same_path("pkg2", "/project3/package.json"));
1154
1155 let mut seed = MockProjectFinder::new()
1156 .with_package(MockPackage::same_path("seed", "/seed/package.json"));
1157 let mut out = seed.projects_mut();
1158 finder.extend_projects_mut(&mut out);
1159 let names = project_names(&out);
1160 drop(out);
1161
1162 let mut expected = vec!["seed".to_string()];
1163 expected.extend(project_names(&finder.projects()));
1164 assert_eq!(names, expected);
1165 }
1166
1167 #[test]
1168 fn test_extend_projects_mut_default_on_empty_finder_is_a_no_op() {
1169 let mut finder = MockProjectFinder::new();
1170 let mut out: Vec<&mut Project> = Vec::new();
1171 finder.extend_projects_mut(&mut out);
1172 assert!(out.is_empty());
1173 }
1174
1175 // The macro override skips the intermediate Vec that `projects()` builds;
1176 // both must still yield the same projects in the same `HashMap::values()`
1177 // order, so a caller can swap one for the other without reordering output.
1178 #[test]
1179 fn test_extend_projects_macro_override_matches_projects_order() {
1180 let finder = HashMapProjectFinder::with_packages(&[
1181 ("pkg1", "/project1/package.json"),
1182 ("pkg2", "/project2/package.json"),
1183 ("pkg3", "/project3/package.json"),
1184 ]);
1185
1186 let mut out: Vec<&Project> = Vec::new();
1187 finder.extend_projects(&mut out);
1188
1189 assert_eq!(out.len(), finder.project_count());
1190 assert_eq!(project_names(&out), project_names(&finder.projects()));
1191 }
1192
1193 // Same equivalence for the mutable override: `HashMap::values_mut()` and
1194 // `HashMap::values()` walk one unmodified map in the same order, so the
1195 // elided body must yield exactly what `projects_mut()` would have.
1196 #[test]
1197 fn test_extend_projects_mut_macro_override_matches_projects_order() {
1198 let mut finder = HashMapProjectFinder::with_packages(&[
1199 ("pkg1", "/project1/package.json"),
1200 ("pkg2", "/project2/package.json"),
1201 ("pkg3", "/project3/package.json"),
1202 ]);
1203
1204 let mut out: Vec<&mut Project> = Vec::new();
1205 finder.extend_projects_mut(&mut out);
1206 let out_len = out.len();
1207 let names = project_names(&out);
1208 drop(out);
1209
1210 assert_eq!(out_len, finder.project_count());
1211 assert_eq!(names, project_names(&finder.projects_mut()));
1212 }
1213
1214 // The borrows handed out must alias the finder's own storage: mutating a
1215 // project through the merged buffer has to be visible on the finder
1216 // afterwards, which is exactly what the no-name `set_name` fallback does.
1217 #[test]
1218 fn test_extend_projects_mut_yields_borrows_that_mutate_the_finder() {
1219 let mut finder = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
1220
1221 let mut out: Vec<&mut Project> = Vec::new();
1222 finder.extend_projects_mut(&mut out);
1223 for project in &mut out {
1224 project.set_name("renamed".to_string());
1225 }
1226 drop(out);
1227
1228 assert_eq!(project_names(&finder.projects()), vec!["renamed"]);
1229 }
1230
1231 // `contains_project` has the same two-body shape as `extend_projects`: a
1232 // defaulted linear scan for external implementors and a hashed override
1233 // from the macro. `MockProjectFinder` does NOT override it, so this pins
1234 // the compatibility path — hit on the exact stored manifest path, miss on
1235 // an unknown one and on a merely-similar one.
1236 #[test]
1237 fn test_contains_project_default_scans_projects_by_path() {
1238 let finder = MockProjectFinder::new()
1239 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1240 .with_workspace(MockWorkspace::same_path("root", "/project2/package.json"));
1241
1242 assert!(finder.contains_project(Path::new("/project1/package.json")));
1243 // Workspace projects count too, not just packages.
1244 assert!(finder.contains_project(Path::new("/project2/package.json")));
1245 assert!(!finder.contains_project(Path::new("/project3/package.json")));
1246 // The probe keys on the whole manifest path, never on the directory.
1247 assert!(!finder.contains_project(Path::new("/project1")));
1248 }
1249
1250 #[test]
1251 fn test_contains_project_default_on_empty_finder_is_always_false() {
1252 let finder = MockProjectFinder::new();
1253 assert!(!finder.contains_project(Path::new("/project1/package.json")));
1254 }
1255
1256 // The macro override must answer identically to the default for every
1257 // probe — that equivalence is what lets the six language finders swap
1258 // their open-coded `self.projects.contains_key(path)` for the trait
1259 // method without changing behaviour.
1260 #[test]
1261 fn test_contains_project_macro_override_matches_default_answers() {
1262 let entries = [
1263 ("pkg1", "/project1/package.json"),
1264 ("pkg2", "/project2/package.json"),
1265 ];
1266 let hashed = HashMapProjectFinder::with_packages(&entries);
1267 let mut scanned = MockProjectFinder::new();
1268 for (name, path) in entries {
1269 scanned = scanned.with_package(MockPackage::same_path(name, path));
1270 }
1271
1272 for probe in [
1273 "/project1/package.json",
1274 "/project2/package.json",
1275 "/project3/package.json",
1276 "/project1",
1277 "",
1278 ] {
1279 assert_eq!(
1280 hashed.contains_project(Path::new(probe)),
1281 scanned.contains_project(Path::new(probe)),
1282 "hashed and scanned answers diverged for {probe:?}"
1283 );
1284 }
1285 }
1286
1287 // `should_visit_manifest` is the consolidated two-guard prelude. Its
1288 // documented order is name/stat gate FIRST, already-discovered probe
1289 // SECOND, and it returns `true` only when both agree the manifest is new.
1290 #[tokio::test]
1291 async fn test_should_visit_manifest_accepts_new_recognized_manifest() {
1292 let temp_dir = tempfile::TempDir::new().unwrap();
1293 let manifest = temp_dir.path().join("package.json");
1294 std::fs::write(&manifest, "{}").unwrap();
1295
1296 let finder = MockProjectFinder::new();
1297 assert!(finder.should_visit_manifest(&manifest).await.unwrap());
1298 }
1299
1300 // The duplicate-visit half: same manifest, but already discovered.
1301 #[tokio::test]
1302 async fn test_should_visit_manifest_rejects_already_discovered_manifest() {
1303 let temp_dir = tempfile::TempDir::new().unwrap();
1304 let manifest = temp_dir.path().join("package.json");
1305 std::fs::write(&manifest, "{}").unwrap();
1306
1307 let finder = MockProjectFinder::new()
1308 .with_package(MockPackage::same_path("pkg", manifest.to_str().unwrap()));
1309 assert!(
1310 !finder.should_visit_manifest(&manifest).await.unwrap(),
1311 "a manifest already in the finder must not be visited twice"
1312 );
1313 }
1314
1315 // The name/stat half: an unrecognized name and a directory that merely
1316 // shares a manifest name are both rejected before anything is parsed.
1317 #[tokio::test]
1318 async fn test_should_visit_manifest_rejects_non_manifest_and_directory() {
1319 let temp_dir = tempfile::TempDir::new().unwrap();
1320 let other = temp_dir.path().join("Cargo.toml");
1321 std::fs::write(&other, "[package]\n").unwrap();
1322 let dir_path = temp_dir.path().join("package.json");
1323 std::fs::create_dir(&dir_path).unwrap();
1324
1325 let finder = MockProjectFinder::new();
1326 assert!(!finder.should_visit_manifest(&other).await.unwrap());
1327 assert!(!finder.should_visit_manifest(&dir_path).await.unwrap());
1328 }
1329
1330 // Equivalence with the hand-rolled prelude the language finders used to
1331 // open-code: `matches_project_file(path)? && !contains_project(path)`.
1332 #[tokio::test]
1333 async fn test_should_visit_manifest_equals_the_open_coded_two_guard_prelude() {
1334 let temp_dir = tempfile::TempDir::new().unwrap();
1335 let manifest = temp_dir.path().join("package.json");
1336 std::fs::write(&manifest, "{}").unwrap();
1337 let unrecognized = temp_dir.path().join("Cargo.toml");
1338 std::fs::write(&unrecognized, "[package]\n").unwrap();
1339 let missing = temp_dir.path().join("nested").join("package.json");
1340
1341 let known = MockProjectFinder::new()
1342 .with_package(MockPackage::same_path("pkg", manifest.to_str().unwrap()));
1343 let empty = MockProjectFinder::new();
1344
1345 for finder in [&known, &empty] {
1346 for probe in [&manifest, &unrecognized, &missing] {
1347 let open_coded = finder.matches_project_file(probe).await.unwrap()
1348 && !finder.contains_project(probe);
1349 assert_eq!(
1350 finder.should_visit_manifest(probe).await.unwrap(),
1351 open_coded,
1352 "consolidated gate diverged from the open-coded prelude for {}",
1353 probe.display()
1354 );
1355 }
1356 }
1357 }
1358
1359 #[test]
1360 fn test_extend_projects_macro_override_preserves_existing_buffer_contents() {
1361 let first = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
1362 let second = HashMapProjectFinder::with_packages(&[("pkg2", "/project2/package.json")]);
1363
1364 // Mirrors the CLI's `collect_projects`: one buffer, several finders.
1365 let mut out: Vec<&Project> = Vec::new();
1366 first.extend_projects(&mut out);
1367 second.extend_projects(&mut out);
1368
1369 assert_eq!(project_names(&out), vec!["pkg1", "pkg2"]);
1370 }
1371
1372 #[test]
1373 fn test_extend_projects_mut_macro_override_preserves_existing_buffer_contents() {
1374 let mut first = HashMapProjectFinder::with_packages(&[("pkg1", "/project1/package.json")]);
1375 let mut second = HashMapProjectFinder::with_packages(&[("pkg2", "/project2/package.json")]);
1376
1377 // Mirrors `find_project_dirs`'s no-name fallback: one buffer, several finders.
1378 let mut out: Vec<&mut Project> = Vec::new();
1379 first.extend_projects_mut(&mut out);
1380 second.extend_projects_mut(&mut out);
1381
1382 assert_eq!(project_names(&out), vec!["pkg1", "pkg2"]);
1383 }
1384
1385 #[test]
1386 fn test_project_finder_check_changed() {
1387 let package = MockPackage::same_path("test", "/project/package.json");
1388 let mut finder = MockProjectFinder::new().with_package(package);
1389
1390 // Check a file that's in the project directory
1391 finder
1392 .check_changed_many(&[PathBuf::from("/project/src/index.js")])
1393 .unwrap();
1394
1395 // The project should be marked as changed
1396 assert!(finder.projects()[0].is_changed());
1397 }
1398
1399 #[test]
1400 fn test_project_finder_check_changed_multiple_projects() {
1401 let package1 = MockPackage::same_path("pkg1", "/project1/package.json");
1402 let package2 = MockPackage::same_path("pkg2", "/project2/package.json");
1403 let mut finder = MockProjectFinder::new()
1404 .with_package(package1)
1405 .with_package(package2);
1406
1407 // Check a file in project1 only
1408 finder
1409 .check_changed_many(&[PathBuf::from("/project1/src/index.js")])
1410 .unwrap();
1411
1412 // Only project1 should be changed
1413 assert!(finder.projects()[0].is_changed());
1414 assert!(!finder.projects()[1].is_changed());
1415 }
1416
1417 #[test]
1418 fn test_project_finder_check_changed_many() {
1419 let package1 = MockPackage::same_path("pkg1", "/project1/package.json");
1420 let package2 = MockPackage::same_path("pkg2", "/project2/package.json");
1421 let workspace = MockWorkspace::same_path("root", "/project3/package.json");
1422 let mut finder = MockProjectFinder::new()
1423 .with_package(package1)
1424 .with_package(package2)
1425 .with_workspace(workspace);
1426
1427 // One batch: a file under project1 and a file under project3 (the
1428 // workspace); nothing under project2. `check_changed_many` must mark
1429 // exactly project1 and project3 — a project is marked changed iff any
1430 // path matches it, proving the project-major loop order is
1431 // behavior-preserving across both Package and Workspace variants.
1432 let paths = [
1433 PathBuf::from("/project1/src/index.js"),
1434 PathBuf::from("/project3/lib/mod.rs"),
1435 ];
1436 finder.check_changed_many(&paths).unwrap();
1437
1438 assert!(finder.projects()[0].is_changed());
1439 assert!(!finder.projects()[1].is_changed());
1440 assert!(finder.projects()[2].is_changed());
1441 }
1442
1443 #[test]
1444 fn test_project_finder_check_changed_many_matches_per_file_traversal() {
1445 // The same inputs fed one-at-a-time (each path its own single-element
1446 // batch, mirroring a per-file traversal) and fed together in ONE batch
1447 // must land the two finders in an identical changed-state, locking the
1448 // order/batch equivalence the driver relies on.
1449 let paths = [
1450 PathBuf::from("/project1/src/index.js"),
1451 PathBuf::from("/project2/README.md"),
1452 ];
1453
1454 let mut per_path = MockProjectFinder::new()
1455 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1456 .with_package(MockPackage::same_path("pkg2", "/project2/package.json"));
1457 for path in &paths {
1458 per_path
1459 .check_changed_many(std::slice::from_ref(path))
1460 .unwrap();
1461 }
1462
1463 let mut batched = MockProjectFinder::new()
1464 .with_package(MockPackage::same_path("pkg1", "/project1/package.json"))
1465 .with_package(MockPackage::same_path("pkg2", "/project2/package.json"));
1466 batched.check_changed_many(&paths).unwrap();
1467
1468 assert_eq!(
1469 per_path.projects()[0].is_changed(),
1470 batched.projects()[0].is_changed()
1471 );
1472 assert_eq!(
1473 per_path.projects()[1].is_changed(),
1474 batched.projects()[1].is_changed()
1475 );
1476 assert!(batched.projects()[0].is_changed());
1477 assert!(batched.projects()[1].is_changed());
1478 }
1479
1480 #[test]
1481 fn test_project_finder_with_workspace() {
1482 let workspace = MockWorkspace::same_path("root", "/project/package.json");
1483 let mut finder = MockProjectFinder::new().with_workspace(workspace);
1484
1485 finder
1486 .check_changed_many(&[PathBuf::from("/project/src/index.js")])
1487 .unwrap();
1488
1489 assert!(finder.projects()[0].is_changed());
1490 }
1491
1492 // Every other `check_changed_many` test ends in `unwrap`, so the `?` on
1493 // `project.check_changed(path)` inside the defaulted body was never
1494 // exercised on its failing branch. The only way that call can fail is
1495 // `should_mark_changed` finding no parent directory for the project
1496 // manifest, so a project rooted at `/` — whose `Path::parent()` is `None`
1497 // on both Windows and Unix — reaches it. The batch loop must surface that
1498 // error instead of swallowing it and reporting the project as unchanged.
1499 #[test]
1500 fn test_project_finder_check_changed_many_propagates_check_changed_error() {
1501 let mut finder = MockProjectFinder::new().with_package(MockPackage::same_path("root", "/"));
1502
1503 let error = finder
1504 .check_changed_many(&[PathBuf::from("/src/index.js")])
1505 .expect_err("a manifest without a parent directory must fail the batch");
1506
1507 let chain = format!("{error:#}");
1508 assert!(
1509 chain.contains("Parent not found"),
1510 "error chain should carry the missing-parent context, got: {chain}"
1511 );
1512 assert!(
1513 !finder.projects()[0].is_changed(),
1514 "a project whose check_changed failed must not be reported as changed"
1515 );
1516 }
1517
1518 #[test]
1519 fn project_finder_entry_points_are_included_in_coverage() {
1520 assert!(
1521 !include_str!("project_finder.rs")
1522 .contains(concat!("#[cfg(not(", "tarpaulin_include))]"))
1523 );
1524 }
1525
1526 #[tokio::test]
1527 async fn test_default_project_finder_finalize_is_covered_no_op() {
1528 let mut finder = MockProjectFinder::new();
1529 let result = finder.finalize().await;
1530 assert!(result.is_ok());
1531 }
1532
1533 #[tokio::test]
1534 async fn test_is_regular_file_with_existing_file() {
1535 let temp_dir = tempfile::TempDir::new().unwrap();
1536 let file_path = temp_dir.path().join("test.txt");
1537 std::fs::write(&file_path, "test content").unwrap();
1538
1539 let result = is_regular_file(&file_path).await;
1540 assert!(result.unwrap());
1541 }
1542
1543 #[tokio::test]
1544 async fn test_is_regular_file_with_directory() {
1545 let temp_dir = tempfile::TempDir::new().unwrap();
1546 let dir_path = temp_dir.path().join("subdir");
1547 std::fs::create_dir(&dir_path).unwrap();
1548
1549 let result = is_regular_file(&dir_path).await;
1550 assert!(!result.unwrap());
1551 }
1552
1553 #[tokio::test]
1554 async fn test_is_regular_file_with_missing_path() {
1555 let temp_dir = tempfile::TempDir::new().unwrap();
1556 let missing_path = temp_dir.path().join("nonexistent.txt");
1557
1558 let result = is_regular_file(&missing_path).await;
1559 assert!(!result.unwrap());
1560 }
1561
1562 #[tokio::test]
1563 async fn test_is_regular_file_propagates_metadata_error_with_path_context() {
1564 let temp_dir = tempfile::TempDir::new().unwrap();
1565 #[cfg(windows)]
1566 let invalid_path = temp_dir.path().join("invalid\0path");
1567 #[cfg(unix)]
1568 let invalid_path = {
1569 use std::os::unix::fs::symlink;
1570
1571 let path = temp_dir.path().join("metadata-loop");
1572 symlink(&path, &path).unwrap();
1573 path
1574 };
1575
1576 let error = is_regular_file(&invalid_path)
1577 .await
1578 .expect_err("metadata errors other than NotFound must be propagated");
1579 let chain = format!("{error:#}");
1580 assert!(
1581 chain.contains(&invalid_path.display().to_string()),
1582 "error chain should name the path whose metadata failed, got: {chain}"
1583 );
1584 }
1585
1586 // `regular_file_metadata` is the ladder `is_regular_file` and the Java
1587 // executable probe both sit on, and it is the only one of the two that
1588 // hands the caller the stat'ed `Metadata`. These cases pin that extra
1589 // guarantee: the returned metadata must describe the file itself, and the
1590 // non-file exits must stay indistinguishable `None`s.
1591 #[tokio::test]
1592 async fn test_regular_file_metadata_returns_metadata_for_existing_file() {
1593 let temp_dir = tempfile::TempDir::new().unwrap();
1594 let file_path = temp_dir.path().join("test.txt");
1595 std::fs::write(&file_path, "test content").unwrap();
1596
1597 let metadata = regular_file_metadata(&file_path)
1598 .await
1599 .unwrap()
1600 .expect("an existing regular file must yield its metadata");
1601 assert!(metadata.is_file());
1602 assert_eq!(metadata.len(), "test content".len() as u64);
1603 }
1604
1605 #[tokio::test]
1606 async fn test_regular_file_metadata_returns_none_for_directory_and_missing_path() {
1607 let temp_dir = tempfile::TempDir::new().unwrap();
1608 let dir_path = temp_dir.path().join("subdir");
1609 std::fs::create_dir(&dir_path).unwrap();
1610
1611 assert!(regular_file_metadata(&dir_path).await.unwrap().is_none());
1612 assert!(
1613 regular_file_metadata(&temp_dir.path().join("nonexistent.txt"))
1614 .await
1615 .unwrap()
1616 .is_none()
1617 );
1618 }
1619
1620 #[tokio::test]
1621 async fn test_regular_file_metadata_propagates_error_with_path_context() {
1622 let temp_dir = tempfile::TempDir::new().unwrap();
1623 #[cfg(windows)]
1624 let invalid_path = temp_dir.path().join("invalid\0path");
1625 #[cfg(unix)]
1626 let invalid_path = {
1627 use std::os::unix::fs::symlink;
1628
1629 let path = temp_dir.path().join("metadata-loop");
1630 symlink(&path, &path).unwrap();
1631 path
1632 };
1633
1634 let error = regular_file_metadata(&invalid_path)
1635 .await
1636 .expect_err("metadata errors other than NotFound must be propagated");
1637 let chain = format!("{error:#}");
1638 assert!(
1639 chain.contains(&format!(
1640 "Failed to read metadata for {}",
1641 invalid_path.display()
1642 )),
1643 "error chain should carry the shared metadata context, got: {chain}"
1644 );
1645 }
1646
1647 // `matches_project_file` is the defaulted gate every non-CSharp language
1648 // finder calls before parsing a manifest. `MockProjectFinder::project_files`
1649 // returns exactly `["package.json"]`, so these cases pin all four exits of
1650 // its documented name-first / stat-last order.
1651
1652 // Exit 4 (the only `true`): recognized name AND a real regular file.
1653 #[tokio::test]
1654 async fn test_matches_project_file_accepts_recognized_regular_file() {
1655 let temp_dir = tempfile::TempDir::new().unwrap();
1656 let manifest = temp_dir.path().join("package.json");
1657 std::fs::write(&manifest, "{}").unwrap();
1658
1659 let finder = MockProjectFinder::new();
1660 assert!(
1661 finder.matches_project_file(&manifest).await.unwrap(),
1662 "a real file named package.json must be recognized"
1663 );
1664 }
1665
1666 // Exit 4 again, negative half: the name matches but the entry is a
1667 // DIRECTORY, so the stat must veto it. This is why the stat cannot simply
1668 // be dropped once the name check is in place.
1669 #[tokio::test]
1670 async fn test_matches_project_file_rejects_directory_with_recognized_name() {
1671 let temp_dir = tempfile::TempDir::new().unwrap();
1672 let dir_path = temp_dir.path().join("package.json");
1673 std::fs::create_dir(&dir_path).unwrap();
1674
1675 let finder = MockProjectFinder::new();
1676 assert!(
1677 !finder.matches_project_file(&dir_path).await.unwrap(),
1678 "a directory named package.json must not be treated as a manifest"
1679 );
1680 }
1681
1682 // Exit 3: an unrecognized name is rejected even though the file really
1683 // exists — the name guard, not the stat, is what filters it out.
1684 #[tokio::test]
1685 async fn test_matches_project_file_rejects_unrecognized_name() {
1686 let temp_dir = tempfile::TempDir::new().unwrap();
1687 let other = temp_dir.path().join("Cargo.toml");
1688 std::fs::write(&other, "[package]\n").unwrap();
1689
1690 let finder = MockProjectFinder::new();
1691 assert!(
1692 !finder.matches_project_file(&other).await.unwrap(),
1693 "Cargo.toml is not in this finder's project_files()"
1694 );
1695 }
1696
1697 // Exit 1: `file_name()` is `None` for a path ending in `..`, even though
1698 // that path resolves to an existing directory. The early return must fire
1699 // before any stat.
1700 #[tokio::test]
1701 async fn test_matches_project_file_rejects_path_without_file_name() {
1702 let temp_dir = tempfile::TempDir::new().unwrap();
1703 let parent_ref = temp_dir.path().join("..");
1704 assert!(parent_ref.file_name().is_none());
1705
1706 let finder = MockProjectFinder::new();
1707 assert!(
1708 !finder.matches_project_file(&parent_ref).await.unwrap(),
1709 "a path with no file name cannot match a manifest name"
1710 );
1711 }
1712
1713 // Exit 2: `to_str()` is `None` for a non-UTF-8 file name. Such a name
1714 // cannot equal any ASCII manifest name, so the guard must short-circuit to
1715 // `Ok(false)` before any stat — exactly what the method doc reasons about.
1716 // The path deliberately does not exist: the early return fires first.
1717 #[tokio::test]
1718 async fn test_matches_project_file_rejects_non_utf8_file_name() {
1719 #[cfg(unix)]
1720 let name: std::ffi::OsString = {
1721 use std::os::unix::ffi::OsStrExt;
1722 std::ffi::OsStr::from_bytes(b"\xFF\xFEpackage.json").to_os_string()
1723 };
1724 #[cfg(windows)]
1725 let name: std::ffi::OsString = {
1726 use std::os::windows::ffi::OsStringExt;
1727 // Unpaired high surrogate — unrepresentable in UTF-8.
1728 std::ffi::OsString::from_wide(&[0xD800, u16::from(b'x')])
1729 };
1730
1731 // Stay honest if a platform ever normalizes the name away.
1732 assert!(
1733 Path::new(&name)
1734 .file_name()
1735 .and_then(std::ffi::OsStr::to_str)
1736 .is_none(),
1737 "fixture must really be a non-UTF-8 file name"
1738 );
1739
1740 let temp_dir = tempfile::TempDir::new().unwrap();
1741 let path = temp_dir.path().join(&name);
1742
1743 let finder = MockProjectFinder::new();
1744 assert!(
1745 !finder.matches_project_file(&path).await.unwrap(),
1746 "a non-UTF-8 file name cannot match an ASCII manifest name"
1747 );
1748 }
1749
1750 /// Finder that returns the leading-dot EXTENSION form of
1751 /// [`ProjectFinder::project_files`], the same shape `CSharpProjectFinder`
1752 /// uses.
1753 #[derive(Debug)]
1754 struct ExtensionProjectFinder;
1755
1756 #[async_trait]
1757 impl ProjectFinder for ExtensionProjectFinder {
1758 fn projects(&self) -> Vec<&Project> {
1759 vec![]
1760 }
1761
1762 fn projects_mut(&mut self) -> Vec<&mut Project> {
1763 vec![]
1764 }
1765
1766 fn project_count(&self) -> usize {
1767 0
1768 }
1769
1770 fn project_files(&self) -> &[&str] {
1771 &[".csproj"]
1772 }
1773
1774 async fn visit(&mut self, _path: &Path, _relative_path: &Path) -> Result<()> {
1775 Ok(())
1776 }
1777 }
1778
1779 // Pins the documented half of the dual contract: the defaulted
1780 // `matches_project_file` implements ONLY the bare-file-name form, so an
1781 // extension entry can never match through it — not even for a real
1782 // `.csproj` file on disk, in any casing. This is exactly why
1783 // `CSharpProjectFinder::visit` gates on `has_extension_ignore_ascii_case`
1784 // + `is_regular_file` instead of calling this method; if that ever
1785 // changed, C# discovery would silently stop finding projects.
1786 #[tokio::test]
1787 async fn test_matches_project_file_never_matches_an_extension_entry() {
1788 let temp_dir = tempfile::TempDir::new().unwrap();
1789 let finder = ExtensionProjectFinder;
1790
1791 for name in ["App.csproj", "App.CSPROJ"] {
1792 let manifest = temp_dir.path().join(name);
1793 std::fs::write(&manifest, "<Project/>").unwrap();
1794
1795 assert!(
1796 !finder.matches_project_file(&manifest).await.unwrap(),
1797 "{name} must not match through the file-name-only gate"
1798 );
1799 // The extension-aware check is the one that accepts it.
1800 assert!(has_extension_ignore_ascii_case(&manifest, "csproj"));
1801 assert!(is_regular_file(&manifest).await.unwrap());
1802 }
1803 }
1804
1805 // Recognized name, nothing on disk: `is_regular_file` maps NotFound to
1806 // `Ok(false)` rather than an error, so the gate stays quiet for deleted
1807 // manifests still listed in the git index.
1808 #[tokio::test]
1809 async fn test_matches_project_file_rejects_missing_manifest() {
1810 let temp_dir = tempfile::TempDir::new().unwrap();
1811 let missing = temp_dir.path().join("package.json");
1812
1813 let finder = MockProjectFinder::new();
1814 assert!(
1815 !finder.matches_project_file(&missing).await.unwrap(),
1816 "a package.json that does not exist must not match"
1817 );
1818 }
1819}