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