cabinpkg 0.14.0

A package manager and build system for C/C++
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
//! CLI orchestration for foundation ports.
//!
//! The CLI is the only layer that performs network access.
//! `cabin-port::prepare` is HTTP-free: it accepts archive bytes
//! via [`PortFetchSource`] but does not download them itself.
//! This module bridges the gap by:
//!
//! 1. discovering every foundation-port dependency reachable from
//!    the root manifest;
//! 2. loading each `port.toml`;
//! 3. resolving the declared archive URL to a
//!    [`PortFetchSource`] — `file://` URLs become
//!    `LocalArchive(...)`, `http(s)://` URLs are downloaded via
//!    [`cabin_index_http::HttpClient`] and wrapped in
//!    `InMemoryArchive(...)`;
//! 4. calling [`cabin_port::prepare`] with one [`PortPlan`];
//! 5. translating the resulting [`cabin_port::PreparedPort`]s
//!    into [`PortPackageSource`] values the workspace loader
//!    understands.

use std::collections::{BTreeMap, BTreeSet};
use std::path::{Path, PathBuf};

use anyhow::{Context, Result, anyhow};
use semver::{Version, VersionReq};

use cabin_core::{DependencyKind, DependencySource, PortDepSource, TargetPlatform};
use cabin_index_http::HttpClient;
use cabin_manifest::load_manifest;
use cabin_port::{
    PortCache, PortEntry, PortFetchSource, PortOrigin, PortPlan, PortPrepareOptions, PreparedPort,
    load_port, prepare,
};
use cabin_workspace::{PackageGraph, PortPackageSource};

/// Inputs to [`discover_and_prepare`].
#[derive(Clone, Copy)]
pub(crate) struct PortPrepInputs<'a> {
    /// Manifest paths the walker uses as entry points — typically
    /// the resolved primary-package set for the current selection.
    /// Walking only these (instead of every workspace member) is
    /// what keeps an unrelated sibling's port from blocking
    /// `cabin build --package <name>` on a fresh checkout.
    pub seeds: &'a [PathBuf],
    /// Where prepared port archives + source trees live. The
    /// caller picks the directory (typically `<cache>/ports`).
    pub cache: &'a PortCache,
    /// `--offline` blocks any HTTP download. `file://` URLs
    /// still work because they read from disk.
    pub offline: bool,
    /// `--frozen` forbids populating the cache. If a prepared
    /// port is not already on disk, preparation fails with
    /// [`cabin_port::PortError::FrozenCacheMiss`].
    pub frozen: bool,
    /// Whether dependencies declared in `[dev-dependencies]`
    /// participate in port discovery. `cabin test` (and any
    /// future command that activates dev edges) sets this to
    /// `true`; ordinary commands like `cabin build` leave it
    /// `false` so they never fetch a port that only a sibling's
    /// dev-deps reference.
    pub include_dev: bool,
}

/// Discover every foundation-port dep reachable from `inputs.seeds`,
/// prepare each port once, and return the prepared records. The
/// returned slice is sorted by canonical port directory so
/// downstream metadata stays deterministic. Seeds are scoped to
/// the caller's resolved selection: walking only those manifests
/// (instead of every workspace member) keeps unrelated members'
/// ports out of the prep set, which matters on `--offline` /
/// uncached environments where a sibling's port would otherwise
/// block the command.
pub(crate) fn discover_and_prepare(inputs: PortPrepInputs<'_>) -> Result<Vec<PreparedPort>> {
    if inputs.seeds.is_empty() {
        return Ok(Vec::new());
    }
    let host_platform = TargetPlatform::current();
    let mut discovery = PortDiscovery::new(&host_platform);
    for seed in inputs.seeds {
        // `include_dev` only applies to the seed itself: dev deps
        // do not propagate through path-dep edges, so a transitive
        // path-dep's `[dev-dependencies]` are never activated by
        // `cabin test`. Mirror that here by walking seeds with
        // `walk_dev = include_dev` and recursing with
        // `walk_dev = false` so transitive dev-only ports never
        // enter prep.
        discovery
            .walk(seed, inputs.include_dev)
            .with_context(|| format!("discovering ports from {}", seed.display()))?;
    }

    if discovery.ports.is_empty() {
        return Ok(Vec::new());
    }

    let entries = build_plan_entries(&discovery, inputs.cache, inputs.offline)?;
    let plan = PortPlan { entries };
    let result = prepare(
        &plan,
        inputs.cache,
        PortPrepareOptions {
            frozen: inputs.frozen,
        },
    )?;
    Ok(result.ports)
}

/// Classify a port-preparation error as a fetch/cache-miss failure
/// that read-only introspection (e.g. `cabin metadata`) can swallow
/// to keep a fresh checkout usable. Structural failures — version
/// conflicts, malformed `port.toml`, checksum mismatches — return
/// `false` so they still surface as command errors. Used by the
/// network-free metadata fallback; callers that actually need port
/// content must propagate the error.
pub(crate) fn is_metadata_recoverable(err: &anyhow::Error) -> bool {
    err.downcast_ref::<cabin_port::PortError>()
        .is_some_and(|e| {
            matches!(
                e,
                cabin_port::PortError::OfflineCacheMiss { .. }
                    | cabin_port::PortError::FrozenCacheMiss { .. }
                    | cabin_port::PortError::MissingArchive { .. }
            )
        })
}

/// Project a [`PreparedPort`] into the
/// [`PortPackageSource`] view the workspace loader consumes.
pub(crate) fn workspace_source(prepared: &PreparedPort) -> PortPackageSource {
    PortPackageSource {
        name: prepared.name.clone(),
        version: prepared.version.clone(),
        manifest_path: prepared.source_dir.join("cabin.toml"),
        origin: prepared.origin.clone(),
    }
}

/// Convenience helper used by every command that loads a workspace:
/// resolve the caller's `selection` against a port-less skeleton,
/// prepare only the foundation ports reachable from that
/// selection's primary packages, and return the full workspace
/// graph with the prepared ports linked in.
///
/// Scoping to the selected closure is what protects
/// `cabin build --package <name>` from being blocked by an
/// unrelated sibling's port: only `<name>` and its transitive
/// path-dep closure are walked for port discovery, so
/// `--offline` and uncached HTTP-backed ports declared elsewhere
/// in the workspace cannot fail the command.
#[allow(clippy::fn_params_excessive_bools)]
pub(crate) fn prepare_ports_and_load_initial_graph(
    manifest_path: &Path,
    cache_dir_override: Option<&Path>,
    offline: bool,
    frozen: bool,
    include_dev: bool,
    selection: &cabin_workspace::PackageSelection,
    no_patches: bool,
) -> Result<(Vec<PreparedPort>, PackageGraph)> {
    // Resolve the cache directory consulting the same precedence
    // chain the rest of the pipeline uses: CLI override ▶
    // `CABIN_CACHE_DIR` env ▶ `[paths] cache-dir` from the merged
    // config files ▶ the user-global XDG fallback. Without the
    // config layer, foundation ports would miss a cache the
    // artifact pipeline subsequently honors, defeating
    // `--frozen` reproducibility.
    let cfg = crate::config_glue::load_effective_config_for_manifest(manifest_path)?;
    let cache_dir = match crate::config_glue::resolve_cache_dir(cache_dir_override, &cfg) {
        Some((p, _)) => p,
        None => crate::cli::cache_dir_for(manifest_path, cache_dir_override)?,
    };
    let port_cache = PortCache::new(cache_dir.join("ports"));

    // Light-load a port-less skeleton so we can resolve the
    // caller's selection without first preparing ports — which
    // is precisely the chicken-and-egg this scoping avoids on
    // every other call to the workspace loader. Port deps are
    // simply absent from the skeleton graph; the walker rebuilds
    // them below.
    let light_skeleton = cabin_workspace::load_workspace_skip_ports(manifest_path)?;
    // Resolve active patches against the port-less skeleton
    // *before* port discovery so the walker sees any patched
    // manifests that introduce new port deps. Without this
    // pre-step, a `[patch]` that pulls in a foundation port
    // would never be prepped, then the full load below would
    // silently drop the patched port edge under the
    // tolerate-missing policy and the user would see a much
    // later compile/link failure.
    let skeleton_config = crate::config_glue::load_effective_config(&light_skeleton)?;
    let active_patches =
        crate::patch_glue::load_active_patches(&light_skeleton, &skeleton_config, no_patches)?;
    let patched_sources = active_patches.workspace_sources();
    // Re-load the skeleton with patches applied so the
    // member manifest paths in the graph point at the patched
    // working copies, not the upstream packages. Tolerate any
    // missing ports (we have none yet) so the loader doesn't
    // bail before discovery runs.
    let skeleton = if patched_sources.is_empty() {
        light_skeleton
    } else {
        cabin_workspace::load_workspace_with_options(
            manifest_path,
            &cabin_workspace::WorkspaceLoadOptions {
                registry: &[],
                patches: &patched_sources,
                ports: &[],
                registry_policy: cabin_workspace::RegistryPolicy::StrictFor(&BTreeSet::new()),
                include_dev_for: &BTreeSet::new(),
                port_policy: cabin_workspace::PortPolicy::TolerateExcept(&BTreeSet::new()),
            },
        )?
    };
    let resolved = cabin_workspace::resolve_package_selection(&skeleton, selection)?;
    // The strict-port set is exactly the path-dep closure of the
    // selected primary packages on the patched skeleton. Names
    // in this set are guaranteed to have their port deps prepped
    // by `discover_and_prepare` below, so they still surface the
    // typed `PortDependencyNotPrepared` diagnostic on a miss
    // (which would only fire if discovery itself had a bug).
    // Unselected siblings — whose ports we intentionally
    // skipped — get the tolerance they need to keep the load
    // from re-introducing the cross-member failure scoping
    // exists to avoid.
    let strict_port_set: BTreeSet<String> = resolved.closure_package_names(&skeleton);
    let mut seeds: Vec<PathBuf> = resolved
        .packages
        .iter()
        .map(|&i| skeleton.packages[i].manifest_path.clone())
        .collect();
    // Patched manifests live outside the workspace graph, so the
    // walker would never reach them via path-dep recursion alone.
    // Add only patches whose name appears in the selected closure;
    // a sibling-selection's patch that is not reachable from
    // `selection` would otherwise drag its uncached HTTP-backed
    // ports into the prep set, defeating the per-package scoping
    // and re-introducing cross-selection coupling.
    seeds.extend(
        active_patches
            .iter()
            .filter(|p| strict_port_set.contains(p.name.as_str()))
            .map(|p| p.manifest_path.clone()),
    );

    let prepared = discover_and_prepare(PortPrepInputs {
        seeds: &seeds,
        cache: &port_cache,
        offline,
        frozen,
        include_dev,
    })?;
    let port_sources: Vec<PortPackageSource> = prepared.iter().map(workspace_source).collect();
    let graph = cabin_workspace::load_workspace_with_options(
        manifest_path,
        &cabin_workspace::WorkspaceLoadOptions {
            registry: &[],
            patches: &patched_sources,
            ports: &port_sources,
            registry_policy: cabin_workspace::RegistryPolicy::StrictFor(&BTreeSet::new()),
            include_dev_for: &BTreeSet::new(),
            port_policy: cabin_workspace::PortPolicy::TolerateExcept(&strict_port_set),
        },
    )?;
    Ok((prepared, graph))
}

/// A discovered foundation-port dependency, keyed for dedup.
#[derive(Debug, Clone, Eq, Ord, PartialEq, PartialOrd)]
enum PortKey {
    /// `{ port-path = "..." }` — keyed by canonical port directory.
    PortDir(PathBuf),
    /// `{ port = true }` — keyed by package name (the dep name).
    Builtin(String),
}

/// Walks the workspace's path-dep graph, recording every
/// `DependencySource::Port` it finds (deduped by key). The walker
/// stays network-free — it never follows version deps or downloads
/// anything. Both filesystem (`port-path`) deps and bundled
/// (`port = true`) deps are recorded.
#[derive(Debug)]
struct PortDiscovery<'a> {
    /// Discovered foundation-port keys reached so far.
    /// `BTreeSet` keeps iteration deterministic for downstream
    /// metadata.
    ports: BTreeSet<PortKey>,
    /// Every version requirement declared against a bundled port
    /// name, in declaration order. `build_plan_entries` resolves
    /// a single recipe against the first entry and then verifies
    /// it satisfies every subsequent entry — silently dropping
    /// later requirements would otherwise let mismatched
    /// consumers compile against a recipe that violates their
    /// declared constraint.
    builtin_reqs: BTreeMap<String, Vec<VersionReq>>,
    /// Manifests we have already parsed, keyed by canonical path.
    /// The stored value is the highest `walk_dev` mode the manifest
    /// has been walked with — `true` once dev edges have been
    /// folded in. Tracking the mode lets a manifest first reached
    /// transitively (with `walk_dev = false`) be revisited when it
    /// later appears as a selected seed (with `walk_dev = true`),
    /// so dev-only port deps on selected packages do not silently
    /// drop just because the same manifest was reached non-dev
    /// first.
    visited: BTreeMap<PathBuf, bool>,
    /// Host platform used to evaluate `[target.'cfg(...)'.<kind>]`
    /// conditions. Cfg-gated deps targeting a non-matching
    /// platform are dropped so the loader's later
    /// `dep.matches_platform` filter is honored up-front.
    host_platform: &'a TargetPlatform,
}

impl<'a> PortDiscovery<'a> {
    fn new(host_platform: &'a TargetPlatform) -> Self {
        Self {
            ports: BTreeSet::new(),
            builtin_reqs: BTreeMap::new(),
            visited: BTreeMap::new(),
            host_platform,
        }
    }

    /// Walk a manifest. `walk_dev` controls whether the manifest's
    /// own `[dev-dependencies]` are considered: the loader's
    /// dev-policy never propagates dev edges through path-dep
    /// recursion, so callers pass `walk_dev = include_dev` for
    /// seed manifests (the selected test runners) and the
    /// recursive walk below pins it to `false` for every
    /// transitively-reached path-dep, even when the seed enabled
    /// dev discovery.
    fn walk(&mut self, manifest_path: &Path, walk_dev: bool) -> Result<()> {
        // Best-effort canonicalization: if the file is missing
        // or the I/O fails, defer to the workspace loader so
        // its canonical diagnostic surfaces.
        let Ok(canonical) = std::fs::canonicalize(manifest_path) else {
            return Ok(());
        };
        // Decide whether to (re)walk. A first visit always runs;
        // a re-visit only earns a pass when we are now opting
        // into dev edges that the prior visit skipped. The
        // `only_dev` flag below confines that second pass to the
        // dev edges so the non-dev portion is not processed
        // twice (avoiding duplicate `builtin_reqs` entries and
        // redundant nested walks).
        let prior = self.visited.get(&canonical).copied();
        let only_dev = match prior {
            Some(true) => return Ok(()),
            Some(false) if !walk_dev => return Ok(()),
            Some(false) => true,
            None => false,
        };
        self.visited.insert(canonical.clone(), walk_dev);

        let manifest_dir = canonical
            .parent()
            .ok_or_else(|| anyhow!("manifest path {} has no parent", canonical.display()))?
            .to_path_buf();
        // Surface parse errors here rather than swallowing them:
        // an unparsable manifest is a hard error the user must
        // see, and walking past it would still let port
        // discovery hand other members' ports to the prep
        // pipeline (network + cache side effects) for a
        // workspace the loader will subsequently reject.
        let parsed = load_manifest(&canonical)
            .with_context(|| format!("parsing manifest at {}", canonical.display()))?;

        // Seeds passed in by `discover_and_prepare` are already
        // member manifests (the resolved primary set), so a
        // walked manifest never carries a `[workspace]` table at
        // this point — workspace expansion belongs to the
        // skeleton load that produced the seeds. The remaining
        // recursion follows `DependencySource::Path` edges from
        // each `[package].dependencies` entry.

        if let Some(pkg) = &parsed.package {
            for dep in &pkg.dependencies {
                // Mirror the workspace loader's active-edge filter:
                // skip [dev-dependencies] unless the caller opted
                // into dev discovery (`cabin test`), and skip
                // [target.'cfg(...)'.<kind>] entries that do not
                // match the host platform. Both filters keep us
                // from prepping a port that no real graph edge
                // will ever reach.
                if !walk_dev && dep.kind == DependencyKind::Dev {
                    continue;
                }
                // Second-pass re-walk to catch dev edges only:
                // the prior non-dev pass already handled normal
                // deps, so skip them now to avoid duplicating the
                // recursive descent and `builtin_reqs` entries.
                if only_dev && dep.kind != DependencyKind::Dev {
                    continue;
                }
                if !dep.matches_platform(self.host_platform) {
                    continue;
                }
                match &dep.source {
                    DependencySource::Port(PortDepSource::Path(rel)) => {
                        let port_dir = manifest_dir.join(rel);
                        // Short-circuit on an unreachable port
                        // directory: continuing would let the
                        // walker keep preparing other ports
                        // (including HTTP fetches for them) on a
                        // workspace the loader is already going
                        // to reject with `PortDirectoryMissing`.
                        // The typed diagnostic still surfaces at
                        // the later workspace load; this just
                        // stops the avoidable side effects.
                        let canonical_port_dir =
                            std::fs::canonicalize(&port_dir).map_err(|source| {
                                anyhow!(
                                    "manifest at {} declares port-path dependency `{}` but its directory is unreachable at {}: {source}",
                                    canonical.display(),
                                    dep.name.as_str(),
                                    port_dir.display()
                                )
                            })?;
                        self.ports.insert(PortKey::PortDir(canonical_port_dir));
                    }
                    DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
                        let key = name.as_str().to_owned();
                        self.builtin_reqs
                            .entry(key.clone())
                            .or_default()
                            .push(version_req.clone());
                        self.ports.insert(PortKey::Builtin(key));
                    }
                    DependencySource::Path(rel) => {
                        let nested = manifest_dir.join(rel).join("cabin.toml");
                        if !nested.is_file() {
                            // Surface the missing-manifest condition
                            // immediately rather than silently
                            // skipping it: deferring to the workspace
                            // loader's typed diagnostic would still
                            // produce a clearer message, but only
                            // after the walker has continued past
                            // this dep and potentially performed
                            // HTTP / cache side effects for other
                            // ports on an already-invalid workspace.
                            return Err(anyhow!(
                                "manifest at {} declares path dependency `{}` but its manifest is missing at {}",
                                canonical.display(),
                                dep.name.as_str(),
                                nested.display()
                            ));
                        }
                        // Always recurse with `walk_dev = false`:
                        // dev deps are non-propagating, so a
                        // transitive path-dep's
                        // `[dev-dependencies]` never become
                        // active graph edges regardless of the
                        // seed's setting.
                        self.walk(&nested, false)?;
                    }
                    DependencySource::Version(_) | DependencySource::Workspace => {}
                }
            }
        }
        Ok(())
    }
}

fn build_plan_entries(
    discovery: &PortDiscovery,
    cache: &PortCache,
    offline: bool,
) -> Result<Vec<PortEntry>> {
    let mut entries: Vec<PortEntry> = Vec::with_capacity(discovery.ports.len());
    let mut http_client: Option<HttpClient> = None;
    for key in &discovery.ports {
        let (descriptor, origin) = match key {
            PortKey::PortDir(port_dir) => {
                let descriptor = load_port(port_dir.join("port.toml"))
                    .with_context(|| format!("loading port at {}", port_dir.display()))?;
                (descriptor, PortOrigin::PortDir(port_dir.clone()))
            }
            PortKey::Builtin(name) => {
                let reqs = discovery
                    .builtin_reqs
                    .get(name)
                    .expect("walk inserts builtin_reqs in lockstep with ports");
                // Use the first declared requirement to pick the
                // recipe; then re-check every other requirement
                // against the resolved version so a mismatched
                // consumer surfaces the same diagnostic it would
                // get from a direct lookup.
                let primary_req = reqs
                    .first()
                    .expect("walk pushes at least one VersionReq per builtin name");
                let Some(recipe) = cabin_port::builtin::lookup(name, primary_req) else {
                    let available: Vec<String> = cabin_port::builtin::iter()
                        .filter(|p| p.name == name)
                        .map(|p| p.version.to_owned())
                        .collect();
                    return Err(if available.is_empty() {
                        cabin_port::PortError::UnknownBuiltin { name: name.clone() }.into()
                    } else {
                        cabin_port::PortError::BuiltinVersionNotFound {
                            name: name.clone(),
                            requirement: primary_req.to_string(),
                            available,
                        }
                        .into()
                    });
                };
                let recipe_version = Version::parse(recipe.version).with_context(|| {
                    format!(
                        "bundled port `{name}` version `{}` is not valid SemVer",
                        recipe.version
                    )
                })?;
                for extra in reqs.iter().skip(1) {
                    if !extra.matches(&recipe_version) {
                        let available: Vec<String> = cabin_port::builtin::iter()
                            .filter(|p| p.name == name)
                            .map(|p| p.version.to_owned())
                            .collect();
                        return Err(cabin_port::PortError::BuiltinVersionNotFound {
                            name: name.clone(),
                            requirement: extra.to_string(),
                            available,
                        }
                        .into());
                    }
                }
                let descriptor =
                    cabin_port::parse_port_str(recipe.port_toml, std::path::Path::new("<builtin>"))
                        .with_context(|| format!("parsing bundled port `{name}`"))?;
                (descriptor, PortOrigin::Builtin(recipe.name))
            }
        };
        let source = resolve_fetch_source(&origin, &descriptor, cache, offline, &mut http_client)?;
        entries.push(PortEntry {
            descriptor,
            origin,
            source,
        });
    }
    entries.sort_by_key(|a| port_sort_key(&a.origin));
    Ok(entries)
}

/// Deterministic ordering for prepared ports: bundled ports
/// first (by name), then filesystem ports (by canonical dir).
fn port_sort_key(origin: &PortOrigin) -> (u8, std::ffi::OsString) {
    match origin {
        PortOrigin::Builtin(name) => (0, std::ffi::OsString::from(*name)),
        PortOrigin::PortDir(p) => (1, p.as_os_str().to_owned()),
    }
}

fn resolve_fetch_source(
    origin: &PortOrigin,
    descriptor: &cabin_port::PortDescriptor,
    cache: &PortCache,
    offline: bool,
    http_client: &mut Option<HttpClient>,
) -> Result<PortFetchSource> {
    let origin_label = match origin {
        PortOrigin::PortDir(p) => p.display().to_string(),
        PortOrigin::Builtin(name) => format!("<builtin:{name}>"),
    };
    let cabin_port::PortSource::Archive { url, sha256, .. } = &descriptor.source;
    // Cache-first: if the archive cache already holds a file
    // whose bytes hash to the declared SHA-256, point cabin-port
    // at the cached path instead of re-downloading. cabin-port's
    // ensure_archive() short-circuits on a hash match so this
    // turns a repeat invocation into a pure-filesystem fast path.
    let expected_hex = sha256.to_hex();
    let cached_archive = cache.archive_path(&expected_hex);
    if archive_matches(&cached_archive, &expected_hex)? {
        return Ok(PortFetchSource::LocalArchive(cached_archive));
    }
    match url.scheme() {
        "file" => {
            let path = url.to_file_path().map_err(|()| {
                anyhow!(
                    "port at {origin_label} declares a file:// URL that does not map to a filesystem path: {url}"
                )
            })?;
            Ok(PortFetchSource::LocalArchive(path))
        }
        "http" | "https" => {
            if offline {
                return Err(cabin_port::PortError::OfflineCacheMiss {
                    name: descriptor.name.as_str().to_owned(),
                    version: descriptor.version.to_string(),
                    url: url.to_string(),
                }
                .into());
            }
            // Foundation-port archive downloads commonly hit
            // GitHub-style 302 redirects out to a CDN origin.
            // The integrity of each port archive is established
            // by the SHA-256 pin in `port.toml`, so following
            // these redirects is safe — unlike sparse-HTTP-index
            // metadata fetches, where same-origin pinning is the
            // promise. The limit of 5 hops matches the redirect
            // budget every other standards-compliant client
            // honors.
            let client = http_client.get_or_insert_with(|| HttpClient::with_redirect_budget(5));
            let label = format!("{}-{}", descriptor.name.as_str(), descriptor.version);
            let bytes = client
                .download(url.as_str(), &label)
                .map_err(|err| anyhow!("failed to download {url}: {err}"))?;
            Ok(PortFetchSource::InMemoryArchive(bytes))
        }
        other => Err(anyhow!(
            "port at {origin_label} declares an unsupported archive URL scheme `{other}`; foundation ports support `file://`, `http://`, and `https://`"
        )),
    }
}

/// Hash check on a cached archive: returns `Ok(true)` when the
/// file exists and its SHA-256 matches `expected_hex`. A missing
/// file is `Ok(false)` (clean cache miss); any other I/O error
/// surfaces as a typed anyhow error so a corrupt or unreadable
/// cache fails loudly instead of silently re-downloading.
fn archive_matches(path: &Path, expected_hex: &str) -> Result<bool> {
    let f = match std::fs::File::open(path) {
        Ok(f) => f,
        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
        Err(err) => {
            return Err(anyhow!(
                "cached port archive at {} could not be opened: {err}",
                path.display()
            ));
        }
    };
    let actual = cabin_core::hash::hash_reader(f)
        .with_context(|| format!("reading cached port archive at {}", path.display()))?;
    Ok(actual == expected_hex)
}