Skip to main content

shape_runtime/
native_resolution.rs

1//! Shared native dependency resolution for CLI, compiler, and runtime.
2//!
3//! This module is the single source of truth for:
4//! - transitive native dependency scope discovery
5//! - target-aware native dependency selection
6//! - vendored library staging
7//! - host probing / availability checks
8//! - native dependency lockfile artifact validation
9
10use 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}