cabinpkg-workspace 0.15.0

Workspace and package-graph loader for Cabin
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
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
1001
1002
1003
1004
1005
1006
1007
1008
1009
1010
1011
1012
1013
1014
1015
1016
1017
1018
1019
1020
1021
1022
1023
1024
1025
1026
1027
1028
1029
1030
1031
1032
1033
1034
1035
1036
1037
1038
1039
1040
1041
1042
1043
1044
1045
1046
1047
1048
1049
1050
1051
1052
1053
1054
1055
1056
1057
1058
1059
1060
use crate::error::WorkspaceError;
use crate::graph::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
use cabin_core::{DependencyKind, DependencySource, PackageName, PortDepSource};
use cabin_manifest::ParsedManifest;
use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
use std::path::{Path, PathBuf};

mod members;
#[cfg(test)]
mod tests;
mod topo;

use self::members::{
    WorkspaceMembers, expand_workspace_members, parse_workspace_dep_source,
    resolve_workspace_dependencies, validate_workspace_pattern,
};
use self::topo::topo_sort;

/// One registry package source that has already been fetched and
/// extracted by `cabin-artifact`. `cabin-workspace` accepts these
/// pre-resolved entries via [`load_workspace_with_options`] so it can
/// fold them into the package graph alongside local packages.
#[derive(Debug, Clone)]
pub struct RegistryPackageSource {
    pub name: PackageName,
    pub version: semver::Version,
    /// Absolute path to the extracted package's `cabin.toml`.
    pub manifest_path: PathBuf,
}

/// One patched package source. Like [`RegistryPackageSource`],
/// the loader stitches the supplied `(name, version,
/// manifest_path)` into the graph; unlike a registry entry, the
/// resulting [`WorkspacePackage`] is tagged `kind = PackageKind::Local`
/// because the user pointed Cabin at a local working copy. The
/// orchestration layer in `cabin` filters the regular
/// registry list so a patched name's only entry comes from
/// `patches`.
#[derive(Debug, Clone)]
pub struct PatchedPackageSource {
    pub name: PackageName,
    pub version: semver::Version,
    /// Absolute path to the patched package's `cabin.toml`.
    pub manifest_path: PathBuf,
}

/// One foundation-port package source. Built by the CLI
/// orchestration layer after [`cabin_port::prepare()`] materializes
/// the port directory; the loader resolves a
/// [`DependencySource::Port`] declaration to the matching entry
/// here and inserts a [`WorkspacePackage`] tagged
/// `kind = PackageKind::Local` (foundation ports are local
/// development policy and never enter published metadata).
#[derive(Debug, Clone)]
pub struct PortPackageSource {
    /// Authoritative identity declared by `port.toml`.
    pub name: PackageName,
    pub version: semver::Version,
    /// Absolute path to the prepared port directory's overlay
    /// `cabin.toml`. The workspace loader treats this as the
    /// dep's `manifest_path`.
    pub manifest_path: PathBuf,
    /// How the recipe was located. Drives whether the dep
    /// walker looks this entry up by canonical port directory
    /// (`PortDir`) or by package name (`Builtin`).
    pub origin: cabin_port::PortOrigin,
}

/// Load a workspace or a single package starting from the given manifest
/// Path. Workspace members and local path dependencies are resolved
/// recursively against the filesystem; a topologically-sorted
/// [`PackageGraph`] is returned.
///
/// This is the convenience form for callers that only have local
/// packages. For registry / patch / dev-dep policy, use
/// [`load_workspace_with_options`].
///
/// # Errors
/// Returns a [`WorkspaceError`] when loading fails — the manifest is
/// missing or unreadable, contains neither `[package]` nor
/// `[workspace]`, a workspace member or local path dependency cannot
/// be resolved, package names collide, a dependency cycle is
/// detected, or (because this runs with the strict port policy) a
/// foundation-port dependency has not been prepared.
pub fn load_workspace(manifest_path: impl AsRef<Path>) -> Result<PackageGraph, WorkspaceError> {
    load_workspace_inner(
        manifest_path,
        &[],
        &[],
        &[],
        &RegistryEnforcement::strict(),
        &BTreeSet::new(),
        &PortMode::Strict,
    )
}

/// Load the workspace structure (members, profiles, package
/// names) without resolving foundation-port dependency edges.
///
/// Use this for commands that only need workspace topology —
/// `cabin clean`, `cabin package`, `cabin publish` — and that
/// must run on fresh checkouts where no port archive has been
/// downloaded yet. Port deps are dropped from the loaded graph
/// (they never become [`DependencyEdge`]s) but the consuming
/// packages still load normally; foundation-port packages
/// themselves are simply absent from `graph.packages`.
///
/// # Errors
/// Returns a [`WorkspaceError`] when loading fails — the manifest is
/// missing or unreadable, contains neither `[package]` nor
/// `[workspace]`, a workspace member or local path dependency cannot
/// be resolved, package names collide, or a dependency cycle is
/// detected. Because port edges are dropped, the
/// port-not-prepared / port-directory-missing variants never apply.
pub fn load_workspace_skip_ports(
    manifest_path: impl AsRef<Path>,
) -> Result<PackageGraph, WorkspaceError> {
    load_workspace_inner(
        manifest_path,
        &[],
        &[],
        &[],
        &RegistryEnforcement::strict(),
        &BTreeSet::new(),
        &PortMode::SkipAll,
    )
}

/// Options bag for the workspace loader. Threads custom policy
/// (registry / patches / ports / dev-dep activation) through a
/// single call.
#[derive(Debug, Clone)]
pub struct WorkspaceLoadOptions<'a> {
    /// Already-resolved registry package sources.
    pub registry: &'a [RegistryPackageSource],
    /// Active patches (resolved by `cabin-workspace::patch`).
    pub patches: &'a [PatchedPackageSource],
    /// Foundation ports that have already been prepared by
    /// `cabin_port::prepare` (downloaded, checksum-verified,
    /// safely extracted with `strip_prefix`, overlay applied).
    /// The loader resolves a [`DependencySource::Port`]
    /// declaration to the matching entry here.
    pub ports: &'a [PortPackageSource],
    /// How the loader treats a missing-registry edge: every parent
    /// is strict by default; pre-resolution loads use
    /// [`RegistryPolicy::StrictFor`] to scope enforcement (or
    /// disable it with an empty set).
    pub registry_policy: RegistryPolicy<'a>,
    /// Names of packages whose `[dev-dependencies]` should be
    /// loaded as real graph edges. Empty matches the
    /// `cabin build` policy of treating dev-deps as
    /// declaration-only; `cabin test` populates this with the
    /// names of the test-running packages.
    pub include_dev_for: &'a BTreeSet<String>,
    /// How the loader resolves `DependencySource::Port` entries.
    /// Defaults to [`PortPolicy::Strict`] — every port-dep must
    /// be present in `ports` (and on disk, for `port-path`).
    /// Callers that scope port preparation to a narrower
    /// selection than the full primary-package set use
    /// [`PortPolicy::TolerateExcept`] with the selected names
    /// so siblings' missing ports are silently skipped while
    /// selected packages still surface the typed
    /// `PortDependencyNotPrepared` / `PortDirectoryMissing`
    /// diagnostic.
    pub port_policy: PortPolicy<'a>,
}

/// How the loader treats `DependencySource::Port` declarations
/// from a [`WorkspaceLoadOptions`] call.
#[derive(Debug, Clone, Default)]
pub enum PortPolicy<'a> {
    /// A port dep must be either a `port-path` directory on disk
    /// plus present in `ports`, or a `port = true` name present in
    /// `ports`. Anything else surfaces the typed
    /// `PortDependencyNotPrepared` / `PortDirectoryMissing`
    /// diagnostic. Default.
    #[default]
    Strict,
    /// Tolerate missing port deps *except* for parent packages
    /// whose names appear in this set — the caller's selected
    /// closure. Names in the set still surface the typed
    /// diagnostics; names outside the set silently skip the
    /// missing edge.
    ///
    /// Passing an empty set tolerates every parent (legacy
    /// "tolerate-all" behavior); pass a populated set to keep
    /// selected packages strict while unselected siblings
    /// tolerate.
    TolerateExcept(&'a BTreeSet<String>),
}

/// How the loader treats a versioned dependency edge whose name is
/// not present in `registry`. Pre-resolution loads (port discovery,
/// `cabin metadata` fallback) carry no registry yet but may carry
/// patches that contribute names to the loader's internal name map;
/// the [`RegistryPolicy::StrictFor`] variant lets callers scope
/// enforcement so the resolver-less paths don't surface bogus
/// missing-registry diagnostics.
#[derive(Debug, Clone, Default)]
pub enum RegistryPolicy<'a> {
    /// Every parent's registry deps must be present in `registry`.
    /// Default. Used after the resolver has populated `registry`
    /// with the closure's full pinned set.
    #[default]
    Strict,
    /// Strict only for parents whose names appear in the set;
    /// names outside silently skip a missing-registry edge.
    /// Passing an empty set tolerates every parent — used by
    /// pre-resolution loads.
    StrictFor(&'a BTreeSet<String>),
}

/// Load the workspace with a single options bag. When
/// `include_dev_for` is empty the loader follows the
/// `cabin build` policy of treating dev-deps as
/// declaration-only; with a non-empty set, listed packages
/// contribute their `[dev-dependencies]` as real graph edges
/// (path-deps are materialized, version-deps reach the
/// resolver). Dev-deps still don't propagate transitively —
/// only the listed packages activate them.
///
/// # Errors
/// Returns a [`WorkspaceError`] when loading fails — covering the
/// manifest, member-expansion, local-path, duplicate-name, and
/// cycle failures of [`load_workspace`], plus the policy-driven
/// variants this entry point enables: unresolved registry
/// dependencies, registry-source name/version mismatches, and
/// unprepared or missing foundation-port dependencies for parents
/// the registry / port policy treats as strict.
pub fn load_workspace_with_options(
    manifest_path: impl AsRef<Path>,
    options: &WorkspaceLoadOptions<'_>,
) -> Result<PackageGraph, WorkspaceError> {
    let policy = match &options.registry_policy {
        RegistryPolicy::Strict => RegistryEnforcement::strict(),
        RegistryPolicy::StrictFor(set) => RegistryEnforcement::scoped((*set).clone()),
    };
    let port_mode = match &options.port_policy {
        PortPolicy::Strict => PortMode::Strict,
        PortPolicy::TolerateExcept(strict) => PortMode::TolerateExcept((*strict).clone()),
    };
    load_workspace_inner(
        manifest_path,
        options.registry,
        options.patches,
        options.ports,
        &policy,
        options.include_dev_for,
        &port_mode,
    )
}

/// How strictly missing registry entries are enforced. Internal
/// mirror of [`RegistryPolicy`] — public callers pick the policy via
/// the enum; the loader collapses it to this owned form so the rest
/// of the load path doesn't carry the lifetime parameter.
#[derive(Debug, Clone)]
struct RegistryEnforcement {
    /// `Some` -> only enforce missing-registry for the listed
    /// package names; `None` -> enforce for every package
    /// (the strict default).
    strict_packages: Option<BTreeSet<String>>,
}

impl RegistryEnforcement {
    fn strict() -> Self {
        Self {
            strict_packages: None,
        }
    }

    fn scoped(strict_packages: BTreeSet<String>) -> Self {
        Self {
            strict_packages: Some(strict_packages),
        }
    }

    fn requires_registry_for(&self, parent_name: &str) -> bool {
        match &self.strict_packages {
            None => true,
            Some(set) => set.contains(parent_name),
        }
    }
}

/// How the loader treats `DependencySource::Port` declarations.
/// Internal mirror of [`PortPolicy`] that also models the
/// "skip every port edge unconditionally" mode used by
/// [`load_workspace_skip_ports`].
#[derive(Debug, Clone)]
enum PortMode {
    /// Default: a port dep must be either a `port-path` directory
    /// on disk + present in `ports`, or a `port = true` name
    /// present in `ports`. Anything else surfaces the typed
    /// `PortDependencyNotPrepared` / `PortDirectoryMissing`
    /// diagnostic. Used by `load_workspace` /
    /// `load_workspace_with_options` against the full
    /// primary-package set.
    Strict,
    /// Drop every port-dep edge silently. Used by
    /// [`load_workspace_skip_ports`] for commands that only need
    /// workspace topology (`cabin clean`, `cabin package`,
    /// `cabin publish`).
    SkipAll,
    /// Link present port deps as graph edges; silently skip ones
    /// whose source is absent from `ports` (or whose port-path
    /// directory is missing on disk) *except* for parents whose
    /// names appear in this set — the caller's selected closure
    /// still surfaces the typed diagnostics so a typoed
    /// `port-path` in a selected package fails fast instead of
    /// being silently dropped.
    TolerateExcept(BTreeSet<String>),
}

fn load_workspace_inner(
    manifest_path: impl AsRef<Path>,
    registry: &[RegistryPackageSource],
    patches: &[PatchedPackageSource],
    ports: &[PortPackageSource],
    policy: &RegistryEnforcement,
    include_dev_for: &BTreeSet<String>,
    port_mode: &PortMode,
) -> Result<PackageGraph, WorkspaceError> {
    let skip_port_edges = matches!(port_mode, PortMode::SkipAll);
    let tolerate_strict_set: Option<&BTreeSet<String>> = match port_mode {
        PortMode::TolerateExcept(set) => Some(set),
        _ => None,
    };
    let manifest_path = canonicalize(manifest_path.as_ref())?;
    let root_dir = manifest_path
        .parent()
        .ok_or_else(|| WorkspaceError::Io {
            path: manifest_path.clone(),
            source: std::io::Error::other("manifest path has no parent directory"),
        })?
        .to_path_buf();

    let root_manifest = parse_manifest(&manifest_path)?;
    if root_manifest.package.is_none() && root_manifest.workspace.is_none() {
        return Err(WorkspaceError::EmptyManifest {
            path: manifest_path,
        });
    }

    // Target-conditional dep tables are evaluated against the
    // host platform — Cabin does not yet support
    // cross-compilation. Future steps may thread an explicit
    // target context through this loader; for now the host is
    // the single source of truth.
    let host_platform = cabin_core::TargetPlatform::current();

    let is_workspace_root = root_manifest.workspace.is_some();

    let mut loader = Loader {
        packages: Vec::new(),
        manifest_index: HashMap::new(),
    };

    // Roots are the entry points whose path-deps we recursively follow
    // and whose primary status we record. They are: the root manifest if
    // it has a [package], and every workspace member.
    let mut primary_manifest_paths: Vec<PathBuf> = Vec::new();

    if root_manifest.package.is_some() {
        primary_manifest_paths.push(manifest_path.clone());
    }

    // Workspace.default_members captured here so we can validate it
    // against the resolved primary set after member expansion.
    let mut workspace_default_members: Vec<String> = Vec::new();
    // Workspace dependency tables captured up-front and parsed
    // once. Member manifests with `dep = { workspace = true }`
    // resolve against the table that matches their declared
    // [`DependencyKind`] — `[workspace.dependencies]` for normal
    // deps, `[workspace.dev-dependencies]` for dev deps.
    // Each entry stores only the resolved `DependencySource` since
    // the inheriting dep already knows its own kind.
    let mut workspace_deps: BTreeMap<DependencyKind, BTreeMap<String, DependencySource>> =
        BTreeMap::new();

    let mut excluded_member_paths: Vec<PathBuf> = Vec::new();
    if let Some(workspace) = &root_manifest.workspace {
        let WorkspaceMembers { included, excluded } =
            expand_workspace_members(&root_dir, &workspace.members, &workspace.exclude)?;
        for canonical in included {
            // reject nested workspaces. A member directory's
            // `cabin.toml` must not declare its own `[workspace]`
            // table, otherwise the load tries to honor two parent
            // workspaces at once.
            let parsed = parse_manifest(&canonical)?;
            if parsed.workspace.is_some() {
                return Err(WorkspaceError::NestedWorkspace { path: canonical });
            }
            primary_manifest_paths.push(canonical);
        }
        excluded_member_paths = excluded;
        workspace_default_members.clone_from(&workspace.default_members);
        for (kind, table) in [
            (DependencyKind::Normal, &workspace.dependencies),
            (DependencyKind::Dev, &workspace.dev_dependencies),
        ] {
            if table.is_empty() {
                continue;
            }
            let entry = workspace_deps.entry(kind).or_default();
            for (name, req) in table {
                entry.insert(name.clone(), parse_workspace_dep_source(name, req)?);
            }
        }
    }

    // Build lookup maps for prepared foundation ports. The dep
    // walker resolves `DependencySource::Port` declarations via
    // one of two maps depending on the origin:
    //   - PortDir: canonical port_dir -> prepared manifest_path
    //   - Builtin: package name -> prepared manifest_path
    // We canonicalize the port_dir up-front so the lookup is a
    // single HashMap probe per dep — and so two consumers that
    // reach the same port through different relative paths still
    // see the same prepared source.
    let mut port_by_canonical_dir: HashMap<PathBuf, PathBuf> = HashMap::new();
    let mut port_by_name: HashMap<String, PathBuf> = HashMap::new();
    // Canonical overlay-manifest paths of every prepared foundation
    // port. Populated once from `ports` in the loop just below, then
    // read both for the `Local` port classification and for the
    // `is_port` graph tag. Keep this the single source of truth — a
    // second, separately populated set risks silently diverging.
    let mut port_canonical_paths: HashSet<PathBuf> = HashSet::new();
    for entry in ports {
        match &entry.origin {
            cabin_port::PortOrigin::PortDir(port_dir) => {
                let port_dir_canonical = canonicalize(port_dir)?;
                if let Some(previous) =
                    port_by_canonical_dir.insert(port_dir_canonical, entry.manifest_path.clone())
                {
                    return Err(WorkspaceError::DuplicatePackageName {
                        name: entry.name.as_str().to_owned(),
                        first: previous,
                        second: entry.manifest_path.clone(),
                    });
                }
            }
            cabin_port::PortOrigin::Builtin(name) => {
                if let Some(previous) =
                    port_by_name.insert((*name).to_owned(), entry.manifest_path.clone())
                {
                    return Err(WorkspaceError::DuplicatePackageName {
                        name: entry.name.as_str().to_owned(),
                        first: previous,
                        second: entry.manifest_path.clone(),
                    });
                }
            }
        }
        port_canonical_paths.insert(canonicalize(&entry.manifest_path)?);
    }

    // Build a name -> registry source map (canonicalizing paths so the
    // dedup-by-canonical-path step below sees a consistent value), plus
    // parallel maps of canonical registry manifest paths to expected
    // (name, version) so loading can compare the actual manifest
    // contents against what the resolver pinned.
    let mut registry_by_name: HashMap<&str, PathBuf> = HashMap::new();
    let mut registry_canonical_names: HashMap<PathBuf, &PackageName> = HashMap::new();
    let mut registry_canonical_versions: HashMap<PathBuf, &semver::Version> = HashMap::new();
    let mut registry_canonical_paths: HashSet<PathBuf> = HashSet::new();
    let mut patch_canonical_paths: HashSet<PathBuf> = HashSet::new();
    for entry in registry {
        let canonical = canonicalize(&entry.manifest_path)?;
        registry_by_name.insert(entry.name.as_str(), canonical.clone());
        registry_canonical_names.insert(canonical.clone(), &entry.name);
        registry_canonical_versions.insert(canonical.clone(), &entry.version);
        registry_canonical_paths.insert(canonical);
    }
    // Patches contribute the same `(name, version, manifest_path)`
    // information as registry entries but ultimately produce
    // local-kind packages. Defensively reject overlap with the
    // registry list so a caller bug never silently flips Local
    // to Registry mid-graph.
    for entry in patches {
        let canonical = canonicalize(&entry.manifest_path)?;
        if registry_canonical_paths.contains(&canonical) {
            return Err(WorkspaceError::PatchConflictsWithRegistry {
                package: entry.name.as_str().to_owned(),
                path: canonical,
            });
        }
        if let Some(existing) = registry_by_name.insert(entry.name.as_str(), canonical.clone()) {
            return Err(WorkspaceError::DuplicatePackageName {
                name: entry.name.as_str().to_owned(),
                first: existing,
                second: canonical,
            });
        }
        registry_canonical_names.insert(canonical.clone(), &entry.name);
        registry_canonical_versions.insert(canonical.clone(), &entry.version);
        registry_canonical_paths.insert(canonical.clone());
        patch_canonical_paths.insert(canonical);
    }

    // Recursively load every primary manifest plus any path deps it pulls
    // in. The loader is iterative — we maintain a stack of unloaded
    // manifests rather than recursing.
    let mut to_load: Vec<PathBuf> = primary_manifest_paths.clone();
    // Make registry packages part of the load set too; they are not
    // primary, but they must appear in the package graph.
    for entry in registry {
        let canonical = canonicalize(&entry.manifest_path)?;
        to_load.push(canonical);
    }
    // Patches are external manifests too; load them so the
    // graph carries the patched `Package` value alongside the
    // workspace members and registry entries.
    for entry in patches {
        let canonical = canonicalize(&entry.manifest_path)?;
        to_load.push(canonical);
    }
    // Ports are also external manifests. They live in the
    // foundation-port cache directory; load them so the graph
    // carries the prepared overlay `Package` value alongside
    // workspace members.
    for entry in ports {
        // `port_canonical_paths` was already populated from `ports`
        // above; here we only enqueue the overlay manifest for loading.
        to_load.push(canonicalize(&entry.manifest_path)?);
    }
    let root_manifest_path = manifest_path.clone();
    while let Some(manifest_path) = to_load.pop() {
        if loader.manifest_index.contains_key(&manifest_path) {
            continue;
        }
        let parsed = parse_manifest(&manifest_path)?;
        let package = parsed.package.ok_or_else(|| {
            // A path dependency that resolves to a workspace-only manifest.
            WorkspaceError::LocalDependencyIsWorkspace {
                dep_name: project_alias_for(&loader, &manifest_path),
                path: manifest_path.clone(),
            }
        })?;

        // `[profile.*]` tables are only honored on the entry-
        // point manifest. Member and path-dep manifests that
        // declare them surface a clear error rather than being
        // silently ignored, so a single workspace key cannot
        // mean different things in different members.
        // Each of the per-table guards below moves `manifest_path`
        // into the returned error because the function returns
        // immediately on the error branch and the field carries
        // the path verbatim; the borrow checker preserves it for
        // the rest of the loop body via the early-return.
        if manifest_path != root_manifest_path && !package.profiles.is_empty() {
            return Err(WorkspaceError::MemberDeclaresProfiles {
                package: package.name.as_str().to_owned(),
                path: manifest_path,
            });
        }
        if manifest_path != root_manifest_path && !package.toolchain.is_empty() {
            return Err(WorkspaceError::MemberDeclaresToolchain {
                package: package.name.as_str().to_owned(),
                path: manifest_path,
            });
        }
        if manifest_path != root_manifest_path && !package.compiler_wrapper.is_empty() {
            return Err(WorkspaceError::MemberDeclaresCompilerWrapper {
                package: package.name.as_str().to_owned(),
                path: manifest_path,
            });
        }
        if manifest_path != root_manifest_path && !package.patches.is_empty() {
            return Err(WorkspaceError::MemberDeclaresPatches {
                package: package.name.as_str().to_owned(),
                path: manifest_path,
            });
        }

        // If this manifest is a known registry package, the resolver
        // pinned a specific (name, version). The artifact crate has
        // already validated the manifest against that pin, but the
        // workspace loader is the user-visible reporter, so we
        // double-check here and surface a clear error if they ever
        // disagree.
        // validate both expected name and version. The
        // registry may have pointed at a directory whose manifest
        // declares a completely different package (a malicious or
        // wrongly extracted artifact); refusing here keeps a wrong
        // package from sneaking into the build graph.
        if let Some(expected_version) = registry_canonical_versions.get(&manifest_path) {
            let expected_name = registry_canonical_names.get(&manifest_path).copied();
            let version_ok = &package.version == *expected_version;
            let name_ok = expected_name.is_none_or(|n| n.as_str() == package.name.as_str());
            if !name_ok {
                return Err(WorkspaceError::RegistryPackageNameMismatch {
                    name: expected_name
                        .map(|n| n.as_str().to_owned())
                        .unwrap_or_default(),
                    actual_name: package.name.as_str().to_owned(),
                    path: manifest_path.clone(),
                });
            }
            if !version_ok {
                return Err(WorkspaceError::RegistryPackageMismatch {
                    name: expected_name
                        .map(|n| n.as_str().to_owned())
                        .unwrap_or_default(),
                    version: expected_version.to_string(),
                    actual_name: package.name.as_str().to_owned(),
                    actual_version: package.version.to_string(),
                    path: manifest_path.clone(),
                });
            }
        }

        let manifest_dir = manifest_path
            .parent()
            .expect("canonicalized manifest path has a parent")
            .to_path_buf();

        // rewrite each `{ workspace = true }` dep into the
        // resolved source from `[workspace.dependencies]` before any
        // other consumer sees it. We hold the rewritten `Package` in
        // `resolved_project` and use it for the rest of this
        // iteration.
        let resolved_project = resolve_workspace_dependencies(package.clone(), &workspace_deps)?;
        let package = resolved_project;

        // Dev dependencies are declaration-only for ordinary
        // commands but become real graph edges when the loader is
        // told to "include dev for" this package — typically by
        // `cabin test` for the test-running packages. The opt-in
        // never propagates: a transitive dep's own dev-deps stay
        // declaration-only.
        let dev_active_for_this_pkg = include_dev_for.contains(package.name.as_str());
        // A downloaded registry package is untrusted. The publish step
        // rejects `path` and `port` dependencies (see cabin-package's
        // `validate`), so a legitimately published package only ever depends
        // on other packages by version. Enforce the same invariant on the
        // consumer side: otherwise a malicious archive could ship a nested
        // `path` sub-package, which the loader would classify as a trusted
        // `PackageKind::Local` package and honor its compiler/linker flags —
        // build-time code execution one dependency hop away.
        //
        // This must match the `PackageKind::Registry` classification below:
        // patches and ports take precedence and stay `Local`, so a patched
        // fork or port overlay that happens to replace a registry entry is
        // still user-controlled and may legitimately declare path/port deps.
        let parent_is_registry = registry_canonical_paths.contains(&manifest_path)
            && !patch_canonical_paths.contains(&manifest_path)
            && !port_canonical_paths.contains(&manifest_path);
        let mut dep_paths: Vec<DepPath> = Vec::with_capacity(package.dependencies.len());
        for dep in &package.dependencies {
            // Skip dependencies that are not in this command's
            // active-kind set. Dev deps remain inactive unless the
            // owning package is in `include_dev_for`. System deps
            // never reach this loop (they live on a separate
            // `system_dependencies` list).
            let kind_active = dep.kind.is_resolved_by_default()
                || (dev_active_for_this_pkg && dep.kind == DependencyKind::Dev);
            if !kind_active {
                continue;
            }
            // Skip dependencies declared inside a non-matching
            // `[target.'cfg(...)'.<kind>]` table. They stay on
            // `package.dependencies` for metadata round-trip but
            // never become package-graph edges or get loaded as
            // path-dep sub-projects on this platform.
            if !dep.matches_platform(&host_platform) {
                continue;
            }
            if parent_is_registry {
                match &dep.source {
                    DependencySource::Path(_) => {
                        return Err(WorkspaceError::RegistryPackageDeclaresPathDependency {
                            package: package.name.as_str().to_owned(),
                            dep_name: dep.name.as_str().to_owned(),
                            path: manifest_path.clone(),
                        });
                    }
                    DependencySource::Port(_) => {
                        return Err(WorkspaceError::RegistryPackageDeclaresPortDependency {
                            package: package.name.as_str().to_owned(),
                            dep_name: dep.name.as_str().to_owned(),
                            path: manifest_path.clone(),
                        });
                    }
                    DependencySource::Version(_) | DependencySource::Workspace => {}
                }
            }
            let canonical = match &dep.source {
                DependencySource::Path(rel) => {
                    let candidate = manifest_dir.join(rel).join("cabin.toml");
                    if !candidate.is_file() {
                        return Err(WorkspaceError::LocalDependencyManifestMissing {
                            dep_name: dep.name.as_str().to_owned(),
                            expected: candidate,
                        });
                    }
                    canonicalize(&candidate)?
                }
                DependencySource::Port(PortDepSource::Path(rel)) => {
                    if skip_port_edges {
                        continue;
                    }
                    // Tolerate when the *parent* package is not in
                    // the selected strict set: discovery skipped
                    // unselected siblings on purpose, so their
                    // missing port deps are expected. Selected
                    // parents (or any parent when strict mode is
                    // in effect) still surface the typed
                    // diagnostics.
                    let tolerate =
                        tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
                    let port_dir = manifest_dir.join(rel);
                    if !port_dir.is_dir() {
                        if tolerate {
                            continue;
                        }
                        return Err(WorkspaceError::PortDirectoryMissing {
                            dep_name: dep.name.as_str().to_owned(),
                            parent: package.name.as_str().to_owned(),
                            port_dir,
                        });
                    }
                    let port_dir_canonical = canonicalize(&port_dir)?;
                    if let Some(manifest_path) = port_by_canonical_dir.get(&port_dir_canonical) {
                        canonicalize(manifest_path)?
                    } else {
                        if tolerate {
                            continue;
                        }
                        return Err(WorkspaceError::PortDependencyNotPrepared {
                            dep_name: dep.name.as_str().to_owned(),
                            parent: package.name.as_str().to_owned(),
                            port_dir: port_dir_canonical,
                        });
                    }
                }
                DependencySource::Port(PortDepSource::Builtin { name, .. }) => {
                    if skip_port_edges {
                        continue;
                    }
                    let tolerate =
                        tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
                    if let Some(manifest_path) = port_by_name.get(name.as_str()) {
                        canonicalize(manifest_path)?
                    } else {
                        if tolerate {
                            continue;
                        }
                        return Err(WorkspaceError::BuiltinPortDependencyNotPrepared {
                            dep_name: dep.name.as_str().to_owned(),
                            parent: package.name.as_str().to_owned(),
                        });
                    }
                }
                DependencySource::Version(_) => {
                    // No registry context: keep the legacy behavior of
                    // skipping versioned deps (used by `cabin metadata`
                    // and `cabin resolve`, which don't materialize
                    // sources).
                    if registry_by_name.is_empty() {
                        continue;
                    }
                    if let Some(path) = registry_by_name.get(dep.name.as_str()) {
                        path.clone()
                    } else {
                        // a missing registry entry is
                        // only an error when the *parent*
                        // package is one the caller flagged as
                        // strict (typically a member of the
                        // selected closure). Unselected
                        // workspace members can declare
                        // versioned deps the current command
                        // did not fetch, so we skip them
                        // silently.
                        if !policy.requires_registry_for(package.name.as_str()) {
                            continue;
                        }
                        return Err(WorkspaceError::UnresolvedRegistryDependency {
                            dep_name: dep.name.as_str().to_owned(),
                            parent: package.name.as_str().to_owned(),
                        });
                    }
                }
                DependencySource::Workspace => {
                    // Workspace inheritance is resolved up-front via
                    // `resolve_workspace_dependencies`. A `Workspace`
                    // source surviving this loop means the workspace
                    // root did not declare the requested name in the
                    // matching `[workspace.<kind>-dependencies]` table.
                    return Err(WorkspaceError::UnresolvedWorkspaceDependency {
                        dep_name: dep.name.as_str().to_owned(),
                        parent: package.name.as_str().to_owned(),
                        kind: dep.kind,
                    });
                }
            };
            dep_paths.push(DepPath {
                name: dep.name.as_str().to_owned(),
                path: canonical,
                kind: dep.kind,
                condition: dep.condition.clone(),
            });
        }

        // Verify the dependency key matches the actual package name. We
        // need to peek at the dep's manifest before fully loading it.
        for DepPath {
            name: dep_name,
            path: dep_manifest_path,
            ..
        } in &dep_paths
        {
            let dep_parsed = parse_manifest(dep_manifest_path)?;
            let actual = dep_parsed.package.as_ref().ok_or_else(|| {
                WorkspaceError::LocalDependencyIsWorkspace {
                    dep_name: dep_name.clone(),
                    path: dep_manifest_path.clone(),
                }
            })?;
            if actual.name.as_str() != dep_name {
                return Err(WorkspaceError::DependencyNameMismatch {
                    dep_name: dep_name.clone(),
                    actual_name: actual.name.as_str().to_owned(),
                    path: dep_manifest_path.clone(),
                });
            }
        }

        let index = loader.packages.len();
        loader.manifest_index.insert(manifest_path.clone(), index);
        loader.packages.push(LoadedPackage {
            package,
            manifest_path: manifest_path.clone(),
            manifest_dir,
            dep_paths,
        });
        for dep in &loader.packages[index].dep_paths {
            to_load.push(dep.path.clone());
        }
    }

    // Detect duplicate package names *across* the loader's packages
    // (different filesystem paths, but the same `[package].name`).
    {
        let mut seen: HashMap<&str, &PathBuf> = HashMap::new();
        for pkg in &loader.packages {
            let name = pkg.package.name.as_str();
            if let Some(prev) = seen.insert(name, &pkg.manifest_path) {
                return Err(WorkspaceError::DuplicatePackageName {
                    name: name.to_owned(),
                    first: prev.clone(),
                    second: pkg.manifest_path.clone(),
                });
            }
        }
    }

    // Resolve dep edges (path -> index in loader.packages).
    let mut packages: Vec<WorkspacePackage> = Vec::with_capacity(loader.packages.len());
    for pkg in &loader.packages {
        let mut deps = Vec::with_capacity(pkg.dep_paths.len());
        for dep in &pkg.dep_paths {
            let idx = *loader
                .manifest_index
                .get(&dep.path)
                .expect("dep manifest should have been loaded");
            deps.push(DependencyEdge {
                index: idx,
                kind: dep.kind,
                condition: dep.condition.clone(),
            });
        }
        let kind = if patch_canonical_paths.contains(&pkg.manifest_path) {
            // Patches resolve to local working copies; treat them
            // exactly like a path dep so downstream consumers
            // (build planner, lockfile, metadata view) do not
            // see a "registry" package that lives on the user's
            // filesystem.
            PackageKind::Local
        } else if port_canonical_paths.contains(&pkg.manifest_path) {
            // Foundation ports are local development policy; their
            // prepared overlays live in the artifact cache but are
            // not registry packages.
            PackageKind::Local
        } else if registry_canonical_paths.contains(&pkg.manifest_path) {
            PackageKind::Registry
        } else {
            PackageKind::Local
        };
        let is_port = port_canonical_paths.contains(&pkg.manifest_path);
        packages.push(WorkspacePackage {
            package: pkg.package.clone(),
            manifest_path: pkg.manifest_path.clone(),
            manifest_dir: pkg.manifest_dir.clone(),
            deps,
            kind,
            is_port,
        });
    }

    let topo = topo_sort(&packages)?;

    // Apply the topological permutation to the packages list and rewrite
    // every dep index so it refers to the new, sorted positions.
    let new_position: HashMap<usize, usize> = topo
        .iter()
        .enumerate()
        .map(|(new_idx, &old_idx)| (old_idx, new_idx))
        .collect();

    let mut sorted: Vec<WorkspacePackage> = topo
        .iter()
        .map(|&old_idx| packages[old_idx].clone())
        .collect();
    for pkg in &mut sorted {
        for edge in &mut pkg.deps {
            edge.index = new_position[&edge.index];
        }
    }

    let primary_packages: Vec<usize> = primary_manifest_paths
        .iter()
        .map(|p| {
            let old_idx = loader.manifest_index[p];
            new_position[&old_idx]
        })
        .collect();

    let root_package = if root_manifest.package.is_some() {
        Some(new_position[&loader.manifest_index[&manifest_path]])
    } else {
        None
    };

    // validate that every workspace.default-members entry
    // resolves to a primary package, then map them to graph indices.
    // The default order matches the manifest, with stable
    // deduplication.
    let mut default_members: Vec<usize> = Vec::new();
    let mut seen_default: HashSet<usize> = HashSet::new();
    for entry in &workspace_default_members {
        // Same path-safety rules as members/exclude — reject
        // absolute and `..` defaults before any filesystem walk.
        validate_workspace_pattern("workspace.default-members", entry)?;
        let dir = root_dir.join(entry);
        let canonical_dir =
            canonicalize(&dir).map_err(|_| WorkspaceError::DefaultMemberNotInMembers {
                member: entry.clone(),
            })?;
        let manifest = canonical_dir.join("cabin.toml");
        let idx = loader
            .manifest_index
            .get(&manifest)
            .copied()
            .ok_or_else(|| WorkspaceError::DefaultMemberNotInMembers {
                member: entry.clone(),
            })?;
        let new_idx = new_position[&idx];
        if !primary_packages.contains(&new_idx) {
            return Err(WorkspaceError::DefaultMemberNotInMembers {
                member: entry.clone(),
            });
        }
        if seen_default.insert(new_idx) {
            default_members.push(new_idx);
        }
    }

    Ok(PackageGraph {
        root_manifest_path: manifest_path,
        root_dir,
        is_workspace_root,
        root_package,
        root_settings: root_manifest.root_settings.into(),
        primary_packages,
        default_members,
        excluded_members: excluded_member_paths,
        packages: sorted,
    })
}

struct Loader {
    packages: Vec<LoadedPackage>,
    /// Map canonical manifest path -> index in `packages`.
    manifest_index: HashMap<PathBuf, usize>,
}

struct LoadedPackage {
    package: cabin_core::Package,
    manifest_path: PathBuf,
    manifest_dir: PathBuf,
    /// One entry per resolved dep edge: `(dep_name, canonical
    /// manifest path, dependency kind, condition)`. Only kinds that
    /// participate in ordinary resolution end up here; dev / system
    /// deps are filtered out earlier.
    dep_paths: Vec<DepPath>,
}

#[derive(Debug, Clone)]
struct DepPath {
    name: String,
    path: PathBuf,
    kind: cabin_core::DependencyKind,
    /// Condition under which this edge was declared. `None`
    /// for unconditional edges; the loader filters out
    /// non-matching conditional edges before reaching this
    /// point, so any value here matches the host platform.
    condition: Option<cabin_core::Condition>,
}

/// Best-effort recovery of a friendly name to mention in the error when a
/// Path dependency turns out to point at a workspace-only manifest. We
/// don't always know what dep we were following, so this falls back to the
/// Path itself.
fn project_alias_for(loader: &Loader, manifest_path: &Path) -> String {
    for pkg in &loader.packages {
        for dep in &pkg.dep_paths {
            if dep.path == manifest_path {
                return dep.name.clone();
            }
        }
    }
    manifest_path.display().to_string()
}

fn parse_manifest(path: &Path) -> Result<ParsedManifest, WorkspaceError> {
    cabin_manifest::load_manifest(path).map_err(|source| WorkspaceError::Manifest {
        path: path.to_path_buf(),
        source: Box::new(source),
    })
}

/// Canonicalize a manifest path and map any I/O failure onto the
/// crate's diagnostic error type.
///
/// Routes through [`cabin_fs::canonicalize`] so every manifest-path
/// identity — dedup, nested-workspace detection, member lookup — shares
/// the project's single Windows-safe canonical spelling (no `\\?\`
/// verbatim prefix, which MSVC's front-end cannot open).
pub(super) fn canonicalize(path: &Path) -> Result<PathBuf, WorkspaceError> {
    cabin_fs::canonicalize(path).map_err(|source| classify_manifest_io(path, source))
}

/// Classify an I/O error from a load-time `canonicalize` call.
/// `NotFound` becomes the dedicated [`WorkspaceError::ManifestNotFound`]
/// variant so the diagnostic layer can emit a structured report with
/// help text. Everything else maps to
/// [`WorkspaceError::ManifestUnreadable`] (permission denied, the
/// path is a directory, …).
fn classify_manifest_io(path: &Path, source: std::io::Error) -> WorkspaceError {
    match source.kind() {
        std::io::ErrorKind::NotFound => WorkspaceError::ManifestNotFound {
            path: path.to_path_buf(),
        },
        _ => WorkspaceError::ManifestUnreadable {
            path: path.to_path_buf(),
            source,
        },
    }
}