Skip to main content

cabin_core/
model.rs

1use std::collections::{BTreeMap, HashSet};
2
3use camino::Utf8PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::build_flags::ProfileSettings;
8use crate::compiler_wrapper::CompilerWrapperManifestSettings;
9use crate::config::Features;
10use crate::error::ValidationError;
11use crate::patch::PatchManifestSettings;
12use crate::profile::{ProfileDefinition, ProfileName};
13use crate::toolchain::ToolchainSettings;
14
15/// Validated package name.
16///
17/// Newtype wrapper so future versions can centralize package-name syntax
18/// rules (e.g. registry-specific patterns) without touching every callsite.
19#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
20#[serde(try_from = "String", into = "String")]
21pub struct PackageName(String);
22
23impl PackageName {
24    /// Construct a [`PackageName`] after running validation rules.
25    ///
26    /// The grammar enforced here covers filesystem path
27    /// components, sparse-HTTP path segments, package archive
28    /// filenames, and Windows-reserved filename characters in a
29    /// single rule. See [`is_path_safe_package_name`] for the
30    /// full predicate.
31    ///
32    /// # Errors
33    /// Returns [`ValidationError::EmptyPackageName`] for an empty name,
34    /// [`ValidationError::PackageNameContainsWhitespace`] when the name contains
35    /// whitespace, and [`ValidationError::UnsafePackageName`] when it fails the
36    /// [`is_path_safe_package_name`] predicate.
37    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
38        validate_path_safe_name(
39            value.into(),
40            ValidationError::EmptyPackageName,
41            ValidationError::PackageNameContainsWhitespace,
42            ValidationError::UnsafePackageName,
43        )
44        .map(Self)
45    }
46
47    pub fn as_str(&self) -> &str {
48        &self.0
49    }
50}
51
52/// Shared package-name validity predicate.
53///
54/// A name passes when it is safe to use **simultaneously** as
55/// (a) a single filesystem path component on every supported
56/// host OS, (b) a single sparse-HTTP URL path segment, and
57/// (c) a fragment of a package archive filename. The grammar is
58/// deliberately strict so the same `PackageName` value can flow
59/// from manifest parsing through the workspace loader, the
60/// resolver, the lockfile, the artifact cache, and the registry
61/// (file or sparse HTTP) without any per-stage re-encoding.
62///
63/// A name is valid iff:
64///
65/// - it is non-empty;
66/// - it consists only of ASCII letters (`A-Z`, `a-z`), ASCII
67///   digits (`0-9`), `_`, `-`, and `.`;
68/// - it is not literally `.` or `..`;
69/// - it does not start with `.` or `-`.
70///
71/// Consequences worth calling out:
72///
73/// - `foo..bar` is **accepted**: it's not a parent reference
74///   because the name is not literally `..` and does not start
75///   with a dot. Path resolvers do not interpret the embedded
76///   `..` substring as a navigation. This is intentional so that
77///   common library names like `boost..hana` (hypothetical) stay
78///   legal under the registry grammar.
79/// - A leading `-` is rejected so the name cannot be mistaken
80///   for a flag when it reaches an argv-driven tool (e.g.,
81///   `pkg-config`, the linker), or for the start of a CLI
82///   short-option block.  An embedded `-` (like `foo-bar`) is
83///   still fine.
84/// - URL-reserved characters (`?`, `#`, `%`, `:`), Windows-
85///   reserved filename characters (`< > : " | ? *`), and path
86///   separators (`/`, `\`) are all outside the allowed alphabet,
87///   so they are rejected without needing a separate enumeration.
88/// - Control characters and non-ASCII characters are also outside
89///   the alphabet, so they fall under the same rule.
90///
91/// The shared helper keeps `cabin-package`, `cabin-registry-file`,
92/// and `cabin-index-http` from drifting on this rule.
93pub fn is_path_safe_package_name(name: &str) -> bool {
94    if name.is_empty() {
95        return false;
96    }
97    if name == "." || name == ".." {
98        return false;
99    }
100    if name.starts_with('.') || name.starts_with('-') {
101        return false;
102    }
103    name.bytes()
104        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
105}
106
107/// Run the shared three-step validation behind every path-safe name
108/// newtype: reject an empty value, reject any whitespace, then enforce
109/// [`is_path_safe_package_name`]. The per-type [`ValidationError`]
110/// variants are supplied by the caller so the rejection still names the
111/// specific kind of name, keeping [`PackageName`] and [`TargetName`]
112/// from drifting on the rule.
113fn validate_path_safe_name(
114    value: String,
115    empty: ValidationError,
116    whitespace: impl FnOnce(String) -> ValidationError,
117    unsafe_name: impl FnOnce(String) -> ValidationError,
118) -> Result<String, ValidationError> {
119    if value.is_empty() {
120        return Err(empty);
121    }
122    if value.chars().any(char::is_whitespace) {
123        return Err(whitespace(value));
124    }
125    if !is_path_safe_package_name(&value) {
126        return Err(unsafe_name(value));
127    }
128    Ok(value)
129}
130
131impl AsRef<str> for PackageName {
132    fn as_ref(&self) -> &str {
133        &self.0
134    }
135}
136
137impl std::fmt::Display for PackageName {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.write_str(&self.0)
140    }
141}
142
143impl TryFrom<String> for PackageName {
144    type Error = ValidationError;
145
146    fn try_from(value: String) -> Result<Self, Self::Error> {
147        PackageName::new(value)
148    }
149}
150
151impl From<PackageName> for String {
152    fn from(value: PackageName) -> Self {
153        value.0
154    }
155}
156
157/// Validated target name.
158#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
159#[serde(try_from = "String", into = "String")]
160pub struct TargetName(String);
161
162impl TargetName {
163    /// Construct a [`TargetName`] after running validation.
164    ///
165    /// Target names are joined into filesystem paths by the build
166    /// planner (object directories, executable paths, Cargo target
167    /// directories), so they share the path-component grammar with
168    /// [`PackageName`]: a name like `[target."../escape"]` would
169    /// otherwise let a malicious manifest write artifacts outside
170    /// the selected `--build-dir`. The grammar is enforced through
171    /// [`is_path_safe_package_name`], which already covers path
172    /// separators, `..` / `.`, leading `.` or `-`, control characters,
173    /// non-ASCII bytes, and Windows-reserved filename characters in a
174    /// single rule.
175    ///
176    /// # Errors
177    /// Returns [`ValidationError::EmptyTargetName`] for an empty name,
178    /// [`ValidationError::TargetNameContainsWhitespace`] when the name contains
179    /// whitespace, and [`ValidationError::UnsafeTargetName`] when it fails the
180    /// [`is_path_safe_package_name`] predicate.
181    pub fn new(value: impl Into<String>) -> Result<Self, ValidationError> {
182        validate_path_safe_name(
183            value.into(),
184            ValidationError::EmptyTargetName,
185            ValidationError::TargetNameContainsWhitespace,
186            ValidationError::UnsafeTargetName,
187        )
188        .map(Self)
189    }
190
191    pub fn as_str(&self) -> &str {
192        &self.0
193    }
194}
195
196impl AsRef<str> for TargetName {
197    fn as_ref(&self) -> &str {
198        &self.0
199    }
200}
201
202impl std::fmt::Display for TargetName {
203    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
204        f.write_str(&self.0)
205    }
206}
207
208impl TryFrom<String> for TargetName {
209    type Error = ValidationError;
210
211    fn try_from(value: String) -> Result<Self, Self::Error> {
212        TargetName::new(value)
213    }
214}
215
216impl From<TargetName> for String {
217    fn from(value: TargetName) -> Self {
218        value.0
219    }
220}
221
222/// What kind of artifact a target produces.
223///
224/// Target kinds describe artifact role only. Source-language
225/// classification is per-file, based on source extension: `.c`
226/// compiles as C, `.cc` / `.cpp` / `.cxx` / `.c++` / `.C` compile
227/// as C++. A single target may freely mix C/C++ sources; the
228/// planner selects the compiler per source and selects the link
229/// driver from the direct and transitive source-language closure
230/// (C++ if any object is C++, otherwise C).
231///
232/// The string representations are stable: they are written by the manifest
233/// parser, surfaced by `cabin metadata`, and consumed by the build graph
234/// planner.
235#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
236pub enum TargetKind {
237    /// Static-archive library (`lib<name>.a`).
238    #[serde(rename = "library")]
239    Library,
240    /// A header-only library. Has no translation units of its own;
241    /// the planner emits no compile or archive actions, and consumers
242    /// pick up its `include_dirs` through the dependency graph.
243    #[serde(rename = "header_only")]
244    HeaderOnly,
245    /// A linked executable. Built by default by `cabin build`.
246    #[serde(rename = "executable")]
247    Executable,
248    /// A test executable. Built and run by `cabin test`. Excluded
249    /// from the default `cabin build` selection.
250    #[serde(rename = "test")]
251    Test,
252    /// An example executable. Excluded from the default
253    /// `cabin build` selection. The only way an example
254    /// reaches the build graph is as a transitive dep of another
255    /// selected target.
256    #[serde(rename = "example")]
257    Example,
258}
259
260impl TargetKind {
261    pub const fn as_str(self) -> &'static str {
262        match self {
263            Self::Library => "library",
264            Self::HeaderOnly => "header_only",
265            Self::Executable => "executable",
266            Self::Test => "test",
267            Self::Example => "example",
268        }
269    }
270
271    /// All kinds, in declaration order. Useful for error messages that list
272    /// the supported types.
273    pub const fn all() -> &'static [TargetKind] {
274        &[
275            Self::Library,
276            Self::HeaderOnly,
277            Self::Executable,
278            Self::Test,
279            Self::Example,
280        ]
281    }
282
283    /// Whether this kind produces an executable (linked binary).
284    /// Library kinds return `false`.
285    pub const fn produces_executable(self) -> bool {
286        matches!(self, Self::Executable | Self::Test | Self::Example)
287    }
288
289    /// Whether this kind produces a static-archive library (`lib<name>.a`).
290    pub const fn produces_archive(self) -> bool {
291        matches!(self, Self::Library)
292    }
293
294    /// Whether this kind is a header-only library (no compile/
295    /// archive actions; consumers pick up `include_dirs`).
296    pub const fn is_header_only(self) -> bool {
297        matches!(self, Self::HeaderOnly)
298    }
299
300    /// Whether ordinary `cabin build` selects this kind by default.
301    /// Dev-only kinds (`test` / `example`) are excluded
302    /// from the default set: tests are built by `cabin test`,
303    /// and examples only reach the build graph as a
304    /// transitive dep of another selected target.
305    ///
306    /// Header-only libraries are included so the dependency
307    /// closure walk reaches them; the planner emits no compile or
308    /// archive actions for them, so saying "yes, this is part of
309    /// the default selection" is a no-op on Ninja's side.
310    pub const fn is_default_buildable(self) -> bool {
311        matches!(self, Self::Library | Self::HeaderOnly | Self::Executable)
312    }
313
314    /// Whether this kind is a *development-only* target — a target
315    /// that exists to support workspace development but is not part
316    /// of the package's public surface. Production callers use this
317    /// to decide whether dev-dependencies should be activated and
318    /// whether the target may be run by `cabin test`.
319    pub const fn is_dev_only(self) -> bool {
320        matches!(self, Self::Test | Self::Example)
321    }
322
323    /// Whether `cabin test` runs this kind after building it. Today
324    /// only `test` runs; `example` is build-only.
325    pub const fn is_test(self) -> bool {
326        matches!(self, Self::Test)
327    }
328}
329
330impl std::fmt::Display for TargetKind {
331    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
332        f.write_str(self.as_str())
333    }
334}
335
336/// A buildable unit within a package.
337#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
338pub struct Target {
339    pub name: TargetName,
340    pub kind: TargetKind,
341    #[serde(default)]
342    pub sources: Vec<Utf8PathBuf>,
343    #[serde(default)]
344    pub include_dirs: Vec<Utf8PathBuf>,
345    #[serde(default)]
346    pub defines: Vec<String>,
347    /// Same-package target names or cross-package references. Cross-package
348    /// references take the form `package` (resolves to the package's default
349    /// library target) or `package:target` (qualified). Resolution against a
350    /// concrete package graph lives in `cabin-build`, not here.
351    ///
352    /// Stored as raw strings, not [`TargetName`], because the qualified
353    /// `package:target` form contains a `:` that the path-safe target-name
354    /// grammar rejects. Validation happens at resolution time against the
355    /// already-validated package / target graph; dep strings never flow
356    /// directly into a filesystem path.
357    #[serde(default)]
358    pub deps: Vec<String>,
359}
360
361fn default_true() -> bool {
362    true
363}
364
365/// A package-level Cabin dependency declared in
366/// `[dependencies]` or `[dev-dependencies]`.
367///
368/// System dependencies (`system = true` entries) are *not*
369/// represented here — they live in [`SystemDependency`] because
370/// they have a different schema and never enter Cabin
371/// resolution.
372#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
373pub struct Dependency {
374    /// The dependency alias used in the manifest. The alias must
375    /// equal the depended-on package's `[package].name`.
376    pub name: PackageName,
377    pub source: DependencySource,
378    /// Which manifest section the dependency was declared in.
379    /// Defaults to [`DependencyKind::Normal`] so manifests that
380    /// only use `[dependencies]` keep their previous serialized
381    /// shape.
382    #[serde(default, skip_serializing_if = "DependencyKind::is_normal")]
383    pub kind: DependencyKind,
384    /// Whether the dependency is optional. Optional dependencies
385    /// only enter ordinary resolution / fetch / build when a
386    /// feature enables them via `dep:<name>` or
387    /// `<name>/<feature>`.
388    #[serde(default, skip_serializing_if = "is_false")]
389    pub optional: bool,
390    /// Features requested on the dependency package by this edge.
391    /// Stored as the raw manifest strings; the feature resolver
392    /// validates them against the depended-on package's
393    /// `[features]` table.
394    #[serde(default, skip_serializing_if = "Vec::is_empty")]
395    pub features: Vec<String>,
396    /// Whether this edge requests the dependency package's
397    /// `default` feature. Defaults to `true`. `default-features =
398    /// false` only narrows *this* edge — if another edge requests
399    /// defaults for the same package, the unified result still
400    /// includes them.
401    #[serde(default = "default_true", skip_serializing_if = "is_true")]
402    pub default_features: bool,
403    /// Optional target condition. `Some` when the dependency was
404    /// declared inside a `[target.'cfg(...)'.<kind>]` table;
405    /// `None` for unconditional declarations. Conditional
406    /// dependencies whose condition does not match the
407    /// evaluation [`crate::TargetPlatform`] are filtered out by
408    /// `cabin-workspace` / `cabin-feature` / `cabin-build`
409    /// before reaching the resolver or the build planner, but they
410    /// stay on `Package::dependencies` for metadata round-trip.
411    #[serde(default, skip_serializing_if = "Option::is_none")]
412    pub condition: Option<crate::Condition>,
413}
414
415fn is_false<T>(value: &T) -> bool
416where
417    T: PartialEq + Default,
418{
419    *value == T::default()
420}
421
422fn is_true<T>(value: &T) -> bool
423where
424    T: PartialEq + Default + std::ops::Not<Output = T>,
425{
426    *value == !T::default()
427}
428
429impl Dependency {
430    /// Whether this declaration is active for the given
431    /// [`crate::TargetPlatform`]. Unconditional declarations
432    /// are always active; conditional declarations are active
433    /// iff their condition evaluates to `true`.
434    pub fn matches_platform(&self, platform: &crate::TargetPlatform) -> bool {
435        match &self.condition {
436            None => true,
437            // Dependency gating is platform-only: a feature-referencing
438            // `cfg` is rejected on dependency tables at manifest load,
439            // so the empty feature set is correct-by-construction here
440            // (any `Feature` leaf would already have been refused).
441            Some(cond) => cond.evaluate(platform, &std::collections::BTreeSet::new()),
442        }
443    }
444}
445
446/// Which kind of dependency is declared.
447///
448/// Cabin distinguishes package dependency kinds (`Normal`, `Dev`)
449/// — both of which are sourced from other Cabin packages — from
450/// system dependencies, which are externally provided and never
451/// enter Cabin resolution. System declarations live alongside the
452/// package kinds as a separate `system = true` flag on a regular
453/// `[dependencies]` / `[dev-dependencies]` entry and are modeled
454/// by [`SystemDependency`].
455///
456/// The wire format mirrors the manifest section names: `"normal"`,
457/// `"dev"`.
458#[derive(
459    Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
460)]
461#[serde(rename_all = "lowercase")]
462pub enum DependencyKind {
463    /// `[dependencies]`. Linked into ordinary builds.
464    #[default]
465    Normal,
466    /// `[dev-dependencies]`. Declaration-only for ordinary
467    /// commands; activated for the selected primary packages by
468    /// `cabin test`.
469    Dev,
470}
471
472impl DependencyKind {
473    /// Stable lowercase label, matching the manifest section name.
474    pub const fn as_str(self) -> &'static str {
475        match self {
476            DependencyKind::Normal => "normal",
477            DependencyKind::Dev => "dev",
478        }
479    }
480
481    /// All kinds in canonical order. `cabin metadata` and the
482    /// canonical package metadata both iterate kinds in this order
483    /// so output stays deterministic.
484    pub const fn all() -> &'static [DependencyKind] {
485        &[DependencyKind::Normal, DependencyKind::Dev]
486    }
487
488    /// Whether this kind is included in the resolver / fetch /
489    /// build pipeline by default. Dev dependencies are excluded.
490    pub const fn is_resolved_by_default(self) -> bool {
491        matches!(self, DependencyKind::Normal)
492    }
493
494    /// Whether this kind contributes link / include edges to
495    /// ordinary `cabin build` targets. Only `Normal` does.
496    pub const fn affects_ordinary_build(self) -> bool {
497        matches!(self, DependencyKind::Normal)
498    }
499
500    /// Helper for `#[serde(skip_serializing_if = ...)]` so
501    /// existing on-disk metadata that omits the `kind` field
502    /// stays byte-identical for `[dependencies]`-only manifests.
503    pub fn is_normal(&self) -> bool {
504        matches!(self, DependencyKind::Normal)
505    }
506
507    /// The manifest section name (`[dependencies]`,
508    /// `[dev-dependencies]`) corresponding to this kind.
509    /// Used in error messages.
510    pub const fn manifest_section(self) -> &'static str {
511        match self {
512            DependencyKind::Normal => "[dependencies]",
513            DependencyKind::Dev => "[dev-dependencies]",
514        }
515    }
516}
517
518impl std::fmt::Display for DependencyKind {
519    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
520        f.write_str(self.as_str())
521    }
522}
523
524/// A system dependency declared with `system = true` on a
525/// `[dependencies]` / `[dev-dependencies]` entry.
526///
527/// System dependencies are externally provided (system libraries,
528/// SDKs, installed tools). Cabin never resolves, fetches,
529/// downloads, or installs them — `cabin-system-deps` probes them
530/// via `pkg-config` at build time, and the resulting cflags /
531/// ldflags are merged into the per-package build flags before
532/// the planner runs. The typed value round-trips through
533/// `cabin metadata`, the canonical package metadata, and the
534/// index metadata so external tooling sees the system-dep set
535/// alongside the Cabin-package deps.
536#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
537pub struct SystemDependency {
538    /// The dependency name as written in the manifest.
539    pub name: PackageName,
540    /// Version requirement string for `pkg-config`. Cabin does
541    /// not interpret it as a `SemVer` constraint; the system-deps
542    /// layer translates the supported comparators for
543    /// `pkg-config` and reports unsupported forms as errors.
544    pub version: String,
545    /// Which dependency table the entry was declared in
546    /// (`[dependencies]` or `[dev-dependencies]`). Drives per-kind
547    /// activation: a dev-kind system dep is only probed when
548    /// `cabin test` is running, mirroring the Cabin-package
549    /// dev-dep rule.
550    #[serde(default)]
551    pub kind: DependencyKind,
552    /// Optional target condition. `Some` when the system
553    /// dependency was declared inside a
554    /// `[target.'cfg(...)'.<kind>-dependencies]` table. The
555    /// condition is preserved so package / index metadata stays
556    /// portable across platforms.
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub condition: Option<crate::Condition>,
559}
560
561/// Where a foundation-port dependency's recipe comes from.
562///
563/// Constructed by the manifest parser from one of the two
564/// recipe-locator fields:
565///
566/// - `{ port = true, version = "..." }` → `Builtin { name, version_req }`. The recipe
567///   is resolved from `cabin_port::builtin::BUILTIN` by the discovery layer using the
568///   consumer-supplied `version_req`.
569/// - `{ port-path = "..." }` → `Path(PathBuf)`. The recipe lives
570///   on disk at the given path, interpreted relative to the
571///   manifest directory that declared it.
572#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
573pub enum PortDepSource {
574    /// Bundled curated recipe. `version_req` is the consumer-supplied requirement,
575    /// resolved against `cabin_port::builtin::BUILTIN` by the discovery layer.
576    Builtin {
577        name: PackageName,
578        version_req: semver::VersionReq,
579    },
580    Path(Utf8PathBuf),
581}
582
583/// Where a dependency is sourced from.
584///
585/// Covers [`DependencySource::Path`] for local path dependencies,
586/// [`DependencySource::Version`] for registry-resolved versioned
587/// dependencies, [`DependencySource::Port`] for foundation-port
588/// dependencies (curated recipes under `crates/cabin-port/ports/`), and
589/// [`DependencySource::Workspace`] for the `{ workspace = true }`
590/// opt-in into the workspace's shared dependency table. The
591/// `Workspace` variant is an unresolved marker —
592/// `cabin-workspace::load_workspace` rewrites it into the
593/// matching `Path` / `Version` / `Port` source from
594/// `[workspace.dependencies]` before any consumer sees a
595/// [`crate::Package`] returned from the workspace loader. If a
596/// `Workspace` source ever reaches a planner or resolver it
597/// indicates the package was loaded outside of
598/// `cabin-workspace`, which is a workspace invariant violation
599/// worth surfacing as a clear error in the caller.
600#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
601pub enum DependencySource {
602    /// Local path dependency. The path is interpreted relative to the
603    /// manifest directory of the package that declared the dependency.
604    #[serde(rename = "path")]
605    Path(Utf8PathBuf),
606    /// Versioned registry dependency. The requirement is matched against
607    /// candidate versions during dependency resolution.
608    #[serde(rename = "version")]
609    Version(semver::VersionReq),
610    /// Foundation-port dependency. The recipe source is one of two
611    /// shapes (see [`PortDepSource`]): a relative path to a port
612    /// directory on disk (`Path`), or a bundled curated recipe keyed
613    /// by the dependency name (`Builtin`). The CLI orchestration
614    /// layer prepares the port (download → verify → safe-extract
615    /// with `strip_prefix` → overlay copy) before the workspace
616    /// loader resolves the dependency to the prepared directory.
617    #[serde(rename = "port")]
618    Port(PortDepSource),
619    /// `dep = { workspace = true }`. An unresolved opt-in
620    /// into the workspace's `[workspace.dependencies]` table.
621    /// `cabin-workspace::load_workspace` resolves these to a
622    /// concrete [`DependencySource::Path`] or
623    /// [`DependencySource::Version`] before producing a
624    /// `PackageGraph`.
625    #[serde(rename = "workspace")]
626    Workspace,
627}
628
629/// Top-level validated package.
630#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
631pub struct Package {
632    pub name: PackageName,
633    pub version: semver::Version,
634    pub targets: Vec<Target>,
635    /// Cabin package dependencies declared under
636    /// `[dependencies]` or `[dev-dependencies]`. Each entry
637    /// carries its [`DependencyKind`]; iteration order is sorted
638    /// by `(kind, name)` so callers see deterministic output.
639    #[serde(default)]
640    pub dependencies: Vec<Dependency>,
641    /// `system = true` declarations. Empty if not
642    /// declared. System dependencies never enter the resolver,
643    /// the lockfile, or the artifact cache; they are
644    /// declaration-only and round-trip through metadata.
645    #[serde(default, skip_serializing_if = "Vec::is_empty")]
646    pub system_dependencies: Vec<SystemDependency>,
647    /// `[features]` declarations. Empty if the manifest has
648    /// no `[features]` table.
649    #[serde(default, skip_serializing_if = "is_empty_features")]
650    pub features: Features,
651    /// `[profile.<name>]` declarations from the manifest, keyed
652    /// by profile name. Built-in profiles do not need to appear
653    /// here; entries that match a built-in name override those
654    /// defaults. Empty for manifests with no profile tables, so
655    /// older manifests stay byte-identical through round-tripping.
656    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
657    pub profiles: BTreeMap<ProfileName, ProfileDefinition>,
658    /// `[toolchain]` plus any `[target.'cfg(...)'.toolchain]`
659    /// overrides declared on this manifest. Only the workspace
660    /// root manifest's settings are honored; member manifests
661    /// that declare a `[toolchain]` table are rejected by the
662    /// workspace loader.
663    #[serde(default, skip_serializing_if = "ToolchainSettings::is_empty")]
664    pub toolchain: ToolchainSettings,
665    /// `[profile]` plus any `[target.'cfg(...)'.profile]`
666    /// declarations for this package. Per-package by design — each
667    /// package may add its own defines / include dirs / extra args.
668    ///
669    /// The raw compiler / linker flag arrays (`cflags` / `cxxflags`
670    /// / `ldflags`) are honored only for local packages — the
671    /// workspace root, its members, and `path` dependencies. They
672    /// are dropped for registry dependencies during flag resolution
673    /// (see `resolve_build_flags`), because they are unvalidated and
674    /// could otherwise smuggle build-time code-execution options
675    /// such as `-fplugin=`. `defines` and `include_dirs` are
676    /// validated and kept for every package.
677    #[serde(default, skip_serializing_if = "ProfileSettings::is_empty")]
678    pub build: ProfileSettings,
679    /// `[profile.cache]` plus any `[target.'cfg(...)'.profile.cache]`
680    /// declarations from the workspace root manifest. Member
681    /// manifests cannot declare cache settings — the workspace
682    /// loader rejects them — so reading off the root is sufficient.
683    /// Round-trips through metadata so packaged manifests preserve
684    /// a publisher's declared wrapper preferences.
685    #[serde(
686        default,
687        skip_serializing_if = "CompilerWrapperManifestSettings::is_empty"
688    )]
689    pub compiler_wrapper: CompilerWrapperManifestSettings,
690    /// `[patch]` declarations on the workspace-root manifest.
691    /// Member manifests cannot declare patches — the workspace
692    /// loader rejects them — and `cabin package` refuses to
693    /// archive a manifest with a non-empty `[patch]` table.
694    /// Patches are *local development policy*, not package
695    /// metadata.
696    #[serde(default, skip_serializing_if = "PatchManifestSettings::is_empty")]
697    pub patches: PatchManifestSettings,
698}
699
700fn is_empty_features(f: &Features) -> bool {
701    f.default.is_empty() && f.features.is_empty()
702}
703
704impl Package {
705    /// Build a validated [`Package`].
706    ///
707    /// Validation:
708    /// - target names are unique
709    /// - dependency names are unique within each kind (the same
710    ///   name may legitimately appear under multiple kinds)
711    /// - system dependency names are unique within the
712    ///   collected `system = true` declarations
713    /// - feature declarations are well-formed
714    ///
715    /// Target-dep references (same-package, cross-package, or
716    /// qualified `package:target`) are resolved by `cabin-build`
717    /// against the full package graph, not here.
718    ///
719    /// # Errors
720    /// Returns a [`ValidationError`] when validation fails: see
721    /// [`Package::with_config`], which performs the checks
722    /// ([`ValidationError::DuplicateTargetName`],
723    /// [`ValidationError::DuplicateDependency`], and feature-table errors).
724    pub fn new(
725        name: PackageName,
726        version: semver::Version,
727        targets: Vec<Target>,
728        dependencies: Vec<Dependency>,
729    ) -> Result<Self, ValidationError> {
730        Self::with_config(PackageConfigInput {
731            name,
732            version,
733            targets,
734            dependencies,
735            system_dependencies: Vec::new(),
736            features: Features::default(),
737        })
738    }
739
740    /// Build a validated [`Package`] with `[features]` declarations
741    /// attached. `cabin-manifest` calls this after parsing the
742    /// `[features]` table.
743    ///
744    /// # Errors
745    /// Returns [`ValidationError::DuplicateTargetName`] for repeated target
746    /// names, [`ValidationError::DuplicateDependency`] for a duplicate
747    /// dependency within a kind, [`ValidationError::DuplicateSystemDependency`]
748    /// for a duplicate system dependency, and propagates any
749    /// [`ValidationError`] from validating the `[features]` table.
750    pub fn with_config(input: PackageConfigInput) -> Result<Self, ValidationError> {
751        let PackageConfigInput {
752            name,
753            version,
754            targets,
755            dependencies,
756            system_dependencies,
757            features,
758        } = input;
759        Self::validate_targets(&targets)?;
760        Self::validate_dependencies(&dependencies)?;
761        Self::validate_system_dependencies(&system_dependencies)?;
762        features.validate()?;
763        Ok(Self {
764            name,
765            version,
766            targets,
767            dependencies,
768            system_dependencies,
769            features,
770            profiles: BTreeMap::new(),
771            toolchain: ToolchainSettings::default(),
772            build: ProfileSettings::default(),
773            compiler_wrapper: CompilerWrapperManifestSettings::default(),
774            patches: PatchManifestSettings::default(),
775        })
776    }
777
778    /// Attach manifest-declared `[profile.*]` definitions to this
779    /// package. Returns the same package so callers can chain it
780    /// after [`Package::with_config`] without exploding the
781    /// constructor signature for every new optional table.
782    pub fn with_profiles(mut self, profiles: BTreeMap<ProfileName, ProfileDefinition>) -> Self {
783        self.profiles = profiles;
784        self
785    }
786}
787
788/// Bundled inputs for [`Package::with_config`].
789///
790/// `cabin-manifest` builds this from the parsed `cabin.toml` and hands
791/// it to [`Package::with_config`]. Threading inputs through one struct
792/// keeps `with_config` callable across the workspace without a fixed
793/// positional argument order.
794#[derive(Debug, Clone)]
795pub struct PackageConfigInput {
796    /// `package.name` from the manifest.
797    pub name: PackageName,
798    /// `package.version` from the manifest.
799    pub version: semver::Version,
800    /// Parsed `[target.*]` definitions.
801    pub targets: Vec<Target>,
802    /// Parsed `[dependencies]` / `[dev-dependencies]`.
803    pub dependencies: Vec<Dependency>,
804    /// Parsed `[system-dependencies]`.
805    pub system_dependencies: Vec<SystemDependency>,
806    /// Parsed `[features]`.
807    pub features: Features,
808}
809
810impl Package {
811    /// Attach the manifest-declared `[toolchain]` /
812    /// `[target.'cfg(...)'.toolchain]` block. Workspace loaders
813    /// reject these declarations on member / path-dep manifests
814    /// so only the entry-point manifest's value reaches downstream
815    /// crates.
816    pub fn with_toolchain(mut self, toolchain: ToolchainSettings) -> Self {
817        self.toolchain = toolchain;
818        self
819    }
820
821    /// Attach the manifest-declared `[profile]` /
822    /// `[target.'cfg(...)'.profile]` block. Per-package by design.
823    pub fn with_build(mut self, build: ProfileSettings) -> Self {
824        self.build = build;
825        self
826    }
827
828    /// Attach the manifest-declared `[profile.cache]` /
829    /// `[target.'cfg(...)'.profile.cache]` blocks. Workspace
830    /// loaders reject these declarations on member / path-dep
831    /// manifests so only the entry-point manifest's value reaches
832    /// downstream crates.
833    pub fn with_compiler_wrapper(mut self, settings: CompilerWrapperManifestSettings) -> Self {
834        self.compiler_wrapper = settings;
835        self
836    }
837
838    /// Attach the manifest-declared `[patch]` block. Workspace
839    /// loaders reject these declarations on member / path-dep
840    /// manifests so only the entry-point manifest's value
841    /// reaches downstream crates.
842    pub fn with_patches(mut self, patches: PatchManifestSettings) -> Self {
843        self.patches = patches;
844        self
845    }
846
847    fn validate_targets(targets: &[Target]) -> Result<(), ValidationError> {
848        let mut seen: HashSet<&str> = HashSet::with_capacity(targets.len());
849        for target in targets {
850            if !seen.insert(target.name.as_str()) {
851                return Err(ValidationError::DuplicateTargetName(
852                    target.name.as_str().to_owned(),
853                ));
854            }
855        }
856        Ok(())
857    }
858
859    fn validate_dependencies(deps: &[Dependency]) -> Result<(), ValidationError> {
860        let mut seen: HashSet<(DependencyKind, &str)> = HashSet::with_capacity(deps.len());
861        for dep in deps {
862            if !seen.insert((dep.kind, dep.name.as_str())) {
863                return Err(ValidationError::DuplicateDependency {
864                    name: dep.name.as_str().to_owned(),
865                    kind: dep.kind,
866                });
867            }
868        }
869        Ok(())
870    }
871
872    fn validate_system_dependencies(deps: &[SystemDependency]) -> Result<(), ValidationError> {
873        let mut seen: HashSet<&str> = HashSet::with_capacity(deps.len());
874        for dep in deps {
875            if !seen.insert(dep.name.as_str()) {
876                return Err(ValidationError::DuplicateSystemDependency(
877                    dep.name.as_str().to_owned(),
878                ));
879            }
880        }
881        Ok(())
882    }
883
884    /// Iterator over the package dependencies that participate in
885    /// the resolver / fetch / build pipeline by default — i.e.
886    /// every Cabin package dependency except `Dev`.
887    pub fn resolved_dependencies(&self) -> impl Iterator<Item = &Dependency> {
888        self.dependencies
889            .iter()
890            .filter(|d| d.kind.is_resolved_by_default())
891    }
892
893    /// Iterator over dependencies of a specific kind. Order is
894    /// the same as `dependencies` (sorted by `(kind, name)`).
895    pub fn dependencies_of_kind(&self, kind: DependencyKind) -> impl Iterator<Item = &Dependency> {
896        self.dependencies.iter().filter(move |d| d.kind == kind)
897    }
898}
899
900#[cfg(test)]
901mod tests {
902    use super::*;
903
904    fn version() -> semver::Version {
905        semver::Version::parse("0.1.0").unwrap()
906    }
907
908    fn pkg(name: &str) -> PackageName {
909        PackageName::new(name).unwrap()
910    }
911
912    fn tgt(name: &str) -> TargetName {
913        TargetName::new(name).unwrap()
914    }
915
916    fn target(name: &str, kind: TargetKind, deps: &[&str]) -> Target {
917        Target {
918            name: tgt(name),
919            kind,
920            sources: Vec::new(),
921            include_dirs: Vec::new(),
922            defines: Vec::new(),
923            deps: deps.iter().map(|d| (*d).to_owned()).collect(),
924        }
925    }
926
927    #[test]
928    fn package_name_rejects_empty() {
929        assert_eq!(
930            PackageName::new("").unwrap_err(),
931            ValidationError::EmptyPackageName
932        );
933    }
934
935    #[test]
936    fn package_name_rejects_whitespace() {
937        let err = PackageName::new("hello world").unwrap_err();
938        assert!(matches!(
939            err,
940            ValidationError::PackageNameContainsWhitespace(_)
941        ));
942    }
943
944    /// The displayed error must describe the actual grammar so a
945    /// user reading the message can fix their manifest without
946    /// reading the source. Pin the exact phrasing so the wording
947    /// can only change deliberately.
948    #[test]
949    fn package_name_error_describes_grammar() {
950        let err = PackageName::new("foo?bar").unwrap_err();
951        let displayed = err.to_string();
952        assert!(
953            displayed.contains("\"foo?bar\""),
954            "error must echo the offending name: {displayed}"
955        );
956        assert!(
957            displayed.contains("ASCII letters")
958                && displayed.contains("ASCII digits")
959                && displayed.contains("`_`")
960                && displayed.contains("`-`")
961                && displayed.contains("`.`"),
962            "error must describe the allowed alphabet: {displayed}"
963        );
964        assert!(
965            displayed.contains("must not start with `.` or `-`")
966                && displayed.contains("must not be `.` or `..`"),
967            "error must describe the structural restrictions: {displayed}"
968        );
969    }
970
971    // -----------------------------------------------------------------
972    // PackageName grammar covers filesystem, URL, and
973    // windows-filename safety simultaneously.
974    // -----------------------------------------------------------------
975
976    #[test]
977    fn package_name_accepts_simple_alphanumeric() {
978        assert!(PackageName::new("fmt").is_ok());
979    }
980
981    #[test]
982    fn package_name_accepts_hyphen_and_underscore() {
983        assert!(PackageName::new("foo-bar").is_ok());
984        assert!(PackageName::new("foo_bar").is_ok());
985        assert!(PackageName::new("foo-bar-baz").is_ok());
986    }
987
988    #[test]
989    fn package_name_accepts_dot_in_middle() {
990        // Dots in the middle of a name are allowed; only literal
991        // `.` / `..` and a leading dot are rejected.
992        assert!(PackageName::new("foo.bar").is_ok());
993        assert!(PackageName::new("foo..bar").is_ok());
994    }
995
996    #[test]
997    fn package_name_rejects_path_traversal() {
998        for raw in [".", "..", "../evil", ".hidden", "foo/bar", "foo\\bar"] {
999            assert!(
1000                matches!(
1001                    PackageName::new(raw).unwrap_err(),
1002                    ValidationError::UnsafePackageName(_)
1003                ),
1004                "{raw:?} should be rejected as unsafe"
1005            );
1006        }
1007    }
1008
1009    /// A leading `-` is rejected so the name cannot be parsed as
1010    /// a flag when it reaches an argv-driven tool (e.g.,
1011    /// `pkg-config` for `system = true` deps, the linker, or
1012    /// `clap` short-option splitting).
1013    #[test]
1014    fn package_name_rejects_leading_dash() {
1015        for raw in ["-foo", "--list-all", "-Lfoo", "-"] {
1016            assert!(
1017                matches!(
1018                    PackageName::new(raw).unwrap_err(),
1019                    ValidationError::UnsafePackageName(_)
1020                ),
1021                "{raw:?} must be rejected because of the leading `-`"
1022            );
1023        }
1024        // Embedded `-` is still fine.
1025        assert!(PackageName::new("foo-bar").is_ok());
1026        assert!(PackageName::new("foo--bar").is_ok());
1027    }
1028
1029    #[test]
1030    fn package_name_rejects_url_reserved() {
1031        for raw in [
1032            "foo?bar",
1033            "foo#bar",
1034            "foo%2Fbar",
1035            "foo:bar",
1036            "foo&bar",
1037            "foo=bar",
1038            "foo+bar",
1039            "foo@bar",
1040        ] {
1041            assert!(
1042                matches!(
1043                    PackageName::new(raw).unwrap_err(),
1044                    ValidationError::UnsafePackageName(_)
1045                ),
1046                "{raw:?} should be rejected as URL-reserved / outside grammar"
1047            );
1048        }
1049    }
1050
1051    #[test]
1052    fn package_name_rejects_windows_reserved_filename_chars() {
1053        for raw in [
1054            "foo<bar", "foo>bar", "foo|bar", "foo\"bar", "foo*bar", "foo:bar",
1055        ] {
1056            assert!(
1057                matches!(
1058                    PackageName::new(raw).unwrap_err(),
1059                    ValidationError::UnsafePackageName(_)
1060                ),
1061                "{raw:?} should be rejected as Windows-reserved filename char"
1062            );
1063        }
1064    }
1065
1066    #[test]
1067    fn package_name_rejects_non_ascii() {
1068        // A grammar limited to ASCII alphanumerics + `_-.` keeps
1069        // the encoding in URLs and tar archives unambiguous.
1070        for raw in ["foo\u{00E9}bar", "\u{4E2D}\u{6587}", "emoji\u{1F600}"] {
1071            assert!(
1072                matches!(
1073                    PackageName::new(raw).unwrap_err(),
1074                    ValidationError::UnsafePackageName(_)
1075                ),
1076                "{raw:?} should be rejected as non-ASCII"
1077            );
1078        }
1079    }
1080
1081    #[test]
1082    fn package_name_rejects_control_chars() {
1083        for raw in ["foo\u{0000}bar", "foo\u{0007}bar", "foo\u{007F}bar"] {
1084            assert!(PackageName::new(raw).is_err(), "{raw:?} should be rejected");
1085        }
1086    }
1087
1088    #[test]
1089    fn target_name_rejects_empty() {
1090        assert_eq!(
1091            TargetName::new("").unwrap_err(),
1092            ValidationError::EmptyTargetName
1093        );
1094    }
1095
1096    #[test]
1097    fn target_name_rejects_whitespace() {
1098        let err = TargetName::new("a b").unwrap_err();
1099        assert!(matches!(
1100            err,
1101            ValidationError::TargetNameContainsWhitespace(_)
1102        ));
1103    }
1104
1105    /// Symmetric with `package_name_rejects_leading_dash`. Target
1106    /// names eventually thread into argv (cargo flags, archiver
1107    /// inputs); a leading `-` would be ambiguous with a flag.
1108    /// Post-tightening this case is reported as `UnsafeTargetName`
1109    /// because the path-safe predicate rejects leading dashes as
1110    /// part of the same rule that excludes path separators.
1111    #[test]
1112    fn target_name_rejects_leading_dash() {
1113        for raw in ["-foo", "--release", "-"] {
1114            assert!(
1115                matches!(
1116                    TargetName::new(raw).unwrap_err(),
1117                    ValidationError::UnsafeTargetName(_)
1118                ),
1119                "{raw:?} must be rejected because of the leading `-`"
1120            );
1121        }
1122        // Embedded `-` is still fine.
1123        assert!(TargetName::new("foo-bar").is_ok());
1124    }
1125
1126    /// Target names are joined into object, executable, and Cargo
1127    /// target directory paths by the build planner. A manifest like
1128    /// `[target."/tmp/out"]` would otherwise let an attacker write
1129    /// build artifacts outside the selected `--build-dir`. Reject
1130    /// the full path-component grammar: path separators, parent
1131    /// references, leading dots, absolute paths, drive letters,
1132    /// and non-ASCII bytes.
1133    #[test]
1134    fn target_name_rejects_path_unsafe_values() {
1135        for raw in [
1136            "/foo",
1137            "foo/bar",
1138            "\\foo",
1139            "foo\\bar",
1140            "..",
1141            "../evil",
1142            ".",
1143            ".hidden",
1144            "/tmp/out",
1145            "C:foo",
1146            "foo\u{00E9}bar",
1147            "foo\u{0000}bar",
1148        ] {
1149            assert!(
1150                matches!(
1151                    TargetName::new(raw).unwrap_err(),
1152                    ValidationError::UnsafeTargetName(_)
1153                ),
1154                "{raw:?} should be rejected as path-unsafe"
1155            );
1156        }
1157    }
1158
1159    #[test]
1160    fn target_name_accepts_path_safe_values() {
1161        for raw in ["foo", "foo-bar", "foo_bar", "foo.bar", "lib1", "a"] {
1162            assert!(TargetName::new(raw).is_ok(), "{raw:?} should be accepted");
1163        }
1164    }
1165
1166    #[test]
1167    fn project_accepts_valid_targets() {
1168        let package = Package::new(
1169            pkg("hello"),
1170            version(),
1171            vec![
1172                target("lib", TargetKind::Library, &[]),
1173                target("exe", TargetKind::Executable, &["lib"]),
1174            ],
1175            Vec::new(),
1176        )
1177        .unwrap();
1178        assert_eq!(package.targets.len(), 2);
1179        assert!(package.dependencies.is_empty());
1180    }
1181
1182    #[test]
1183    fn project_rejects_duplicate_targets() {
1184        let err = Package::new(
1185            pkg("hello"),
1186            version(),
1187            vec![
1188                target("a", TargetKind::Library, &[]),
1189                target("a", TargetKind::Executable, &[]),
1190            ],
1191            Vec::new(),
1192        )
1193        .unwrap_err();
1194        assert_eq!(err, ValidationError::DuplicateTargetName("a".into()));
1195    }
1196
1197    #[test]
1198    fn project_accepts_unknown_target_dep_for_planner_resolution() {
1199        // target-dep existence is resolved by cabin-build against
1200        // the full package graph, so cabin-core no longer rejects unknown
1201        // names here.
1202        let package = Package::new(
1203            pkg("hello"),
1204            version(),
1205            vec![target("exe", TargetKind::Executable, &["external"])],
1206            Vec::new(),
1207        )
1208        .unwrap();
1209        assert_eq!(package.targets[0].deps[0].as_str(), "external");
1210    }
1211
1212    fn dep(name: &str, kind: DependencyKind) -> Dependency {
1213        Dependency {
1214            name: pkg(name),
1215            source: DependencySource::Path(Utf8PathBuf::from("../somewhere")),
1216            kind,
1217            optional: false,
1218            features: Vec::new(),
1219            default_features: true,
1220            condition: None,
1221        }
1222    }
1223
1224    #[test]
1225    fn project_rejects_duplicate_dependencies_within_a_kind() {
1226        let err = Package::new(
1227            pkg("hello"),
1228            version(),
1229            Vec::new(),
1230            vec![
1231                dep("greet", DependencyKind::Normal),
1232                dep("greet", DependencyKind::Normal),
1233            ],
1234        )
1235        .unwrap_err();
1236        assert_eq!(
1237            err,
1238            ValidationError::DuplicateDependency {
1239                name: "greet".into(),
1240                kind: DependencyKind::Normal,
1241            }
1242        );
1243    }
1244
1245    #[test]
1246    fn project_accepts_same_name_across_different_kinds() {
1247        // The same package may appear under multiple dependency
1248        // kind sections — that is the documented duplicate policy.
1249        let package = Package::new(
1250            pkg("hello"),
1251            version(),
1252            Vec::new(),
1253            vec![
1254                dep("fmt", DependencyKind::Normal),
1255                dep("fmt", DependencyKind::Dev),
1256            ],
1257        )
1258        .expect("same name across distinct kinds is allowed");
1259        assert_eq!(package.dependencies.len(), 2);
1260    }
1261
1262    #[test]
1263    fn project_rejects_duplicate_system_dependencies() {
1264        let sys = |n: &str| SystemDependency {
1265            name: pkg(n),
1266            version: ">=1".into(),
1267            kind: DependencyKind::Normal,
1268            condition: None,
1269        };
1270        let err = Package::with_config(PackageConfigInput {
1271            name: pkg("hello"),
1272            version: version(),
1273            targets: Vec::new(),
1274            dependencies: Vec::new(),
1275            system_dependencies: vec![sys("zlib"), sys("zlib")],
1276            features: Features::default(),
1277        })
1278        .unwrap_err();
1279        assert_eq!(
1280            err,
1281            ValidationError::DuplicateSystemDependency("zlib".into())
1282        );
1283    }
1284
1285    #[test]
1286    fn dependency_kind_lists_are_consistent() {
1287        // `all()` covers every variant.
1288        let all = DependencyKind::all();
1289        assert_eq!(all.len(), 2);
1290        // Resolution policy: dev is excluded by default.
1291        assert!(DependencyKind::Normal.is_resolved_by_default());
1292        assert!(!DependencyKind::Dev.is_resolved_by_default());
1293        // Linkage policy: only Normal contributes to ordinary builds.
1294        assert!(DependencyKind::Normal.affects_ordinary_build());
1295        assert!(!DependencyKind::Dev.affects_ordinary_build());
1296    }
1297
1298    #[test]
1299    fn target_kind_str_round_trip() {
1300        for kind in TargetKind::all() {
1301            assert_eq!(kind.to_string(), kind.as_str());
1302        }
1303    }
1304
1305    #[test]
1306    fn target_kind_classification_matches_documented_policy() {
1307        // `library` / `executable` are the production surface
1308        // that `cabin build` enumerates by default.
1309        for kind in [TargetKind::Library, TargetKind::Executable] {
1310            assert!(
1311                kind.is_default_buildable(),
1312                "{kind} must be default-buildable"
1313            );
1314            assert!(!kind.is_dev_only(), "{kind} must not be dev-only");
1315            assert!(!kind.is_test(), "{kind} must not be classed as a test");
1316        }
1317        // The dev-only kinds: `cabin build` ignores them; `cabin
1318        // test` runs `test` only.
1319        for kind in [TargetKind::Test, TargetKind::Example] {
1320            assert!(
1321                !kind.is_default_buildable(),
1322                "{kind} must NOT be default-buildable"
1323            );
1324            assert!(kind.is_dev_only(), "{kind} must be dev-only");
1325            assert!(kind.produces_executable(), "{kind} produces an executable");
1326        }
1327        assert!(TargetKind::Test.is_test());
1328        assert!(!TargetKind::Example.is_test());
1329    }
1330
1331    #[test]
1332    fn produces_executable_matches_kind_intent() {
1333        assert!(!TargetKind::Library.produces_executable());
1334        assert!(!TargetKind::HeaderOnly.produces_executable());
1335        assert!(TargetKind::Executable.produces_executable());
1336        assert!(TargetKind::Test.produces_executable());
1337        assert!(TargetKind::Example.produces_executable());
1338    }
1339
1340    #[test]
1341    fn header_only_is_default_buildable_but_produces_nothing() {
1342        // Header-only is included in the default selection so the
1343        // dep-closure walk reaches it, but the planner emits no
1344        // compile / archive / link actions for it.
1345        assert!(TargetKind::HeaderOnly.is_default_buildable());
1346        assert!(TargetKind::HeaderOnly.is_header_only());
1347        assert!(!TargetKind::HeaderOnly.produces_archive());
1348        assert!(!TargetKind::HeaderOnly.produces_executable());
1349    }
1350}