Skip to main content

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}