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