1mod assemblers;
8#[cfg(test)]
9mod assembly_test;
10mod bazel_prune;
11mod cargo_resource_assign;
12mod cargo_workspace_merge;
13mod clojure_deps_assign;
14mod cocoapods_merge;
15mod composer_resource_assign;
16mod conda_rootfs_merge;
17mod dart_workspace_merge;
18mod debian_source_merge;
19pub mod file_ref_resolve;
20mod go_workspace;
21mod gradle_multiproject;
22mod hackage_merge;
23mod huggingface_merge;
24mod ivy_dependencies_properties_assign;
25mod maven_reactor;
26mod mix_umbrella_merge;
27mod nested_merge;
28mod nix_flake_compat_merge;
29mod npm_resource_assign;
30mod npm_workspace_merge;
31mod nuget_cpm_resolve;
32mod path_identity;
33mod pixi_topology;
34mod project_dependency_assign;
35mod python_requirements_assign;
36mod resource_assign;
37mod ruby_resource_assign;
38mod sibling_merge;
39mod swift_merge;
40mod topology;
41mod uv_workspace;
42mod windows_update_merge;
43
44use std::collections::{BTreeSet, HashMap, HashSet};
45use std::path::PathBuf;
46use std::sync::LazyLock;
47
48use crate::models::{DatasourceId, Dependency, FileInfo, Package, PackageUid, TopLevelDependency};
49
50pub use assemblers::ASSEMBLERS;
51
52type DirectoryMergeOutput = (Option<Package>, Vec<TopLevelDependency>, Vec<usize>);
53
54static ASSEMBLER_LOOKUP: LazyLock<HashMap<DatasourceId, DatasourceId>> = LazyLock::new(|| {
57 let mut lookup = HashMap::new();
58 for config in ASSEMBLERS {
59 let key = *config
60 .datasource_ids
61 .first()
62 .expect("assembler must have at least one datasource_id");
63 for &dsid in config.datasource_ids {
64 lookup.insert(dsid, key);
65 }
66 }
67 lookup
68});
69
70static ASSEMBLER_CONFIG_LOOKUP: LazyLock<HashMap<DatasourceId, &'static AssemblerConfig>> =
71 LazyLock::new(|| {
72 let mut lookup = HashMap::new();
73 for config in ASSEMBLERS {
74 let key = *config
75 .datasource_ids
76 .first()
77 .expect("assembler must have at least one datasource_id");
78 lookup.insert(key, config);
79 }
80 lookup
81 });
82
83pub struct AssemblyResult {
86 pub packages: Vec<Package>,
87 pub dependencies: Vec<TopLevelDependency>,
88}
89
90#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92pub enum AssemblyMode {
93 SiblingMerge,
95 SiblingMergePerIdentity,
103 OnePerPackageData,
106}
107
108pub type DirectoryMergeFn =
117 fn(&AssemblerConfig, &[FileInfo], &[usize]) -> Vec<DirectoryMergeOutput>;
118
119pub struct AssemblerConfig {
120 pub datasource_ids: &'static [DatasourceId],
121 pub sibling_file_patterns: &'static [&'static str],
122 pub mode: AssemblyMode,
123 pub directory_merger: Option<DirectoryMergeFn>,
127}
128
129pub fn assemble(files: &mut [FileInfo]) -> AssemblyResult {
135 let assembler_lookup = &*ASSEMBLER_LOOKUP;
136 let assembler_config_lookup = &*ASSEMBLER_CONFIG_LOOKUP;
137 let mut packages = Vec::new();
138 let mut dependencies = Vec::new();
139
140 let dir_files = group_files_by_directory(files);
141 let topology_plan = topology::TopologyPlan::build(files, &dir_files);
142
143 for file_indices in dir_files.values() {
144 let groups = active_config_keys(files, file_indices, assembler_lookup);
145
146 for &config_key in &groups {
147 let config = assembler_config_lookup
148 .get(&config_key)
149 .copied()
150 .expect("assembler config must exist");
151
152 if topology_plan.claims_directory_assembly(config, file_indices, files) {
153 continue;
154 }
155
156 if let Some(directory_merger) = config.directory_merger {
157 let results = directory_merger(config, files, file_indices);
158 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
159 continue;
160 }
161
162 match config.mode {
163 AssemblyMode::SiblingMerge => {
164 let results = sibling_merge::assemble_siblings(config, files, file_indices);
165 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
166 }
167 AssemblyMode::SiblingMergePerIdentity => {
168 let results =
169 sibling_merge::assemble_siblings_per_identity(config, files, file_indices);
170 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
171 }
172 AssemblyMode::OnePerPackageData => {
173 let results = assemble_one_per_package_data(config, files, file_indices);
174 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
175 }
176 }
177 }
178 }
179
180 topology_plan.apply_directory_scoped_domains(files, &mut packages, &mut dependencies);
181
182 for config in ASSEMBLERS {
183 if !matches!(
184 config.mode,
185 AssemblyMode::SiblingMerge | AssemblyMode::SiblingMergePerIdentity
186 ) {
187 continue;
188 }
189 if let Some((pkg, deps, affected_indices)) =
190 nested_merge::assemble_nested_patterns(files, config)
191 {
192 let package_uid = pkg.package_uid.clone();
193
194 let removed_package_uids: HashSet<PackageUid> = affected_indices
200 .iter()
201 .flat_map(|idx| files[*idx].for_packages.iter().cloned())
202 .filter(|uid| *uid != package_uid)
203 .collect();
204
205 packages.retain(|p| !removed_package_uids.contains(&p.package_uid));
206 dependencies.retain(|d| {
207 d.for_package_uid
208 .as_ref()
209 .is_none_or(|old_uid| !removed_package_uids.contains(old_uid))
210 });
211
212 for idx in &affected_indices {
213 files[*idx].for_packages.clear();
214 files[*idx].for_packages.push(package_uid.clone());
215 }
216
217 packages.push(pkg);
218 dependencies.extend(deps);
219 }
220 }
221
222 assemblers::run_post_assembly_passes(files, &mut packages, &mut dependencies, &topology_plan);
223 hoist_unassembled_file_dependencies(files, &mut dependencies);
224
225 for package in &mut packages {
226 package.datafile_paths.sort();
227 package.datafile_paths.dedup();
228 package.datasource_ids.sort_by_key(|left| left.to_string());
229 package.datasource_ids.dedup();
230 }
231
232 for file in files.iter_mut() {
233 file.for_packages
234 .sort_by(|left, right| left.stable_key().cmp(&right.stable_key()));
235 file.for_packages.dedup();
236 }
237
238 packages
239 .sort_by(|left, right| stable_package_sort_key(left).cmp(&stable_package_sort_key(right)));
240 dependencies.sort_by(|left, right| {
241 left.purl
242 .as_deref()
243 .cmp(&right.purl.as_deref())
244 .then_with(|| {
245 left.extracted_requirement
246 .as_deref()
247 .cmp(&right.extracted_requirement.as_deref())
248 })
249 .then_with(|| left.scope.as_deref().cmp(&right.scope.as_deref()))
250 .then_with(|| left.datafile_path.cmp(&right.datafile_path))
251 .then_with(|| {
252 left.datasource_id
253 .to_string()
254 .cmp(&right.datasource_id.to_string())
255 })
256 .then_with(|| {
257 left.for_package_uid
258 .as_ref()
259 .map(|uid| uid.stable_key())
260 .cmp(&right.for_package_uid.as_ref().map(|uid| uid.stable_key()))
261 })
262 });
263
264 AssemblyResult {
265 packages,
266 dependencies,
267 }
268}
269
270fn apply_directory_merge_results(
271 files: &mut [FileInfo],
272 packages: &mut Vec<Package>,
273 dependencies: &mut Vec<TopLevelDependency>,
274 results: Vec<DirectoryMergeOutput>,
275) {
276 for (package, deps, affected_indices) in results {
277 if let Some(package) = package {
278 let package_uid = package.package_uid.clone();
279 for idx in &affected_indices {
280 if !files[*idx].for_packages.contains(&package_uid) {
281 files[*idx].for_packages.push(package_uid.clone());
282 }
283 }
284 packages.push(package);
285 }
286 dependencies.extend(deps);
287 }
288}
289
290fn hoist_unassembled_file_dependencies(
307 files: &[FileInfo],
308 dependencies: &mut Vec<TopLevelDependency>,
309) {
310 let already_emitted: HashSet<(&str, DatasourceId)> = dependencies
311 .iter()
312 .map(|dependency| (dependency.datafile_path.as_str(), dependency.datasource_id))
313 .collect();
314
315 let mut hoisted = Vec::new();
316 for file in files {
317 if !file.for_packages.is_empty() {
318 continue;
319 }
320
321 for pkg_data in &file.package_data {
322 let Some(datasource_id) = pkg_data.datasource_id else {
323 continue;
324 };
325
326 if !should_hoist_unassembled_dependencies(datasource_id) {
327 continue;
328 }
329
330 if already_emitted.contains(&(file.path.as_str(), datasource_id)) {
331 continue;
332 }
333
334 hoisted.extend(pkg_data.dependencies.iter().map(|dep| {
335 TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
336 }));
337 }
338 }
339
340 dependencies.extend(hoisted);
341}
342
343pub(super) fn is_reportable_dependency(dependency: &Dependency) -> bool {
355 dependency.purl.is_some() || dependency.extracted_requirement.is_some()
356}
357
358const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
359
360fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
361 if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
362 return true;
363 }
364
365 if !assemblers::is_unassembled_datasource(datasource_id) {
366 return false;
367 }
368
369 !matches!(
370 datasource_id,
371 DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
372 )
373}
374
375fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
376 (
377 package.purl.as_deref(),
378 package.name.as_deref(),
379 package.version.as_deref(),
380 package
381 .datafile_paths
382 .first()
383 .map(String::as_str)
384 .unwrap_or(""),
385 )
386}
387
388fn assemble_one_per_package_data(
389 config: &AssemblerConfig,
390 files: &[FileInfo],
391 file_indices: &[usize],
392) -> Vec<DirectoryMergeOutput> {
393 let mut results = Vec::new();
394
395 for &idx in file_indices {
396 let file = &files[idx];
397 for pkg_data in &file.package_data {
398 let dsid_matches = pkg_data
399 .datasource_id
400 .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
401
402 if !dsid_matches || should_skip_placeholder_only_cocoapods_podspec(pkg_data) {
403 continue;
404 }
405
406 let Some(datasource_id) = pkg_data.datasource_id else {
407 continue;
408 };
409 let datafile_path = file.path.clone();
410
411 let (package, affected) = if pkg_data.purl.is_some() {
416 (
417 Some(Package::from_package_data(pkg_data, datafile_path.clone())),
418 vec![idx],
419 )
420 } else {
421 (None, Vec::new())
422 };
423 let for_package_uid = package.as_ref().map(|pkg| pkg.package_uid.clone());
424
425 let deps: Vec<TopLevelDependency> = pkg_data
426 .dependencies
427 .iter()
428 .filter(|dep| is_reportable_dependency(dep))
429 .map(|dep| {
430 TopLevelDependency::from_dependency(
431 dep,
432 datafile_path.clone(),
433 datasource_id,
434 for_package_uid.clone(),
435 )
436 })
437 .collect();
438
439 if package.is_none() && deps.is_empty() {
440 continue;
441 }
442
443 results.push((package, deps, affected));
444 }
445 }
446
447 results
448}
449
450pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
451 pkg_data: &crate::models::PackageData,
452) -> bool {
453 pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
454 && pkg_data
455 .extra_data
456 .as_ref()
457 .and_then(|data| data.get("dynamic_identity_placeholders"))
458 .and_then(|value| value.as_bool())
459 == Some(true)
460}
461
462fn active_config_keys(
470 files: &[FileInfo],
471 file_indices: &[usize],
472 assembler_lookup: &HashMap<DatasourceId, DatasourceId>,
473) -> BTreeSet<DatasourceId> {
474 let mut groups: BTreeSet<DatasourceId> = BTreeSet::new();
475 for &idx in file_indices {
476 for pkg_data in &files[idx].package_data {
477 if let Some(dsid) = pkg_data.datasource_id
478 && let Some(&config_key) = assembler_lookup.get(&dsid)
479 {
480 groups.insert(config_key);
481 }
482 }
483 }
484 groups
485}
486
487fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
493 let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
494 for (idx, file) in files.iter().enumerate() {
495 if let Some(parent) = path_identity::scanned_file_dir(&file.path) {
496 groups.entry(parent).or_default().push(idx);
497 }
498 }
499 groups
500}