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, 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(
291 files: &[FileInfo],
292 dependencies: &mut Vec<TopLevelDependency>,
293) {
294 for file in files {
295 if !file.for_packages.is_empty() {
296 continue;
297 }
298
299 for pkg_data in &file.package_data {
300 let Some(datasource_id) = pkg_data.datasource_id else {
301 continue;
302 };
303
304 if !should_hoist_unassembled_dependencies(datasource_id) {
305 continue;
306 }
307
308 dependencies.extend(pkg_data.dependencies.iter().map(|dep| {
309 TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
310 }));
311 }
312 }
313}
314
315const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
316
317fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
318 if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
319 return true;
320 }
321
322 if !assemblers::is_unassembled_datasource(datasource_id) {
323 return false;
324 }
325
326 !matches!(
327 datasource_id,
328 DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
329 )
330}
331
332fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
333 (
334 package.purl.as_deref(),
335 package.name.as_deref(),
336 package.version.as_deref(),
337 package
338 .datafile_paths
339 .first()
340 .map(String::as_str)
341 .unwrap_or(""),
342 )
343}
344
345fn assemble_one_per_package_data(
346 config: &AssemblerConfig,
347 files: &[FileInfo],
348 file_indices: &[usize],
349) -> Vec<DirectoryMergeOutput> {
350 let mut results = Vec::new();
351
352 for &idx in file_indices {
353 let file = &files[idx];
354 for pkg_data in &file.package_data {
355 let dsid_matches = pkg_data
356 .datasource_id
357 .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
358
359 if !dsid_matches || should_skip_placeholder_only_cocoapods_podspec(pkg_data) {
360 continue;
361 }
362
363 let Some(datasource_id) = pkg_data.datasource_id else {
364 continue;
365 };
366 let datafile_path = file.path.clone();
367
368 let (package, affected) = if pkg_data.purl.is_some() {
373 (
374 Some(Package::from_package_data(pkg_data, datafile_path.clone())),
375 vec![idx],
376 )
377 } else {
378 (None, Vec::new())
379 };
380 let for_package_uid = package.as_ref().map(|pkg| pkg.package_uid.clone());
381
382 let deps: Vec<TopLevelDependency> = pkg_data
383 .dependencies
384 .iter()
385 .filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
386 .map(|dep| {
387 TopLevelDependency::from_dependency(
388 dep,
389 datafile_path.clone(),
390 datasource_id,
391 for_package_uid.clone(),
392 )
393 })
394 .collect();
395
396 if package.is_none() && deps.is_empty() {
397 continue;
398 }
399
400 results.push((package, deps, affected));
401 }
402 }
403
404 results
405}
406
407pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
408 pkg_data: &crate::models::PackageData,
409) -> bool {
410 pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
411 && pkg_data
412 .extra_data
413 .as_ref()
414 .and_then(|data| data.get("dynamic_identity_placeholders"))
415 .and_then(|value| value.as_bool())
416 == Some(true)
417}
418
419fn active_config_keys(
427 files: &[FileInfo],
428 file_indices: &[usize],
429 assembler_lookup: &HashMap<DatasourceId, DatasourceId>,
430) -> BTreeSet<DatasourceId> {
431 let mut groups: BTreeSet<DatasourceId> = BTreeSet::new();
432 for &idx in file_indices {
433 for pkg_data in &files[idx].package_data {
434 if let Some(dsid) = pkg_data.datasource_id
435 && let Some(&config_key) = assembler_lookup.get(&dsid)
436 {
437 groups.insert(config_key);
438 }
439 }
440 }
441 groups
442}
443
444fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
450 let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
451 for (idx, file) in files.iter().enumerate() {
452 if let Some(parent) = path_identity::scanned_file_dir(&file.path) {
453 groups.entry(parent).or_default().push(idx);
454 }
455 }
456 groups
457}