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