Skip to main content

cabin_workspace/
loader.rs

1use std::collections::{BTreeMap, BTreeSet, HashMap, HashSet};
2use std::path::{Path, PathBuf};
3
4use cabin_core::{DependencyKind, DependencySource, PackageName, PortDepSource};
5use cabin_manifest::ParsedManifest;
6
7use crate::error::WorkspaceError;
8use crate::graph::{DependencyEdge, PackageGraph, PackageKind, WorkspacePackage};
9
10/// One registry package source that has already been fetched and
11/// extracted by `cabin-artifact`. `cabin-workspace` accepts these
12/// pre-resolved entries via [`load_workspace_with_options`] so it can
13/// fold them into the package graph alongside local packages.
14#[derive(Debug, Clone)]
15pub struct RegistryPackageSource {
16    pub name: PackageName,
17    pub version: semver::Version,
18    /// Absolute path to the extracted package's `cabin.toml`.
19    pub manifest_path: PathBuf,
20}
21
22/// One patched package source. Like [`RegistryPackageSource`],
23/// the loader stitches the supplied `(name, version,
24/// manifest_path)` into the graph; unlike a registry entry, the
25/// resulting [`WorkspacePackage`] is tagged `kind = PackageKind::Local`
26/// because the user pointed Cabin at a local working copy. The
27/// orchestration layer in `cabin` filters the regular
28/// registry list so a patched name's only entry comes from
29/// `patches`.
30#[derive(Debug, Clone)]
31pub struct PatchedPackageSource {
32    pub name: PackageName,
33    pub version: semver::Version,
34    /// Absolute path to the patched package's `cabin.toml`.
35    pub manifest_path: PathBuf,
36}
37
38/// One foundation-port package source. Built by the CLI
39/// orchestration layer after [`cabin_port::prepare()`] materializes
40/// the port directory; the loader resolves a
41/// [`DependencySource::Port`] declaration to the matching entry
42/// here and inserts a [`WorkspacePackage`] tagged
43/// `kind = PackageKind::Local` (foundation ports are local
44/// development policy and never enter published metadata).
45#[derive(Debug, Clone)]
46pub struct PortPackageSource {
47    /// Authoritative identity declared by `port.toml`.
48    pub name: PackageName,
49    pub version: semver::Version,
50    /// Absolute path to the prepared port directory's overlay
51    /// `cabin.toml`. The workspace loader treats this as the
52    /// dep's `manifest_path`.
53    pub manifest_path: PathBuf,
54    /// How the recipe was located. Drives whether the dep
55    /// walker looks this entry up by canonical port directory
56    /// (`PortDir`) or by package name (`Builtin`).
57    pub origin: cabin_port::PortOrigin,
58}
59
60/// Load a workspace or a single package starting from the given manifest
61/// Path. Workspace members and local path dependencies are resolved
62/// recursively against the filesystem; a topologically-sorted
63/// [`PackageGraph`] is returned.
64///
65/// This is the convenience form for callers that only have local
66/// packages. For registry / patch / dev-dep policy, use
67/// [`load_workspace_with_options`].
68///
69/// # Errors
70/// Returns a [`WorkspaceError`] when loading fails — the manifest is
71/// missing or unreadable, contains neither `[package]` nor
72/// `[workspace]`, a workspace member or local path dependency cannot
73/// be resolved, package names collide, a dependency cycle is
74/// detected, or (because this runs with the strict port policy) a
75/// foundation-port dependency has not been prepared.
76pub fn load_workspace(manifest_path: impl AsRef<Path>) -> Result<PackageGraph, WorkspaceError> {
77    load_workspace_inner(
78        manifest_path,
79        &[],
80        &[],
81        &[],
82        &RegistryEnforcement::strict(),
83        &BTreeSet::new(),
84        &PortMode::Strict,
85    )
86}
87
88/// Load the workspace structure (members, profiles, package
89/// names) without resolving foundation-port dependency edges.
90///
91/// Use this for commands that only need workspace topology —
92/// `cabin clean`, `cabin package`, `cabin publish` — and that
93/// must run on fresh checkouts where no port archive has been
94/// downloaded yet. Port deps are dropped from the loaded graph
95/// (they never become [`DependencyEdge`]s) but the consuming
96/// packages still load normally; foundation-port packages
97/// themselves are simply absent from `graph.packages`.
98///
99/// # Errors
100/// Returns a [`WorkspaceError`] when loading fails — the manifest is
101/// missing or unreadable, contains neither `[package]` nor
102/// `[workspace]`, a workspace member or local path dependency cannot
103/// be resolved, package names collide, or a dependency cycle is
104/// detected. Because port edges are dropped, the
105/// port-not-prepared / port-directory-missing variants never apply.
106pub fn load_workspace_skip_ports(
107    manifest_path: impl AsRef<Path>,
108) -> Result<PackageGraph, WorkspaceError> {
109    load_workspace_inner(
110        manifest_path,
111        &[],
112        &[],
113        &[],
114        &RegistryEnforcement::strict(),
115        &BTreeSet::new(),
116        &PortMode::SkipAll,
117    )
118}
119
120/// Options bag for the workspace loader. Threads custom policy
121/// (registry / patches / ports / dev-dep activation) through a
122/// single call.
123#[derive(Debug, Clone)]
124pub struct WorkspaceLoadOptions<'a> {
125    /// Already-resolved registry package sources.
126    pub registry: &'a [RegistryPackageSource],
127    /// Active patches (resolved by `cabin-workspace::patch`).
128    pub patches: &'a [PatchedPackageSource],
129    /// Foundation ports that have already been prepared by
130    /// `cabin_port::prepare` (downloaded, checksum-verified,
131    /// safely extracted with `strip_prefix`, overlay applied).
132    /// The loader resolves a [`DependencySource::Port`]
133    /// declaration to the matching entry here.
134    pub ports: &'a [PortPackageSource],
135    /// How the loader treats a missing-registry edge: every parent
136    /// is strict by default; pre-resolution loads use
137    /// [`RegistryPolicy::StrictFor`] to scope enforcement (or
138    /// disable it with an empty set).
139    pub registry_policy: RegistryPolicy<'a>,
140    /// Names of packages whose `[dev-dependencies]` should be
141    /// loaded as real graph edges. Empty matches the
142    /// `cabin build` policy of treating dev-deps as
143    /// declaration-only; `cabin test` populates this with the
144    /// names of the test-running packages.
145    pub include_dev_for: &'a BTreeSet<String>,
146    /// How the loader resolves `DependencySource::Port` entries.
147    /// Defaults to [`PortPolicy::Strict`] — every port-dep must
148    /// be present in `ports` (and on disk, for `port-path`).
149    /// Callers that scope port preparation to a narrower
150    /// selection than the full primary-package set use
151    /// [`PortPolicy::TolerateExcept`] with the selected names
152    /// so siblings' missing ports are silently skipped while
153    /// selected packages still surface the typed
154    /// `PortDependencyNotPrepared` / `PortDirectoryMissing`
155    /// diagnostic.
156    pub port_policy: PortPolicy<'a>,
157}
158
159/// How the loader treats `DependencySource::Port` declarations
160/// from a [`WorkspaceLoadOptions`] call.
161#[derive(Debug, Clone, Default)]
162pub enum PortPolicy<'a> {
163    /// A port dep must be either a `port-path` directory on disk
164    /// plus present in `ports`, or a `port = true` name present in
165    /// `ports`. Anything else surfaces the typed
166    /// `PortDependencyNotPrepared` / `PortDirectoryMissing`
167    /// diagnostic. Default.
168    #[default]
169    Strict,
170    /// Tolerate missing port deps *except* for parent packages
171    /// whose names appear in this set — the caller's selected
172    /// closure. Names in the set still surface the typed
173    /// diagnostics; names outside the set silently skip the
174    /// missing edge.
175    ///
176    /// Passing an empty set tolerates every parent (legacy
177    /// "tolerate-all" behavior); pass a populated set to keep
178    /// selected packages strict while unselected siblings
179    /// tolerate.
180    TolerateExcept(&'a BTreeSet<String>),
181}
182
183/// How the loader treats a versioned dependency edge whose name is
184/// not present in `registry`. Pre-resolution loads (port discovery,
185/// `cabin metadata` fallback) carry no registry yet but may carry
186/// patches that contribute names to the loader's internal name map;
187/// the [`RegistryPolicy::StrictFor`] variant lets callers scope
188/// enforcement so the resolver-less paths don't surface bogus
189/// missing-registry diagnostics.
190#[derive(Debug, Clone, Default)]
191pub enum RegistryPolicy<'a> {
192    /// Every parent's registry deps must be present in `registry`.
193    /// Default. Used after the resolver has populated `registry`
194    /// with the closure's full pinned set.
195    #[default]
196    Strict,
197    /// Strict only for parents whose names appear in the set;
198    /// names outside silently skip a missing-registry edge.
199    /// Passing an empty set tolerates every parent — used by
200    /// pre-resolution loads.
201    StrictFor(&'a BTreeSet<String>),
202}
203
204/// Load the workspace with a single options bag. When
205/// `include_dev_for` is empty the loader follows the
206/// `cabin build` policy of treating dev-deps as
207/// declaration-only; with a non-empty set, listed packages
208/// contribute their `[dev-dependencies]` as real graph edges
209/// (path-deps are materialized, version-deps reach the
210/// resolver). Dev-deps still don't propagate transitively —
211/// only the listed packages activate them.
212///
213/// # Errors
214/// Returns a [`WorkspaceError`] when loading fails — covering the
215/// manifest, member-expansion, local-path, duplicate-name, and
216/// cycle failures of [`load_workspace`], plus the policy-driven
217/// variants this entry point enables: unresolved registry
218/// dependencies, registry-source name/version mismatches, and
219/// unprepared or missing foundation-port dependencies for parents
220/// the registry / port policy treats as strict.
221pub fn load_workspace_with_options(
222    manifest_path: impl AsRef<Path>,
223    options: &WorkspaceLoadOptions<'_>,
224) -> Result<PackageGraph, WorkspaceError> {
225    let policy = match &options.registry_policy {
226        RegistryPolicy::Strict => RegistryEnforcement::strict(),
227        RegistryPolicy::StrictFor(set) => RegistryEnforcement::scoped((*set).clone()),
228    };
229    let port_mode = match &options.port_policy {
230        PortPolicy::Strict => PortMode::Strict,
231        PortPolicy::TolerateExcept(strict) => PortMode::TolerateExcept((*strict).clone()),
232    };
233    load_workspace_inner(
234        manifest_path,
235        options.registry,
236        options.patches,
237        options.ports,
238        &policy,
239        options.include_dev_for,
240        &port_mode,
241    )
242}
243
244/// How strictly missing registry entries are enforced. Internal
245/// mirror of [`RegistryPolicy`] — public callers pick the policy via
246/// the enum; the loader collapses it to this owned form so the rest
247/// of the load path doesn't carry the lifetime parameter.
248#[derive(Debug, Clone)]
249struct RegistryEnforcement {
250    /// `Some` -> only enforce missing-registry for the listed
251    /// package names; `None` -> enforce for every package
252    /// (the strict default).
253    strict_packages: Option<BTreeSet<String>>,
254}
255
256impl RegistryEnforcement {
257    fn strict() -> Self {
258        Self {
259            strict_packages: None,
260        }
261    }
262
263    fn scoped(strict_packages: BTreeSet<String>) -> Self {
264        Self {
265            strict_packages: Some(strict_packages),
266        }
267    }
268
269    fn requires_registry_for(&self, parent_name: &str) -> bool {
270        match &self.strict_packages {
271            None => true,
272            Some(set) => set.contains(parent_name),
273        }
274    }
275}
276
277/// How the loader treats `DependencySource::Port` declarations.
278/// Internal mirror of [`PortPolicy`] that also models the
279/// "skip every port edge unconditionally" mode used by
280/// [`load_workspace_skip_ports`].
281#[derive(Debug, Clone)]
282enum PortMode {
283    /// Default: a port dep must be either a `port-path` directory
284    /// on disk + present in `ports`, or a `port = true` name
285    /// present in `ports`. Anything else surfaces the typed
286    /// `PortDependencyNotPrepared` / `PortDirectoryMissing`
287    /// diagnostic. Used by `load_workspace` /
288    /// `load_workspace_with_options` against the full
289    /// primary-package set.
290    Strict,
291    /// Drop every port-dep edge silently. Used by
292    /// [`load_workspace_skip_ports`] for commands that only need
293    /// workspace topology (`cabin clean`, `cabin package`,
294    /// `cabin publish`).
295    SkipAll,
296    /// Link present port deps as graph edges; silently skip ones
297    /// whose source is absent from `ports` (or whose port-path
298    /// directory is missing on disk) *except* for parents whose
299    /// names appear in this set — the caller's selected closure
300    /// still surfaces the typed diagnostics so a typoed
301    /// `port-path` in a selected package fails fast instead of
302    /// being silently dropped.
303    TolerateExcept(BTreeSet<String>),
304}
305
306fn load_workspace_inner(
307    manifest_path: impl AsRef<Path>,
308    registry: &[RegistryPackageSource],
309    patches: &[PatchedPackageSource],
310    ports: &[PortPackageSource],
311    policy: &RegistryEnforcement,
312    include_dev_for: &BTreeSet<String>,
313    port_mode: &PortMode,
314) -> Result<PackageGraph, WorkspaceError> {
315    let skip_port_edges = matches!(port_mode, PortMode::SkipAll);
316    let tolerate_strict_set: Option<&BTreeSet<String>> = match port_mode {
317        PortMode::TolerateExcept(set) => Some(set),
318        _ => None,
319    };
320    let manifest_path = canonicalize(manifest_path.as_ref())?;
321    let root_dir = manifest_path
322        .parent()
323        .ok_or_else(|| WorkspaceError::Io {
324            path: manifest_path.clone(),
325            source: std::io::Error::other("manifest path has no parent directory"),
326        })?
327        .to_path_buf();
328
329    let root_manifest = parse_manifest(&manifest_path)?;
330    if root_manifest.package.is_none() && root_manifest.workspace.is_none() {
331        return Err(WorkspaceError::EmptyManifest {
332            path: manifest_path,
333        });
334    }
335
336    // Target-conditional dep tables are evaluated against the
337    // host platform — Cabin does not yet support
338    // cross-compilation. Future steps may thread an explicit
339    // target context through this loader; for now the host is
340    // the single source of truth.
341    let host_platform = cabin_core::TargetPlatform::current();
342
343    let is_workspace_root = root_manifest.workspace.is_some();
344
345    let mut loader = Loader {
346        packages: Vec::new(),
347        manifest_index: HashMap::new(),
348    };
349
350    // Roots are the entry points whose path-deps we recursively follow
351    // and whose primary status we record. They are: the root manifest if
352    // it has a [package], and every workspace member.
353    let mut primary_manifest_paths: Vec<PathBuf> = Vec::new();
354
355    if root_manifest.package.is_some() {
356        primary_manifest_paths.push(manifest_path.clone());
357    }
358
359    // Workspace.default_members captured here so we can validate it
360    // against the resolved primary set after member expansion.
361    let mut workspace_default_members: Vec<String> = Vec::new();
362    // Workspace dependency tables captured up-front and parsed
363    // once. Member manifests with `dep = { workspace = true }`
364    // resolve against the table that matches their declared
365    // [`DependencyKind`] — `[workspace.dependencies]` for normal
366    // deps, `[workspace.dev-dependencies]` for dev deps.
367    // Each entry stores only the resolved `DependencySource` since
368    // the inheriting dep already knows its own kind.
369    let mut workspace_deps: BTreeMap<DependencyKind, BTreeMap<String, DependencySource>> =
370        BTreeMap::new();
371
372    let mut excluded_member_paths: Vec<PathBuf> = Vec::new();
373    if let Some(workspace) = &root_manifest.workspace {
374        let WorkspaceMembers { included, excluded } =
375            expand_workspace_members(&root_dir, &workspace.members, &workspace.exclude)?;
376        for canonical in included {
377            // reject nested workspaces. A member directory's
378            // `cabin.toml` must not declare its own `[workspace]`
379            // table, otherwise the load tries to honor two parent
380            // workspaces at once.
381            let parsed = parse_manifest(&canonical)?;
382            if parsed.workspace.is_some() {
383                return Err(WorkspaceError::NestedWorkspace { path: canonical });
384            }
385            primary_manifest_paths.push(canonical);
386        }
387        excluded_member_paths = excluded;
388        workspace_default_members.clone_from(&workspace.default_members);
389        for (kind, table) in [
390            (DependencyKind::Normal, &workspace.dependencies),
391            (DependencyKind::Dev, &workspace.dev_dependencies),
392        ] {
393            if table.is_empty() {
394                continue;
395            }
396            let entry = workspace_deps.entry(kind).or_default();
397            for (name, req) in table {
398                entry.insert(name.clone(), parse_workspace_dep_source(name, req)?);
399            }
400        }
401    }
402
403    // Build lookup maps for prepared foundation ports. The dep
404    // walker resolves `DependencySource::Port` declarations via
405    // one of two maps depending on the origin:
406    //   - PortDir: canonical port_dir -> prepared manifest_path
407    //   - Builtin: package name -> prepared manifest_path
408    // We canonicalize the port_dir up-front so the lookup is a
409    // single HashMap probe per dep — and so two consumers that
410    // reach the same port through different relative paths still
411    // see the same prepared source.
412    let mut port_by_canonical_dir: HashMap<PathBuf, PathBuf> = HashMap::new();
413    let mut port_by_name: HashMap<String, PathBuf> = HashMap::new();
414    let mut port_canonical_paths: HashSet<PathBuf> = HashSet::new();
415    for entry in ports {
416        match &entry.origin {
417            cabin_port::PortOrigin::PortDir(port_dir) => {
418                let port_dir_canonical = canonicalize(port_dir)?;
419                if let Some(previous) =
420                    port_by_canonical_dir.insert(port_dir_canonical, entry.manifest_path.clone())
421                {
422                    return Err(WorkspaceError::DuplicatePackageName {
423                        name: entry.name.as_str().to_owned(),
424                        first: previous,
425                        second: entry.manifest_path.clone(),
426                    });
427                }
428            }
429            cabin_port::PortOrigin::Builtin(name) => {
430                if let Some(previous) =
431                    port_by_name.insert((*name).to_owned(), entry.manifest_path.clone())
432                {
433                    return Err(WorkspaceError::DuplicatePackageName {
434                        name: entry.name.as_str().to_owned(),
435                        first: previous,
436                        second: entry.manifest_path.clone(),
437                    });
438                }
439            }
440        }
441        port_canonical_paths.insert(canonicalize(&entry.manifest_path)?);
442    }
443
444    // Build a name -> registry source map (canonicalizing paths so the
445    // dedup-by-canonical-path step below sees a consistent value), plus
446    // parallel maps of canonical registry manifest paths to expected
447    // (name, version) so loading can compare the actual manifest
448    // contents against what the resolver pinned.
449    let mut registry_by_name: HashMap<&str, PathBuf> = HashMap::new();
450    let mut registry_canonical_names: HashMap<PathBuf, &PackageName> = HashMap::new();
451    let mut registry_canonical_versions: HashMap<PathBuf, &semver::Version> = HashMap::new();
452    let mut registry_canonical_paths: HashSet<PathBuf> = HashSet::new();
453    let mut patch_canonical_paths: HashSet<PathBuf> = HashSet::new();
454    for entry in registry {
455        let canonical = canonicalize(&entry.manifest_path)?;
456        registry_by_name.insert(entry.name.as_str(), canonical.clone());
457        registry_canonical_names.insert(canonical.clone(), &entry.name);
458        registry_canonical_versions.insert(canonical.clone(), &entry.version);
459        registry_canonical_paths.insert(canonical);
460    }
461    // Patches contribute the same `(name, version, manifest_path)`
462    // information as registry entries but ultimately produce
463    // local-kind packages. Defensively reject overlap with the
464    // registry list so a caller bug never silently flips Local
465    // to Registry mid-graph.
466    for entry in patches {
467        let canonical = canonicalize(&entry.manifest_path)?;
468        if registry_canonical_paths.contains(&canonical) {
469            return Err(WorkspaceError::PatchConflictsWithRegistry {
470                package: entry.name.as_str().to_owned(),
471                path: canonical,
472            });
473        }
474        if let Some(existing) = registry_by_name.insert(entry.name.as_str(), canonical.clone()) {
475            return Err(WorkspaceError::DuplicatePackageName {
476                name: entry.name.as_str().to_owned(),
477                first: existing,
478                second: canonical,
479            });
480        }
481        registry_canonical_names.insert(canonical.clone(), &entry.name);
482        registry_canonical_versions.insert(canonical.clone(), &entry.version);
483        registry_canonical_paths.insert(canonical.clone());
484        patch_canonical_paths.insert(canonical);
485    }
486
487    // Recursively load every primary manifest plus any path deps it pulls
488    // in. The loader is iterative — we maintain a stack of unloaded
489    // manifests rather than recursing.
490    let mut to_load: Vec<PathBuf> = primary_manifest_paths.clone();
491    // Make registry packages part of the load set too; they are not
492    // primary, but they must appear in the package graph.
493    for entry in registry {
494        let canonical = canonicalize(&entry.manifest_path)?;
495        to_load.push(canonical);
496    }
497    // Patches are external manifests too; load them so the
498    // graph carries the patched `Package` value alongside the
499    // workspace members and registry entries.
500    for entry in patches {
501        let canonical = canonicalize(&entry.manifest_path)?;
502        to_load.push(canonical);
503    }
504    // Ports are also external manifests. They live in the
505    // foundation-port cache directory; load them so the graph
506    // carries the prepared overlay `Package` value alongside
507    // workspace members.
508    for entry in ports {
509        let canonical = canonicalize(&entry.manifest_path)?;
510        to_load.push(canonical);
511    }
512    let root_manifest_path = manifest_path.clone();
513    while let Some(manifest_path) = to_load.pop() {
514        if loader.manifest_index.contains_key(&manifest_path) {
515            continue;
516        }
517        let parsed = parse_manifest(&manifest_path)?;
518        let package = parsed.package.ok_or_else(|| {
519            // A path dependency that resolves to a workspace-only manifest.
520            WorkspaceError::LocalDependencyIsWorkspace {
521                dep_name: project_alias_for(&loader, &manifest_path),
522                path: manifest_path.clone(),
523            }
524        })?;
525
526        // `[profile.*]` tables are only honored on the entry-
527        // point manifest. Member and path-dep manifests that
528        // declare them surface a clear error rather than being
529        // silently ignored, so a single workspace key cannot
530        // mean different things in different members.
531        // Each of the per-table guards below moves `manifest_path`
532        // into the returned error because the function returns
533        // immediately on the error branch and the field carries
534        // the path verbatim; the borrow checker preserves it for
535        // the rest of the loop body via the early-return.
536        if manifest_path != root_manifest_path && !package.profiles.is_empty() {
537            return Err(WorkspaceError::MemberDeclaresProfiles {
538                package: package.name.as_str().to_owned(),
539                path: manifest_path,
540            });
541        }
542        if manifest_path != root_manifest_path && !package.toolchain.is_empty() {
543            return Err(WorkspaceError::MemberDeclaresToolchain {
544                package: package.name.as_str().to_owned(),
545                path: manifest_path,
546            });
547        }
548        if manifest_path != root_manifest_path && !package.compiler_wrapper.is_empty() {
549            return Err(WorkspaceError::MemberDeclaresCompilerWrapper {
550                package: package.name.as_str().to_owned(),
551                path: manifest_path,
552            });
553        }
554        if manifest_path != root_manifest_path && !package.patches.is_empty() {
555            return Err(WorkspaceError::MemberDeclaresPatches {
556                package: package.name.as_str().to_owned(),
557                path: manifest_path,
558            });
559        }
560
561        // If this manifest is a known registry package, the resolver
562        // pinned a specific (name, version). The artifact crate has
563        // already validated the manifest against that pin, but the
564        // workspace loader is the user-visible reporter, so we
565        // double-check here and surface a clear error if they ever
566        // disagree.
567        // validate both expected name and version. The
568        // registry may have pointed at a directory whose manifest
569        // declares a completely different package (a malicious or
570        // wrongly extracted artifact); refusing here keeps a wrong
571        // package from sneaking into the build graph.
572        if let Some(expected_version) = registry_canonical_versions.get(&manifest_path) {
573            let expected_name = registry_canonical_names.get(&manifest_path).copied();
574            let version_ok = &package.version == *expected_version;
575            let name_ok = expected_name.is_none_or(|n| n.as_str() == package.name.as_str());
576            if !name_ok {
577                return Err(WorkspaceError::RegistryPackageNameMismatch {
578                    name: expected_name
579                        .map(|n| n.as_str().to_owned())
580                        .unwrap_or_default(),
581                    actual_name: package.name.as_str().to_owned(),
582                    path: manifest_path.clone(),
583                });
584            }
585            if !version_ok {
586                return Err(WorkspaceError::RegistryPackageMismatch {
587                    name: expected_name
588                        .map(|n| n.as_str().to_owned())
589                        .unwrap_or_default(),
590                    version: expected_version.to_string(),
591                    actual_name: package.name.as_str().to_owned(),
592                    actual_version: package.version.to_string(),
593                    path: manifest_path.clone(),
594                });
595            }
596        }
597
598        let manifest_dir = manifest_path
599            .parent()
600            .expect("canonicalized manifest path has a parent")
601            .to_path_buf();
602
603        // rewrite each `{ workspace = true }` dep into the
604        // resolved source from `[workspace.dependencies]` before any
605        // other consumer sees it. We hold the rewritten `Package` in
606        // `resolved_project` and use it for the rest of this
607        // iteration.
608        let resolved_project = resolve_workspace_dependencies(package.clone(), &workspace_deps)?;
609        let package = resolved_project;
610
611        // Dev dependencies are declaration-only for ordinary
612        // commands but become real graph edges when the loader is
613        // told to "include dev for" this package — typically by
614        // `cabin test` for the test-running packages. The opt-in
615        // never propagates: a transitive dep's own dev-deps stay
616        // declaration-only.
617        let dev_active_for_this_pkg = include_dev_for.contains(package.name.as_str());
618        // A downloaded registry package is untrusted. The publish step
619        // rejects `path` and `port` dependencies (see cabin-package's
620        // `validate`), so a legitimately published package only ever depends
621        // on other packages by version. Enforce the same invariant on the
622        // consumer side: otherwise a malicious archive could ship a nested
623        // `path` sub-package, which the loader would classify as a trusted
624        // `PackageKind::Local` package and honor its compiler/linker flags —
625        // build-time code execution one dependency hop away.
626        //
627        // This must match the `PackageKind::Registry` classification below:
628        // patches and ports take precedence and stay `Local`, so a patched
629        // fork or port overlay that happens to replace a registry entry is
630        // still user-controlled and may legitimately declare path/port deps.
631        let parent_is_registry = registry_canonical_paths.contains(&manifest_path)
632            && !patch_canonical_paths.contains(&manifest_path)
633            && !port_canonical_paths.contains(&manifest_path);
634        let mut dep_paths: Vec<DepPath> = Vec::with_capacity(package.dependencies.len());
635        for dep in &package.dependencies {
636            // Skip dependencies that are not in this command's
637            // active-kind set. Dev deps remain inactive unless the
638            // owning package is in `include_dev_for`. System deps
639            // never reach this loop (they live on a separate
640            // `system_dependencies` list).
641            let kind_active = dep.kind.is_resolved_by_default()
642                || (dev_active_for_this_pkg && dep.kind == DependencyKind::Dev);
643            if !kind_active {
644                continue;
645            }
646            // Skip dependencies declared inside a non-matching
647            // `[target.'cfg(...)'.<kind>]` table. They stay on
648            // `package.dependencies` for metadata round-trip but
649            // never become package-graph edges or get loaded as
650            // path-dep sub-projects on this platform.
651            if !dep.matches_platform(&host_platform) {
652                continue;
653            }
654            if parent_is_registry {
655                match &dep.source {
656                    DependencySource::Path(_) => {
657                        return Err(WorkspaceError::RegistryPackageDeclaresPathDependency {
658                            package: package.name.as_str().to_owned(),
659                            dep_name: dep.name.as_str().to_owned(),
660                            path: manifest_path.clone(),
661                        });
662                    }
663                    DependencySource::Port(_) => {
664                        return Err(WorkspaceError::RegistryPackageDeclaresPortDependency {
665                            package: package.name.as_str().to_owned(),
666                            dep_name: dep.name.as_str().to_owned(),
667                            path: manifest_path.clone(),
668                        });
669                    }
670                    DependencySource::Version(_) | DependencySource::Workspace => {}
671                }
672            }
673            let canonical = match &dep.source {
674                DependencySource::Path(rel) => {
675                    let candidate = manifest_dir.join(rel).join("cabin.toml");
676                    if !candidate.is_file() {
677                        return Err(WorkspaceError::LocalDependencyManifestMissing {
678                            dep_name: dep.name.as_str().to_owned(),
679                            expected: candidate,
680                        });
681                    }
682                    canonicalize(&candidate)?
683                }
684                DependencySource::Port(PortDepSource::Path(rel)) => {
685                    if skip_port_edges {
686                        continue;
687                    }
688                    // Tolerate when the *parent* package is not in
689                    // the selected strict set: discovery skipped
690                    // unselected siblings on purpose, so their
691                    // missing port deps are expected. Selected
692                    // parents (or any parent when strict mode is
693                    // in effect) still surface the typed
694                    // diagnostics.
695                    let tolerate =
696                        tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
697                    let port_dir = manifest_dir.join(rel);
698                    if !port_dir.is_dir() {
699                        if tolerate {
700                            continue;
701                        }
702                        return Err(WorkspaceError::PortDirectoryMissing {
703                            dep_name: dep.name.as_str().to_owned(),
704                            parent: package.name.as_str().to_owned(),
705                            port_dir,
706                        });
707                    }
708                    let port_dir_canonical = canonicalize(&port_dir)?;
709                    if let Some(manifest_path) = port_by_canonical_dir.get(&port_dir_canonical) {
710                        canonicalize(manifest_path)?
711                    } else {
712                        if tolerate {
713                            continue;
714                        }
715                        return Err(WorkspaceError::PortDependencyNotPrepared {
716                            dep_name: dep.name.as_str().to_owned(),
717                            parent: package.name.as_str().to_owned(),
718                            port_dir: port_dir_canonical,
719                        });
720                    }
721                }
722                DependencySource::Port(PortDepSource::Builtin { name, .. }) => {
723                    if skip_port_edges {
724                        continue;
725                    }
726                    let tolerate =
727                        tolerate_strict_set.is_some_and(|set| !set.contains(package.name.as_str()));
728                    if let Some(manifest_path) = port_by_name.get(name.as_str()) {
729                        canonicalize(manifest_path)?
730                    } else {
731                        if tolerate {
732                            continue;
733                        }
734                        return Err(WorkspaceError::BuiltinPortDependencyNotPrepared {
735                            dep_name: dep.name.as_str().to_owned(),
736                            parent: package.name.as_str().to_owned(),
737                        });
738                    }
739                }
740                DependencySource::Version(_) => {
741                    // No registry context: keep the legacy behavior of
742                    // skipping versioned deps (used by `cabin metadata`
743                    // and `cabin resolve`, which don't materialize
744                    // sources).
745                    if registry_by_name.is_empty() {
746                        continue;
747                    }
748                    if let Some(path) = registry_by_name.get(dep.name.as_str()) {
749                        path.clone()
750                    } else {
751                        // a missing registry entry is
752                        // only an error when the *parent*
753                        // package is one the caller flagged as
754                        // strict (typically a member of the
755                        // selected closure). Unselected
756                        // workspace members can declare
757                        // versioned deps the current command
758                        // did not fetch, so we skip them
759                        // silently.
760                        if !policy.requires_registry_for(package.name.as_str()) {
761                            continue;
762                        }
763                        return Err(WorkspaceError::UnresolvedRegistryDependency {
764                            dep_name: dep.name.as_str().to_owned(),
765                            parent: package.name.as_str().to_owned(),
766                        });
767                    }
768                }
769                DependencySource::Workspace => {
770                    // Workspace inheritance is resolved up-front via
771                    // `resolve_workspace_dependencies`. A `Workspace`
772                    // source surviving this loop means the workspace
773                    // root did not declare the requested name in the
774                    // matching `[workspace.<kind>-dependencies]` table.
775                    return Err(WorkspaceError::UnresolvedWorkspaceDependency {
776                        dep_name: dep.name.as_str().to_owned(),
777                        parent: package.name.as_str().to_owned(),
778                        kind: dep.kind,
779                    });
780                }
781            };
782            dep_paths.push(DepPath {
783                name: dep.name.as_str().to_owned(),
784                path: canonical,
785                kind: dep.kind,
786                condition: dep.condition.clone(),
787            });
788        }
789
790        // Verify the dependency key matches the actual package name. We
791        // need to peek at the dep's manifest before fully loading it.
792        for DepPath {
793            name: dep_name,
794            path: dep_manifest_path,
795            ..
796        } in &dep_paths
797        {
798            let dep_parsed = parse_manifest(dep_manifest_path)?;
799            let actual = dep_parsed.package.as_ref().ok_or_else(|| {
800                WorkspaceError::LocalDependencyIsWorkspace {
801                    dep_name: dep_name.clone(),
802                    path: dep_manifest_path.clone(),
803                }
804            })?;
805            if actual.name.as_str() != dep_name {
806                return Err(WorkspaceError::DependencyNameMismatch {
807                    dep_name: dep_name.clone(),
808                    actual_name: actual.name.as_str().to_owned(),
809                    path: dep_manifest_path.clone(),
810                });
811            }
812        }
813
814        let index = loader.packages.len();
815        loader.manifest_index.insert(manifest_path.clone(), index);
816        loader.packages.push(LoadedPackage {
817            package,
818            manifest_path: manifest_path.clone(),
819            manifest_dir,
820            dep_paths,
821        });
822        for dep in &loader.packages[index].dep_paths {
823            to_load.push(dep.path.clone());
824        }
825    }
826
827    // Detect duplicate package names *across* the loader's packages
828    // (different filesystem paths, but the same `[package].name`).
829    {
830        let mut seen: HashMap<&str, &PathBuf> = HashMap::new();
831        for pkg in &loader.packages {
832            let name = pkg.package.name.as_str();
833            if let Some(prev) = seen.insert(name, &pkg.manifest_path) {
834                return Err(WorkspaceError::DuplicatePackageName {
835                    name: name.to_owned(),
836                    first: prev.clone(),
837                    second: pkg.manifest_path.clone(),
838                });
839            }
840        }
841    }
842
843    // Resolve dep edges (path -> index in loader.packages).
844    let mut packages: Vec<WorkspacePackage> = Vec::with_capacity(loader.packages.len());
845    for pkg in &loader.packages {
846        let mut deps = Vec::with_capacity(pkg.dep_paths.len());
847        for dep in &pkg.dep_paths {
848            let idx = *loader
849                .manifest_index
850                .get(&dep.path)
851                .expect("dep manifest should have been loaded");
852            deps.push(DependencyEdge {
853                index: idx,
854                kind: dep.kind,
855                condition: dep.condition.clone(),
856            });
857        }
858        let kind = if patch_canonical_paths.contains(&pkg.manifest_path) {
859            // Patches resolve to local working copies; treat them
860            // exactly like a path dep so downstream consumers
861            // (build planner, lockfile, metadata view) do not
862            // see a "registry" package that lives on the user's
863            // filesystem.
864            PackageKind::Local
865        } else if port_canonical_paths.contains(&pkg.manifest_path) {
866            // Foundation ports are local development policy; their
867            // prepared overlays live in the artifact cache but are
868            // not registry packages.
869            PackageKind::Local
870        } else if registry_canonical_paths.contains(&pkg.manifest_path) {
871            PackageKind::Registry
872        } else {
873            PackageKind::Local
874        };
875        packages.push(WorkspacePackage {
876            package: pkg.package.clone(),
877            manifest_path: pkg.manifest_path.clone(),
878            manifest_dir: pkg.manifest_dir.clone(),
879            deps,
880            kind,
881        });
882    }
883
884    let topo = topo_sort(&packages)?;
885
886    // Apply the topological permutation to the packages list and rewrite
887    // every dep index so it refers to the new, sorted positions.
888    let new_position: HashMap<usize, usize> = topo
889        .iter()
890        .enumerate()
891        .map(|(new_idx, &old_idx)| (old_idx, new_idx))
892        .collect();
893
894    let mut sorted: Vec<WorkspacePackage> = topo
895        .iter()
896        .map(|&old_idx| packages[old_idx].clone())
897        .collect();
898    for pkg in &mut sorted {
899        for edge in &mut pkg.deps {
900            edge.index = new_position[&edge.index];
901        }
902    }
903
904    let primary_packages: Vec<usize> = primary_manifest_paths
905        .iter()
906        .map(|p| {
907            let old_idx = loader.manifest_index[p];
908            new_position[&old_idx]
909        })
910        .collect();
911
912    let root_package = if root_manifest.package.is_some() {
913        Some(new_position[&loader.manifest_index[&manifest_path]])
914    } else {
915        None
916    };
917
918    // validate that every workspace.default-members entry
919    // resolves to a primary package, then map them to graph indices.
920    // The default order matches the manifest, with stable
921    // deduplication.
922    let mut default_members: Vec<usize> = Vec::new();
923    let mut seen_default: HashSet<usize> = HashSet::new();
924    for entry in &workspace_default_members {
925        // Same path-safety rules as members/exclude — reject
926        // absolute and `..` defaults before any filesystem walk.
927        validate_workspace_pattern("workspace.default-members", entry)?;
928        let dir = root_dir.join(entry);
929        let canonical_dir =
930            canonicalize(&dir).map_err(|_| WorkspaceError::DefaultMemberNotInMembers {
931                member: entry.clone(),
932            })?;
933        let manifest = canonical_dir.join("cabin.toml");
934        let idx = loader
935            .manifest_index
936            .get(&manifest)
937            .copied()
938            .ok_or_else(|| WorkspaceError::DefaultMemberNotInMembers {
939                member: entry.clone(),
940            })?;
941        let new_idx = new_position[&idx];
942        if !primary_packages.contains(&new_idx) {
943            return Err(WorkspaceError::DefaultMemberNotInMembers {
944                member: entry.clone(),
945            });
946        }
947        if seen_default.insert(new_idx) {
948            default_members.push(new_idx);
949        }
950    }
951
952    Ok(PackageGraph {
953        root_manifest_path: manifest_path,
954        root_dir,
955        is_workspace_root,
956        root_package,
957        root_settings: root_manifest.root_settings.into(),
958        primary_packages,
959        default_members,
960        excluded_members: excluded_member_paths,
961        packages: sorted,
962    })
963}
964
965struct Loader {
966    packages: Vec<LoadedPackage>,
967    /// Map canonical manifest path -> index in `packages`.
968    manifest_index: HashMap<PathBuf, usize>,
969}
970
971struct LoadedPackage {
972    package: cabin_core::Package,
973    manifest_path: PathBuf,
974    manifest_dir: PathBuf,
975    /// One entry per resolved dep edge: `(dep_name, canonical
976    /// manifest path, dependency kind, condition)`. Only kinds that
977    /// participate in ordinary resolution end up here; dev / system
978    /// deps are filtered out earlier.
979    dep_paths: Vec<DepPath>,
980}
981
982#[derive(Debug, Clone)]
983struct DepPath {
984    name: String,
985    path: PathBuf,
986    kind: cabin_core::DependencyKind,
987    /// Condition under which this edge was declared. `None`
988    /// for unconditional edges; the loader filters out
989    /// non-matching conditional edges before reaching this
990    /// point, so any value here matches the host platform.
991    condition: Option<cabin_core::Condition>,
992}
993
994/// Best-effort recovery of a friendly name to mention in the error when a
995/// Path dependency turns out to point at a workspace-only manifest. We
996/// don't always know what dep we were following, so this falls back to the
997/// Path itself.
998fn project_alias_for(loader: &Loader, manifest_path: &Path) -> String {
999    for pkg in &loader.packages {
1000        for dep in &pkg.dep_paths {
1001            if dep.path == manifest_path {
1002                return dep.name.clone();
1003            }
1004        }
1005    }
1006    manifest_path.display().to_string()
1007}
1008
1009fn parse_manifest(path: &Path) -> Result<ParsedManifest, WorkspaceError> {
1010    cabin_manifest::load_manifest(path).map_err(|source| WorkspaceError::Manifest {
1011        path: path.to_path_buf(),
1012        source: Box::new(source),
1013    })
1014}
1015
1016fn canonicalize(path: &Path) -> Result<PathBuf, WorkspaceError> {
1017    std::fs::canonicalize(path).map_err(|source| classify_manifest_io(path, source))
1018}
1019
1020/// Classify an I/O error from a load-time `canonicalize` call.
1021/// `NotFound` becomes the dedicated [`WorkspaceError::ManifestNotFound`]
1022/// variant so the diagnostic layer can emit a structured report with
1023/// help text. Everything else maps to
1024/// [`WorkspaceError::ManifestUnreadable`] (permission denied, the
1025/// path is a directory, …).
1026fn classify_manifest_io(path: &Path, source: std::io::Error) -> WorkspaceError {
1027    match source.kind() {
1028        std::io::ErrorKind::NotFound => WorkspaceError::ManifestNotFound {
1029            path: path.to_path_buf(),
1030        },
1031        _ => WorkspaceError::ManifestUnreadable {
1032            path: path.to_path_buf(),
1033            source,
1034        },
1035    }
1036}
1037
1038/// Expansion result for `[workspace.members]` /
1039/// `[workspace.exclude]`. `included` is a sorted, deduplicated list
1040/// of canonical manifest paths. `excluded` is the list of relative
1041/// paths (under `workspace_dir`) the loader removed from the
1042/// candidate set, surfaced for metadata.
1043struct WorkspaceMembers {
1044    included: Vec<PathBuf>,
1045    excluded: Vec<PathBuf>,
1046}
1047
1048fn expand_workspace_members(
1049    workspace_dir: &Path,
1050    members: &[String],
1051    exclude: &[String],
1052) -> Result<WorkspaceMembers, WorkspaceError> {
1053    // Expand member patterns. Membership is tracked by canonicalized
1054    // directory path so two patterns matching the same dir collapse
1055    // to one entry.
1056    let mut included: BTreeSet<PathBuf> = BTreeSet::new();
1057    for pattern in members {
1058        let dirs = expand_member_pattern(workspace_dir, pattern)?;
1059        for dir in dirs {
1060            let manifest = dir.join("cabin.toml");
1061            if !manifest.is_file() {
1062                return Err(WorkspaceError::WorkspaceMemberMissing {
1063                    pattern: pattern.clone(),
1064                    root: workspace_dir.to_path_buf(),
1065                });
1066            }
1067            let canonical_dir = canonicalize(&dir)?;
1068            included.insert(canonical_dir);
1069        }
1070    }
1071
1072    // Expand exclude patterns. Globs are best-effort: an exclude
1073    // pattern need not match any directory that contains a cabin.toml
1074    // (a partial match such as `third_party/*` covering some
1075    // subdirectories without manifests is fine), but the pattern as a
1076    // whole must hit at least one entry already in the member set so
1077    // typos surface.
1078    let mut excluded: BTreeSet<PathBuf> = BTreeSet::new();
1079    let canonical_root = canonicalize(workspace_dir)?;
1080    for pattern in exclude {
1081        if pattern.is_empty() {
1082            return Err(WorkspaceError::UnsupportedWorkspacePattern {
1083                pattern: pattern.clone(),
1084            });
1085        }
1086        let dirs = expand_exclude_pattern(workspace_dir, pattern)?;
1087        let mut hit_any = false;
1088        for dir in dirs {
1089            // We only canonicalize existing dirs; missing exclude
1090            // dirs collapse to no-op without erroring (the pattern
1091            // itself may have legitimately hit non-package
1092            // directories).
1093            if !dir.is_dir() {
1094                continue;
1095            }
1096            let Ok(canonical_dir) = canonicalize(&dir) else {
1097                continue;
1098            };
1099            if included.remove(&canonical_dir) {
1100                hit_any = true;
1101                if let Ok(rel) = canonical_dir.strip_prefix(&canonical_root) {
1102                    excluded.insert(rel.to_path_buf());
1103                } else {
1104                    excluded.insert(canonical_dir.clone());
1105                }
1106            }
1107        }
1108        if !hit_any {
1109            return Err(WorkspaceError::UnusedExcludePattern {
1110                pattern: pattern.clone(),
1111                root: workspace_dir.to_path_buf(),
1112            });
1113        }
1114    }
1115
1116    // Convert the surviving directories to canonical manifest paths.
1117    let mut out: Vec<PathBuf> = Vec::with_capacity(included.len());
1118    for dir in &included {
1119        let manifest = dir.join("cabin.toml");
1120        out.push(canonicalize(&manifest)?);
1121    }
1122    out.sort();
1123    let excluded_paths: Vec<PathBuf> = excluded.into_iter().collect();
1124    Ok(WorkspaceMembers {
1125        included: out,
1126        excluded: excluded_paths,
1127    })
1128}
1129
1130/// Resolve every `DependencySource::Workspace` entry on
1131/// `package` by looking it up in the workspace table that matches
1132/// each entry's [`DependencyKind`]. Returns a `Package` whose
1133/// dependencies are entirely `Path` or `Version`. References that
1134/// have no matching workspace entry are surfaced as a clear
1135/// kind-aware error.
1136fn resolve_workspace_dependencies(
1137    mut package: cabin_core::Package,
1138    workspace_deps: &BTreeMap<DependencyKind, BTreeMap<String, DependencySource>>,
1139) -> Result<cabin_core::Package, WorkspaceError> {
1140    for dep in &mut package.dependencies {
1141        if !matches!(dep.source, DependencySource::Workspace) {
1142            continue;
1143        }
1144        let table = workspace_deps.get(&dep.kind);
1145        let resolved = table
1146            .and_then(|t| t.get(dep.name.as_str()))
1147            .ok_or_else(|| WorkspaceError::UnresolvedWorkspaceDependency {
1148                dep_name: dep.name.as_str().to_owned(),
1149                parent: package.name.as_str().to_owned(),
1150                kind: dep.kind,
1151            })?;
1152        dep.source = resolved.clone();
1153    }
1154    Ok(package)
1155}
1156
1157/// Parse a `[workspace.<kind>-dependencies]` value into a
1158/// `DependencySource`. Uses the existing manifest-side parser so
1159/// requirement-string handling stays a single source of truth.
1160fn parse_workspace_dep_source(name: &str, req: &str) -> Result<DependencySource, WorkspaceError> {
1161    // Wrap the raw requirement in a tiny manifest to reuse the
1162    // existing dependency parser. We round-trip through the
1163    // manifest crate so error messages mention the dependency name
1164    // and the failing requirement consistently.
1165    let manifest = format!(
1166        "[package]\nname = \"__workspace_root__\"\nversion = \"0.0.0\"\n[dependencies]\n{name} = \"{}\"\n",
1167        req.replace('"', "\\\""),
1168    );
1169    let parsed = cabin_manifest::parse_manifest_str(&manifest).map_err(|source| {
1170        WorkspaceError::InvalidWorkspaceDependency {
1171            name: name.to_owned(),
1172            source: Box::new(source),
1173        }
1174    })?;
1175    let package = parsed
1176        .package
1177        .expect("inline manifest always has [package]");
1178    let dep = package
1179        .dependencies
1180        .into_iter()
1181        .next()
1182        .expect("inline manifest declared exactly one dependency");
1183    Ok(dep.source)
1184}
1185
1186/// Reject workspace patterns that escape the workspace
1187/// root or that use absolute paths. Applied to every `members`,
1188/// `exclude`, and `default-members` entry so an unsafe pattern
1189/// fails fast with a clear error before any filesystem walk.
1190fn validate_workspace_pattern(field: &'static str, pattern: &str) -> Result<(), WorkspaceError> {
1191    if pattern.is_empty() {
1192        return Err(WorkspaceError::UnsupportedWorkspacePattern {
1193            pattern: pattern.to_owned(),
1194        });
1195    }
1196    let p = std::path::Path::new(pattern);
1197    if p.is_absolute() {
1198        return Err(WorkspaceError::WorkspacePatternEscapesRoot {
1199            field,
1200            pattern: pattern.to_owned(),
1201        });
1202    }
1203    for component in p.components() {
1204        if matches!(
1205            component,
1206            std::path::Component::ParentDir | std::path::Component::Prefix(_)
1207        ) {
1208            return Err(WorkspaceError::WorkspacePatternEscapesRoot {
1209                field,
1210                pattern: pattern.to_owned(),
1211            });
1212        }
1213    }
1214    Ok(())
1215}
1216
1217/// Resolve a `[workspace.members]` pattern to a list of directories
1218/// containing `cabin.toml`. The supported syntaxes are:
1219///
1220/// - exact relative path (`tools/hello`)
1221/// - single-`*` glob in the final component (`packages/*`)
1222fn expand_member_pattern(
1223    workspace_dir: &Path,
1224    pattern: &str,
1225) -> Result<Vec<PathBuf>, WorkspaceError> {
1226    validate_workspace_pattern("workspace.members", pattern)?;
1227
1228    if !pattern.contains('*') {
1229        let dir = workspace_dir.join(pattern);
1230        return Ok(vec![dir]);
1231    }
1232
1233    // Single trailing `/*` only.
1234    let Some(trimmed) = pattern.strip_suffix("/*") else {
1235        return Err(WorkspaceError::UnsupportedWorkspacePattern {
1236            pattern: pattern.to_owned(),
1237        });
1238    };
1239    if trimmed.contains('*') {
1240        return Err(WorkspaceError::UnsupportedWorkspacePattern {
1241            pattern: pattern.to_owned(),
1242        });
1243    }
1244
1245    let prefix_dir = if trimmed.is_empty() {
1246        workspace_dir.to_path_buf()
1247    } else {
1248        workspace_dir.join(trimmed)
1249    };
1250    if !prefix_dir.is_dir() {
1251        return Err(WorkspaceError::WorkspaceMemberMissing {
1252            pattern: pattern.to_owned(),
1253            root: workspace_dir.to_path_buf(),
1254        });
1255    }
1256
1257    let entries = std::fs::read_dir(&prefix_dir).map_err(|source| WorkspaceError::Io {
1258        path: prefix_dir.clone(),
1259        source,
1260    })?;
1261    let mut out = Vec::new();
1262    for entry in entries {
1263        let entry = entry.map_err(|source| WorkspaceError::Io {
1264            path: prefix_dir.clone(),
1265            source,
1266        })?;
1267        let path = entry.path();
1268        if path.is_dir() && path.join("cabin.toml").is_file() {
1269            out.push(path);
1270        }
1271    }
1272    if out.is_empty() {
1273        return Err(WorkspaceError::WorkspaceMemberMissing {
1274            pattern: pattern.to_owned(),
1275            root: workspace_dir.to_path_buf(),
1276        });
1277    }
1278    out.sort();
1279    Ok(out)
1280}
1281
1282/// Resolve a `[workspace.exclude]` pattern. Same grammar as
1283/// `expand_member_pattern`, but more lenient about empty matches:
1284/// The pattern may legitimately match directories that do not
1285/// contain a `cabin.toml`. The caller validates that the overall
1286/// pattern hit at least one declared member.
1287fn expand_exclude_pattern(
1288    workspace_dir: &Path,
1289    pattern: &str,
1290) -> Result<Vec<PathBuf>, WorkspaceError> {
1291    validate_workspace_pattern("workspace.exclude", pattern)?;
1292
1293    if !pattern.contains('*') {
1294        return Ok(vec![workspace_dir.join(pattern)]);
1295    }
1296
1297    let Some(trimmed) = pattern.strip_suffix("/*") else {
1298        return Err(WorkspaceError::UnsupportedWorkspacePattern {
1299            pattern: pattern.to_owned(),
1300        });
1301    };
1302    if trimmed.contains('*') {
1303        return Err(WorkspaceError::UnsupportedWorkspacePattern {
1304            pattern: pattern.to_owned(),
1305        });
1306    }
1307
1308    let prefix_dir = if trimmed.is_empty() {
1309        workspace_dir.to_path_buf()
1310    } else {
1311        workspace_dir.join(trimmed)
1312    };
1313    if !prefix_dir.is_dir() {
1314        return Ok(Vec::new());
1315    }
1316
1317    let entries = std::fs::read_dir(&prefix_dir).map_err(|source| WorkspaceError::Io {
1318        path: prefix_dir.clone(),
1319        source,
1320    })?;
1321    let mut out = Vec::new();
1322    for entry in entries {
1323        let entry = entry.map_err(|source| WorkspaceError::Io {
1324            path: prefix_dir.clone(),
1325            source,
1326        })?;
1327        let path = entry.path();
1328        if path.is_dir() {
1329            out.push(path);
1330        }
1331    }
1332    out.sort();
1333    Ok(out)
1334}
1335
1336fn topo_sort(packages: &[WorkspacePackage]) -> Result<Vec<usize>, WorkspaceError> {
1337    #[derive(Clone, Copy)]
1338    enum Color {
1339        Visiting,
1340        Done,
1341    }
1342
1343    fn visit(
1344        node: usize,
1345        packages: &[WorkspacePackage],
1346        state: &mut Vec<Option<Color>>,
1347        path: &mut Vec<usize>,
1348        order: &mut Vec<usize>,
1349    ) -> Result<(), WorkspaceError> {
1350        match state[node] {
1351            Some(Color::Done) => return Ok(()),
1352            Some(Color::Visiting) => {
1353                let start = path.iter().position(|n| *n == node).unwrap_or(0);
1354                let mut cycle: Vec<String> = path[start..]
1355                    .iter()
1356                    .map(|i| packages[*i].package.name.as_str().to_owned())
1357                    .collect();
1358                cycle.push(packages[node].package.name.as_str().to_owned());
1359                return Err(WorkspaceError::PackageDependencyCycle(cycle));
1360            }
1361            None => {}
1362        }
1363        state[node] = Some(Color::Visiting);
1364        path.push(node);
1365        for edge in &packages[node].deps {
1366            visit(edge.index, packages, state, path, order)?;
1367        }
1368        path.pop();
1369        state[node] = Some(Color::Done);
1370        order.push(node);
1371        Ok(())
1372    }
1373
1374    let mut state: Vec<Option<Color>> = vec![None; packages.len()];
1375    let mut order = Vec::with_capacity(packages.len());
1376    let mut path = Vec::new();
1377
1378    // Visit packages in their original (insertion) order so the output is
1379    // deterministic for inputs that don't fully order themselves.
1380    for i in 0..packages.len() {
1381        if state[i].is_none() {
1382            visit(i, packages, &mut state, &mut path, &mut order)?;
1383        }
1384    }
1385    Ok(order)
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390    use super::*;
1391    use assert_fs::TempDir;
1392    use assert_fs::prelude::*;
1393
1394    #[test]
1395    fn loads_single_package_with_no_deps() {
1396        let dir = TempDir::new().unwrap();
1397        dir.child("cabin.toml")
1398            .write_str(
1399                r#"[package]
1400name = "solo"
1401version = "0.1.0"
1402"#,
1403            )
1404            .unwrap();
1405        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1406        assert!(!graph.is_workspace_root);
1407        assert_eq!(graph.packages.len(), 1);
1408        assert_eq!(graph.packages[0].package.name.as_str(), "solo");
1409        assert_eq!(graph.packages[0].deps.len(), 0);
1410        assert_eq!(graph.primary_packages, vec![0]);
1411        assert_eq!(graph.root_package, Some(0));
1412    }
1413
1414    #[test]
1415    fn loads_package_with_local_path_dep() {
1416        let dir = TempDir::new().unwrap();
1417        dir.child("greet/cabin.toml")
1418            .write_str(
1419                r#"[package]
1420name = "greet"
1421version = "0.1.0"
1422"#,
1423            )
1424            .unwrap();
1425        dir.child("app/cabin.toml")
1426            .write_str(
1427                r#"[package]
1428name = "app"
1429version = "0.1.0"
1430
1431[dependencies]
1432greet = { path = "../greet" }
1433"#,
1434            )
1435            .unwrap();
1436        let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
1437        assert_eq!(graph.packages.len(), 2);
1438        // greet must come before app in topological order.
1439        assert_eq!(graph.packages[0].package.name.as_str(), "greet");
1440        assert_eq!(graph.packages[1].package.name.as_str(), "app");
1441        assert_eq!(
1442            graph.packages[1]
1443                .deps
1444                .iter()
1445                .map(|e| (e.index, e.kind))
1446                .collect::<Vec<_>>(),
1447            vec![(0, DependencyKind::Normal)]
1448        );
1449        assert_eq!(graph.primary_packages, vec![1]);
1450    }
1451
1452    #[test]
1453    fn loads_transitive_local_path_deps() {
1454        let dir = TempDir::new().unwrap();
1455        dir.child("c/cabin.toml")
1456            .write_str(
1457                r#"[package]
1458name = "c"
1459version = "0.1.0"
1460"#,
1461            )
1462            .unwrap();
1463        dir.child("b/cabin.toml")
1464            .write_str(
1465                r#"[package]
1466name = "b"
1467version = "0.1.0"
1468
1469[dependencies]
1470c = { path = "../c" }
1471"#,
1472            )
1473            .unwrap();
1474        dir.child("a/cabin.toml")
1475            .write_str(
1476                r#"[package]
1477name = "a"
1478version = "0.1.0"
1479
1480[dependencies]
1481b = { path = "../b" }
1482"#,
1483            )
1484            .unwrap();
1485        let graph = load_workspace(dir.path().join("a/cabin.toml")).unwrap();
1486        assert_eq!(graph.packages.len(), 3);
1487        let names: Vec<&str> = graph
1488            .packages
1489            .iter()
1490            .map(|p| p.package.name.as_str())
1491            .collect();
1492        // Topo order: c before b before a.
1493        let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
1494        assert!(pos("c") < pos("b"));
1495        assert!(pos("b") < pos("a"));
1496    }
1497
1498    #[test]
1499    fn detects_package_cycle() {
1500        let dir = TempDir::new().unwrap();
1501        dir.child("a/cabin.toml")
1502            .write_str(
1503                r#"[package]
1504name = "a"
1505version = "0.1.0"
1506
1507[dependencies]
1508b = { path = "../b" }
1509"#,
1510            )
1511            .unwrap();
1512        dir.child("b/cabin.toml")
1513            .write_str(
1514                r#"[package]
1515name = "b"
1516version = "0.1.0"
1517
1518[dependencies]
1519a = { path = "../a" }
1520"#,
1521            )
1522            .unwrap();
1523        let err = load_workspace(dir.path().join("a/cabin.toml")).unwrap_err();
1524        match err {
1525            WorkspaceError::PackageDependencyCycle(cycle) => {
1526                assert_eq!(cycle.first(), cycle.last());
1527                assert!(cycle.contains(&"a".to_owned()));
1528                assert!(cycle.contains(&"b".to_owned()));
1529            }
1530            other => panic!("expected PackageDependencyCycle, got {other:?}"),
1531        }
1532    }
1533
1534    #[test]
1535    fn loads_workspace_with_exact_member_path() {
1536        let dir = TempDir::new().unwrap();
1537        dir.child("cabin.toml")
1538            .write_str(
1539                r#"[workspace]
1540members = ["packages/greet"]
1541"#,
1542            )
1543            .unwrap();
1544        dir.child("packages/greet/cabin.toml")
1545            .write_str(
1546                r#"[package]
1547name = "greet"
1548version = "0.1.0"
1549"#,
1550            )
1551            .unwrap();
1552        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1553        assert!(graph.is_workspace_root);
1554        assert!(graph.root_package.is_none());
1555        assert_eq!(graph.packages.len(), 1);
1556        assert_eq!(graph.packages[0].package.name.as_str(), "greet");
1557    }
1558
1559    #[test]
1560    fn pure_workspace_root_policy_is_available_on_graph() {
1561        let dir = TempDir::new().unwrap();
1562        dir.child("cabin.toml")
1563            .write_str(
1564                r#"[workspace]
1565members = ["packages/greet"]
1566
1567[profile.release]
1568opt-level = 0
1569
1570[toolchain]
1571cxx = "clang++"
1572
1573[profile.cache]
1574compiler-wrapper = "ccache"
1575
1576[patch]
1577fmt = { path = "../fmt" }
1578"#,
1579            )
1580            .unwrap();
1581        dir.child("packages/greet/cabin.toml")
1582            .write_str(
1583                r#"[package]
1584name = "greet"
1585version = "0.1.0"
1586"#,
1587            )
1588            .unwrap();
1589        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1590        assert!(graph.is_workspace_root);
1591        assert!(graph.root_package.is_none());
1592
1593        let release = cabin_core::ProfileName::new("release").unwrap();
1594        assert_eq!(
1595            graph
1596                .root_settings
1597                .profiles
1598                .get(&release)
1599                .and_then(|p| p.opt_level),
1600            Some(cabin_core::OptLevel::O0)
1601        );
1602        assert_eq!(
1603            graph
1604                .root_settings
1605                .toolchain
1606                .general
1607                .get(cabin_core::ToolKind::CxxCompiler)
1608                .map(cabin_core::ToolSpec::display)
1609                .as_deref(),
1610            Some("clang++")
1611        );
1612        assert_eq!(
1613            graph.root_settings.compiler_wrapper.general,
1614            Some(cabin_core::CompilerWrapperRequest::Use {
1615                wrapper: cabin_core::CompilerWrapperKind::Ccache,
1616            })
1617        );
1618        assert_eq!(graph.root_settings.patches.entries.len(), 1);
1619    }
1620
1621    #[test]
1622    fn loads_workspace_with_glob_member_pattern() {
1623        let dir = TempDir::new().unwrap();
1624        dir.child("cabin.toml")
1625            .write_str(
1626                r#"[workspace]
1627members = ["packages/*"]
1628"#,
1629            )
1630            .unwrap();
1631        dir.child("packages/a/cabin.toml")
1632            .write_str(
1633                r#"[package]
1634name = "a"
1635version = "0.1.0"
1636"#,
1637            )
1638            .unwrap();
1639        dir.child("packages/b/cabin.toml")
1640            .write_str(
1641                r#"[package]
1642name = "b"
1643version = "0.1.0"
1644"#,
1645            )
1646            .unwrap();
1647        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
1648        assert_eq!(graph.packages.len(), 2);
1649        let names: Vec<&str> = graph
1650            .packages
1651            .iter()
1652            .map(|p| p.package.name.as_str())
1653            .collect();
1654        assert!(names.contains(&"a"));
1655        assert!(names.contains(&"b"));
1656    }
1657
1658    #[test]
1659    fn rejects_duplicate_package_names_in_workspace() {
1660        let dir = TempDir::new().unwrap();
1661        dir.child("cabin.toml")
1662            .write_str(
1663                r#"[workspace]
1664members = ["packages/*"]
1665"#,
1666            )
1667            .unwrap();
1668        dir.child("packages/a/cabin.toml")
1669            .write_str(
1670                r#"[package]
1671name = "shared"
1672version = "0.1.0"
1673"#,
1674            )
1675            .unwrap();
1676        dir.child("packages/b/cabin.toml")
1677            .write_str(
1678                r#"[package]
1679name = "shared"
1680version = "0.2.0"
1681"#,
1682            )
1683            .unwrap();
1684        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
1685        match err {
1686            WorkspaceError::DuplicatePackageName { name, .. } => assert_eq!(name, "shared"),
1687            other => panic!("expected DuplicatePackageName, got {other:?}"),
1688        }
1689    }
1690
1691    #[test]
1692    fn missing_local_dependency_manifest_errors() {
1693        let dir = TempDir::new().unwrap();
1694        dir.child("app/cabin.toml")
1695            .write_str(
1696                r#"[package]
1697name = "app"
1698version = "0.1.0"
1699
1700[dependencies]
1701greet = { path = "../greet" }
1702"#,
1703            )
1704            .unwrap();
1705        let err = load_workspace(dir.path().join("app/cabin.toml")).unwrap_err();
1706        assert!(matches!(
1707            err,
1708            WorkspaceError::LocalDependencyManifestMissing { .. }
1709        ));
1710    }
1711
1712    #[test]
1713    fn dependency_name_mismatch_errors() {
1714        let dir = TempDir::new().unwrap();
1715        dir.child("greet/cabin.toml")
1716            .write_str(
1717                r#"[package]
1718name = "actually-hello"
1719version = "0.1.0"
1720"#,
1721            )
1722            .unwrap();
1723        dir.child("app/cabin.toml")
1724            .write_str(
1725                r#"[package]
1726name = "app"
1727version = "0.1.0"
1728
1729[dependencies]
1730greet = { path = "../greet" }
1731"#,
1732            )
1733            .unwrap();
1734        let err = load_workspace(dir.path().join("app/cabin.toml")).unwrap_err();
1735        match err {
1736            WorkspaceError::DependencyNameMismatch {
1737                dep_name,
1738                actual_name,
1739                ..
1740            } => {
1741                assert_eq!(dep_name, "greet");
1742                assert_eq!(actual_name, "actually-hello");
1743            }
1744            other => panic!("expected DependencyNameMismatch, got {other:?}"),
1745        }
1746    }
1747
1748    #[test]
1749    fn versioned_dependencies_are_preserved_but_not_traversed() {
1750        let dir = TempDir::new().unwrap();
1751        dir.child("app/cabin.toml")
1752            .write_str(
1753                r#"[package]
1754name = "app"
1755version = "0.1.0"
1756
1757[dependencies]
1758fmt = ">=10.0.0 <11.0.0"
1759"#,
1760            )
1761            .unwrap();
1762        let graph = load_workspace(dir.path().join("app/cabin.toml")).unwrap();
1763        // Only the root package is loaded — versioned deps don't pull in
1764        // any local manifests.
1765        assert_eq!(graph.packages.len(), 1);
1766        let app = &graph.packages[0];
1767        assert!(app.deps.is_empty());
1768        // But the Package still records the declared dependency.
1769        assert_eq!(app.package.dependencies.len(), 1);
1770        assert_eq!(app.package.dependencies[0].name.as_str(), "fmt");
1771        assert!(matches!(
1772            &app.package.dependencies[0].source,
1773            cabin_core::DependencySource::Version(_)
1774        ));
1775    }
1776
1777    #[test]
1778    fn unsupported_glob_pattern_errors() {
1779        let dir = TempDir::new().unwrap();
1780        dir.child("cabin.toml")
1781            .write_str(
1782                r#"[workspace]
1783members = ["packages/*/foo"]
1784"#,
1785            )
1786            .unwrap();
1787        dir.child("packages/a/foo/cabin.toml")
1788            .write_str(
1789                r#"[package]
1790name = "a"
1791version = "0.1.0"
1792"#,
1793            )
1794            .unwrap();
1795        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
1796        assert!(matches!(
1797            err,
1798            WorkspaceError::UnsupportedWorkspacePattern { .. }
1799        ));
1800    }
1801
1802    #[test]
1803    fn missing_workspace_member_errors() {
1804        let dir = TempDir::new().unwrap();
1805        dir.child("cabin.toml")
1806            .write_str(
1807                r#"[workspace]
1808members = ["packages/missing"]
1809"#,
1810            )
1811            .unwrap();
1812        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
1813        assert!(matches!(err, WorkspaceError::WorkspaceMemberMissing { .. }));
1814    }
1815
1816    // -------------------------------------------------------------------
1817    // registry package integration
1818    // -------------------------------------------------------------------
1819
1820    fn pkg(name: &str) -> PackageName {
1821        PackageName::new(name).unwrap()
1822    }
1823
1824    fn ver(s: &str) -> semver::Version {
1825        semver::Version::parse(s).unwrap()
1826    }
1827
1828    #[test]
1829    fn loads_registry_package_via_versioned_dep() {
1830        let dir = TempDir::new().unwrap();
1831        // Root depends on `fmt` versionally.
1832        dir.child("app/cabin.toml")
1833            .write_str(
1834                r#"[package]
1835name = "app"
1836version = "0.1.0"
1837
1838[dependencies]
1839fmt = ">=10.0.0 <11.0.0"
1840"#,
1841            )
1842            .unwrap();
1843        // Registry "extracted source" lives in a sibling directory.
1844        dir.child("registry/fmt/cabin.toml")
1845            .write_str(
1846                r#"[package]
1847name = "fmt"
1848version = "10.2.1"
1849"#,
1850            )
1851            .unwrap();
1852        let registry = vec![RegistryPackageSource {
1853            name: pkg("fmt"),
1854            version: ver("10.2.1"),
1855            manifest_path: dir.path().join("registry/fmt/cabin.toml"),
1856        }];
1857        let graph = load_workspace_with_options(
1858            dir.path().join("app/cabin.toml"),
1859            &WorkspaceLoadOptions {
1860                registry: &registry,
1861                patches: &[],
1862                ports: &[],
1863                registry_policy: RegistryPolicy::Strict,
1864                include_dev_for: &BTreeSet::new(),
1865                port_policy: PortPolicy::Strict,
1866            },
1867        )
1868        .unwrap();
1869        assert_eq!(graph.packages.len(), 2);
1870        // Topological order: fmt before app.
1871        assert_eq!(graph.packages[0].package.name.as_str(), "fmt");
1872        assert_eq!(graph.packages[0].kind, PackageKind::Registry);
1873        assert_eq!(graph.packages[1].package.name.as_str(), "app");
1874        assert_eq!(graph.packages[1].kind, PackageKind::Local);
1875        // Only `app` is primary.
1876        assert_eq!(graph.primary_packages, vec![1]);
1877        // The dep edge is recorded so cabin-build can resolve target deps.
1878        let edges: Vec<(usize, DependencyKind)> = graph.packages[1]
1879            .deps
1880            .iter()
1881            .map(|e| (e.index, e.kind))
1882            .collect();
1883        assert_eq!(edges, vec![(0, DependencyKind::Normal)]);
1884    }
1885
1886    #[test]
1887    fn registry_package_declaring_path_dependency_is_rejected() {
1888        let dir = TempDir::new().unwrap();
1889        dir.child("app/cabin.toml")
1890            .write_str(
1891                r#"[package]
1892name = "app"
1893version = "0.1.0"
1894
1895[dependencies]
1896evil = ">=1.0.0 <2.0.0"
1897"#,
1898            )
1899            .unwrap();
1900        // A malicious registry archive ships a nested `path` sub-package
1901        // whose `[profile]` smuggles a build-time code-execution flag. The
1902        // loader must refuse the path dependency rather than load the
1903        // sub-package as a trusted `PackageKind::Local`.
1904        dir.child("registry/evil/cabin.toml")
1905            .write_str(
1906                r#"[package]
1907name = "evil"
1908version = "1.0.0"
1909
1910[dependencies]
1911inner = { path = "inner" }
1912"#,
1913            )
1914            .unwrap();
1915        dir.child("registry/evil/inner/cabin.toml")
1916            .write_str(
1917                r#"[package]
1918name = "inner"
1919version = "1.0.0"
1920
1921[profile]
1922cxxflags = ["-fplugin=evil.so"]
1923"#,
1924            )
1925            .unwrap();
1926        let registry = vec![RegistryPackageSource {
1927            name: pkg("evil"),
1928            version: ver("1.0.0"),
1929            manifest_path: dir.path().join("registry/evil/cabin.toml"),
1930        }];
1931        let err = load_workspace_with_options(
1932            dir.path().join("app/cabin.toml"),
1933            &WorkspaceLoadOptions {
1934                registry: &registry,
1935                patches: &[],
1936                ports: &[],
1937                registry_policy: RegistryPolicy::Strict,
1938                include_dev_for: &BTreeSet::new(),
1939                port_policy: PortPolicy::Strict,
1940            },
1941        )
1942        .unwrap_err();
1943        assert!(
1944            matches!(
1945                err,
1946                WorkspaceError::RegistryPackageDeclaresPathDependency { .. }
1947            ),
1948            "expected RegistryPackageDeclaresPathDependency, got {err:?}"
1949        );
1950    }
1951
1952    #[test]
1953    fn registry_package_declaring_port_dependency_is_rejected() {
1954        let dir = TempDir::new().unwrap();
1955        dir.child("app/cabin.toml")
1956            .write_str(
1957                r#"[package]
1958name = "app"
1959version = "0.1.0"
1960
1961[dependencies]
1962evil = ">=1.0.0 <2.0.0"
1963"#,
1964            )
1965            .unwrap();
1966        // The same invariant covers port dependencies: a downloaded registry
1967        // archive may not pull in a port (a port is prepared as a trusted
1968        // `PackageKind::Local` package).
1969        dir.child("registry/evil/cabin.toml")
1970            .write_str(
1971                r#"[package]
1972name = "evil"
1973version = "1.0.0"
1974
1975[dependencies]
1976inner = { port-path = "ports/inner" }
1977"#,
1978            )
1979            .unwrap();
1980        let registry = vec![RegistryPackageSource {
1981            name: pkg("evil"),
1982            version: ver("1.0.0"),
1983            manifest_path: dir.path().join("registry/evil/cabin.toml"),
1984        }];
1985        let err = load_workspace_with_options(
1986            dir.path().join("app/cabin.toml"),
1987            &WorkspaceLoadOptions {
1988                registry: &registry,
1989                patches: &[],
1990                ports: &[],
1991                registry_policy: RegistryPolicy::Strict,
1992                include_dev_for: &BTreeSet::new(),
1993                port_policy: PortPolicy::Strict,
1994            },
1995        )
1996        .unwrap_err();
1997        assert!(
1998            matches!(
1999                err,
2000                WorkspaceError::RegistryPackageDeclaresPortDependency { .. }
2001            ),
2002            "expected RegistryPackageDeclaresPortDependency, got {err:?}"
2003        );
2004    }
2005
2006    #[test]
2007    fn unresolved_registry_dep_errors() {
2008        let dir = TempDir::new().unwrap();
2009        dir.child("app/cabin.toml")
2010            .write_str(
2011                r#"[package]
2012name = "app"
2013version = "0.1.0"
2014
2015[dependencies]
2016fmt = ">=10"
2017spdlog = ">=1"
2018"#,
2019            )
2020            .unwrap();
2021        dir.child("registry/fmt/cabin.toml")
2022            .write_str(
2023                r#"[package]
2024name = "fmt"
2025version = "10.2.1"
2026"#,
2027            )
2028            .unwrap();
2029        // Only `fmt` is in the registry; `spdlog` is missing.
2030        let registry = vec![RegistryPackageSource {
2031            name: pkg("fmt"),
2032            version: ver("10.2.1"),
2033            manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2034        }];
2035        let err = load_workspace_with_options(
2036            dir.path().join("app/cabin.toml"),
2037            &WorkspaceLoadOptions {
2038                registry: &registry,
2039                patches: &[],
2040                ports: &[],
2041                registry_policy: RegistryPolicy::Strict,
2042                include_dev_for: &BTreeSet::new(),
2043                port_policy: PortPolicy::Strict,
2044            },
2045        )
2046        .unwrap_err();
2047        match err {
2048            WorkspaceError::UnresolvedRegistryDependency { dep_name, parent } => {
2049                assert_eq!(dep_name, "spdlog");
2050                assert_eq!(parent, "app");
2051            }
2052            other => panic!("expected UnresolvedRegistryDependency, got {other:?}"),
2053        }
2054    }
2055
2056    #[test]
2057    fn registry_dep_chained_through_extracted_manifest() {
2058        let dir = TempDir::new().unwrap();
2059        // Root -> spdlog -> fmt.
2060        dir.child("app/cabin.toml")
2061            .write_str(
2062                r#"[package]
2063name = "app"
2064version = "0.1.0"
2065
2066[dependencies]
2067spdlog = ">=1"
2068"#,
2069            )
2070            .unwrap();
2071        dir.child("registry/spdlog/cabin.toml")
2072            .write_str(
2073                r#"[package]
2074name = "spdlog"
2075version = "1.13.0"
2076
2077[dependencies]
2078fmt = ">=10"
2079"#,
2080            )
2081            .unwrap();
2082        dir.child("registry/fmt/cabin.toml")
2083            .write_str(
2084                r#"[package]
2085name = "fmt"
2086version = "10.2.1"
2087"#,
2088            )
2089            .unwrap();
2090        let registry = vec![
2091            RegistryPackageSource {
2092                name: pkg("fmt"),
2093                version: ver("10.2.1"),
2094                manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2095            },
2096            RegistryPackageSource {
2097                name: pkg("spdlog"),
2098                version: ver("1.13.0"),
2099                manifest_path: dir.path().join("registry/spdlog/cabin.toml"),
2100            },
2101        ];
2102        let graph = load_workspace_with_options(
2103            dir.path().join("app/cabin.toml"),
2104            &WorkspaceLoadOptions {
2105                registry: &registry,
2106                patches: &[],
2107                ports: &[],
2108                registry_policy: RegistryPolicy::Strict,
2109                include_dev_for: &BTreeSet::new(),
2110                port_policy: PortPolicy::Strict,
2111            },
2112        )
2113        .unwrap();
2114        assert_eq!(graph.packages.len(), 3);
2115        // Topological order: fmt before spdlog before app.
2116        let names: Vec<&str> = graph
2117            .packages
2118            .iter()
2119            .map(|p| p.package.name.as_str())
2120            .collect();
2121        let pos = |n: &str| names.iter().position(|x| *x == n).unwrap();
2122        assert!(pos("fmt") < pos("spdlog"));
2123        assert!(pos("spdlog") < pos("app"));
2124    }
2125
2126    #[test]
2127    fn registry_package_version_mismatch_errors() {
2128        let dir = TempDir::new().unwrap();
2129        dir.child("app/cabin.toml")
2130            .write_str(
2131                r#"[package]
2132name = "app"
2133version = "0.1.0"
2134
2135[dependencies]
2136fmt = ">=10"
2137"#,
2138            )
2139            .unwrap();
2140        dir.child("registry/fmt/cabin.toml")
2141            .write_str(
2142                r#"[package]
2143name = "fmt"
2144version = "10.1.0"
2145"#,
2146            )
2147            .unwrap();
2148        let registry = vec![RegistryPackageSource {
2149            name: pkg("fmt"),
2150            version: ver("10.2.1"),
2151            manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2152        }];
2153        let err = load_workspace_with_options(
2154            dir.path().join("app/cabin.toml"),
2155            &WorkspaceLoadOptions {
2156                registry: &registry,
2157                patches: &[],
2158                ports: &[],
2159                registry_policy: RegistryPolicy::Strict,
2160                include_dev_for: &BTreeSet::new(),
2161                port_policy: PortPolicy::Strict,
2162            },
2163        )
2164        .unwrap_err();
2165        assert!(matches!(
2166            err,
2167            WorkspaceError::RegistryPackageMismatch { .. }
2168        ));
2169    }
2170
2171    // -----------------------------------------------------------------
2172    // workspace.exclude / default-members / dependency
2173    // inheritance / nested workspaces.
2174    // -----------------------------------------------------------------
2175
2176    #[test]
2177    fn exclude_drops_member_from_primary_set() {
2178        let dir = TempDir::new().unwrap();
2179        dir.child("cabin.toml")
2180            .write_str(
2181                r#"[workspace]
2182members = ["packages/*"]
2183exclude = ["packages/skipme"]
2184"#,
2185            )
2186            .unwrap();
2187        dir.child("packages/keep/cabin.toml")
2188            .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2189            .unwrap();
2190        dir.child("packages/skipme/cabin.toml")
2191            .write_str("[package]\nname = \"skipme\"\nversion = \"0.1.0\"\n")
2192            .unwrap();
2193        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2194        let names: Vec<&str> = graph
2195            .primary_packages
2196            .iter()
2197            .map(|i| graph.packages[*i].package.name.as_str())
2198            .collect();
2199        assert_eq!(names, vec!["keep"]);
2200        assert_eq!(graph.excluded_members.len(), 1);
2201        assert!(
2202            graph.excluded_members[0]
2203                .to_string_lossy()
2204                .ends_with("skipme")
2205        );
2206    }
2207
2208    #[test]
2209    fn unused_exclude_pattern_errors() {
2210        let dir = TempDir::new().unwrap();
2211        dir.child("cabin.toml")
2212            .write_str(
2213                r#"[workspace]
2214members = ["packages/keep"]
2215exclude = ["packages/missing"]
2216"#,
2217            )
2218            .unwrap();
2219        dir.child("packages/keep/cabin.toml")
2220            .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2221            .unwrap();
2222        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2223        match err {
2224            WorkspaceError::UnusedExcludePattern { pattern, .. } => {
2225                assert_eq!(pattern, "packages/missing");
2226            }
2227            other => panic!("expected UnusedExcludePattern, got {other:?}"),
2228        }
2229    }
2230
2231    #[test]
2232    fn default_members_must_be_workspace_members() {
2233        let dir = TempDir::new().unwrap();
2234        dir.child("cabin.toml")
2235            .write_str(
2236                r#"[workspace]
2237members = ["packages/keep"]
2238default-members = ["packages/missing"]
2239"#,
2240            )
2241            .unwrap();
2242        dir.child("packages/keep/cabin.toml")
2243            .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2244            .unwrap();
2245        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2246        match err {
2247            WorkspaceError::DefaultMemberNotInMembers { member } => {
2248                assert_eq!(member, "packages/missing");
2249            }
2250            other => panic!("expected DefaultMemberNotInMembers, got {other:?}"),
2251        }
2252    }
2253
2254    #[test]
2255    fn default_members_resolved_to_indices() {
2256        let dir = TempDir::new().unwrap();
2257        dir.child("cabin.toml")
2258            .write_str(
2259                r#"[workspace]
2260members = ["packages/*"]
2261default-members = ["packages/a"]
2262"#,
2263            )
2264            .unwrap();
2265        dir.child("packages/a/cabin.toml")
2266            .write_str("[package]\nname = \"a\"\nversion = \"0.1.0\"\n")
2267            .unwrap();
2268        dir.child("packages/b/cabin.toml")
2269            .write_str("[package]\nname = \"b\"\nversion = \"0.1.0\"\n")
2270            .unwrap();
2271        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2272        assert_eq!(graph.default_members.len(), 1);
2273        let name = graph.packages[graph.default_members[0]]
2274            .package
2275            .name
2276            .as_str();
2277        assert_eq!(name, "a");
2278    }
2279
2280    #[test]
2281    fn workspace_dependency_inheritance() {
2282        let dir = TempDir::new().unwrap();
2283        dir.child("cabin.toml")
2284            .write_str(
2285                r#"[workspace]
2286members = ["packages/app"]
2287
2288[workspace.dependencies]
2289fmt = ">=10 <11"
2290"#,
2291            )
2292            .unwrap();
2293        dir.child("packages/app/cabin.toml")
2294            .write_str(
2295                r#"[package]
2296name = "app"
2297version = "0.1.0"
2298
2299[dependencies]
2300fmt = { workspace = true }
2301"#,
2302            )
2303            .unwrap();
2304        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2305        let app = graph
2306            .packages
2307            .iter()
2308            .find(|p| p.package.name.as_str() == "app")
2309            .unwrap();
2310        assert_eq!(app.package.dependencies.len(), 1);
2311        match &app.package.dependencies[0].source {
2312            cabin_core::DependencySource::Version(req) => {
2313                assert!(req.to_string().contains(">=10"));
2314            }
2315            other => panic!("expected resolved Version, got {other:?}"),
2316        }
2317    }
2318
2319    #[test]
2320    fn workspace_dependency_inheritance_per_kind() {
2321        // Each `dep = { workspace = true }` looks up the matching
2322        // `[workspace.<kind>-dependencies]` table — never a sibling
2323        // table.
2324        let dir = TempDir::new().unwrap();
2325        dir.child("cabin.toml")
2326            .write_str(
2327                r#"[workspace]
2328members = ["packages/app"]
2329
2330[workspace.dependencies]
2331fmt = ">=10"
2332
2333[workspace.dev-dependencies]
2334gtest = "^1.14"
2335"#,
2336            )
2337            .unwrap();
2338        dir.child("packages/app/cabin.toml")
2339            .write_str(
2340                r#"[package]
2341name = "app"
2342version = "0.1.0"
2343
2344[dependencies]
2345fmt = { workspace = true }
2346
2347[dev-dependencies]
2348gtest = { workspace = true }
2349"#,
2350            )
2351            .unwrap();
2352        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2353        let app = graph
2354            .packages
2355            .iter()
2356            .find(|p| p.package.name.as_str() == "app")
2357            .unwrap();
2358        for (name, kind) in [
2359            ("fmt", DependencyKind::Normal),
2360            ("gtest", DependencyKind::Dev),
2361        ] {
2362            let dep = app
2363                .package
2364                .dependencies
2365                .iter()
2366                .find(|d| d.name.as_str() == name && d.kind == kind)
2367                .unwrap_or_else(|| panic!("expected {name} in {kind:?}"));
2368            assert!(
2369                matches!(dep.source, cabin_core::DependencySource::Version(_)),
2370                "workspace inheritance should rewrite {name} into a Version source"
2371            );
2372        }
2373    }
2374
2375    #[test]
2376    fn workspace_dependency_kind_does_not_cross_tables() {
2377        // `[dev-dependencies] foo = { workspace = true }` must
2378        // *not* fall back to `[workspace.dependencies]` — the
2379        // lookup is strictly kind-specific.
2380        let dir = TempDir::new().unwrap();
2381        dir.child("cabin.toml")
2382            .write_str(
2383                r#"[workspace]
2384members = ["packages/app"]
2385
2386[workspace.dependencies]
2387fmt = ">=10"
2388"#,
2389            )
2390            .unwrap();
2391        dir.child("packages/app/cabin.toml")
2392            .write_str(
2393                r#"[package]
2394name = "app"
2395version = "0.1.0"
2396
2397[dev-dependencies]
2398fmt = { workspace = true }
2399"#,
2400            )
2401            .unwrap();
2402        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2403        match err {
2404            WorkspaceError::UnresolvedWorkspaceDependency {
2405                dep_name,
2406                parent,
2407                kind,
2408            } => {
2409                assert_eq!(dep_name, "fmt");
2410                assert_eq!(parent, "app");
2411                assert_eq!(kind, DependencyKind::Dev);
2412            }
2413            other => panic!("expected UnresolvedWorkspaceDependency for dev, got {other:?}"),
2414        }
2415    }
2416
2417    #[test]
2418    fn dev_path_dependency_is_not_loaded_into_graph() {
2419        // Dev path-deps are declaration-only: they appear on
2420        // `package.dependencies` but never become a graph node, so
2421        // a missing dev-dep target directory is *not* an error
2422        // for ordinary commands.
2423        let dir = TempDir::new().unwrap();
2424        dir.child("cabin.toml")
2425            .write_str(
2426                r#"[package]
2427name = "app"
2428version = "0.1.0"
2429
2430[dev-dependencies]
2431harness = { path = "../harness-that-does-not-exist" }
2432"#,
2433            )
2434            .unwrap();
2435        let graph = load_workspace(dir.path().join("cabin.toml"))
2436            .expect("dev path-dep should not be traversed by ordinary load");
2437        // Only the root package is loaded.
2438        assert_eq!(graph.packages.len(), 1);
2439        // But the package still records the declaration.
2440        let app = &graph.packages[0];
2441        assert_eq!(app.package.dependencies.len(), 1);
2442        assert_eq!(app.package.dependencies[0].kind, DependencyKind::Dev);
2443    }
2444
2445    #[test]
2446    fn unresolved_workspace_dependency_errors() {
2447        let dir = TempDir::new().unwrap();
2448        dir.child("cabin.toml")
2449            .write_str(
2450                r#"[workspace]
2451members = ["packages/app"]
2452"#,
2453            )
2454            .unwrap();
2455        dir.child("packages/app/cabin.toml")
2456            .write_str(
2457                r#"[package]
2458name = "app"
2459version = "0.1.0"
2460
2461[dependencies]
2462fmt = { workspace = true }
2463"#,
2464            )
2465            .unwrap();
2466        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2467        match err {
2468            WorkspaceError::UnresolvedWorkspaceDependency {
2469                dep_name,
2470                parent,
2471                kind,
2472            } => {
2473                assert_eq!(dep_name, "fmt");
2474                assert_eq!(parent, "app");
2475                assert_eq!(kind, DependencyKind::Normal);
2476            }
2477            other => panic!("expected UnresolvedWorkspaceDependency, got {other:?}"),
2478        }
2479    }
2480
2481    #[test]
2482    fn nested_workspace_rejected() {
2483        let dir = TempDir::new().unwrap();
2484        dir.child("cabin.toml")
2485            .write_str(
2486                r#"[workspace]
2487members = ["nested"]
2488"#,
2489            )
2490            .unwrap();
2491        dir.child("nested/cabin.toml")
2492            .write_str(
2493                r"[workspace]
2494members = []
2495",
2496            )
2497            .unwrap();
2498        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2499        match err {
2500            WorkspaceError::NestedWorkspace { path } => {
2501                assert!(path.to_string_lossy().contains("nested"));
2502            }
2503            other => panic!("expected NestedWorkspace, got {other:?}"),
2504        }
2505    }
2506
2507    #[test]
2508    fn member_expansion_is_deterministic() {
2509        let dir = TempDir::new().unwrap();
2510        dir.child("cabin.toml")
2511            .write_str(
2512                r#"[workspace]
2513members = ["packages/*"]
2514"#,
2515            )
2516            .unwrap();
2517        for name in ["zeta", "alpha", "mu", "kappa"] {
2518            dir.child(format!("packages/{name}/cabin.toml"))
2519                .write_str(&format!(
2520                    "[package]\nname = \"{name}\"\nversion = \"0.1.0\"\n"
2521                ))
2522                .unwrap();
2523        }
2524        let graph = load_workspace(dir.path().join("cabin.toml")).unwrap();
2525        let names: Vec<&str> = graph
2526            .primary_packages
2527            .iter()
2528            .map(|i| graph.packages[*i].package.name.as_str())
2529            .collect();
2530        assert_eq!(names, vec!["alpha", "kappa", "mu", "zeta"]);
2531    }
2532
2533    // -----------------------------------------------------------------
2534    // Workspace pattern paths must be relative to the workspace root.
2535    // Absolute and `..` patterns are rejected.
2536    // -----------------------------------------------------------------
2537
2538    fn workspace_with_outside_member(pattern: &str) -> TempDir {
2539        let dir = TempDir::new().unwrap();
2540        dir.child("cabin.toml")
2541            .write_str(&format!("[workspace]\nmembers = [\"{pattern}\"]\n"))
2542            .unwrap();
2543        dir
2544    }
2545
2546    #[test]
2547    fn member_pattern_with_absolute_path_rejected() {
2548        // `/tmp/outside` is platform-dependent but path::is_absolute
2549        // covers `\\` and `C:\` on Windows too — write a Unix path
2550        // for the test (the manifest never reaches the FS in the
2551        // failing branch).
2552        let dir = workspace_with_outside_member("/tmp/outside");
2553        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2554        match err {
2555            WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2556                assert_eq!(field, "workspace.members");
2557                assert_eq!(pattern, "/tmp/outside");
2558            }
2559            other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2560        }
2561    }
2562
2563    #[test]
2564    fn member_pattern_with_parent_dir_rejected() {
2565        // Set up a sibling directory the pattern would pull in,
2566        // proving the validator stops the load *before* expansion.
2567        let dir = TempDir::new().unwrap();
2568        let workspace_dir = dir.child("ws");
2569        let outside_dir = dir.child("outside");
2570        workspace_dir
2571            .child("cabin.toml")
2572            .write_str(
2573                r#"[workspace]
2574members = ["../outside"]
2575"#,
2576            )
2577            .unwrap();
2578        outside_dir
2579            .child("cabin.toml")
2580            .write_str("[package]\nname = \"sneaky\"\nversion = \"0.1.0\"\n")
2581            .unwrap();
2582        let err = load_workspace(workspace_dir.path().join("cabin.toml")).unwrap_err();
2583        match err {
2584            WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2585                assert_eq!(field, "workspace.members");
2586                assert_eq!(pattern, "../outside");
2587            }
2588            other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2589        }
2590    }
2591
2592    #[test]
2593    fn exclude_pattern_with_parent_dir_rejected() {
2594        let dir = TempDir::new().unwrap();
2595        dir.child("cabin.toml")
2596            .write_str(
2597                r#"[workspace]
2598members = ["packages/keep"]
2599exclude = ["../outside"]
2600"#,
2601            )
2602            .unwrap();
2603        dir.child("packages/keep/cabin.toml")
2604            .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2605            .unwrap();
2606        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2607        match err {
2608            WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2609                assert_eq!(field, "workspace.exclude");
2610                assert_eq!(pattern, "../outside");
2611            }
2612            other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2613        }
2614    }
2615
2616    #[test]
2617    fn default_member_with_parent_dir_rejected() {
2618        let dir = TempDir::new().unwrap();
2619        dir.child("cabin.toml")
2620            .write_str(
2621                r#"[workspace]
2622members = ["packages/keep"]
2623default-members = ["../outside"]
2624"#,
2625            )
2626            .unwrap();
2627        dir.child("packages/keep/cabin.toml")
2628            .write_str("[package]\nname = \"keep\"\nversion = \"0.1.0\"\n")
2629            .unwrap();
2630        let err = load_workspace(dir.path().join("cabin.toml")).unwrap_err();
2631        match err {
2632            WorkspaceError::WorkspacePatternEscapesRoot { field, pattern } => {
2633                assert_eq!(field, "workspace.default-members");
2634                assert_eq!(pattern, "../outside");
2635            }
2636            other => panic!("expected WorkspacePatternEscapesRoot, got {other:?}"),
2637        }
2638    }
2639
2640    // -----------------------------------------------------------------
2641    // selection-aware registry materialization.
2642    // -----------------------------------------------------------------
2643
2644    #[test]
2645    fn for_selection_skips_versioned_deps_outside_strict_set() {
2646        // app needs fmt; unrelated `b` declares spdlog. The
2647        // strict set is {app}; the registry only has fmt. Loading
2648        // must succeed because b's spdlog dep is skipped.
2649        let dir = TempDir::new().unwrap();
2650        dir.child("cabin.toml")
2651            .write_str(
2652                r#"[workspace]
2653members = ["packages/*"]
2654"#,
2655            )
2656            .unwrap();
2657        dir.child("packages/app/cabin.toml")
2658            .write_str(
2659                r#"[package]
2660name = "app"
2661version = "0.1.0"
2662
2663[dependencies]
2664fmt = ">=10 <11"
2665"#,
2666            )
2667            .unwrap();
2668        dir.child("packages/b/cabin.toml")
2669            .write_str(
2670                r#"[package]
2671name = "b"
2672version = "0.1.0"
2673
2674[dependencies]
2675spdlog = "^1"
2676"#,
2677            )
2678            .unwrap();
2679        // Pretend we already extracted fmt 10.2.1 somewhere on disk.
2680        dir.child("registry/fmt/cabin.toml")
2681            .write_str("[package]\nname = \"fmt\"\nversion = \"10.2.1\"\n")
2682            .unwrap();
2683        let registry = vec![RegistryPackageSource {
2684            name: PackageName::new("fmt").unwrap(),
2685            version: ver("10.2.1"),
2686            manifest_path: dir.path().join("registry/fmt/cabin.toml"),
2687        }];
2688        let mut strict: BTreeSet<String> = BTreeSet::new();
2689        strict.insert("app".into());
2690        let graph = load_workspace_with_options(
2691            dir.path().join("cabin.toml"),
2692            &WorkspaceLoadOptions {
2693                registry: &registry,
2694                patches: &[],
2695                ports: &[],
2696                registry_policy: RegistryPolicy::StrictFor(&strict),
2697                include_dev_for: &BTreeSet::new(),
2698                port_policy: PortPolicy::Strict,
2699            },
2700        )
2701        .expect("selection-aware load should not require spdlog");
2702        // app, b, and fmt all loaded; no `spdlog` was added.
2703        let names: BTreeSet<&str> = graph
2704            .packages
2705            .iter()
2706            .map(|p| p.package.name.as_str())
2707            .collect();
2708        assert!(names.contains("app"));
2709        assert!(names.contains("b"));
2710        assert!(names.contains("fmt"));
2711        assert!(!names.contains("spdlog"));
2712    }
2713
2714    #[test]
2715    fn for_selection_still_errors_when_strict_dep_missing() {
2716        // app is strict and depends on fmt, but the registry is
2717        // empty. The selection-aware loader must still error on
2718        // app's missing fmt.
2719        let dir = TempDir::new().unwrap();
2720        dir.child("cabin.toml")
2721            .write_str(
2722                r#"[workspace]
2723members = ["packages/*"]
2724"#,
2725            )
2726            .unwrap();
2727        dir.child("packages/app/cabin.toml")
2728            .write_str(
2729                r#"[package]
2730name = "app"
2731version = "0.1.0"
2732
2733[dependencies]
2734fmt = ">=10 <11"
2735"#,
2736            )
2737            .unwrap();
2738        // A non-empty registry shifts the loader out of the
2739        // legacy "skip versioned deps" mode. Build a sham entry
2740        // for some other package so registry_by_name is
2741        // populated but does not contain `fmt`.
2742        dir.child("registry/other/cabin.toml")
2743            .write_str("[package]\nname = \"other\"\nversion = \"1.0.0\"\n")
2744            .unwrap();
2745        let registry = vec![RegistryPackageSource {
2746            name: PackageName::new("other").unwrap(),
2747            version: ver("1.0.0"),
2748            manifest_path: dir.path().join("registry/other/cabin.toml"),
2749        }];
2750        let mut strict: BTreeSet<String> = BTreeSet::new();
2751        strict.insert("app".into());
2752        let err = load_workspace_with_options(
2753            dir.path().join("cabin.toml"),
2754            &WorkspaceLoadOptions {
2755                registry: &registry,
2756                patches: &[],
2757                ports: &[],
2758                registry_policy: RegistryPolicy::StrictFor(&strict),
2759                include_dev_for: &BTreeSet::new(),
2760                port_policy: PortPolicy::Strict,
2761            },
2762        )
2763        .expect_err("expected UnresolvedRegistryDependency for selected closure dep");
2764        match err {
2765            WorkspaceError::UnresolvedRegistryDependency { dep_name, parent } => {
2766                assert_eq!(dep_name, "fmt");
2767                assert_eq!(parent, "app");
2768            }
2769            other => panic!("expected UnresolvedRegistryDependency, got {other:?}"),
2770        }
2771    }
2772
2773    // ---------------------------------------------------------------
2774    // Foundation-port resolution
2775    // ---------------------------------------------------------------
2776
2777    #[test]
2778    fn resolves_port_dep_via_supplied_source() {
2779        let tmp = TempDir::new().unwrap();
2780
2781        // Port directory (contains port.toml in real life, but
2782        // the workspace loader only cares about the canonical
2783        // path).
2784        let port_dir = tmp.child("ports/zlib/1.3.1");
2785        port_dir.create_dir_all().unwrap();
2786
2787        // Prepared overlay manifest directory (the CLI
2788        // orchestration step writes the upstream sources here
2789        // before the loader runs).
2790        let prepared = tmp.child("cache/sources/sha256/abc");
2791        prepared
2792            .child("cabin.toml")
2793            .write_str(
2794                "[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[target.zlib]\ntype = \"library\"\nsources = [\"zlib.c\"]\n",
2795            )
2796            .unwrap();
2797        prepared
2798            .child("zlib.c")
2799            .write_str("int zlib_dummy(void){return 0;}\n")
2800            .unwrap();
2801
2802        // Consumer manifest that references the port by
2803        // relative path.
2804        let consumer = tmp.child("consumer");
2805        consumer
2806            .child("cabin.toml")
2807            .write_str(
2808                r#"
2809[package]
2810name = "consumer"
2811version = "0.1.0"
2812
2813[dependencies]
2814zlib = { port-path = "../ports/zlib/1.3.1" }
2815
2816[target.consumer]
2817type = "executable"
2818sources = ["src/main.c"]
2819deps = ["zlib"]
2820"#,
2821            )
2822            .unwrap();
2823        consumer
2824            .child("src/main.c")
2825            .write_str("int main(void){return 0;}\n")
2826            .unwrap();
2827
2828        let port_sources = vec![PortPackageSource {
2829            name: PackageName::new("zlib").unwrap(),
2830            version: semver::Version::new(1, 3, 1),
2831            manifest_path: prepared.path().join("cabin.toml"),
2832            origin: cabin_port::PortOrigin::PortDir(port_dir.to_path_buf()),
2833        }];
2834        let graph = load_workspace_with_options(
2835            consumer.path().join("cabin.toml"),
2836            &WorkspaceLoadOptions {
2837                registry: &[],
2838                patches: &[],
2839                ports: &port_sources,
2840                registry_policy: RegistryPolicy::Strict,
2841                include_dev_for: &BTreeSet::new(),
2842                port_policy: PortPolicy::Strict,
2843            },
2844        )
2845        .unwrap();
2846        // Two packages: the consumer and the zlib port.
2847        assert_eq!(graph.packages.len(), 2);
2848        let zlib = graph
2849            .packages
2850            .iter()
2851            .find(|p| p.package.name.as_str() == "zlib")
2852            .unwrap();
2853        assert_eq!(
2854            zlib.manifest_dir,
2855            std::fs::canonicalize(prepared.path()).unwrap()
2856        );
2857        // Foundation ports are local development policy, so the
2858        // package kind is Local.
2859        assert_eq!(zlib.kind, PackageKind::Local);
2860    }
2861
2862    #[test]
2863    fn resolves_builtin_port_dep_by_name() {
2864        let tmp = TempDir::new().unwrap();
2865
2866        // The "prepared" overlay (in a real build this is in the
2867        // cabin cache). The loader only needs the [package] block
2868        // to match the dep, plus a source file for the target.
2869        let prepared = tmp.child("cache/sources/sha256/abc");
2870        prepared
2871            .child("cabin.toml")
2872            .write_str(
2873                "[package]\nname = \"zlib\"\nversion = \"1.3.1\"\n\n[target.zlib]\ntype = \"library\"\nsources = [\"zlib.c\"]\n",
2874            )
2875            .unwrap();
2876        prepared
2877            .child("zlib.c")
2878            .write_str("int zlib_dummy(void){return 0;}\n")
2879            .unwrap();
2880
2881        let consumer = tmp.child("consumer");
2882        consumer
2883            .child("cabin.toml")
2884            .write_str(
2885                r#"
2886[package]
2887name = "consumer"
2888version = "0.1.0"
2889
2890[dependencies]
2891zlib = { port = true, version = "^1.3" }
2892
2893[target.consumer]
2894type = "executable"
2895sources = ["src/main.c"]
2896deps = ["zlib"]
2897"#,
2898            )
2899            .unwrap();
2900        consumer
2901            .child("src/main.c")
2902            .write_str("int main(void){return 0;}\n")
2903            .unwrap();
2904
2905        let port_sources = vec![PortPackageSource {
2906            name: PackageName::new("zlib").unwrap(),
2907            version: semver::Version::new(1, 3, 1),
2908            manifest_path: prepared.path().join("cabin.toml"),
2909            origin: cabin_port::PortOrigin::Builtin("zlib"),
2910        }];
2911        let graph = load_workspace_with_options(
2912            consumer.path().join("cabin.toml"),
2913            &WorkspaceLoadOptions {
2914                registry: &[],
2915                patches: &[],
2916                ports: &port_sources,
2917                registry_policy: RegistryPolicy::Strict,
2918                include_dev_for: &BTreeSet::new(),
2919                port_policy: PortPolicy::Strict,
2920            },
2921        )
2922        .unwrap();
2923        assert_eq!(graph.packages.len(), 2);
2924        let zlib = graph
2925            .packages
2926            .iter()
2927            .find(|p| p.package.name.as_str() == "zlib")
2928            .unwrap();
2929        assert_eq!(zlib.kind, PackageKind::Local);
2930    }
2931
2932    #[test]
2933    fn rejects_port_dep_without_prepared_source() {
2934        let tmp = TempDir::new().unwrap();
2935        let port_dir = tmp.child("ports/zlib/1.3.1");
2936        port_dir.create_dir_all().unwrap();
2937
2938        let consumer = tmp.child("consumer");
2939        consumer
2940            .child("cabin.toml")
2941            .write_str(
2942                r#"
2943[package]
2944name = "consumer"
2945version = "0.1.0"
2946
2947[dependencies]
2948zlib = { port-path = "../ports/zlib/1.3.1" }
2949"#,
2950            )
2951            .unwrap();
2952
2953        let err = load_workspace_with_options(
2954            consumer.path().join("cabin.toml"),
2955            &WorkspaceLoadOptions {
2956                registry: &[],
2957                patches: &[],
2958                ports: &[],
2959                registry_policy: RegistryPolicy::Strict,
2960                include_dev_for: &BTreeSet::new(),
2961                port_policy: PortPolicy::Strict,
2962            },
2963        )
2964        .unwrap_err();
2965        assert!(
2966            matches!(err, WorkspaceError::PortDependencyNotPrepared { .. }),
2967            "{err:?}"
2968        );
2969    }
2970
2971    #[test]
2972    fn rejects_port_dep_with_missing_port_directory() {
2973        let tmp = TempDir::new().unwrap();
2974
2975        let consumer = tmp.child("consumer");
2976        consumer
2977            .child("cabin.toml")
2978            .write_str(
2979                r#"
2980[package]
2981name = "consumer"
2982version = "0.1.0"
2983
2984[dependencies]
2985zlib = { port-path = "../nonexistent/zlib" }
2986"#,
2987            )
2988            .unwrap();
2989
2990        let err = load_workspace_with_options(
2991            consumer.path().join("cabin.toml"),
2992            &WorkspaceLoadOptions {
2993                registry: &[],
2994                patches: &[],
2995                ports: &[],
2996                registry_policy: RegistryPolicy::Strict,
2997                include_dev_for: &BTreeSet::new(),
2998                port_policy: PortPolicy::Strict,
2999            },
3000        )
3001        .unwrap_err();
3002        assert!(
3003            matches!(err, WorkspaceError::PortDirectoryMissing { .. }),
3004            "{err:?}"
3005        );
3006    }
3007}