Skip to main content

provenant/assembly/
assemblers.rs

1// SPDX-FileCopyrightText: nexB Inc. and others
2// ScanCode is a trademark of nexB Inc.
3// SPDX-FileCopyrightText: Provenant contributors
4// SPDX-License-Identifier: Apache-2.0
5// Derived from ScanCode Toolkit (Apache-2.0); modified. See NOTICE.
6
7use std::collections::HashSet;
8
9use crate::models::PackageType;
10use crate::models::{DatasourceId, FileInfo, Package, TopLevelDependency};
11use strum::EnumIter;
12
13use super::{
14    AssemblerConfig, AssemblyMode, DirectoryMergeOutput, bazel_prune, cargo_resource_assign,
15    composer_resource_assign, conda_rootfs_merge, debian_source_merge, file_ref_resolve,
16    hackage_merge, nix_flake_compat_merge, npm_resource_assign, nuget_cpm_resolve,
17    python_requirements_assign, ruby_resource_assign, swift_merge, topology,
18};
19
20#[derive(Clone, Copy)]
21pub(super) enum SpecialDirectoryMergerKind {
22    Skip,
23    Cocoapods,
24    DebianSource,
25    Hackage,
26    Huggingface,
27    WindowsUpdate,
28}
29
30#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, EnumIter)]
31pub(super) enum PostAssemblyPassKind {
32    SwiftMerge,
33    CondaRootfsMerge,
34    NpmResourceAssign,
35    PythonRequirementsAssign,
36    FileReferenceResolve,
37    RpmYumdbMerge,
38    NpmWorkspaceMerge,
39    CargoWorkspaceMerge,
40    NugetCpmResolve,
41    CargoResourceAssign,
42    ComposerResourceAssign,
43    RubyResourceAssign,
44    NixFlakeCompatMerge,
45    BazelPrune,
46}
47
48pub(super) fn special_directory_merger_for(
49    config_key: DatasourceId,
50) -> Option<SpecialDirectoryMergerKind> {
51    match config_key {
52        DatasourceId::CocoapodsPodspec => Some(SpecialDirectoryMergerKind::Cocoapods),
53        DatasourceId::DebianControlInSource => Some(SpecialDirectoryMergerKind::DebianSource),
54        DatasourceId::HackageCabal => Some(SpecialDirectoryMergerKind::Hackage),
55        DatasourceId::HuggingfaceModelCard => Some(SpecialDirectoryMergerKind::Huggingface),
56        DatasourceId::MicrosoftUpdateManifestMum => Some(SpecialDirectoryMergerKind::WindowsUpdate),
57        DatasourceId::SwiftPackageManifestJson => Some(SpecialDirectoryMergerKind::Skip),
58        _ => None,
59    }
60}
61
62pub(super) static POST_ASSEMBLY_PASSES: &[PostAssemblyPassKind] = &[
63    PostAssemblyPassKind::SwiftMerge,
64    PostAssemblyPassKind::CondaRootfsMerge,
65    PostAssemblyPassKind::NpmResourceAssign,
66    PostAssemblyPassKind::PythonRequirementsAssign,
67    PostAssemblyPassKind::FileReferenceResolve,
68    PostAssemblyPassKind::RpmYumdbMerge,
69    PostAssemblyPassKind::NpmWorkspaceMerge,
70    PostAssemblyPassKind::CargoWorkspaceMerge,
71    PostAssemblyPassKind::NugetCpmResolve,
72    PostAssemblyPassKind::CargoResourceAssign,
73    PostAssemblyPassKind::ComposerResourceAssign,
74    PostAssemblyPassKind::RubyResourceAssign,
75    PostAssemblyPassKind::NixFlakeCompatMerge,
76    PostAssemblyPassKind::BazelPrune,
77];
78
79const SWIFT_POST_ASSEMBLY_DATASOURCE_IDS: &[DatasourceId] = &[
80    DatasourceId::SwiftPackageManifestJson,
81    DatasourceId::SwiftPackageResolved,
82    DatasourceId::SwiftPackageShowDependencies,
83];
84
85const CONDA_ROOTFS_POST_ASSEMBLY_DATASOURCE_IDS: &[DatasourceId] =
86    &[DatasourceId::CondaMetaJson, DatasourceId::CondaMetaYaml];
87
88const RPM_INSTALLED_DATABASE_DATASOURCE_IDS: &[DatasourceId] = &[
89    DatasourceId::RpmInstalledDatabaseBdb,
90    DatasourceId::RpmInstalledDatabaseNdb,
91    DatasourceId::RpmInstalledDatabaseSqlite,
92];
93
94const NUGET_CPM_CONFIG_DATASOURCE_IDS: &[DatasourceId] = &[
95    DatasourceId::NugetDirectoryBuildProps,
96    DatasourceId::NugetDirectoryPackagesProps,
97];
98
99const NUGET_CPM_PROJECT_DATASOURCE_IDS: &[DatasourceId] = &[
100    DatasourceId::NugetCsproj,
101    DatasourceId::NugetFsproj,
102    DatasourceId::NugetVbproj,
103];
104
105#[derive(Default)]
106struct PostAssemblyInputs {
107    package_types: HashSet<PackageType>,
108    file_datasource_ids: HashSet<DatasourceId>,
109    has_npm_workspace_markers: bool,
110    has_cargo_workspace_markers: bool,
111}
112
113pub(super) fn run_post_assembly_passes(
114    files: &mut [FileInfo],
115    packages: &mut Vec<Package>,
116    dependencies: &mut Vec<TopLevelDependency>,
117    topology_plan: &topology::TopologyPlan,
118) {
119    let inputs = PostAssemblyInputs::collect(files, packages);
120
121    for pass in POST_ASSEMBLY_PASSES {
122        if !pass.should_run(&inputs) {
123            continue;
124        }
125
126        pass.run(files, packages, dependencies, topology_plan);
127    }
128}
129
130impl PostAssemblyInputs {
131    fn collect(files: &[FileInfo], packages: &[Package]) -> Self {
132        let mut inputs = Self {
133            package_types: packages
134                .iter()
135                .filter_map(|package| package.package_type)
136                .collect(),
137            ..Self::default()
138        };
139
140        for file in files {
141            for package_data in &file.package_data {
142                let Some(datasource_id) = package_data.datasource_id else {
143                    continue;
144                };
145
146                inputs.file_datasource_ids.insert(datasource_id);
147
148                if matches!(
149                    datasource_id,
150                    DatasourceId::NpmPackageJson | DatasourceId::PnpmWorkspaceYaml
151                ) && package_data
152                    .extra_data
153                    .as_ref()
154                    .is_some_and(|extra_data| extra_data.contains_key("workspaces"))
155                {
156                    inputs.has_npm_workspace_markers = true;
157                }
158
159                if datasource_id == DatasourceId::CargoToml
160                    && package_data
161                        .extra_data
162                        .as_ref()
163                        .and_then(|extra_data| extra_data.get("workspace"))
164                        .and_then(|workspace| workspace.get("members"))
165                        .and_then(|members| members.as_array())
166                        .is_some_and(|members| !members.is_empty())
167                {
168                    inputs.has_cargo_workspace_markers = true;
169                }
170            }
171        }
172
173        inputs
174    }
175
176    fn has_package_type(&self, package_type: PackageType) -> bool {
177        self.package_types.contains(&package_type)
178    }
179
180    fn has_any_file_datasource(&self, datasource_ids: &[DatasourceId]) -> bool {
181        datasource_ids
182            .iter()
183            .any(|datasource_id| self.file_datasource_ids.contains(datasource_id))
184    }
185
186    fn has_all_file_datasources(&self, datasource_ids: &[DatasourceId]) -> bool {
187        datasource_ids
188            .iter()
189            .all(|datasource_id| self.file_datasource_ids.contains(datasource_id))
190    }
191}
192
193impl SpecialDirectoryMergerKind {
194    pub(super) fn run(
195        self,
196        config: &AssemblerConfig,
197        files: &[FileInfo],
198        file_indices: &[usize],
199    ) -> Vec<DirectoryMergeOutput> {
200        match self {
201            Self::Skip => Vec::new(),
202            Self::Cocoapods => {
203                super::cocoapods_merge::assemble_cocoapods_packages(config, files, file_indices)
204            }
205            Self::DebianSource => {
206                debian_source_merge::assemble_debian_source_packages(config, files, file_indices)
207            }
208            Self::Hackage => hackage_merge::assemble_hackage_packages(files, file_indices),
209            Self::Huggingface => {
210                super::huggingface_merge::assemble_huggingface_packages(files, file_indices)
211            }
212            Self::WindowsUpdate => super::windows_update_merge::assemble_windows_update_packages(
213                config,
214                files,
215                file_indices,
216            ),
217        }
218    }
219}
220
221impl PostAssemblyPassKind {
222    fn should_run(self, inputs: &PostAssemblyInputs) -> bool {
223        match self {
224            Self::SwiftMerge => inputs.has_any_file_datasource(SWIFT_POST_ASSEMBLY_DATASOURCE_IDS),
225            Self::CondaRootfsMerge => {
226                inputs.has_all_file_datasources(CONDA_ROOTFS_POST_ASSEMBLY_DATASOURCE_IDS)
227            }
228            Self::NpmResourceAssign => inputs.has_package_type(PackageType::Npm),
229            Self::PythonRequirementsAssign => {
230                inputs.has_package_type(PackageType::Pypi)
231                    && inputs.has_any_file_datasource(&[DatasourceId::PipRequirements])
232            }
233            Self::FileReferenceResolve => {
234                file_ref_resolve::has_relevant_file_reference_datasource_ids(
235                    &inputs.file_datasource_ids,
236                )
237            }
238            Self::RpmYumdbMerge => {
239                inputs.has_any_file_datasource(&[DatasourceId::RpmYumdb])
240                    && inputs.has_any_file_datasource(RPM_INSTALLED_DATABASE_DATASOURCE_IDS)
241            }
242            Self::NpmWorkspaceMerge => inputs.has_npm_workspace_markers,
243            Self::CargoWorkspaceMerge => inputs.has_cargo_workspace_markers,
244            Self::NugetCpmResolve => {
245                inputs.has_any_file_datasource(NUGET_CPM_CONFIG_DATASOURCE_IDS)
246                    && inputs.has_any_file_datasource(NUGET_CPM_PROJECT_DATASOURCE_IDS)
247            }
248            Self::CargoResourceAssign => inputs.has_package_type(PackageType::Cargo),
249            Self::ComposerResourceAssign => inputs.has_package_type(PackageType::Composer),
250            Self::RubyResourceAssign => inputs.has_package_type(PackageType::Gem),
251            Self::NixFlakeCompatMerge => {
252                inputs.has_any_file_datasource(&[DatasourceId::NixDefaultNix])
253                    && inputs.has_any_file_datasource(&[
254                        DatasourceId::NixFlakeNix,
255                        DatasourceId::NixFlakeLock,
256                    ])
257            }
258            Self::BazelPrune => inputs.has_package_type(PackageType::Bazel),
259        }
260    }
261
262    fn run(
263        self,
264        files: &mut [FileInfo],
265        packages: &mut Vec<Package>,
266        dependencies: &mut Vec<TopLevelDependency>,
267        topology_plan: &topology::TopologyPlan,
268    ) {
269        match self {
270            Self::SwiftMerge => swift_merge::assemble_swift_packages(files, packages, dependencies),
271            Self::CondaRootfsMerge => {
272                conda_rootfs_merge::merge_conda_rootfs_metadata(files, packages, dependencies)
273            }
274            Self::NpmResourceAssign => {
275                npm_resource_assign::assign_npm_package_resources(files, packages)
276            }
277            Self::PythonRequirementsAssign => {
278                python_requirements_assign::assign_python_requirements_to_projects(
279                    files,
280                    packages,
281                    dependencies,
282                )
283            }
284            Self::FileReferenceResolve => {
285                file_ref_resolve::resolve_file_references(files, packages, dependencies)
286            }
287            Self::RpmYumdbMerge => file_ref_resolve::merge_rpm_yumdb_metadata(files, packages),
288            Self::NpmWorkspaceMerge => {
289                topology_plan.apply_npm_workspace_domains(files, packages, dependencies)
290            }
291            Self::CargoWorkspaceMerge => {
292                topology_plan.apply_cargo_workspace_domains(files, packages, dependencies)
293            }
294            Self::NugetCpmResolve => {
295                nuget_cpm_resolve::resolve_nuget_cpm_versions(files, dependencies)
296            }
297            Self::CargoResourceAssign => {
298                cargo_resource_assign::assign_cargo_package_resources(files, packages)
299            }
300            Self::ComposerResourceAssign => {
301                composer_resource_assign::assign_composer_package_resources(files, packages)
302            }
303            Self::RubyResourceAssign => {
304                ruby_resource_assign::assign_ruby_package_resources(files, packages)
305            }
306            Self::NixFlakeCompatMerge => {
307                nix_flake_compat_merge::attach_flake_compat_default_files(files, packages)
308            }
309            Self::BazelPrune => {
310                bazel_prune::prune_unused_bazel_packages(files, packages, dependencies)
311            }
312        }
313    }
314}
315
316pub static ASSEMBLERS: &[AssemblerConfig] = &[
317    // ── Sibling-merge assemblers ──
318    //
319    // npm ecosystem: package.json + lockfiles in same directory.
320    // NOTE: npm-shrinkwrap.json emits "npm_package_lock_json" as its datasource_id,
321    // so "npm_shrinkwrap_json" is NOT a real datasource_id.
322    AssemblerConfig {
323        datasource_ids: &[
324            DatasourceId::BunLock,
325            DatasourceId::BunLockb,
326            DatasourceId::NpmPackageJson,
327            DatasourceId::NpmPackageLockJson,
328            DatasourceId::YarnLock,
329            DatasourceId::YarnLockV1,
330            DatasourceId::YarnLockV2,
331            DatasourceId::YarnPnpCjs,
332            DatasourceId::PnpmLockYaml,
333            DatasourceId::PnpmWorkspaceYaml,
334        ],
335        sibling_file_patterns: &[
336            "package.json",
337            "bun.lock",
338            "bun.lockb",
339            ".package-lock.json",
340            "package-lock.json",
341            ".npm-shrinkwrap.json",
342            "npm-shrinkwrap.json",
343            "yarn.lock",
344            ".pnp.cjs",
345            "pnpm-lock.yaml",
346            "shrinkwrap.yaml",
347            "pnpm-workspace.yaml",
348        ],
349        mode: AssemblyMode::SiblingMerge,
350    },
351    // Rust/Cargo ecosystem
352    AssemblerConfig {
353        datasource_ids: &[DatasourceId::CargoToml, DatasourceId::CargoLock],
354        sibling_file_patterns: &["Cargo.toml", "Cargo.lock"],
355        mode: AssemblyMode::SiblingMerge,
356    },
357    // Julia ecosystem
358    AssemblerConfig {
359        datasource_ids: &[
360            DatasourceId::JuliaProjectToml,
361            DatasourceId::JuliaManifestToml,
362        ],
363        sibling_file_patterns: &["Project.toml", "Manifest.toml"],
364        mode: AssemblyMode::SiblingMerge,
365    },
366    // Erlang/OTP Rebar ecosystem
367    AssemblerConfig {
368        datasource_ids: &[DatasourceId::RebarConfig, DatasourceId::RebarLock],
369        sibling_file_patterns: &["rebar.config", "rebar.lock"],
370        mode: AssemblyMode::SiblingMerge,
371    },
372    // Elixir/Hex ecosystem: `mix.exs` carries the project identity (app + version)
373    // and its direct deps; `mix.lock` contributes the resolved locked deps. They
374    // sibling-merge into one `pkg:hex/<app>` package, the same shape as
375    // Cargo.toml + Cargo.lock. A standalone `mix.lock` with no sibling `mix.exs`
376    // has no identity, so its deps stay hoisted (no package is formed).
377    AssemblerConfig {
378        datasource_ids: &[DatasourceId::HexMixExs, DatasourceId::HexMixLock],
379        sibling_file_patterns: &["mix.exs", "mix.lock"],
380        mode: AssemblyMode::SiblingMerge,
381    },
382    // Erlang OTP application resource files (`src/<app>.app.src`). The app name
383    // and version live in the `{application, <name>, [{vsn, ...}]}` tuple, so the
384    // `.app.src` is the app's identity source (`pkg:hex/<app>`); one package per
385    // record. (`rebar.config` carries build config/deps, not the app identity.)
386    AssemblerConfig {
387        datasource_ids: &[DatasourceId::ErlangOtpAppSrc],
388        sibling_file_patterns: &["*.app.src"],
389        mode: AssemblyMode::OnePerPackageData,
390    },
391    // Carthage ecosystem
392    AssemblerConfig {
393        datasource_ids: &[
394            DatasourceId::CarthageCartfile,
395            DatasourceId::CarthageCartfileResolved,
396        ],
397        sibling_file_patterns: &["Cartfile", "Cartfile.private", "Cartfile.resolved"],
398        mode: AssemblyMode::SiblingMerge,
399    },
400    // CocoaPods ecosystem
401    AssemblerConfig {
402        datasource_ids: &[
403            DatasourceId::CocoapodsPodspec,
404            DatasourceId::CocoapodsPodspecJson,
405            DatasourceId::CocoapodsPodfile,
406            DatasourceId::CocoapodsPodfileLock,
407        ],
408        sibling_file_patterns: &["*.podspec", "*.podspec.json", "Podfile", "Podfile.lock"],
409        mode: AssemblyMode::SiblingMerge,
410    },
411    // PHP Composer ecosystem
412    AssemblerConfig {
413        datasource_ids: &[DatasourceId::PhpComposerJson, DatasourceId::PhpComposerLock],
414        sibling_file_patterns: &[
415            "*composer.json",
416            "composer.*.json",
417            "*composer.lock",
418            "composer.*.lock",
419        ],
420        mode: AssemblyMode::SiblingMerge,
421    },
422    // Go ecosystem (includes legacy Godeps)
423    AssemblerConfig {
424        datasource_ids: &[
425            DatasourceId::GoMod,
426            DatasourceId::GoModGraph,
427            DatasourceId::GoSum,
428            DatasourceId::GoWork,
429            DatasourceId::Godeps,
430        ],
431        sibling_file_patterns: &[
432            "go.mod",
433            "go.work",
434            "go.mod.graph",
435            "go.modgraph",
436            "go.sum",
437            "Godeps.json",
438        ],
439        mode: AssemblyMode::SiblingMerge,
440    },
441    // Dart/Flutter ecosystem
442    AssemblerConfig {
443        datasource_ids: &[DatasourceId::PubspecYaml, DatasourceId::PubspecLock],
444        sibling_file_patterns: &["pubspec.yaml", "pubspec.lock"],
445        mode: AssemblyMode::SiblingMerge,
446    },
447    // Pixi ecosystem
448    AssemblerConfig {
449        datasource_ids: &[DatasourceId::PixiToml, DatasourceId::PixiLock],
450        sibling_file_patterns: &["pixi.toml", "pixi.lock"],
451        mode: AssemblyMode::SiblingMerge,
452    },
453    AssemblerConfig {
454        datasource_ids: &[DatasourceId::NixFlakeNix, DatasourceId::NixFlakeLock],
455        sibling_file_patterns: &["flake.nix", "flake.lock"],
456        mode: AssemblyMode::SiblingMerge,
457    },
458    AssemblerConfig {
459        datasource_ids: &[DatasourceId::NixDefaultNix],
460        sibling_file_patterns: &["default.nix"],
461        mode: AssemblyMode::OnePerPackageData,
462    },
463    // Helm chart ecosystem
464    AssemblerConfig {
465        datasource_ids: &[DatasourceId::HelmChartYaml, DatasourceId::HelmChartLock],
466        sibling_file_patterns: &["Chart.yaml", "Chart.lock"],
467        mode: AssemblyMode::SiblingMerge,
468    },
469    AssemblerConfig {
470        datasource_ids: &[
471            DatasourceId::HackageCabal,
472            DatasourceId::HackageCabalProject,
473            DatasourceId::HackageStackYaml,
474        ],
475        sibling_file_patterns: &["*.cabal", "cabal.project", "stack.yaml"],
476        mode: AssemblyMode::SiblingMerge,
477    },
478    // Chef ecosystem
479    AssemblerConfig {
480        datasource_ids: &[
481            DatasourceId::ChefCookbookMetadataJson,
482            DatasourceId::ChefCookbookMetadataRb,
483        ],
484        sibling_file_patterns: &["metadata.json", "metadata.rb"],
485        mode: AssemblyMode::SiblingMerge,
486    },
487    // Conan (C/C++) ecosystem
488    AssemblerConfig {
489        datasource_ids: &[
490            DatasourceId::ConanConanFilePy,
491            DatasourceId::ConanConanFileTxt,
492            DatasourceId::ConanLock,
493            DatasourceId::ConanConanDataYml,
494        ],
495        sibling_file_patterns: &[
496            "conanfile.py",
497            "conanfile.txt",
498            "conan.lock",
499            "conandata.yml",
500        ],
501        mode: AssemblyMode::SiblingMerge,
502    },
503    // vcpkg (C/C++) ports and manifests. Each `CONTROL` or `vcpkg.json` that
504    // names a port/project is an independent package, so one package per record:
505    // a ports tree holds hundreds of distinct `CONTROL`/`vcpkg.json` files that
506    // must each surface (and own their `Build-Depends`/`dependencies`) rather
507    // than being dropped. `vcpkg-configuration.json` and `vcpkg-lock.json` carry
508    // no package identity and stay unassembled.
509    AssemblerConfig {
510        datasource_ids: &[DatasourceId::VcpkgControl, DatasourceId::VcpkgJson],
511        sibling_file_patterns: &["CONTROL", "vcpkg.json"],
512        mode: AssemblyMode::OnePerPackageData,
513    },
514    // Maven/Java ecosystem (nested merge via META-INF)
515    AssemblerConfig {
516        datasource_ids: &[
517            DatasourceId::MavenPom,
518            DatasourceId::MavenPomProperties,
519            DatasourceId::JavaJarManifest,
520            DatasourceId::JavaOsgiManifest,
521        ],
522        sibling_file_patterns: &[
523            "pom.xml",
524            "*.pom",
525            "pom.properties",
526            "**/META-INF/MANIFEST.MF",
527        ],
528        // A directory can hold one module (pom.xml + supplementary siblings) or
529        // many standalone `.pom` files with distinct GAVs; split per identity.
530        mode: AssemblyMode::SiblingMergePerIdentity,
531    },
532    // Leiningen `project.clj` declares a `defproject` with Maven coordinates, so
533    // each is an independent package that owns its `:dependencies`. One package
534    // per record (the `deps.edn` datasource carries no own identity and stays
535    // unassembled, pending sibling-merge with a co-located project.clj).
536    AssemblerConfig {
537        datasource_ids: &[DatasourceId::ClojureProjectClj],
538        sibling_file_patterns: &["project.clj"],
539        mode: AssemblyMode::OnePerPackageData,
540    },
541    // sbt `build.sbt` declares a project with Maven coordinates and owns its
542    // `libraryDependencies`. One package per record so the project surfaces and
543    // owns its deps instead of orphaning them.
544    AssemblerConfig {
545        datasource_ids: &[DatasourceId::SbtBuildSbt],
546        sibling_file_patterns: &["build.sbt"],
547        mode: AssemblyMode::OnePerPackageData,
548    },
549    AssemblerConfig {
550        datasource_ids: &[DatasourceId::PypiWheel, DatasourceId::PypiPipOriginJson],
551        sibling_file_patterns: &["*.whl", "origin.json"],
552        // A wheelhouse (`pip download -d wheelhouse/`) holds many distinct wheels
553        // in one directory, each a distinct `pkg:pypi/<name>@<v>` identity, so one
554        // package per identity. A pip-cache leaf directory (one wheel plus its
555        // `origin.json`, sharing the wheel's identity) falls back to one package.
556        mode: AssemblyMode::SiblingMergePerIdentity,
557    },
558    // Python/PyPI ecosystem
559    AssemblerConfig {
560        datasource_ids: &[
561            DatasourceId::PypiPyprojectToml,
562            DatasourceId::PypiPoetryPyprojectToml,
563            DatasourceId::PypiSetupPy,
564            DatasourceId::PypiSetupCfg,
565            DatasourceId::PypiWheelMetadata,
566            DatasourceId::PypiEgg,
567            DatasourceId::PypiEggPkginfo,
568            DatasourceId::PypiEditableEggPkginfo,
569            DatasourceId::PypiJson,
570            DatasourceId::PypiSdist,
571            DatasourceId::PypiSdistPkginfo,
572            DatasourceId::PypiInspectDeplock,
573            DatasourceId::PipRequirements,
574            DatasourceId::PypiPoetryLock,
575            DatasourceId::PypiPylockToml,
576            DatasourceId::PypiUvLock,
577            DatasourceId::Pipfile,
578            DatasourceId::PipfileLock,
579        ],
580        sibling_file_patterns: &[
581            "pyproject.toml",
582            "setup.py",
583            "setup.cfg",
584            "PKG-INFO",
585            "METADATA",
586            "pypi.json",
587            "pip-inspect.deplock",
588            "*.tar.gz",
589            "*.tgz",
590            "*.tar.bz2",
591            "*.tar.xz",
592            "*.zip",
593            "requirements*.txt",
594            "Pipfile",
595            "Pipfile.lock",
596            "poetry.lock",
597            "pylock.toml",
598            "pylock.*.toml",
599            "uv.lock",
600        ],
601        mode: AssemblyMode::SiblingMerge,
602    },
603    AssemblerConfig {
604        datasource_ids: &[DatasourceId::DenoJson, DatasourceId::DenoLock],
605        sibling_file_patterns: &["deno.json", "deno.jsonc", "deno.lock"],
606        mode: AssemblyMode::SiblingMerge,
607    },
608    // Ruby/RubyGems ecosystem
609    AssemblerConfig {
610        datasource_ids: &[
611            DatasourceId::GemArchiveExtracted,
612            DatasourceId::Gemspec,
613            DatasourceId::GemspecExtracted,
614            DatasourceId::Gemfile,
615            DatasourceId::GemfileExtracted,
616            DatasourceId::GemfileLock,
617            DatasourceId::GemfileLockExtracted,
618        ],
619        sibling_file_patterns: &[
620            "metadata.gz-extract",
621            "**/data.gz-extract/*.gemspec",
622            "**/data.gz-extract/Gemfile",
623            "**/data.gz-extract/Gemfile.lock",
624            "*.gemspec",
625            "Gemfile",
626            "Gemfile.lock",
627        ],
628        // A directory usually holds one gem (its `.gemspec` plus a `Gemfile`/
629        // `Gemfile.lock`), but can hold several independent `.gemspec` files with
630        // distinct identities; collapsing the latter into one package loses the
631        // others, so split per identity. A single-gem directory (one purled
632        // gemspec plus purl-less `Gemfile`/lock siblings) falls back unchanged.
633        mode: AssemblyMode::SiblingMergePerIdentity,
634    },
635    // Installed RubyGems specifications (`specifications/*.gemspec`). A
636    // `vendor/bundle` / gem-home `specifications` directory holds one gemspec
637    // per installed gem, each a distinct `pkg:gem/<name>@<v>` identity, so one
638    // package per record.
639    AssemblerConfig {
640        datasource_ids: &[DatasourceId::GemGemspecInstalledSpecifications],
641        sibling_file_patterns: &["**/specifications/*.gemspec"],
642        mode: AssemblyMode::OnePerPackageData,
643    },
644    AssemblerConfig {
645        datasource_ids: &[DatasourceId::GemArchive],
646        sibling_file_patterns: &["*.gem"],
647        mode: AssemblyMode::OnePerPackageData,
648    },
649    // Conda ecosystem: recipes and environment files describe one package per
650    // directory, so they sibling-merge.
651    AssemblerConfig {
652        datasource_ids: &[DatasourceId::CondaMetaYaml, DatasourceId::CondaYaml],
653        sibling_file_patterns: &[
654            "meta.yaml",
655            "meta.yml",
656            "recipe.yaml",
657            "recipe.yml",
658            "environment.yml",
659            "environment.yaml",
660            "conda.yaml",
661            "conda.yml",
662            "*conda*.yaml",
663            "*conda*.yml",
664            "env.yaml",
665            "env.yml",
666            "*env*.yaml",
667            "*env*.yml",
668            "*environment*.yaml",
669            "*environment*.yml",
670            "*.json",
671        ],
672        mode: AssemblyMode::SiblingMerge,
673    },
674    // Conda installed-environment records: each `conda-meta/<pkg>.json` is one
675    // installed package, like the Alpine/RPM/Debian installed databases below,
676    // so every record becomes its own package rather than collapsing the whole
677    // `conda-meta/` directory into a single sibling-merged package.
678    AssemblerConfig {
679        datasource_ids: &[DatasourceId::CondaMetaJson],
680        sibling_file_patterns: &["*.json"],
681        mode: AssemblyMode::OnePerPackageData,
682    },
683    // RPM specfile (source packages)
684    AssemblerConfig {
685        datasource_ids: &[DatasourceId::RpmSpecfile],
686        sibling_file_patterns: &["*.spec"],
687        mode: AssemblyMode::OnePerPackageData,
688    },
689    // Debian source packages (nested merge via debian/ directory)
690    AssemblerConfig {
691        datasource_ids: &[
692            DatasourceId::DebianControlInSource,
693            DatasourceId::DebianCopyrightInSource,
694        ],
695        sibling_file_patterns: &["control", "copyright"],
696        mode: AssemblyMode::SiblingMerge,
697    },
698    // Gradle/Android ecosystem
699    AssemblerConfig {
700        datasource_ids: &[DatasourceId::BuildGradle, DatasourceId::GradleLockfile],
701        sibling_file_patterns: &["build.gradle", "build.gradle.kts", "gradle.lockfile"],
702        mode: AssemblyMode::SiblingMerge,
703    },
704    AssemblerConfig {
705        datasource_ids: &[DatasourceId::GradleModule],
706        sibling_file_patterns: &["*.module"],
707        mode: AssemblyMode::OnePerPackageData,
708    },
709    // Hugging Face model/dataset metadata. The model-card README.md, Transformers
710    // config.json, and Diffusers model_index.json in one repository directory
711    // describe a single logical model, so they are merged into one package by a
712    // dedicated directory merger (see huggingface_merge). Only files the Hugging
713    // Face parsers actually claim (carrying a huggingface datasource id)
714    // participate, so a generic README.md/config.json never triggers a merge.
715    AssemblerConfig {
716        datasource_ids: &[
717            DatasourceId::HuggingfaceModelCard,
718            DatasourceId::HuggingfaceConfigJson,
719            DatasourceId::HuggingfaceModelIndexJson,
720        ],
721        sibling_file_patterns: &["README.md", "config.json", "model_index.json"],
722        mode: AssemblyMode::SiblingMerge,
723    },
724    // CPAN/Perl ecosystem
725    AssemblerConfig {
726        datasource_ids: &[
727            DatasourceId::CpanMetaJson,
728            DatasourceId::CpanMetaYml,
729            DatasourceId::CpanManifest,
730            DatasourceId::CpanDistIni,
731            DatasourceId::CpanMakefile,
732        ],
733        sibling_file_patterns: &[
734            "META.json",
735            "META.yml",
736            "MANIFEST",
737            "dist.ini",
738            "Makefile.PL",
739        ],
740        mode: AssemblyMode::SiblingMerge,
741    },
742    // NuGet/.NET ecosystem
743    AssemblerConfig {
744        datasource_ids: &[
745            DatasourceId::NugetCsproj,
746            DatasourceId::NugetFsproj,
747            DatasourceId::NugetNuspec,
748            DatasourceId::NugetNupkg,
749            DatasourceId::NugetProjectJson,
750            DatasourceId::NugetProjectLockJson,
751            DatasourceId::NugetPackagesConfig,
752            DatasourceId::NugetPackagesLock,
753            DatasourceId::NugetVbproj,
754        ],
755        sibling_file_patterns: &[
756            "*.csproj",
757            "*.fsproj",
758            "*.nuspec",
759            "*.nupkg",
760            "project.json",
761            "project.lock.json",
762            "packages.config",
763            "packages.lock.json",
764            "*.packages.lock.json",
765            "*.vbproj",
766        ],
767        // A directory can hold one project (its `.csproj`/`.nuspec` plus
768        // supplementary `packages.config`/lock siblings) or several independent
769        // projects / `.nuspec` packaging outputs with distinct identities;
770        // collapsing the latter into one package loses the others, so split per
771        // identity. A single-identity directory falls back to one package.
772        mode: AssemblyMode::SiblingMergePerIdentity,
773    },
774    AssemblerConfig {
775        datasource_ids: &[DatasourceId::NugetDepsJson],
776        sibling_file_patterns: &["*.deps.json"],
777        mode: AssemblyMode::OnePerPackageData,
778    },
779    // Swift/SPM ecosystem
780    AssemblerConfig {
781        datasource_ids: &[
782            DatasourceId::SwiftPackageManifestJson,
783            DatasourceId::SwiftPackageResolved,
784            DatasourceId::SwiftPackageShowDependencies,
785        ],
786        sibling_file_patterns: &[
787            "Package.swift.json",
788            "Package.swift.deplock",
789            "Package.resolved",
790            ".package.resolved",
791            "swift-show-dependencies.deplock",
792        ],
793        mode: AssemblyMode::SiblingMerge,
794    },
795    // ── Standalone assemblers (single file → single package) ──
796    //
797    // These ecosystems have only one manifest file type with no sibling merging.
798    // They still need configs so their datasource_ids are recognized by the assembler.
799    //
800    // Bower (JavaScript)
801    AssemblerConfig {
802        datasource_ids: &[DatasourceId::BowerJson],
803        sibling_file_patterns: &["bower.json"],
804        mode: AssemblyMode::SiblingMerge,
805    },
806    // CRAN (R language)
807    AssemblerConfig {
808        datasource_ids: &[DatasourceId::CranDescription],
809        sibling_file_patterns: &["DESCRIPTION"],
810        mode: AssemblyMode::SiblingMerge,
811    },
812    // FreeBSD packages
813    AssemblerConfig {
814        datasource_ids: &[DatasourceId::FreebsdCompactManifest],
815        sibling_file_patterns: &["+COMPACT_MANIFEST"],
816        mode: AssemblyMode::SiblingMerge,
817    },
818    // Haxe ecosystem
819    AssemblerConfig {
820        datasource_ids: &[DatasourceId::HaxelibJson],
821        sibling_file_patterns: &["haxelib.json"],
822        mode: AssemblyMode::SiblingMerge,
823    },
824    AssemblerConfig {
825        datasource_ids: &[DatasourceId::Gitmodules],
826        sibling_file_patterns: &[".gitmodules"],
827        mode: AssemblyMode::SiblingMerge,
828    },
829    // OCaml/opam ecosystem. A multi-package opam project ships several
830    // `<name>.opam` files at its root, each a distinct `pkg:opam/<name>`
831    // identity; collapsing the directory into one package loses the others, so
832    // split per identity. A single-package directory falls back unchanged.
833    AssemblerConfig {
834        datasource_ids: &[DatasourceId::OpamFile],
835        sibling_file_patterns: &["opam", "*.opam"],
836        mode: AssemblyMode::SiblingMergePerIdentity,
837    },
838    AssemblerConfig {
839        datasource_ids: &[DatasourceId::RpmYumdb],
840        sibling_file_patterns: &["**/var/lib/yum/yumdb/*/*/from_repo"],
841        mode: AssemblyMode::OnePerPackageData,
842    },
843    // Microsoft Update Manifest
844    AssemblerConfig {
845        datasource_ids: &[DatasourceId::MicrosoftUpdateManifestMum],
846        sibling_file_patterns: &["*.mum"],
847        mode: AssemblyMode::SiblingMerge,
848    },
849    // Autotools (C/C++ build system)
850    AssemblerConfig {
851        datasource_ids: &[DatasourceId::AutotoolsConfigure],
852        sibling_file_patterns: &["configure", "configure.ac"],
853        mode: AssemblyMode::SiblingMerge,
854    },
855    // Bazel (build system). BUILD targets sibling-merge into one component per
856    // build directory rather than one package per target: internal build targets
857    // carry no license/dependency/version metadata, so per-target emission only
858    // floods the inventory with name-only shells. Kept consistent with Buck below.
859    // See docs/improvements/bazel-buck-build-targets.md.
860    AssemblerConfig {
861        datasource_ids: &[DatasourceId::BazelBuild],
862        sibling_file_patterns: &["BUILD"],
863        mode: AssemblyMode::SiblingMerge,
864    },
865    AssemblerConfig {
866        datasource_ids: &[DatasourceId::BazelModule],
867        sibling_file_patterns: &["MODULE.bazel"],
868        mode: AssemblyMode::OnePerPackageData,
869    },
870    // Buck (build system)
871    AssemblerConfig {
872        datasource_ids: &[DatasourceId::BuckFile, DatasourceId::BuckMetadata],
873        sibling_file_patterns: &["BUCK", "METADATA.bzl", ".buckconfig"],
874        mode: AssemblyMode::SiblingMerge,
875    },
876    // Meson (build system). Each `meson.build` that declares a `project()` is an
877    // independent package rooted at its directory, so one package per identity
878    // rather than a sibling merge: nested `meson.build` files in a project's
879    // subdirectories carry no `project()` (hence no purl) and are skipped, so the
880    // top-level package list is not flooded with subdir build files. Using
881    // `OnePerPackageData` also keeps independent sibling projects from collapsing
882    // into one another via nested merge.
883    AssemblerConfig {
884        datasource_ids: &[DatasourceId::MesonBuild],
885        sibling_file_patterns: &["meson.build"],
886        mode: AssemblyMode::OnePerPackageData,
887    },
888    // Ant/Ivy (Java dependency management)
889    AssemblerConfig {
890        datasource_ids: &[DatasourceId::AntIvyXml],
891        sibling_file_patterns: &["ivy.xml"],
892        mode: AssemblyMode::SiblingMerge,
893    },
894    // JVM archives introspected in place (one archive == one package).
895    AssemblerConfig {
896        datasource_ids: &[
897            DatasourceId::JavaJar,
898            DatasourceId::JavaWarArchive,
899            DatasourceId::AndroidAarLibrary,
900        ],
901        sibling_file_patterns: &["*.jar", "*.war", "*.aar"],
902        mode: AssemblyMode::OnePerPackageData,
903    },
904    // Meteor (JavaScript platform)
905    AssemblerConfig {
906        datasource_ids: &[DatasourceId::MeteorPackage],
907        sibling_file_patterns: &["package.js"],
908        mode: AssemblyMode::SiblingMerge,
909    },
910    // ── One-per-PackageData assemblers (database files with many packages) ──
911    //
912    // Alpine installed package database
913    AssemblerConfig {
914        datasource_ids: &[DatasourceId::AlpineInstalledDb],
915        sibling_file_patterns: &["installed"],
916        mode: AssemblyMode::OnePerPackageData,
917    },
918    AssemblerConfig {
919        datasource_ids: &[DatasourceId::AlpineApkbuild],
920        sibling_file_patterns: &["APKBUILD"],
921        mode: AssemblyMode::SiblingMerge,
922    },
923    // Arch Linux package metadata. A `.SRCINFO`/`.PKGINFO`/`.AURINFO` names one
924    // or more `pkg:alpm/*` packages (a split recipe emits several subpackages),
925    // each owning its `depends`/`makedepends`. One package per record.
926    AssemblerConfig {
927        datasource_ids: &[
928            DatasourceId::ArchSrcinfo,
929            DatasourceId::ArchPkginfo,
930            DatasourceId::ArchAurinfo,
931        ],
932        sibling_file_patterns: &[".SRCINFO", ".PKGINFO", ".AURINFO"],
933        mode: AssemblyMode::OnePerPackageData,
934    },
935    // RPM installed package databases (BDB, NDB, SQLite)
936    AssemblerConfig {
937        datasource_ids: &[
938            DatasourceId::RpmInstalledDatabaseBdb,
939            DatasourceId::RpmInstalledDatabaseNdb,
940            DatasourceId::RpmInstalledDatabaseSqlite,
941            DatasourceId::RpmMarinerManifest,
942        ],
943        sibling_file_patterns: &[
944            "Packages",
945            "Packages.db",
946            "rpmdb.sqlite",
947            "container-manifest-2",
948        ],
949        mode: AssemblyMode::OnePerPackageData,
950    },
951    AssemblerConfig {
952        datasource_ids: &[DatasourceId::RpmArchive],
953        sibling_file_patterns: &["*.rpm", "*.srpm"],
954        mode: AssemblyMode::OnePerPackageData,
955    },
956    // Debian installed package databases
957    AssemblerConfig {
958        datasource_ids: &[DatasourceId::DebianDeb],
959        sibling_file_patterns: &["*.deb"],
960        mode: AssemblyMode::OnePerPackageData,
961    },
962    AssemblerConfig {
963        datasource_ids: &[
964            DatasourceId::DebianInstalledStatusDb,
965            DatasourceId::DebianDistrolessInstalledDb,
966        ],
967        sibling_file_patterns: &["status"],
968        mode: AssemblyMode::OnePerPackageData,
969    },
970    AssemblerConfig {
971        datasource_ids: &[
972            DatasourceId::DebianControlExtractedDeb,
973            DatasourceId::DebianMd5SumsInExtractedDeb,
974        ],
975        sibling_file_patterns: &["control", "md5sums"],
976        mode: AssemblyMode::SiblingMerge,
977    },
978    AssemblerConfig {
979        datasource_ids: &[DatasourceId::DebianSourceControlDsc],
980        sibling_file_patterns: &["*.dsc"],
981        mode: AssemblyMode::OnePerPackageData,
982    },
983    AssemblerConfig {
984        datasource_ids: &[DatasourceId::AboutFile],
985        sibling_file_patterns: &["*.ABOUT"],
986        mode: AssemblyMode::OnePerPackageData,
987    },
988    AssemblerConfig {
989        datasource_ids: &[
990            DatasourceId::BitbakeRecipe,
991            DatasourceId::BitbakeRecipeAppend,
992        ],
993        sibling_file_patterns: &["*.bb", "*.bbappend"],
994        // A recipe directory routinely holds several distinct `.bb` recipes
995        // (e.g. per-version or sibling components) each with its own
996        // `pkg:yocto/<pn>@<pv>` identity; collapsing the directory into one
997        // package loses the others, so split per identity. A single-recipe
998        // directory (recipe + its `.bbappend`/files) falls back to the
999        // one-package result unchanged.
1000        mode: AssemblyMode::SiblingMergePerIdentity,
1001    },
1002];
1003
1004// Datasource IDs intentionally excluded from package assembly.
1005//
1006// This list is runtime-significant: files with these datasource IDs may remain
1007// unowned by any Package, while their dependencies are still eligible for
1008// top-level hoisting. Tests also use it to enforce explicit assembly accounting.
1009/// Why a `DatasourceId` is intentionally not assembled into a top-level package.
1010///
1011/// Every entry in [`UNASSEMBLED_DATASOURCE_IDS`] must state one of these reasons.
1012/// There is deliberately **no** "deferred"/"TODO" variant: a datasource whose
1013/// parser emits a `purl`-bearing package identity with dependencies must be
1014/// assembled (an `OnePerPackageData` config for standalone manifests), not
1015/// parked here. See `docs/HOW_TO_ADD_A_PARSER.md` and ADR 0006.
1016#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1017pub(super) enum UnassembledReason {
1018    /// The file does not describe a package (README, OS-release, deployment- or
1019    /// image-descriptor fragment, Dockerfile, …). There is no identity to assemble.
1020    NotAPackage,
1021    /// A compiled binary or binary archive whose contents are scanned or extracted
1022    /// elsewhere (ExtractCode), not a source manifest with its own assembled identity.
1023    BinaryArtifact,
1024    /// Metadata that enriches another datasource's package, or is consumed by a
1025    /// dedicated post-assembly pass, rather than defining a package on its own.
1026    SupplementaryMetadata,
1027    /// A dependency or lock list with no package identity of its own; its
1028    /// dependencies are hoisted, but it cannot become a package.
1029    DependenciesOnlyNoIdentity,
1030}
1031
1032pub(super) static UNASSEMBLED_DATASOURCE_IDS: &[(DatasourceId, UnassembledReason)] = &[
1033    (DatasourceId::Readme, UnassembledReason::NotAPackage),
1034    (DatasourceId::EtcOsRelease, UnassembledReason::NotAPackage),
1035    (
1036        DatasourceId::AndroidManifestXml,
1037        UnassembledReason::NotAPackage,
1038    ),
1039    (
1040        DatasourceId::AndroidSoongMetadata,
1041        UnassembledReason::NotAPackage,
1042    ),
1043    (DatasourceId::Dockerfile, UnassembledReason::NotAPackage),
1044    (DatasourceId::OciImageIndex, UnassembledReason::NotAPackage),
1045    (
1046        DatasourceId::OciImageManifest,
1047        UnassembledReason::NotAPackage,
1048    ),
1049    (DatasourceId::Axis2ModuleXml, UnassembledReason::NotAPackage),
1050    (
1051        DatasourceId::JavaEarApplicationXml,
1052        UnassembledReason::NotAPackage,
1053    ),
1054    (DatasourceId::JavaWarWebXml, UnassembledReason::NotAPackage),
1055    (
1056        DatasourceId::JbossServiceXml,
1057        UnassembledReason::NotAPackage,
1058    ),
1059    // Compiled binaries and binary archives (extracted/scanned elsewhere).
1060    (
1061        DatasourceId::AlpineApkArchive,
1062        UnassembledReason::BinaryArtifact,
1063    ),
1064    (DatasourceId::AndroidAab, UnassembledReason::BinaryArtifact),
1065    (DatasourceId::AndroidApk, UnassembledReason::BinaryArtifact),
1066    (DatasourceId::AppleDmg, UnassembledReason::BinaryArtifact),
1067    (DatasourceId::Axis2Mar, UnassembledReason::BinaryArtifact),
1068    (DatasourceId::ChromeCrx, UnassembledReason::BinaryArtifact),
1069    (
1070        DatasourceId::DebianOriginalSourceTarball,
1071        UnassembledReason::BinaryArtifact,
1072    ),
1073    (
1074        DatasourceId::DebianSourceMetadataTarball,
1075        UnassembledReason::BinaryArtifact,
1076    ),
1077    (
1078        DatasourceId::InstallshieldInstaller,
1079        UnassembledReason::BinaryArtifact,
1080    ),
1081    (DatasourceId::IosIpa, UnassembledReason::BinaryArtifact),
1082    (
1083        DatasourceId::IsoDiskImage,
1084        UnassembledReason::BinaryArtifact,
1085    ),
1086    (
1087        DatasourceId::JavaEarArchive,
1088        UnassembledReason::BinaryArtifact,
1089    ),
1090    (DatasourceId::JbossSar, UnassembledReason::BinaryArtifact),
1091    (
1092        DatasourceId::MicrosoftCabinet,
1093        UnassembledReason::BinaryArtifact,
1094    ),
1095    (DatasourceId::MozillaXpi, UnassembledReason::BinaryArtifact),
1096    (
1097        DatasourceId::NsisInstaller,
1098        UnassembledReason::BinaryArtifact,
1099    ),
1100    (
1101        DatasourceId::SharShellArchive,
1102        UnassembledReason::BinaryArtifact,
1103    ),
1104    (
1105        DatasourceId::SquashfsDiskImage,
1106        UnassembledReason::BinaryArtifact,
1107    ),
1108    (DatasourceId::GoBinary, UnassembledReason::BinaryArtifact),
1109    (
1110        DatasourceId::WindowsExecutable,
1111        UnassembledReason::BinaryArtifact,
1112    ),
1113    (DatasourceId::RustBinary, UnassembledReason::BinaryArtifact),
1114    // Metadata merged into another datasource's package or a post-assembly pass.
1115    (
1116        DatasourceId::DebianInstalledFilesList,
1117        UnassembledReason::SupplementaryMetadata,
1118    ),
1119    (
1120        DatasourceId::DebianInstalledMd5Sums,
1121        UnassembledReason::SupplementaryMetadata,
1122    ),
1123    (
1124        DatasourceId::DebianCopyright,
1125        UnassembledReason::SupplementaryMetadata,
1126    ),
1127    (
1128        DatasourceId::DebianCopyrightInPackage,
1129        UnassembledReason::SupplementaryMetadata,
1130    ),
1131    (
1132        DatasourceId::DebianCopyrightStandalone,
1133        UnassembledReason::SupplementaryMetadata,
1134    ),
1135    (
1136        DatasourceId::AntIvyDependenciesProperties,
1137        UnassembledReason::SupplementaryMetadata,
1138    ),
1139    (
1140        DatasourceId::NugetDirectoryBuildProps,
1141        UnassembledReason::SupplementaryMetadata,
1142    ),
1143    (
1144        DatasourceId::NugetDirectoryPackagesProps,
1145        UnassembledReason::SupplementaryMetadata,
1146    ),
1147    (
1148        DatasourceId::CitationCff,
1149        UnassembledReason::SupplementaryMetadata,
1150    ),
1151    (
1152        DatasourceId::PubliccodeYaml,
1153        UnassembledReason::SupplementaryMetadata,
1154    ),
1155    (
1156        DatasourceId::RpmPackageLicenses,
1157        UnassembledReason::SupplementaryMetadata,
1158    ),
1159    (
1160        DatasourceId::VcpkgConfigurationJson,
1161        UnassembledReason::SupplementaryMetadata,
1162    ),
1163    (
1164        DatasourceId::VcpkgLockJson,
1165        UnassembledReason::SupplementaryMetadata,
1166    ),
1167    // Dependency/lock lists with no package identity of their own.
1168    (
1169        DatasourceId::ClojureDepsEdn,
1170        UnassembledReason::DependenciesOnlyNoIdentity,
1171    ),
1172];
1173
1174/// Whether `datasource_id` is intentionally left unassembled (see
1175/// [`UNASSEMBLED_DATASOURCE_IDS`]).
1176pub(super) fn is_unassembled_datasource(datasource_id: DatasourceId) -> bool {
1177    UNASSEMBLED_DATASOURCE_IDS
1178        .iter()
1179        .any(|(dsid, _)| *dsid == datasource_id)
1180}
1181
1182#[cfg(test)]
1183mod tests {
1184    use super::*;
1185    use std::collections::HashSet;
1186    use strum::IntoEnumIterator;
1187
1188    #[test]
1189    fn test_every_datasource_id_is_accounted_for() {
1190        let mut assembled: HashSet<DatasourceId> = HashSet::new();
1191        for config in ASSEMBLERS {
1192            for &dsid in config.datasource_ids {
1193                assembled.insert(dsid);
1194            }
1195        }
1196
1197        let unassembled: HashSet<DatasourceId> =
1198            UNASSEMBLED_DATASOURCE_IDS.iter().map(|(d, _)| *d).collect();
1199        assert_eq!(
1200            unassembled.len(),
1201            UNASSEMBLED_DATASOURCE_IDS.len(),
1202            "UNASSEMBLED_DATASOURCE_IDS lists a datasource more than once"
1203        );
1204
1205        let overlap: Vec<_> = assembled.intersection(&unassembled).collect();
1206        assert!(
1207            overlap.is_empty(),
1208            "Datasource IDs in BOTH ASSEMBLERS and UNASSEMBLED: {overlap:?}"
1209        );
1210
1211        let missing: Vec<_> = DatasourceId::iter()
1212            .filter(|dsid| !assembled.contains(dsid) && !unassembled.contains(dsid))
1213            .collect();
1214
1215        assert!(
1216            missing.is_empty(),
1217            "Datasource IDs in NEITHER ASSEMBLERS nor UNASSEMBLED: {missing:?}\n\
1218             Add each to an AssemblerConfig in ASSEMBLERS, or to UNASSEMBLED_DATASOURCE_IDS."
1219        );
1220    }
1221
1222    #[test]
1223    fn test_post_assembly_passes_are_unique() {
1224        let unique: HashSet<PostAssemblyPassKind> = POST_ASSEMBLY_PASSES.iter().copied().collect();
1225
1226        assert_eq!(
1227            unique.len(),
1228            POST_ASSEMBLY_PASSES.len(),
1229            "POST_ASSEMBLY_PASSES contains duplicate entries"
1230        );
1231    }
1232
1233    #[test]
1234    fn test_every_post_assembly_pass_kind_is_registered_once() {
1235        let registered: HashSet<PostAssemblyPassKind> =
1236            POST_ASSEMBLY_PASSES.iter().copied().collect();
1237
1238        let missing: Vec<_> = PostAssemblyPassKind::iter()
1239            .filter(|pass| !registered.contains(pass))
1240            .collect();
1241
1242        assert!(
1243            missing.is_empty(),
1244            "Post-assembly pass variants not registered in POST_ASSEMBLY_PASSES: {missing:?}"
1245        );
1246
1247        for pass in PostAssemblyPassKind::iter() {
1248            let count = POST_ASSEMBLY_PASSES
1249                .iter()
1250                .filter(|registered| **registered == pass)
1251                .count();
1252            assert_eq!(
1253                count, 1,
1254                "Post-assembly pass {pass:?} should be registered exactly once"
1255            );
1256        }
1257    }
1258
1259    #[test]
1260    fn test_post_assembly_passes_skip_irrelevant_inputs() {
1261        let inputs = PostAssemblyInputs::default();
1262
1263        for pass in PostAssemblyPassKind::iter() {
1264            assert!(
1265                !pass.should_run(&inputs),
1266                "{pass:?} should skip when no relevant inputs are present"
1267            );
1268        }
1269    }
1270
1271    #[test]
1272    fn test_npm_workspace_inputs_only_run_npm_passes() {
1273        let inputs = PostAssemblyInputs {
1274            package_types: HashSet::from([PackageType::Npm]),
1275            file_datasource_ids: HashSet::from([DatasourceId::NpmPackageJson]),
1276            has_npm_workspace_markers: true,
1277            has_cargo_workspace_markers: false,
1278        };
1279
1280        let runnable: HashSet<_> = PostAssemblyPassKind::iter()
1281            .filter(|pass| pass.should_run(&inputs))
1282            .collect();
1283
1284        assert_eq!(
1285            runnable,
1286            HashSet::from([
1287                PostAssemblyPassKind::NpmResourceAssign,
1288                PostAssemblyPassKind::NpmWorkspaceMerge,
1289            ])
1290        );
1291    }
1292
1293    #[test]
1294    fn test_cargo_workspace_merge_requires_workspace_markers() {
1295        let without_markers = PostAssemblyInputs {
1296            package_types: HashSet::from([PackageType::Cargo]),
1297            file_datasource_ids: HashSet::from([DatasourceId::CargoToml]),
1298            has_npm_workspace_markers: false,
1299            has_cargo_workspace_markers: false,
1300        };
1301
1302        assert!(!PostAssemblyPassKind::CargoWorkspaceMerge.should_run(&without_markers));
1303
1304        let with_markers = PostAssemblyInputs {
1305            has_cargo_workspace_markers: true,
1306            ..without_markers
1307        };
1308
1309        assert!(PostAssemblyPassKind::CargoWorkspaceMerge.should_run(&with_markers));
1310    }
1311}