cabinpkg 0.17.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
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
//! 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::{ParsedManifest, load_manifest, parse_manifest_str};
use cabin_port::{
    PortCache, PortEntry, PortFetchSource, PortOrigin, PortPlan, PortPrepareOptions, PreparedPort,
    load_port, prepare,
};
use cabin_workspace::{PackageGraph, PortPackageSource};

use crate::cli::term_verbosity::Reporter;

/// 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, inputs.frozen)?;
    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 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(),
    }
}

/// Emit a cargo-style `Downloaded <name> v<ver>` status for every
/// port whose archive was fetched over the network during this run.
/// Ports served from the cache or a `file://`/local archive stay
/// silent, matching cargo's behavior of only announcing real
/// downloads.  The human-facing `cabin build` / `run` / `test` paths
/// call this right after port preparation, before the `Compiling`
/// banners; machine-output commands (`metadata`, `tree --format
/// json`, `resolve`) never call it, so stdout stays clean for them.
pub(crate) fn report_downloaded_ports(reporter: Reporter, prepared: &[PreparedPort]) {
    for port in prepared.iter().filter(|p| p.downloaded) {
        reporter.status(
            "Downloaded",
            format_args!("{} v{}", port.name, port.version),
        );
    }
}

/// 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::cli::config::load_effective_config_for_manifest(manifest_path)?;
    let cache_dir = match crate::cli::config::resolve_cache_dir(cache_dir_override, &cfg) {
        Some((p, _)) => p,
        None => crate::cli::cache_dir_for(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
    // 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::cli::config::load_effective_config(&light_skeleton)?;
    let active_patches =
        crate::cli::patch::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 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
                        // 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.note_portdir(&canonical_port_dir);
                    }
                    DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
                        self.note_builtin(name.as_str(), version_req.clone());
                    }
                    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(())
    }

    /// Record a `port-path` port and, on first discovery, recurse
    /// into its overlay so the port's own transitive port deps are
    /// found too.  Deduping on the `ports` set both avoids redundant
    /// work and terminates cycles (A → B → A).
    fn note_portdir(&mut self, canonical_port_dir: &Path) {
        if self
            .ports
            .insert(PortKey::PortDir(canonical_port_dir.to_path_buf()))
        {
            self.recurse_portdir_overlay(canonical_port_dir);
        }
    }

    /// Record a bundled (`port = true`) port.  Every declared
    /// version requirement is accumulated (so `build_plan_entries`
    /// can still reject conflicts); the overlay is walked only on
    /// the first discovery of the name.
    fn note_builtin(&mut self, name: &str, version_req: VersionReq) {
        self.builtin_reqs
            .entry(name.to_owned())
            .or_default()
            .push(version_req);
        if self.ports.insert(PortKey::Builtin(name.to_owned())) {
            self.recurse_builtin_overlay(name);
        }
    }

    /// Walk a filesystem port's overlay for transitive port deps.
    /// Best-effort: a port whose recipe / overlay cannot be read is
    /// still recorded above (its own preparation surfaces the
    /// error); we only skip recursing into it.
    fn recurse_portdir_overlay(&mut self, port_dir: &Path) {
        let Ok(descriptor) = load_port(port_dir.join("port.toml")) else {
            return;
        };
        let overlay_path = port_dir.join(&descriptor.overlay.relative_path);
        let Ok(parsed) = load_manifest(&overlay_path) else {
            return;
        };
        let base = overlay_path.parent().map(Path::to_path_buf);
        self.walk_overlay_deps(&parsed, base.as_deref());
    }

    /// Walk a bundled port's embedded overlay for transitive port
    /// deps.  Network-free: the overlay text is `include_str!`d into
    /// the binary, parsed here via `parse_manifest_str`.
    fn recurse_builtin_overlay(&mut self, name: &str) {
        // Pick the recipe by the first declared requirement, as
        // `build_plan_entries` does.
        let Some(req) = self
            .builtin_reqs
            .get(name)
            .and_then(|reqs| reqs.first())
            .cloned()
        else {
            return;
        };
        let Some(recipe) = cabin_port::builtin::lookup(name, &req) else {
            return;
        };
        let Ok(parsed) = parse_manifest_str(recipe.overlay_toml) else {
            return;
        };
        // A bundled overlay has no on-disk location, so it cannot
        // carry resolvable `path` / `port-path` deps - only bundled
        // (`port = true`) port deps are relocatable. `base_dir =
        // None` makes the walker skip the path forms.
        self.walk_overlay_deps(&parsed, None);
    }

    /// Process an overlay manifest's dependencies for transitive
    /// port edges.  Mirrors the seed walk's active-edge filter
    /// (non-dev, host-matching) and routes port deps through the
    /// same `note_*` recorders so discovery and cycle handling stay
    /// uniform. `base_dir` is the directory relative `path` deps
    /// resolve against (`Some` for a filesystem port's recipe dir,
    /// `None` for a bundled overlay).
    ///
    /// Only bundled (`port = true`) port deps propagate transitively.
    /// A `port-path` dep declared inside a port overlay is deliberately
    /// not followed: the overlay is copied verbatim into the
    /// content-addressed cache, so its relative `port-path` would not
    /// resolve to the prepared nested port at load time. (Top-level
    /// `port-path` deps in a consumer manifest are handled by `walk`
    /// and are unaffected.)
    fn walk_overlay_deps(&mut self, parsed: &ParsedManifest, base_dir: Option<&Path>) {
        let Some(pkg) = &parsed.package else {
            return;
        };
        for dep in &pkg.dependencies {
            if dep.kind == DependencyKind::Dev {
                continue;
            }
            if !dep.matches_platform(self.host_platform) {
                continue;
            }
            match &dep.source {
                DependencySource::Port(PortDepSource::Builtin { name, version_req }) => {
                    self.note_builtin(name.as_str(), version_req.clone());
                }
                DependencySource::Path(rel) => {
                    if let Some(base) = base_dir {
                        let nested = base.join(rel).join("cabin.toml");
                        if nested.is_file() {
                            let _ = self.walk(&nested, false);
                        }
                    }
                }
                // `port-path` inside an overlay is intentionally not
                // followed (see the doc comment above); only bundled
                // port deps propagate transitively.
                DependencySource::Port(PortDepSource::Path(_))
                | DependencySource::Version(_)
                | DependencySource::Workspace => {}
            }
        }
    }
}

fn build_plan_entries(
    discovery: &PortDiscovery,
    cache: &PortCache,
    offline: bool,
    frozen: 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,
            frozen,
            &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,
    frozen: 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, cabin_port::ArchiveKind::from_url(url));
    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 frozen {
                return Err(cabin_port::PortError::FrozenCacheMiss {
                    name: descriptor.name.as_str().to_owned(),
                    version: descriptor.version.to_string(),
                }
                .into());
            }
            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)
}

#[cfg(test)]
mod tests {
    use super::*;
    use assert_fs::TempDir;
    use assert_fs::prelude::*;

    const SHA_ZEROS: &str = "0000000000000000000000000000000000000000000000000000000000000000";

    fn port_toml(name: &str) -> String {
        format!(
            "[port]\nname = \"{name}\"\nversion = \"1.0.0\"\n\n[source]\ntype = \"archive\"\nurl = \"https://example.invalid/{name}.tar.gz\"\nsha256 = \"{SHA_ZEROS}\"\n\n[overlay]\nmanifest = \"cabin.toml\"\n"
        )
    }

    /// A `port-path` port whose overlay depends on the bundled
    /// `zlib` port (the libpng → zlib shape).  Discovery must record
    /// both the filesystem port *and* the transitive builtin, with
    /// the builtin's version requirement captured.
    #[test]
    fn discovery_recurses_overlay_for_transitive_builtin_dep() {
        let tmp = TempDir::new().unwrap();
        tmp.child("ports/foo/1.0.0/port.toml")
            .write_str(&port_toml("foo"))
            .unwrap();
        tmp.child("ports/foo/1.0.0/cabin.toml")
            .write_str(
                "[package]\nname = \"foo\"\nversion = \"1.0.0\"\n\n[dependencies]\nzlib = { port = true, version = \"^1.3\" }\n",
            )
            .unwrap();
        tmp.child("consumer/cabin.toml")
            .write_str(
                "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nfoo = { port-path = \"../ports/foo/1.0.0\" }\n",
            )
            .unwrap();

        let host = TargetPlatform::current();
        let mut discovery = PortDiscovery::new(&host);
        discovery
            .walk(&tmp.path().join("consumer/cabin.toml"), false)
            .unwrap();

        let foo = std::fs::canonicalize(tmp.path().join("ports/foo/1.0.0")).unwrap();
        assert!(
            discovery.ports.contains(&PortKey::PortDir(foo)),
            "foo recorded: {:?}",
            discovery.ports
        );
        assert!(
            discovery
                .ports
                .contains(&PortKey::Builtin("zlib".to_owned())),
            "transitive builtin zlib must be discovered: {:?}",
            discovery.ports
        );
        assert!(
            discovery.builtin_reqs.contains_key("zlib"),
            "transitive builtin requirement must be captured for the conflict check"
        );
    }

    /// A `port-path` dep declared *inside* a port overlay is NOT
    /// followed transitively: the overlay is copied into the
    /// content-addressed cache, so its relative `port-path` would not
    /// resolve to the prepared nested port at load time.  Discovery
    /// records the directly-referenced port (alpha) but not the nested
    /// `port-path` (beta).  Only bundled (`port = true`) deps propagate
    /// transitively.
    #[test]
    fn discovery_does_not_follow_port_path_inside_overlay() {
        let tmp = TempDir::new().unwrap();
        tmp.child("ports/beta/1.0.0/port.toml")
            .write_str(&port_toml("beta"))
            .unwrap();
        tmp.child("ports/beta/1.0.0/cabin.toml")
            .write_str("[package]\nname = \"beta\"\nversion = \"1.0.0\"\n")
            .unwrap();
        tmp.child("ports/alpha/1.0.0/port.toml")
            .write_str(&port_toml("alpha"))
            .unwrap();
        tmp.child("ports/alpha/1.0.0/cabin.toml")
            .write_str(
                "[package]\nname = \"alpha\"\nversion = \"1.0.0\"\n\n[dependencies]\nbeta = { port-path = \"../../beta/1.0.0\" }\n",
            )
            .unwrap();
        tmp.child("consumer/cabin.toml")
            .write_str(
                "[package]\nname = \"consumer\"\nversion = \"0.1.0\"\n\n[dependencies]\nalpha = { port-path = \"../ports/alpha/1.0.0\" }\n",
            )
            .unwrap();

        let host = TargetPlatform::current();
        let mut discovery = PortDiscovery::new(&host);
        discovery
            .walk(&tmp.path().join("consumer/cabin.toml"), false)
            .unwrap();

        let alpha = std::fs::canonicalize(tmp.path().join("ports/alpha/1.0.0")).unwrap();
        let beta = std::fs::canonicalize(tmp.path().join("ports/beta/1.0.0")).unwrap();
        assert!(
            discovery.ports.contains(&PortKey::PortDir(alpha)),
            "directly-referenced port alpha must be discovered: {:?}",
            discovery.ports
        );
        assert!(
            !discovery.ports.contains(&PortKey::PortDir(beta)),
            "port-path inside an overlay must NOT be followed: {:?}",
            discovery.ports
        );
    }
}