1use crate::package_bundle::PackageBundle;
11use crate::package_lock::{ArtifactDeterminism, LockedArtifact, PackageLock};
12use crate::project::{
13 ExternalLockMode, NativeDependencyProvider, NativeDependencySpec, NativeTarget, ProjectRoot,
14 ShapeProject, parse_shape_project_toml,
15};
16use anyhow::{Context, Result, bail};
17use shape_wire::WireValue;
18use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
19use std::path::{Path, PathBuf};
20
21const NATIVE_LIB_NAMESPACE: &str = "external.native.library";
22const NATIVE_LIB_PRODUCER: &str = "shape-runtime/native_resolution@v1";
23
24#[derive(Debug, Clone)]
25pub struct NativeDependencyScope {
26 pub package_name: String,
27 pub package_version: String,
28 pub package_key: String,
29 pub root_path: PathBuf,
30 pub dependencies: HashMap<String, NativeDependencySpec>,
31}
32
33#[derive(Debug, Clone)]
34pub struct NativeLibraryProbe {
35 pub provider: NativeDependencyProvider,
36 pub resolved: String,
37 pub load_target: String,
38 pub is_path: bool,
39 pub path_exists: bool,
40 pub cached: bool,
41 pub available: bool,
42 pub fingerprint: String,
43 pub declared_version: Option<String>,
44 pub cache_key: Option<String>,
45 pub error: Option<String>,
46}
47
48#[derive(Debug, Clone)]
49pub enum NativeProvenance {
50 LockValidated,
51 UpdateResolved,
52}
53
54#[derive(Debug, Clone)]
55pub struct ResolvedNativeDependency {
56 pub package_name: String,
57 pub package_version: String,
58 pub package_key: String,
59 pub alias: String,
60 pub target: NativeTarget,
61 pub provider: NativeDependencyProvider,
62 pub resolved_value: String,
63 pub load_target: String,
64 pub fingerprint: String,
65 pub declared_version: Option<String>,
66 pub cache_key: Option<String>,
67 pub provenance: NativeProvenance,
68}
69
70#[derive(Debug, Clone, Default)]
71pub struct NativeResolutionSet {
72 pub by_package_alias: HashMap<(String, String), ResolvedNativeDependency>,
73}
74
75impl NativeResolutionSet {
76 pub fn insert(&mut self, item: ResolvedNativeDependency) {
77 self.by_package_alias
78 .insert((item.package_key.clone(), item.alias.clone()), item);
79 }
80}
81
82#[derive(Debug, Clone)]
83struct NativeResolutionIssue {
84 package_key: String,
85 detail: String,
86}
87
88#[derive(Debug)]
89struct NativeResolutionEntry {
90 dependency: ResolvedNativeDependency,
91 artifact: Option<LockedArtifact>,
92}
93
94fn native_provider_label(provider: NativeDependencyProvider) -> &'static str {
95 match provider {
96 NativeDependencyProvider::System => "system",
97 NativeDependencyProvider::Path => "path",
98 NativeDependencyProvider::Vendored => "vendored",
99 }
100}
101
102fn is_path_like_library_spec(spec: &str) -> bool {
103 let path = Path::new(spec);
104 path.is_absolute()
105 || spec.starts_with("./")
106 || spec.starts_with("../")
107 || spec.contains('/')
108 || spec.contains('\\')
109 || (spec.len() >= 2 && spec.as_bytes()[1] == b':')
110}
111
112fn normalize_package_identity(
113 project: &ShapeProject,
114 fallback_name: &str,
115 fallback_version: &str,
116) -> (String, String, String) {
117 let package_name = if project.project.name.trim().is_empty() {
118 fallback_name.to_string()
119 } else {
120 project.project.name.trim().to_string()
121 };
122 let package_version = if project.project.version.trim().is_empty() {
123 fallback_version.to_string()
124 } else {
125 project.project.version.trim().to_string()
126 };
127 let package_key = format!("{package_name}@{package_version}");
128 (package_name, package_version, package_key)
129}
130
131fn native_cache_root() -> PathBuf {
132 dirs::cache_dir()
133 .map(|dir| dir.join("shape").join("native"))
134 .unwrap_or_else(|| PathBuf::from(".shape").join("native"))
135}
136
137fn current_target() -> NativeTarget {
138 NativeTarget::current()
139}
140
141fn native_target_id(target: &NativeTarget) -> String {
142 target.id()
143}
144
145fn native_artifact_key(package_key: &str, alias: &str) -> String {
146 format!("{package_key}::{alias}")
147}
148
149fn stage_vendored_library(
150 target: &NativeTarget,
151 root_path: &Path,
152 alias: &str,
153 resolved: &str,
154 cache_key_hint: Option<&str>,
155) -> Result<(String, String, String)> {
156 if !is_path_like_library_spec(resolved) {
157 bail!(
158 "vendored native dependency '{}' must resolve to a concrete file path, got '{}'",
159 alias,
160 resolved
161 );
162 }
163
164 let source_path = if Path::new(resolved).is_absolute() {
165 PathBuf::from(resolved)
166 } else {
167 root_path.join(resolved)
168 };
169 if !source_path.is_file() {
170 bail!(
171 "vendored native dependency '{}' path not found: {}",
172 alias,
173 source_path.display()
174 );
175 }
176
177 let source_hash = PackageLock::hash_path(&source_path)
178 .map_err(|e| anyhow::anyhow!("failed to hash vendored native library: {e}"))?;
179 let cache_key = cache_key_hint.unwrap_or(&source_hash).to_string();
180
181 let file_name = source_path.file_name().ok_or_else(|| {
182 anyhow::anyhow!(
183 "vendored native dependency '{}' has invalid file path '{}'",
184 alias,
185 source_path.display()
186 )
187 })?;
188
189 let cache_dir = native_cache_root()
190 .join(native_target_id(target))
191 .join(alias)
192 .join(&cache_key);
193 std::fs::create_dir_all(&cache_dir).with_context(|| {
194 format!(
195 "failed to create native cache directory {}",
196 cache_dir.display()
197 )
198 })?;
199
200 let cached_path = cache_dir.join(file_name);
201 let needs_copy = if cached_path.is_file() {
202 match PackageLock::hash_path(&cached_path) {
203 Ok(hash) => hash != source_hash,
204 Err(_) => true,
205 }
206 } else {
207 true
208 };
209
210 if needs_copy {
211 std::fs::copy(&source_path, &cached_path).with_context(|| {
212 format!(
213 "failed to copy vendored native library '{}' to cache '{}'",
214 source_path.display(),
215 cached_path.display()
216 )
217 })?;
218 }
219
220 Ok((
221 cached_path.to_string_lossy().to_string(),
222 format!("vendored:sha256:{source_hash}:cache_key:{cache_key}"),
223 cache_key,
224 ))
225}
226
227pub fn probe_native_library(
228 target: &NativeTarget,
229 root_path: &Path,
230 alias: &str,
231 spec: &NativeDependencySpec,
232 resolved: &str,
233) -> Result<NativeLibraryProbe> {
234 let provider = spec.provider_for_target(target);
235 let declared_version = spec.declared_version().map(ToString::to_string);
236 let mut cache_key = spec.cache_key().map(ToString::to_string);
237
238 let (load_target, is_path, path_exists, cached, fingerprint) = match provider {
239 NativeDependencyProvider::Vendored => {
240 let (target_path, fp, staged_cache_key) =
241 stage_vendored_library(target, root_path, alias, resolved, spec.cache_key())?;
242 if cache_key.is_none() {
243 cache_key = Some(staged_cache_key);
244 }
245 (target_path, true, true, true, fp)
246 }
247 NativeDependencyProvider::Path => {
248 let path = if Path::new(resolved).is_absolute() {
249 PathBuf::from(resolved)
250 } else {
251 root_path.join(resolved)
252 };
253 let exists = path.is_file();
254 let fingerprint = if exists {
255 match PackageLock::hash_path(&path) {
256 Ok(hash) => format!("sha256:{hash}"),
257 Err(err) => format!("io-error:{err}"),
258 }
259 } else {
260 format!("missing-path:{}", path.display())
261 };
262 (
263 path.to_string_lossy().to_string(),
264 true,
265 exists,
266 false,
267 fingerprint,
268 )
269 }
270 NativeDependencyProvider::System => {
271 if is_path_like_library_spec(resolved) {
272 let path = if Path::new(resolved).is_absolute() {
273 PathBuf::from(resolved)
274 } else {
275 root_path.join(resolved)
276 };
277 let exists = path.is_file();
278 let fingerprint = if exists {
279 match PackageLock::hash_path(&path) {
280 Ok(hash) => format!("sha256:{hash}"),
281 Err(err) => format!("io-error:{err}"),
282 }
283 } else {
284 format!("missing-path:{}", path.display())
285 };
286 (
287 path.to_string_lossy().to_string(),
288 true,
289 exists,
290 false,
291 fingerprint,
292 )
293 } else {
294 let version_segment = declared_version
295 .as_deref()
296 .map(|value| format!("version:{value}"))
297 .unwrap_or_else(|| "version:unspecified".to_string());
298 (
299 resolved.to_string(),
300 false,
301 false,
302 false,
303 format!("system-name:{resolved}:{version_segment}"),
304 )
305 }
306 }
307 };
308
309 let probe = unsafe { libloading::Library::new(&load_target) };
310 Ok(match probe {
311 Ok(lib) => {
312 drop(lib);
313 NativeLibraryProbe {
314 provider,
315 resolved: resolved.to_string(),
316 load_target,
317 is_path,
318 path_exists,
319 cached,
320 available: true,
321 fingerprint,
322 declared_version,
323 cache_key,
324 error: None,
325 }
326 }
327 Err(err) => NativeLibraryProbe {
328 provider,
329 resolved: resolved.to_string(),
330 load_target,
331 is_path,
332 path_exists,
333 cached,
334 available: false,
335 fingerprint,
336 declared_version,
337 cache_key,
338 error: Some(err.to_string()),
339 },
340 })
341}
342
343fn native_artifact_inputs(
344 target: &NativeTarget,
345 package_name: &str,
346 package_version: &str,
347 package_key: &str,
348 alias: &str,
349 probe: &NativeLibraryProbe,
350) -> (BTreeMap<String, String>, ArtifactDeterminism) {
351 let mut inputs = BTreeMap::new();
352 inputs.insert("package_name".to_string(), package_name.to_string());
353 inputs.insert("package_version".to_string(), package_version.to_string());
354 inputs.insert("package_key".to_string(), package_key.to_string());
355 inputs.insert("alias".to_string(), alias.to_string());
356 inputs.insert("resolved".to_string(), probe.resolved.clone());
357 inputs.insert(
358 "provider".to_string(),
359 native_provider_label(probe.provider).to_string(),
360 );
361 inputs.insert("target".to_string(), native_target_id(target));
362 inputs.insert("os".to_string(), target.os.clone());
363 inputs.insert("arch".to_string(), target.arch.clone());
364 if let Some(env) = &target.env {
365 inputs.insert("env".to_string(), env.clone());
366 }
367 if let Some(version) = &probe.declared_version {
368 inputs.insert("declared_version".to_string(), version.clone());
369 }
370 if let Some(cache_key) = &probe.cache_key {
371 inputs.insert("cache_key".to_string(), cache_key.clone());
372 }
373
374 let fingerprints = BTreeMap::from([(
375 format!(
376 "native:{}:{}:{}:{}",
377 native_target_id(target),
378 package_key,
379 alias,
380 native_provider_label(probe.provider)
381 ),
382 probe.fingerprint.clone(),
383 )]);
384
385 (inputs, ArtifactDeterminism::External { fingerprints })
386}
387
388fn artifact_payload(
389 target: &NativeTarget,
390 scope: &NativeDependencyScope,
391 alias: &str,
392 probe: &NativeLibraryProbe,
393) -> WireValue {
394 WireValue::Object(BTreeMap::from([
395 ("alias".to_string(), WireValue::String(alias.to_string())),
396 (
397 "package_name".to_string(),
398 WireValue::String(scope.package_name.clone()),
399 ),
400 (
401 "package_version".to_string(),
402 WireValue::String(scope.package_version.clone()),
403 ),
404 (
405 "package_key".to_string(),
406 WireValue::String(scope.package_key.clone()),
407 ),
408 (
409 "target".to_string(),
410 WireValue::String(native_target_id(target)),
411 ),
412 ("os".to_string(), WireValue::String(target.os.clone())),
413 ("arch".to_string(), WireValue::String(target.arch.clone())),
414 (
415 "env".to_string(),
416 target
417 .env
418 .clone()
419 .map(WireValue::String)
420 .unwrap_or(WireValue::Null),
421 ),
422 (
423 "resolved".to_string(),
424 WireValue::String(probe.resolved.clone()),
425 ),
426 (
427 "load_target".to_string(),
428 WireValue::String(probe.load_target.clone()),
429 ),
430 (
431 "provider".to_string(),
432 WireValue::String(native_provider_label(probe.provider).to_string()),
433 ),
434 ("available".to_string(), WireValue::Bool(probe.available)),
435 ("cached".to_string(), WireValue::Bool(probe.cached)),
436 ("path_like".to_string(), WireValue::Bool(probe.is_path)),
437 (
438 "path_exists".to_string(),
439 WireValue::Bool(probe.path_exists),
440 ),
441 (
442 "fingerprint".to_string(),
443 WireValue::String(probe.fingerprint.clone()),
444 ),
445 (
446 "declared_version".to_string(),
447 probe
448 .declared_version
449 .clone()
450 .map(WireValue::String)
451 .unwrap_or(WireValue::Null),
452 ),
453 (
454 "cache_key".to_string(),
455 probe
456 .cache_key
457 .clone()
458 .map(WireValue::String)
459 .unwrap_or(WireValue::Null),
460 ),
461 ]))
462}
463
464fn format_native_resolution_issues(
465 target: &NativeTarget,
466 issues: &[NativeResolutionIssue],
467) -> String {
468 let mut grouped: BTreeMap<&str, Vec<&str>> = BTreeMap::new();
469 for issue in issues {
470 grouped
471 .entry(issue.package_key.as_str())
472 .or_default()
473 .push(issue.detail.as_str());
474 }
475
476 let mut lines = vec![format!(
477 "native dependency preflight failed for target '{}':",
478 native_target_id(target)
479 )];
480 for (package_key, package_issues) in grouped {
481 lines.push(format!("package '{}':", package_key));
482 for detail in package_issues {
483 lines.push(format!(" - {}", detail));
484 }
485 }
486 lines.join("\n")
487}
488
489fn resolve_native_dependency_entry(
490 scope: &NativeDependencyScope,
491 alias: &str,
492 spec: &NativeDependencySpec,
493 target: &NativeTarget,
494 lock: &PackageLock,
495 external_mode: ExternalLockMode,
496) -> Result<NativeResolutionEntry, String> {
497 let target_id = native_target_id(target);
498 let resolved = spec
499 .resolve_for_target(target)
500 .ok_or_else(|| format!("alias '{}' has no value for target '{}'", alias, target_id))?;
501 let provider = spec.provider_for_target(target);
502 let provider_label = native_provider_label(provider);
503 let probe =
504 probe_native_library(target, &scope.root_path, alias, spec, &resolved).map_err(|e| {
505 format!(
506 "alias '{}' ({}) could not be prepared from '{}' for target '{}': {}",
507 alias, provider_label, resolved, target_id, e
508 )
509 })?;
510
511 if matches!(probe.provider, NativeDependencyProvider::System)
512 && !probe.is_path
513 && probe.declared_version.is_none()
514 && matches!(external_mode, ExternalLockMode::Frozen)
515 {
516 return Err(format!(
517 "alias '{}' (system) uses loader alias '{}' without a declared version. Add `[native-dependencies.{}].version = \"...\"` in package '{}'.",
518 alias, resolved, alias, scope.package_name
519 ));
520 }
521
522 let artifact_key = native_artifact_key(&scope.package_key, alias);
523 let (inputs, determinism) = native_artifact_inputs(
524 target,
525 &scope.package_name,
526 &scope.package_version,
527 &scope.package_key,
528 alias,
529 &probe,
530 );
531 let inputs_hash =
532 PackageLock::artifact_inputs_hash(inputs.clone(), &determinism).map_err(|e| {
533 format!(
534 "alias '{}' could not compute lock fingerprint: {}",
535 alias, e
536 )
537 })?;
538
539 if !probe.available {
540 if probe.is_path && !probe.path_exists {
541 return Err(format!(
542 "alias '{}' ({}) path not found: {}",
543 alias,
544 native_provider_label(probe.provider),
545 probe.load_target
546 ));
547 }
548 return Err(format!(
549 "alias '{}' ({}) failed to load from '{}': {}",
550 alias,
551 native_provider_label(probe.provider),
552 probe.load_target,
553 probe.error.as_deref().unwrap_or("unknown load error")
554 ));
555 }
556
557 if matches!(external_mode, ExternalLockMode::Frozen)
558 && lock
559 .artifact(NATIVE_LIB_NAMESPACE, &artifact_key, &inputs_hash)
560 .is_none()
561 {
562 return Err(format!(
563 "alias '{}' ({}) is not locked for target '{}' and fingerprint '{}'. Switch build.external.mode to 'update' and rerun to refresh shape.lock.",
564 alias,
565 native_provider_label(probe.provider),
566 target_id,
567 probe.fingerprint
568 ));
569 }
570
571 let provenance = if matches!(external_mode, ExternalLockMode::Frozen) {
572 NativeProvenance::LockValidated
573 } else {
574 NativeProvenance::UpdateResolved
575 };
576
577 let artifact = if matches!(external_mode, ExternalLockMode::Update) {
578 Some(
579 LockedArtifact::new(
580 NATIVE_LIB_NAMESPACE,
581 artifact_key,
582 NATIVE_LIB_PRODUCER,
583 determinism,
584 inputs,
585 artifact_payload(target, scope, alias, &probe),
586 )
587 .map_err(|e| {
588 format!(
589 "alias '{}' ({}) could not be recorded in shape.lock: {}",
590 alias,
591 native_provider_label(probe.provider),
592 e
593 )
594 })?,
595 )
596 } else {
597 None
598 };
599
600 Ok(NativeResolutionEntry {
601 dependency: ResolvedNativeDependency {
602 package_name: scope.package_name.clone(),
603 package_version: scope.package_version.clone(),
604 package_key: scope.package_key.clone(),
605 alias: alias.to_string(),
606 target: target.clone(),
607 provider: probe.provider,
608 resolved_value: probe.resolved.clone(),
609 load_target: probe.load_target.clone(),
610 fingerprint: probe.fingerprint.clone(),
611 declared_version: probe.declared_version.clone(),
612 cache_key: probe.cache_key.clone(),
613 provenance,
614 },
615 artifact,
616 })
617}
618
619pub fn collect_native_dependency_scopes(
620 root_path: &Path,
621 project: &ShapeProject,
622) -> Result<Vec<NativeDependencyScope>> {
623 let fallback_root_name = root_path
624 .file_name()
625 .and_then(|name| name.to_str())
626 .filter(|name| !name.is_empty())
627 .unwrap_or("root");
628 let (root_name, root_version, root_key) =
629 normalize_package_identity(project, fallback_root_name, "0.0.0");
630
631 let mut queue: VecDeque<(PathBuf, ShapeProject, String, String, String)> = VecDeque::new();
632 queue.push_back((
633 root_path.to_path_buf(),
634 project.clone(),
635 root_name,
636 root_version,
637 root_key,
638 ));
639
640 let mut scopes = Vec::new();
641 let mut visited_roots: HashSet<PathBuf> = HashSet::new();
642
643 while let Some((package_root, package, package_name, package_version, package_key)) =
644 queue.pop_front()
645 {
646 let canonical_root = package_root
647 .canonicalize()
648 .unwrap_or_else(|_| package_root.clone());
649 if !visited_roots.insert(canonical_root.clone()) {
650 continue;
651 }
652
653 let native_deps = package.native_dependencies().map_err(|e| {
654 anyhow::anyhow!(
655 "invalid [native-dependencies] in package '{}': {}",
656 package_name,
657 e
658 )
659 })?;
660 if !native_deps.is_empty() {
661 scopes.push(NativeDependencyScope {
662 package_name: package_name.clone(),
663 package_version: package_version.clone(),
664 package_key: package_key.clone(),
665 root_path: canonical_root.clone(),
666 dependencies: native_deps,
667 });
668 }
669
670 if package.dependencies.is_empty() {
671 continue;
672 }
673
674 let Some(resolver) =
675 crate::dependency_resolver::DependencyResolver::new(canonical_root.clone())
676 else {
677 continue;
678 };
679 let resolved = resolver.resolve(&package.dependencies).map_err(|e| {
680 anyhow::anyhow!(
681 "failed to resolve dependencies for package '{}': {}",
682 package_name,
683 e
684 )
685 })?;
686
687 for resolved_dep in resolved {
688 if resolved_dep
689 .path
690 .extension()
691 .is_some_and(|ext| ext == "shapec")
692 {
693 let bundle = PackageBundle::read_from_file(&resolved_dep.path).map_err(|e| {
694 anyhow::anyhow!(
695 "failed to read dependency bundle '{}': {}",
696 resolved_dep.path.display(),
697 e
698 )
699 })?;
700
701 let bundle_root = resolved_dep
702 .path
703 .parent()
704 .map(Path::to_path_buf)
705 .unwrap_or_else(|| canonical_root.clone());
706 for scope in bundle.native_dependency_scopes {
707 scopes.push(NativeDependencyScope {
708 package_name: scope.package_name,
709 package_version: scope.package_version,
710 package_key: scope.package_key,
711 root_path: bundle_root.clone(),
712 dependencies: scope.dependencies,
713 });
714 }
715 continue;
716 }
717
718 let dep_root = resolved_dep.path;
719 let dep_toml = dep_root.join("shape.toml");
720 let dep_source = match std::fs::read_to_string(&dep_toml) {
721 Ok(content) => content,
722 Err(_) => continue,
723 };
724 let dep_project = parse_shape_project_toml(&dep_source).map_err(|err| {
725 anyhow::anyhow!(
726 "failed to parse dependency project '{}': {}",
727 dep_toml.display(),
728 err
729 )
730 })?;
731 let (dep_name, dep_version, dep_key) =
732 normalize_package_identity(&dep_project, &resolved_dep.name, &resolved_dep.version);
733 queue.push_back((dep_root, dep_project, dep_name, dep_version, dep_key));
734 }
735 }
736
737 Ok(scopes)
738}
739
740pub fn resolve_native_dependency_scopes(
741 scopes: &[NativeDependencyScope],
742 lock_path: Option<&Path>,
743 external_mode: ExternalLockMode,
744 persist_lock: bool,
745) -> Result<NativeResolutionSet> {
746 let target = current_target();
747 let mut lock = lock_path
748 .and_then(PackageLock::read)
749 .unwrap_or_else(PackageLock::new);
750 let mut resolutions = NativeResolutionSet::default();
751 let mut issues = Vec::new();
752
753 let mut sorted_scopes = scopes.to_vec();
754 sorted_scopes.sort_by(|a, b| {
755 a.package_key
756 .cmp(&b.package_key)
757 .then_with(|| a.root_path.cmp(&b.root_path))
758 });
759
760 for scope in sorted_scopes {
761 let mut entries: Vec<_> = scope.dependencies.iter().collect();
762 entries.sort_by(|(a, _), (b, _)| a.cmp(b));
763
764 for (alias, spec) in entries {
765 match resolve_native_dependency_entry(
766 &scope,
767 alias.as_str(),
768 spec,
769 &target,
770 &lock,
771 external_mode,
772 ) {
773 Ok(entry) => {
774 if let Some(artifact) = entry.artifact {
775 if let Err(err) = lock.upsert_artifact_variant(artifact) {
776 issues.push(NativeResolutionIssue {
777 package_key: scope.package_key.clone(),
778 detail: format!(
779 "alias '{}' could not be stored in shape.lock: {}",
780 alias, err
781 ),
782 });
783 continue;
784 }
785 }
786 resolutions.insert(entry.dependency);
787 }
788 Err(detail) => issues.push(NativeResolutionIssue {
789 package_key: scope.package_key.clone(),
790 detail,
791 }),
792 }
793 }
794 }
795
796 if !issues.is_empty() {
797 bail!(format_native_resolution_issues(&target, &issues));
798 }
799
800 if persist_lock && matches!(external_mode, ExternalLockMode::Update) {
801 let lock_path = lock_path.ok_or_else(|| anyhow::anyhow!("lock path is required"))?;
802 lock.write(lock_path)
803 .with_context(|| format!("failed to write lockfile {}", lock_path.display()))?;
804 }
805
806 Ok(resolutions)
807}
808
809pub fn resolve_native_dependencies_for_project(
810 project: &ProjectRoot,
811 lock_path: &Path,
812 external_mode: ExternalLockMode,
813) -> Result<NativeResolutionSet> {
814 let scopes = collect_native_dependency_scopes(&project.root_path, &project.config)?;
815 resolve_native_dependency_scopes(&scopes, Some(lock_path), external_mode, true)
816}
817
818#[cfg(test)]
819mod tests {
820 use super::*;
821 use std::collections::HashMap;
822 use tempfile::tempdir;
823
824 fn test_scope(
825 root_path: PathBuf,
826 package_name: &str,
827 package_version: &str,
828 alias: &str,
829 spec: NativeDependencySpec,
830 ) -> NativeDependencyScope {
831 NativeDependencyScope {
832 package_name: package_name.to_string(),
833 package_version: package_version.to_string(),
834 package_key: format!("{package_name}@{package_version}"),
835 root_path,
836 dependencies: HashMap::from([(alias.to_string(), spec)]),
837 }
838 }
839
840 #[test]
841 fn test_native_resolution_reports_all_preflight_failures() {
842 let tmp = tempdir().expect("tempdir");
843 let alpha_root = tmp.path().join("alpha");
844 let beta_root = tmp.path().join("beta");
845 std::fs::create_dir_all(&alpha_root).expect("alpha root");
846 std::fs::create_dir_all(&beta_root).expect("beta root");
847
848 let scopes = vec![
849 test_scope(
850 alpha_root,
851 "alpha",
852 "0.1.0",
853 "alpha_native",
854 NativeDependencySpec::Simple("./missing-alpha.so".to_string()),
855 ),
856 test_scope(
857 beta_root,
858 "beta",
859 "0.2.0",
860 "beta_native",
861 NativeDependencySpec::Simple("./missing-beta.so".to_string()),
862 ),
863 ];
864
865 let err = resolve_native_dependency_scopes(&scopes, None, ExternalLockMode::Update, false)
866 .expect_err("preflight should aggregate failures");
867 let message = err.to_string();
868
869 assert!(message.contains("native dependency preflight failed for target '"));
870 assert!(message.contains("package 'alpha@0.1.0':"));
871 assert!(message.contains("alias 'alpha_native' (path) path not found:"));
872 assert!(message.contains("package 'beta@0.2.0':"));
873 assert!(message.contains("alias 'beta_native' (path) path not found:"));
874 }
875}