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}