1mod assemblers;
8#[cfg(test)]
9mod assembly_test;
10mod bazel_merge;
11mod bazel_prune;
12mod cargo_resource_assign;
13mod cargo_workspace_merge;
14mod composer_resource_assign;
15mod conda_rootfs_merge;
16mod debian_source_merge;
17pub mod file_ref_resolve;
18mod hackage_merge;
19mod huggingface_merge;
20mod nested_merge;
21mod nix_flake_compat_merge;
22mod npm_resource_assign;
23mod npm_workspace_merge;
24mod nuget_cpm_resolve;
25mod python_requirements_assign;
26mod ruby_resource_assign;
27mod sibling_merge;
28mod swift_merge;
29mod topology;
30mod windows_update_merge;
31
32use std::collections::{BTreeSet, HashMap, HashSet};
33use std::path::PathBuf;
34use std::sync::LazyLock;
35
36use crate::models::{DatasourceId, FileInfo, Package, PackageUid, TopLevelDependency};
37
38pub use assemblers::ASSEMBLERS;
39
40type DirectoryMergeOutput = (Option<Package>, Vec<TopLevelDependency>, Vec<usize>);
41
42static ASSEMBLER_LOOKUP: LazyLock<HashMap<DatasourceId, DatasourceId>> = LazyLock::new(|| {
45 let mut lookup = HashMap::new();
46 for config in ASSEMBLERS {
47 let key = *config
48 .datasource_ids
49 .first()
50 .expect("assembler must have at least one datasource_id");
51 for &dsid in config.datasource_ids {
52 lookup.insert(dsid, key);
53 }
54 }
55 lookup
56});
57
58static ASSEMBLER_CONFIG_LOOKUP: LazyLock<HashMap<DatasourceId, &'static AssemblerConfig>> =
59 LazyLock::new(|| {
60 let mut lookup = HashMap::new();
61 for config in ASSEMBLERS {
62 let key = *config
63 .datasource_ids
64 .first()
65 .expect("assembler must have at least one datasource_id");
66 lookup.insert(key, config);
67 }
68 lookup
69 });
70
71pub struct AssemblyResult {
74 pub packages: Vec<Package>,
75 pub dependencies: Vec<TopLevelDependency>,
76}
77
78#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum AssemblyMode {
81 SiblingMerge,
83 OnePerPackageData,
86}
87
88pub struct AssemblerConfig {
89 pub datasource_ids: &'static [DatasourceId],
90 pub sibling_file_patterns: &'static [&'static str],
91 pub mode: AssemblyMode,
92}
93
94pub fn assemble(files: &mut [FileInfo]) -> AssemblyResult {
100 let assembler_lookup = &*ASSEMBLER_LOOKUP;
101 let assembler_config_lookup = &*ASSEMBLER_CONFIG_LOOKUP;
102 let mut packages = Vec::new();
103 let mut dependencies = Vec::new();
104
105 let dir_files = group_files_by_directory(files);
106 let topology_plan = topology::TopologyPlan::build(files, &dir_files);
107
108 for file_indices in dir_files.values() {
109 let groups = active_config_keys(files, file_indices, assembler_lookup);
110
111 for &config_key in &groups {
112 let config = assembler_config_lookup
113 .get(&config_key)
114 .copied()
115 .expect("assembler config must exist");
116
117 if topology_plan.claims_directory_assembly(config, file_indices, files) {
118 continue;
119 }
120
121 if let Some(special_merger) = assemblers::special_directory_merger_for(config_key) {
122 let results = special_merger.run(config, files, file_indices);
123 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
124 continue;
125 }
126
127 match config.mode {
128 AssemblyMode::SiblingMerge => {
129 let results = sibling_merge::assemble_siblings(config, files, file_indices);
130 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
131 }
132 AssemblyMode::OnePerPackageData => {
133 let results = assemble_one_per_package_data(config, files, file_indices)
134 .into_iter()
135 .map(|(pkg, deps, affected_idx)| (Some(pkg), deps, vec![affected_idx]))
136 .collect();
137 apply_directory_merge_results(files, &mut packages, &mut dependencies, results);
138 }
139 }
140 }
141 }
142
143 topology_plan.apply_directory_scoped_domains(files, &mut packages, &mut dependencies);
144
145 for config in ASSEMBLERS {
146 if config.mode != AssemblyMode::SiblingMerge {
147 continue;
148 }
149 if let Some((pkg, deps, affected_indices)) =
150 nested_merge::assemble_nested_patterns(files, config)
151 {
152 let package_uid = pkg.package_uid.clone();
153
154 let removed_package_uids: HashSet<PackageUid> = affected_indices
160 .iter()
161 .flat_map(|idx| files[*idx].for_packages.iter().cloned())
162 .filter(|uid| *uid != package_uid)
163 .collect();
164
165 packages.retain(|p| !removed_package_uids.contains(&p.package_uid));
166 dependencies.retain(|d| {
167 d.for_package_uid
168 .as_ref()
169 .is_none_or(|old_uid| !removed_package_uids.contains(old_uid))
170 });
171
172 for idx in &affected_indices {
173 files[*idx].for_packages.clear();
174 files[*idx].for_packages.push(package_uid.clone());
175 }
176
177 packages.push(pkg);
178 dependencies.extend(deps);
179 }
180 }
181
182 assemblers::run_post_assembly_passes(files, &mut packages, &mut dependencies, &topology_plan);
183 hoist_unassembled_file_dependencies(files, &mut dependencies);
184
185 for package in &mut packages {
186 package.datafile_paths.sort();
187 package.datafile_paths.dedup();
188 package.datasource_ids.sort_by_key(|left| left.to_string());
189 package.datasource_ids.dedup();
190 }
191
192 for file in files.iter_mut() {
193 file.for_packages
194 .sort_by(|left, right| left.stable_key().cmp(right.stable_key()));
195 file.for_packages.dedup();
196 }
197
198 packages
199 .sort_by(|left, right| stable_package_sort_key(left).cmp(&stable_package_sort_key(right)));
200 dependencies.sort_by(|left, right| {
201 left.purl
202 .as_deref()
203 .cmp(&right.purl.as_deref())
204 .then_with(|| {
205 left.extracted_requirement
206 .as_deref()
207 .cmp(&right.extracted_requirement.as_deref())
208 })
209 .then_with(|| left.scope.as_deref().cmp(&right.scope.as_deref()))
210 .then_with(|| left.datafile_path.cmp(&right.datafile_path))
211 .then_with(|| {
212 left.datasource_id
213 .to_string()
214 .cmp(&right.datasource_id.to_string())
215 })
216 .then_with(|| {
217 left.for_package_uid
218 .as_ref()
219 .map(|uid| uid.stable_key())
220 .cmp(&right.for_package_uid.as_ref().map(|uid| uid.stable_key()))
221 })
222 });
223
224 AssemblyResult {
225 packages,
226 dependencies,
227 }
228}
229
230fn apply_directory_merge_results(
231 files: &mut [FileInfo],
232 packages: &mut Vec<Package>,
233 dependencies: &mut Vec<TopLevelDependency>,
234 results: Vec<DirectoryMergeOutput>,
235) {
236 for (package, deps, affected_indices) in results {
237 if let Some(package) = package {
238 let package_uid = package.package_uid.clone();
239 for idx in &affected_indices {
240 if !files[*idx].for_packages.contains(&package_uid) {
241 files[*idx].for_packages.push(package_uid.clone());
242 }
243 }
244 packages.push(package);
245 }
246 dependencies.extend(deps);
247 }
248}
249
250fn hoist_unassembled_file_dependencies(
251 files: &[FileInfo],
252 dependencies: &mut Vec<TopLevelDependency>,
253) {
254 for file in files {
255 if !file.for_packages.is_empty() {
256 continue;
257 }
258
259 for pkg_data in &file.package_data {
260 let Some(datasource_id) = pkg_data.datasource_id else {
261 continue;
262 };
263
264 if !should_hoist_unassembled_dependencies(datasource_id) {
265 continue;
266 }
267
268 dependencies.extend(pkg_data.dependencies.iter().map(|dep| {
269 TopLevelDependency::from_dependency(dep, file.path.clone(), datasource_id, None)
270 }));
271 }
272 }
273}
274
275const HOIST_IF_UNOWNED_DATASOURCE_IDS: &[DatasourceId] = &[DatasourceId::PipRequirements];
276
277fn should_hoist_unassembled_dependencies(datasource_id: DatasourceId) -> bool {
278 if HOIST_IF_UNOWNED_DATASOURCE_IDS.contains(&datasource_id) {
279 return true;
280 }
281
282 if !assemblers::UNASSEMBLED_DATASOURCE_IDS.contains(&datasource_id) {
283 return false;
284 }
285
286 !matches!(
287 datasource_id,
288 DatasourceId::NugetDirectoryBuildProps | DatasourceId::NugetDirectoryPackagesProps
289 )
290}
291
292fn stable_package_sort_key(package: &Package) -> (Option<&str>, Option<&str>, Option<&str>, &str) {
293 (
294 package.purl.as_deref(),
295 package.name.as_deref(),
296 package.version.as_deref(),
297 package
298 .datafile_paths
299 .first()
300 .map(String::as_str)
301 .unwrap_or(""),
302 )
303}
304
305fn assemble_one_per_package_data(
306 config: &AssemblerConfig,
307 files: &[FileInfo],
308 file_indices: &[usize],
309) -> Vec<(Package, Vec<TopLevelDependency>, usize)> {
310 let mut results = Vec::new();
311
312 for &idx in file_indices {
313 let file = &files[idx];
314 for pkg_data in &file.package_data {
315 let dsid_matches = pkg_data
316 .datasource_id
317 .is_some_and(|dsid| config.datasource_ids.contains(&dsid));
318
319 if !dsid_matches
320 || pkg_data.purl.is_none()
321 || should_skip_placeholder_only_cocoapods_podspec(pkg_data)
322 {
323 continue;
324 }
325
326 let datafile_path = file.path.clone();
327 let datasource_id = pkg_data.datasource_id.expect("datasource_id must be Some");
328 let pkg = Package::from_package_data(pkg_data, datafile_path.clone());
329 let for_package_uid = Some(pkg.package_uid.clone());
330
331 let deps: Vec<TopLevelDependency> = pkg_data
332 .dependencies
333 .iter()
334 .filter(|dep| dep.purl.is_some() || dep.extracted_requirement.is_some())
335 .map(|dep| {
336 TopLevelDependency::from_dependency(
337 dep,
338 datafile_path.clone(),
339 datasource_id,
340 for_package_uid.clone(),
341 )
342 })
343 .collect();
344
345 results.push((pkg, deps, idx));
346 }
347 }
348
349 results
350}
351
352pub(super) fn should_skip_placeholder_only_cocoapods_podspec(
353 pkg_data: &crate::models::PackageData,
354) -> bool {
355 pkg_data.datasource_id == Some(DatasourceId::CocoapodsPodspec)
356 && pkg_data
357 .extra_data
358 .as_ref()
359 .and_then(|data| data.get("dynamic_identity_placeholders"))
360 .and_then(|value| value.as_bool())
361 == Some(true)
362}
363
364fn active_config_keys(
372 files: &[FileInfo],
373 file_indices: &[usize],
374 assembler_lookup: &HashMap<DatasourceId, DatasourceId>,
375) -> BTreeSet<DatasourceId> {
376 let mut groups: BTreeSet<DatasourceId> = BTreeSet::new();
377 for &idx in file_indices {
378 for pkg_data in &files[idx].package_data {
379 if let Some(dsid) = pkg_data.datasource_id
380 && let Some(&config_key) = assembler_lookup.get(&dsid)
381 {
382 groups.insert(config_key);
383 }
384 }
385 }
386 groups
387}
388
389fn group_files_by_directory(files: &[FileInfo]) -> HashMap<PathBuf, Vec<usize>> {
391 let mut groups: HashMap<PathBuf, Vec<usize>> = HashMap::new();
392 for (idx, file) in files.iter().enumerate() {
393 if let Some(parent) = std::path::Path::new(&file.path).parent() {
394 groups.entry(parent.to_path_buf()).or_default().push(idx);
395 }
396 }
397 groups
398}