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