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::CompilerWrapperRequest;
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 equal to `.` 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 equal to `..` 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 this kind is "library-like" - a static-archive
302 /// library or a header-only library. These are the kinds that
303 /// carry a public interface (include dirs, interface standards)
304 /// to their consumers, as opposed to executable-like kinds.
305 pub const fn is_library_like(self) -> bool {
306 self.produces_archive() || self.is_header_only()
307 }
308
309 /// Whether ordinary `cabin build` selects this kind by default.
310 /// Dev-only kinds (`test` / `example`) are excluded
311 /// from the default set: tests are built by `cabin test`,
312 /// and examples only reach the build graph as a
313 /// transitive dep of another selected target.
314 ///
315 /// Header-only libraries are included so the dependency
316 /// closure walk reaches them; the planner emits no compile or
317 /// archive actions for them, so saying "yes, this is part of
318 /// the default selection" is a no-op on Ninja's side.
319 pub const fn is_default_buildable(self) -> bool {
320 matches!(self, Self::Library | Self::HeaderOnly | Self::Executable)
321 }
322
323 /// Whether this kind is a *development-only* target - a target
324 /// that exists to support workspace development but is not part
325 /// of the package's public surface. Production callers use this
326 /// to decide whether dev-dependencies should be activated and
327 /// whether the target may be run by `cabin test`.
328 pub const fn is_dev_only(self) -> bool {
329 matches!(self, Self::Test | Self::Example)
330 }
331
332 /// Whether `cabin test` runs this kind after building it. Today
333 /// only `test` runs; `example` is build-only.
334 pub const fn is_test(self) -> bool {
335 matches!(self, Self::Test)
336 }
337}
338
339impl std::fmt::Display for TargetKind {
340 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
341 f.write_str(self.as_str())
342 }
343}
344
345/// One declared entry of a target's `deps` array.
346///
347/// The manifest accepts two spellings: a bare reference string
348/// (`"foo"`, `"pkg:target"`), which declares a *private* edge, and
349/// the table form (`{ name = "foo", public = true }`), which
350/// additionally sets the per-edge visibility. Both forms keep the
351/// reference exactly as written; alias resolution (`foo` ->
352/// `foo:foo`) happens in `cabin-build` against a concrete package
353/// graph, and the resolved edge carries this declaration's
354/// visibility.
355#[derive(Debug, Clone, PartialEq, Eq)]
356pub struct TargetDep {
357 /// Raw target reference exactly as written in the manifest -
358 /// a bare name or a qualified `package:target`. See
359 /// [`Target::deps`] for why this is a `String`, not a
360 /// [`TargetName`].
361 pub reference: String,
362 /// Whether this edge re-exports the dependency's public
363 /// headers to the target's own consumers. Declarative only
364 /// today: recorded on the resolved dependency graph, consumed
365 /// by nothing yet.
366 pub public: bool,
367}
368
369impl TargetDep {
370 /// A private edge to `reference` - the meaning of the string
371 /// shorthand in manifests.
372 pub fn private(reference: impl Into<String>) -> Self {
373 Self {
374 reference: reference.into(),
375 public: false,
376 }
377 }
378}
379
380impl From<&str> for TargetDep {
381 fn from(reference: &str) -> Self {
382 Self::private(reference)
383 }
384}
385
386// The serialized shape mirrors the manifest surface: a private
387// edge stays a bare string (so existing manifests and the
388// `cabin metadata` JSON view keep their previous shape), and a
389// public edge serializes as the `{ name, public }` table.
390impl Serialize for TargetDep {
391 fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
392 where
393 S: serde::Serializer,
394 {
395 if self.public {
396 use serde::ser::SerializeMap;
397 let mut map = serializer.serialize_map(Some(2))?;
398 map.serialize_entry("name", &self.reference)?;
399 map.serialize_entry("public", &self.public)?;
400 map.end()
401 } else {
402 serializer.serialize_str(&self.reference)
403 }
404 }
405}
406
407// Hand-rolled Deserialize so the table form reports its own typed
408// errors (including the `deny_unknown_fields` "unknown field
409// `<name>`" message); an untagged derive would collapse every
410// failure to "data did not match any variant".
411impl<'de> Deserialize<'de> for TargetDep {
412 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
413 where
414 D: serde::Deserializer<'de>,
415 {
416 #[derive(Deserialize)]
417 #[serde(deny_unknown_fields)]
418 struct TargetDepTable {
419 name: String,
420 #[serde(default)]
421 public: bool,
422 }
423
424 struct TargetDepVisitor;
425
426 impl<'de> serde::de::Visitor<'de> for TargetDepVisitor {
427 type Value = TargetDep;
428
429 fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
430 f.write_str("a target reference string or a `{ name, public }` table")
431 }
432
433 fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
434 where
435 E: serde::de::Error,
436 {
437 Ok(TargetDep::private(v))
438 }
439
440 fn visit_string<E>(self, v: String) -> Result<Self::Value, E>
441 where
442 E: serde::de::Error,
443 {
444 Ok(TargetDep::private(v))
445 }
446
447 fn visit_map<M>(self, map: M) -> Result<Self::Value, M::Error>
448 where
449 M: serde::de::MapAccess<'de>,
450 {
451 let table =
452 TargetDepTable::deserialize(serde::de::value::MapAccessDeserializer::new(map))?;
453 Ok(TargetDep {
454 reference: table.name,
455 public: table.public,
456 })
457 }
458 }
459
460 deserializer.deserialize_any(TargetDepVisitor)
461 }
462}
463
464/// A buildable unit within a package.
465#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
466pub struct Target {
467 pub name: TargetName,
468 pub kind: TargetKind,
469 #[serde(default)]
470 pub sources: Vec<Utf8PathBuf>,
471 #[serde(default)]
472 pub include_dirs: Vec<Utf8PathBuf>,
473 #[serde(default)]
474 pub defines: Vec<String>,
475 /// Explicit references to the linked targets. A bare name
476 /// resolves to a same-package target first, then as the
477 /// same-name shorthand on a dependency package (`foo` means
478 /// `foo:foo`, matching the dependency's library / header-only
479 /// targets only); every other cross-package reference is the
480 /// qualified `package:target` form. A package dependency only
481 /// makes the package available - it never exports a *default*
482 /// target, so a bare name that matches neither a local target
483 /// nor a same-named linkable dependency target is a hard error.
484 /// Resolution against a concrete package graph lives in
485 /// `cabin-build`, not here.
486 ///
487 /// References are stored as raw strings, not [`TargetName`], because
488 /// the qualified `package:target` form contains a `:` that the
489 /// path-safe target-name grammar rejects. Validation happens at
490 /// resolution time against the already-validated package / target
491 /// graph; dep strings never flow directly into a filesystem path.
492 /// Each entry also carries the declared per-edge visibility - see
493 /// [`TargetDep`].
494 #[serde(default)]
495 pub deps: Vec<TargetDep>,
496 /// Package features that must all be enabled for this target
497 /// to be built or used. Entries name features declared in the
498 /// owning package's `[features]` table;
499 /// [`Package::with_config`] rejects unknown names. Default
500 /// target enumeration skips a target whose required features
501 /// are not enabled; naming one explicitly (a `deps` entry, a
502 /// manifest-target selector, `cabin test --test`) is a hard
503 /// error instead.
504 #[serde(default, skip_serializing_if = "Vec::is_empty")]
505 pub required_features: Vec<String>,
506 /// Per-target `c-standard` / `cxx-standard` /
507 /// `interface-c-standard` / `interface-cxx-standard` overrides.
508 /// Interface fields are only meaningful on `library` /
509 /// `header-only` kinds; the manifest parser rejects them on
510 /// executable-like targets.
511 #[serde(default, skip_serializing_if = "LanguageStandardSettings::is_empty")]
512 pub language: LanguageStandardSettings,
513}
514
515impl Target {
516 /// The subset of this target's `required-features` that is not
517 /// in `enabled`, in declaration order. Empty when the target
518 /// is buildable under the given feature set.
519 pub fn missing_required_features(
520 &self,
521 enabled: &std::collections::BTreeSet<String>,
522 ) -> Vec<String> {
523 self.required_features
524 .iter()
525 .filter(|f| !enabled.contains(*f))
526 .cloned()
527 .collect()
528 }
529}
530
531fn default_true() -> bool {
532 true
533}
534
535/// A package-level Cabin dependency declared in
536/// `[dependencies]` or `[dev-dependencies]`.
537///
538/// System dependencies (`system = true` entries) are *not*
539/// represented here - they live in [`SystemDependency`] because
540/// they have a different schema and never enter Cabin
541/// resolution.
542#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
543pub struct Dependency {
544 /// The dependency alias used in the manifest. The alias must
545 /// equal the depended-on package's `[package].name`.
546 pub name: PackageName,
547 pub source: DependencySource,
548 /// Which manifest section the dependency was declared in.
549 /// Defaults to [`DependencyKind::Normal`] so manifests that
550 /// only use `[dependencies]` keep their previous serialized
551 /// shape.
552 #[serde(default, skip_serializing_if = "DependencyKind::is_normal")]
553 pub kind: DependencyKind,
554 /// Whether the dependency is optional. Optional dependencies
555 /// only enter ordinary resolution / fetch / build when a
556 /// feature enables them via `dep:<name>` or
557 /// `<name>/<feature>`.
558 #[serde(default, skip_serializing_if = "is_false")]
559 pub optional: bool,
560 /// Features requested on the dependency package by this edge.
561 /// Stored as the raw manifest strings; the feature resolver
562 /// validates them against the depended-on package's
563 /// `[features]` table.
564 #[serde(default, skip_serializing_if = "Vec::is_empty")]
565 pub features: Vec<String>,
566 /// Whether this edge requests the dependency package's
567 /// `default` feature. Defaults to `true`. `default-features =
568 /// false` only narrows *this* edge - if another edge requests
569 /// defaults for the same package, the unified result still
570 /// includes them.
571 #[serde(default = "default_true", skip_serializing_if = "is_true")]
572 pub default_features: bool,
573 /// Optional target condition. `Some` when the dependency was
574 /// declared inside a `[target.'cfg(...)'.<kind>]` table;
575 /// `None` for unconditional declarations. Conditional
576 /// dependencies whose condition does not match the
577 /// evaluation [`crate::TargetPlatform`] are filtered out by
578 /// `cabin-workspace` / `cabin-feature` / `cabin-build`
579 /// before reaching the resolver or the build planner, but they
580 /// stay on `Package::dependencies` for metadata round-trip.
581 #[serde(default, skip_serializing_if = "Option::is_none")]
582 pub condition: Option<crate::Condition>,
583 /// `ignore-interface-standard = true`: exempt exactly this
584 /// dependency edge from the standard-compatibility check. The
585 /// check still reports the edge as unchecked; the field is
586 /// deliberately per-edge only (no package-wide or global
587 /// variant).
588 #[serde(default, skip_serializing_if = "is_false")]
589 pub ignore_interface_standard: bool,
590}
591
592fn is_false<T>(value: &T) -> bool
593where
594 T: PartialEq + Default,
595{
596 *value == T::default()
597}
598
599fn is_true<T>(value: &T) -> bool
600where
601 T: PartialEq + Default + std::ops::Not<Output = T>,
602{
603 *value == !T::default()
604}
605
606impl Dependency {
607 /// Whether this declaration is active for the given
608 /// [`crate::TargetPlatform`]. Unconditional declarations
609 /// are always active; conditional declarations are active
610 /// iff their condition evaluates to `true`.
611 pub fn matches_platform(&self, platform: &crate::TargetPlatform) -> bool {
612 match &self.condition {
613 None => true,
614 // Dependency gating is platform-only: a feature- or
615 // compiler-referencing `cfg` is rejected on dependency
616 // tables at manifest load, so the platform-only context is
617 // correct-by-construction here (any such leaf would
618 // already have been refused).
619 Some(cond) => cond.evaluate(&crate::ConditionContext::platform_only(platform)),
620 }
621 }
622}
623
624/// Which kind of dependency is declared.
625///
626/// Cabin distinguishes package dependency kinds (`Normal`, `Dev`)
627/// - both of which are sourced from other Cabin packages - from
628/// system dependencies, which are externally provided and never
629/// enter Cabin resolution. System declarations live alongside the
630/// package kinds as a separate `system = true` flag on a regular
631/// `[dependencies]` / `[dev-dependencies]` entry and are modeled
632/// by [`SystemDependency`].
633///
634/// The wire format mirrors the manifest section names: `"normal"`,
635/// `"dev"`.
636#[derive(
637 Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default, Serialize, Deserialize,
638)]
639#[serde(rename_all = "lowercase")]
640pub enum DependencyKind {
641 /// `[dependencies]`. Linked into ordinary builds.
642 #[default]
643 Normal,
644 /// `[dev-dependencies]`. Declaration-only for ordinary
645 /// commands; activated for the selected primary packages by
646 /// `cabin test`.
647 Dev,
648}
649
650impl DependencyKind {
651 /// Stable lowercase label, matching the manifest section name.
652 pub const fn as_str(self) -> &'static str {
653 match self {
654 DependencyKind::Normal => "normal",
655 DependencyKind::Dev => "dev",
656 }
657 }
658
659 /// All kinds in canonical order. `cabin metadata` and the
660 /// canonical package metadata both iterate kinds in this order
661 /// so output stays deterministic.
662 pub const fn all() -> &'static [DependencyKind] {
663 &[DependencyKind::Normal, DependencyKind::Dev]
664 }
665
666 /// Whether this kind is included in the resolver / fetch /
667 /// build pipeline by default. Dev dependencies are excluded.
668 pub const fn is_resolved_by_default(self) -> bool {
669 matches!(self, DependencyKind::Normal)
670 }
671
672 /// Helper for `#[serde(skip_serializing_if = ...)]` so
673 /// existing on-disk metadata that omits the `kind` field
674 /// stays byte-identical for `[dependencies]`-only manifests.
675 pub fn is_normal(&self) -> bool {
676 matches!(self, DependencyKind::Normal)
677 }
678
679 /// The manifest section name (`[dependencies]`,
680 /// `[dev-dependencies]`) corresponding to this kind.
681 /// Used in error messages.
682 pub const fn manifest_section(self) -> &'static str {
683 match self {
684 DependencyKind::Normal => "[dependencies]",
685 DependencyKind::Dev => "[dev-dependencies]",
686 }
687 }
688}
689
690impl std::fmt::Display for DependencyKind {
691 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
692 f.write_str(self.as_str())
693 }
694}
695
696/// Raw requirement strings from the workspace root's
697/// `[workspace.<kind>-dependencies]` tables, keyed by kind then
698/// dependency name. Carried for publish-time archive
699/// normalization, which writes the author's original spelling -
700/// the parsed [`semver::VersionReq`] would respell it (`"0.2"`
701/// renders as `"^0.2"`).
702#[derive(Debug, Clone, Default, PartialEq, Eq)]
703pub struct WorkspaceDepRequirements {
704 entries: BTreeMap<DependencyKind, BTreeMap<String, String>>,
705}
706
707impl WorkspaceDepRequirements {
708 /// Record the raw requirement string for `(kind, name)`.
709 pub fn insert(&mut self, kind: DependencyKind, name: String, requirement: String) {
710 self.entries
711 .entry(kind)
712 .or_default()
713 .insert(name, requirement);
714 }
715
716 /// The raw requirement string for `(kind, name)`. The lookup is
717 /// strictly kind-specific, mirroring the loader's rule.
718 #[must_use]
719 pub fn requirement(&self, kind: DependencyKind, name: &str) -> Option<&str> {
720 self.entries.get(&kind)?.get(name).map(String::as_str)
721 }
722}
723
724/// A system dependency declared with `system = true` on a
725/// `[dependencies]` / `[dev-dependencies]` entry.
726///
727/// System dependencies are externally provided (system libraries,
728/// SDKs, installed tools). Cabin never resolves, fetches,
729/// downloads, or installs them - `cabin-system-deps` probes them
730/// via `pkg-config` at build time, and the resulting cflags /
731/// ldflags are merged into the per-package build flags before
732/// the planner runs. The typed value round-trips through
733/// `cabin metadata`, the canonical package metadata, and the
734/// index metadata so external tooling sees the system-dep set
735/// alongside the Cabin-package deps.
736#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
737pub struct SystemDependency {
738 /// The dependency name as written in the manifest.
739 pub name: PackageName,
740 /// Version requirement string for `pkg-config`. Cabin does
741 /// not interpret it as a `SemVer` constraint; the system-deps
742 /// layer translates the supported comparators for
743 /// `pkg-config` and reports unsupported forms as errors.
744 pub version: String,
745 /// Which dependency table the entry was declared in
746 /// (`[dependencies]` or `[dev-dependencies]`). Drives per-kind
747 /// activation: a dev-kind system dep is only probed when
748 /// `cabin test` is running, mirroring the Cabin-package
749 /// dev-dep rule.
750 #[serde(default)]
751 pub kind: DependencyKind,
752 /// Optional target condition. `Some` when the system
753 /// dependency was declared inside a
754 /// `[target.'cfg(...)'.<kind>-dependencies]` table. The
755 /// condition is preserved so package / index metadata stays
756 /// portable across platforms.
757 #[serde(default, skip_serializing_if = "Option::is_none")]
758 pub condition: Option<crate::Condition>,
759}
760
761/// Where a foundation-port dependency's recipe comes from.
762///
763/// Constructed by the manifest parser from one of the two
764/// recipe-locator fields:
765///
766/// - `{ port = true, version = "..." }` → `Builtin { name, version_req }`. The recipe
767/// is resolved from `cabin_port::builtin::BUILTIN` by the discovery layer using the
768/// consumer-supplied `version_req`.
769/// - `{ port-path = "..." }` → `Path(PathBuf)`. The recipe lives
770/// on disk at the given path, interpreted relative to the
771/// manifest directory that declared it.
772#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
773pub enum PortDepSource {
774 /// Bundled curated recipe. `version_req` is the consumer-supplied requirement,
775 /// resolved against `cabin_port::builtin::BUILTIN` by the discovery layer.
776 Builtin {
777 name: PackageName,
778 version_req: semver::VersionReq,
779 },
780 Path(Utf8PathBuf),
781}
782
783/// Where a dependency is sourced from.
784///
785/// Covers [`DependencySource::Path`] for local path dependencies,
786/// [`DependencySource::Version`] for registry-resolved versioned
787/// dependencies, [`DependencySource::Port`] for foundation-port
788/// dependencies (curated recipes under `crates/cabin-port/ports/`), and
789/// [`DependencySource::Workspace`] for the `{ workspace = true }`
790/// opt-in into the workspace's shared dependency table. The
791/// `Workspace` variant is an unresolved marker -
792/// `cabin-workspace::load_workspace` rewrites it into the
793/// matching `Path` / `Version` / `Port` source from
794/// `[workspace.dependencies]` before any consumer sees a
795/// [`crate::Package`] returned from the workspace loader. If a
796/// `Workspace` source ever reaches a planner or resolver it
797/// indicates the package was loaded outside of
798/// `cabin-workspace`, which is a workspace invariant violation
799/// worth surfacing as a clear error in the caller.
800#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
801pub enum DependencySource {
802 /// Local path dependency. The path is interpreted relative to the
803 /// manifest directory of the package that declared the dependency.
804 #[serde(rename = "path")]
805 Path(Utf8PathBuf),
806 /// Versioned registry dependency. The requirement is matched against
807 /// candidate versions during dependency resolution.
808 #[serde(rename = "version")]
809 Version(semver::VersionReq),
810 /// Foundation-port dependency. The recipe source is one of two
811 /// shapes (see [`PortDepSource`]): a relative path to a port
812 /// directory on disk (`Path`), or a bundled curated recipe keyed
813 /// by the dependency name (`Builtin`). The CLI orchestration
814 /// layer prepares the port (download → verify → safe-extract
815 /// with `strip_prefix` → overlay copy) before the workspace
816 /// loader resolves the dependency to the prepared directory.
817 #[serde(rename = "port")]
818 Port(PortDepSource),
819 /// `dep = { workspace = true }`. An unresolved opt-in
820 /// into the workspace's `[workspace.dependencies]` table.
821 /// `cabin-workspace::load_workspace` resolves these to a
822 /// concrete [`DependencySource::Path`] or
823 /// [`DependencySource::Version`] before producing a
824 /// `PackageGraph`.
825 #[serde(rename = "workspace")]
826 Workspace,
827}
828
829/// Top-level validated package.
830#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
831pub struct Package {
832 pub name: PackageName,
833 pub version: semver::Version,
834 pub targets: Vec<Target>,
835 /// Cabin package dependencies declared under
836 /// `[dependencies]` or `[dev-dependencies]`. Each entry
837 /// carries its [`DependencyKind`]; iteration order is sorted
838 /// by `(kind, name)` so callers see deterministic output.
839 #[serde(default)]
840 pub dependencies: Vec<Dependency>,
841 /// `system = true` declarations. Empty if not
842 /// declared. System dependencies never enter the resolver,
843 /// the lockfile, or the artifact cache; they are
844 /// declaration-only and round-trip through metadata.
845 #[serde(default, skip_serializing_if = "Vec::is_empty")]
846 pub system_dependencies: Vec<SystemDependency>,
847 /// `[features]` declarations. Empty if the manifest has
848 /// no `[features]` table.
849 #[serde(default, skip_serializing_if = "is_empty_features")]
850 pub features: Features,
851 /// `[profile.<name>]` declarations from the manifest, keyed
852 /// by profile name. Built-in profiles do not need to appear
853 /// here; entries that match a built-in name override those
854 /// defaults. Empty for manifests with no profile tables, so
855 /// older manifests stay byte-identical through round-tripping.
856 #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
857 pub profiles: BTreeMap<ProfileName, ProfileDefinition>,
858 /// `[toolchain]` plus any `[target.'cfg(...)'.toolchain]`
859 /// overrides declared on this manifest. Only the workspace
860 /// root manifest's settings are honored; member manifests
861 /// that declare a `[toolchain]` table are rejected by the
862 /// workspace loader.
863 #[serde(default, skip_serializing_if = "ToolchainSettings::is_empty")]
864 pub toolchain: ToolchainSettings,
865 /// `[profile]` plus any general or named
866 /// `[target.'cfg(...)'.profile...]`
867 /// declarations for this package. Per-package by design - each
868 /// package may add its own defines / include dirs / extra args.
869 ///
870 /// The raw compiler / linker flag arrays (`cflags` / `cxxflags`
871 /// / `ldflags`) are honored only for local packages - the
872 /// workspace root, its members, and `path` dependencies. They
873 /// are dropped for registry dependencies during flag resolution
874 /// (see `resolve_build_flags`), because they are unvalidated and
875 /// could otherwise smuggle build-time code-execution options
876 /// such as `-fplugin=`. `defines` and `include_dirs` are
877 /// validated and kept for every package.
878 #[serde(default, skip_serializing_if = "ProfileSettings::is_empty")]
879 pub build: ProfileSettings,
880 /// `[package]`-level `c-standard` / `cxx-standard` /
881 /// `interface-c-standard` / `interface-cxx-standard`
882 /// declarations. Honored for every package kind - unlike the
883 /// raw flag escape hatches, a typed standard is a bounded
884 /// correctness requirement, so registry packages keep theirs.
885 #[serde(default, skip_serializing_if = "LanguageStandardSettings::is_empty")]
886 pub language: LanguageStandardSettings,
887 /// Workspace-root `[build] compiler-wrapper` declaration.
888 /// Member manifests cannot declare build execution settings.
889 #[serde(default, skip_serializing_if = "Option::is_none")]
890 pub compiler_wrapper: Option<CompilerWrapperRequest>,
891 /// `[patch]` declarations on the workspace-root manifest.
892 /// Member manifests cannot declare patches - the workspace
893 /// loader rejects them - and `cabin package` refuses to
894 /// archive a manifest with a non-empty `[patch]` table.
895 /// Patches are *local development policy*, not package
896 /// metadata.
897 #[serde(default, skip_serializing_if = "PatchManifestSettings::is_empty")]
898 pub patches: PatchManifestSettings,
899}
900
901fn is_empty_features(f: &Features) -> bool {
902 f.default.is_empty() && f.features.is_empty()
903}
904
905impl Package {
906 /// Build a validated [`Package`].
907 ///
908 /// Validation:
909 /// - target names are unique
910 /// - dependency names are unique within each kind (the same
911 /// name may legitimately appear under multiple kinds)
912 /// - system dependency names are unique within the
913 /// collected `system = true` declarations
914 /// - feature declarations are well-formed
915 ///
916 /// Target-dep references (same-package, cross-package, or
917 /// qualified `package:target`) are resolved by `cabin-build`
918 /// against the full package graph, not here.
919 ///
920 /// # Errors
921 /// Returns a [`ValidationError`] when validation fails: see
922 /// [`Package::with_config`], which performs the checks
923 /// ([`ValidationError::DuplicateTargetName`],
924 /// [`ValidationError::DuplicateDependency`], and feature-table errors).
925 pub fn new(
926 name: PackageName,
927 version: semver::Version,
928 targets: Vec<Target>,
929 dependencies: Vec<Dependency>,
930 ) -> Result<Self, ValidationError> {
931 Self::with_config(PackageConfigInput {
932 name,
933 version,
934 targets,
935 dependencies,
936 system_dependencies: Vec::new(),
937 features: Features::default(),
938 })
939 }
940
941 /// Build a validated [`Package`] with `[features]` declarations
942 /// attached. `cabin-manifest` calls this after parsing the
943 /// `[features]` table.
944 ///
945 /// # Errors
946 /// Returns [`ValidationError::DuplicateTargetName`] for repeated target
947 /// names, [`ValidationError::DuplicateDependency`] for a duplicate
948 /// dependency within a kind, [`ValidationError::DuplicateSystemDependency`]
949 /// for a duplicate system dependency, and propagates any
950 /// [`ValidationError`] from validating the `[features]` table.
951 pub fn with_config(input: PackageConfigInput) -> Result<Self, ValidationError> {
952 let PackageConfigInput {
953 name,
954 version,
955 targets,
956 dependencies,
957 system_dependencies,
958 features,
959 } = input;
960 Self::validate_targets(&targets)?;
961 Self::validate_dependencies(&dependencies)?;
962 Self::validate_system_dependencies(&system_dependencies)?;
963 features.validate()?;
964 Self::validate_required_features(&targets, &features)?;
965 Ok(Self {
966 name,
967 version,
968 targets,
969 dependencies,
970 system_dependencies,
971 features,
972 profiles: BTreeMap::new(),
973 toolchain: ToolchainSettings::default(),
974 build: ProfileSettings::default(),
975 language: LanguageStandardSettings::default(),
976 compiler_wrapper: None,
977 patches: PatchManifestSettings::default(),
978 })
979 }
980
981 /// Attach manifest-declared `[profile.*]` definitions to this
982 /// package. Returns the same package so callers can chain it
983 /// after [`Package::with_config`] without exploding the
984 /// constructor signature for every new optional table.
985 #[must_use]
986 pub fn with_profiles(mut self, profiles: BTreeMap<ProfileName, ProfileDefinition>) -> Self {
987 self.profiles = profiles;
988 self
989 }
990}
991
992/// Bundled inputs for [`Package::with_config`].
993///
994/// `cabin-manifest` builds this from the parsed `cabin.toml` and hands
995/// it to [`Package::with_config`]. Threading inputs through one struct
996/// keeps `with_config` callable across the workspace without a fixed
997/// positional argument order.
998#[derive(Debug, Clone)]
999pub struct PackageConfigInput {
1000 /// `package.name` from the manifest.
1001 pub name: PackageName,
1002 /// `package.version` from the manifest.
1003 pub version: semver::Version,
1004 /// Parsed `[target.*]` definitions.
1005 pub targets: Vec<Target>,
1006 /// Parsed `[dependencies]` / `[dev-dependencies]`.
1007 pub dependencies: Vec<Dependency>,
1008 /// Parsed `[system-dependencies]`.
1009 pub system_dependencies: Vec<SystemDependency>,
1010 /// Parsed `[features]`.
1011 pub features: Features,
1012}
1013
1014impl Package {
1015 /// Attach the manifest-declared `[toolchain]` /
1016 /// `[target.'cfg(...)'.toolchain]` block. Workspace loaders
1017 /// reject these declarations on member / path-dep manifests
1018 /// so only the entry-point manifest's value reaches downstream
1019 /// crates.
1020 #[must_use]
1021 pub fn with_toolchain(mut self, toolchain: ToolchainSettings) -> Self {
1022 self.toolchain = toolchain;
1023 self
1024 }
1025
1026 /// Attach the manifest-declared `[profile]` and general or named
1027 /// `[target.'cfg(...)'.profile...]` blocks. Per-package by design.
1028 #[must_use]
1029 pub fn with_build(mut self, build: ProfileSettings) -> Self {
1030 self.build = build;
1031 self
1032 }
1033
1034 /// Attach the manifest-declared `[package]`-level language
1035 /// standard fields. Per-package by design: registry packages'
1036 /// standard declarations are honored, unlike their raw flag
1037 /// escape hatches.
1038 #[must_use]
1039 pub fn with_language(mut self, language: LanguageStandardSettings) -> Self {
1040 self.language = language;
1041 self
1042 }
1043
1044 /// Attach the manifest-declared `[build] compiler-wrapper`.
1045 /// Workspace loaders reject this declaration on member / path-dep
1046 /// manifests.
1047 #[must_use]
1048 pub fn with_compiler_wrapper(mut self, request: Option<CompilerWrapperRequest>) -> Self {
1049 self.compiler_wrapper = request;
1050 self
1051 }
1052
1053 /// Attach the manifest-declared `[patch]` block. Workspace
1054 /// loaders reject these declarations on member / path-dep
1055 /// manifests so only the entry-point manifest's value
1056 /// reaches downstream crates.
1057 #[must_use]
1058 pub fn with_patches(mut self, patches: PatchManifestSettings) -> Self {
1059 self.patches = patches;
1060 self
1061 }
1062
1063 fn validate_targets(targets: &[Target]) -> Result<(), ValidationError> {
1064 let mut seen: HashSet<&str> = HashSet::with_capacity(targets.len());
1065 for target in targets {
1066 if !seen.insert(target.name.as_str()) {
1067 return Err(ValidationError::DuplicateTargetName(
1068 target.name.as_str().to_owned(),
1069 ));
1070 }
1071 }
1072 Ok(())
1073 }
1074
1075 /// Every `required-features` entry must satisfy the feature
1076 /// identifier grammar and name a feature declared in this
1077 /// package's `[features]` table. The reserved `default` key is
1078 /// not a declared feature, so requiring it is rejected too.
1079 fn validate_required_features(
1080 targets: &[Target],
1081 features: &Features,
1082 ) -> Result<(), ValidationError> {
1083 for target in targets {
1084 for name in &target.required_features {
1085 crate::config::validate_feature_identifier(name)?;
1086 if !features.features.contains_key(name) {
1087 return Err(ValidationError::UnknownRequiredFeature {
1088 target: target.name.as_str().to_owned(),
1089 feature: name.clone(),
1090 });
1091 }
1092 }
1093 }
1094 Ok(())
1095 }
1096
1097 fn validate_dependencies(deps: &[Dependency]) -> Result<(), ValidationError> {
1098 let mut seen: HashSet<(DependencyKind, &str)> = HashSet::with_capacity(deps.len());
1099 for dep in deps {
1100 if !seen.insert((dep.kind, dep.name.as_str())) {
1101 return Err(ValidationError::DuplicateDependency {
1102 name: dep.name.as_str().to_owned(),
1103 kind: dep.kind,
1104 });
1105 }
1106 }
1107 Ok(())
1108 }
1109
1110 fn validate_system_dependencies(deps: &[SystemDependency]) -> Result<(), ValidationError> {
1111 let mut seen: HashSet<&str> = HashSet::with_capacity(deps.len());
1112 for dep in deps {
1113 if !seen.insert(dep.name.as_str()) {
1114 return Err(ValidationError::DuplicateSystemDependency(
1115 dep.name.as_str().to_owned(),
1116 ));
1117 }
1118 }
1119 Ok(())
1120 }
1121
1122 /// Iterator over dependencies of a specific kind. Order is
1123 /// the same as `dependencies` (sorted by `(kind, name)`).
1124 pub fn dependencies_of_kind(&self, kind: DependencyKind) -> impl Iterator<Item = &Dependency> {
1125 self.dependencies.iter().filter(move |d| d.kind == kind)
1126 }
1127}
1128
1129#[cfg(test)]
1130mod tests {
1131 use super::*;
1132
1133 fn version() -> semver::Version {
1134 semver::Version::parse("0.1.0").unwrap()
1135 }
1136
1137 fn pkg(name: &str) -> PackageName {
1138 PackageName::new(name).unwrap()
1139 }
1140
1141 fn tgt(name: &str) -> TargetName {
1142 TargetName::new(name).unwrap()
1143 }
1144
1145 fn target(name: &str, kind: TargetKind, deps: &[&str]) -> Target {
1146 Target {
1147 name: tgt(name),
1148 kind,
1149 sources: Vec::new(),
1150 include_dirs: Vec::new(),
1151 defines: Vec::new(),
1152 deps: deps.iter().map(|d| TargetDep::from(*d)).collect(),
1153 required_features: Vec::new(),
1154 language: LanguageStandardSettings::default(),
1155 }
1156 }
1157
1158 #[test]
1159 fn package_name_rejects_empty() {
1160 assert_eq!(
1161 PackageName::new("").unwrap_err(),
1162 ValidationError::EmptyPackageName
1163 );
1164 }
1165
1166 #[test]
1167 fn package_name_rejects_whitespace() {
1168 let err = PackageName::new("hello world").unwrap_err();
1169 assert!(matches!(
1170 err,
1171 ValidationError::PackageNameContainsWhitespace(_)
1172 ));
1173 }
1174
1175 /// The displayed error must describe the actual grammar so a
1176 /// user reading the message can fix their manifest without
1177 /// reading the source. Pin the exact phrasing so the wording
1178 /// can only change deliberately.
1179 #[test]
1180 fn package_name_error_describes_grammar() {
1181 let err = PackageName::new("foo?bar").unwrap_err();
1182 let displayed = err.to_string();
1183 assert!(
1184 displayed.contains("\"foo?bar\""),
1185 "error must echo the offending name: {displayed}"
1186 );
1187 assert!(
1188 displayed.contains("ASCII letters")
1189 && displayed.contains("ASCII digits")
1190 && displayed.contains("`_`")
1191 && displayed.contains("`-`")
1192 && displayed.contains("`.`"),
1193 "error must describe the allowed alphabet: {displayed}"
1194 );
1195 assert!(
1196 displayed.contains("must not start with `.` or `-`")
1197 && displayed.contains("must not be `.` or `..`"),
1198 "error must describe the structural restrictions: {displayed}"
1199 );
1200 }
1201
1202 // -----------------------------------------------------------------
1203 // PackageName grammar covers filesystem, URL, and
1204 // windows-filename safety simultaneously.
1205 // -----------------------------------------------------------------
1206
1207 #[test]
1208 fn package_name_accepts_simple_alphanumeric() {
1209 assert!(PackageName::new("fmt").is_ok());
1210 }
1211
1212 #[test]
1213 fn package_name_accepts_hyphen_and_underscore() {
1214 assert!(PackageName::new("foo-bar").is_ok());
1215 assert!(PackageName::new("foo_bar").is_ok());
1216 assert!(PackageName::new("foo-bar-baz").is_ok());
1217 }
1218
1219 #[test]
1220 fn package_name_accepts_dot_in_middle() {
1221 // Dots in the middle of a name are allowed; only literal
1222 // `.` / `..` and a leading dot are rejected.
1223 assert!(PackageName::new("foo.bar").is_ok());
1224 assert!(PackageName::new("foo..bar").is_ok());
1225 }
1226
1227 #[test]
1228 fn package_name_rejects_path_traversal() {
1229 for raw in [".", "..", "../evil", ".hidden", "foo/bar", "foo\\bar"] {
1230 assert!(
1231 matches!(
1232 PackageName::new(raw).unwrap_err(),
1233 ValidationError::UnsafePackageName(_)
1234 ),
1235 "{raw:?} should be rejected as unsafe"
1236 );
1237 }
1238 }
1239
1240 /// A leading `-` is rejected so the name cannot be parsed as
1241 /// a flag when it reaches an argv-driven tool (e.g.,
1242 /// `pkg-config` for `system = true` deps, the linker, or
1243 /// `clap` short-option splitting).
1244 #[test]
1245 fn package_name_rejects_leading_dash() {
1246 for raw in ["-foo", "--list-all", "-Lfoo", "-"] {
1247 assert!(
1248 matches!(
1249 PackageName::new(raw).unwrap_err(),
1250 ValidationError::UnsafePackageName(_)
1251 ),
1252 "{raw:?} must be rejected because of the leading `-`"
1253 );
1254 }
1255 // Embedded `-` is still fine.
1256 assert!(PackageName::new("foo-bar").is_ok());
1257 assert!(PackageName::new("foo--bar").is_ok());
1258 }
1259
1260 #[test]
1261 fn package_name_rejects_url_reserved() {
1262 for raw in [
1263 "foo?bar",
1264 "foo#bar",
1265 "foo%2Fbar",
1266 "foo:bar",
1267 "foo&bar",
1268 "foo=bar",
1269 "foo+bar",
1270 "foo@bar",
1271 ] {
1272 assert!(
1273 matches!(
1274 PackageName::new(raw).unwrap_err(),
1275 ValidationError::UnsafePackageName(_)
1276 ),
1277 "{raw:?} should be rejected as URL-reserved / outside grammar"
1278 );
1279 }
1280 }
1281
1282 #[test]
1283 fn package_name_rejects_windows_reserved_filename_chars() {
1284 for raw in [
1285 "foo<bar", "foo>bar", "foo|bar", "foo\"bar", "foo*bar", "foo:bar",
1286 ] {
1287 assert!(
1288 matches!(
1289 PackageName::new(raw).unwrap_err(),
1290 ValidationError::UnsafePackageName(_)
1291 ),
1292 "{raw:?} should be rejected as Windows-reserved filename char"
1293 );
1294 }
1295 }
1296
1297 #[test]
1298 fn package_name_rejects_non_ascii() {
1299 // A grammar limited to ASCII alphanumerics + `_-.` keeps
1300 // the encoding in URLs and tar archives unambiguous.
1301 for raw in ["foo\u{00E9}bar", "\u{4E2D}\u{6587}", "emoji\u{1F600}"] {
1302 assert!(
1303 matches!(
1304 PackageName::new(raw).unwrap_err(),
1305 ValidationError::UnsafePackageName(_)
1306 ),
1307 "{raw:?} should be rejected as non-ASCII"
1308 );
1309 }
1310 }
1311
1312 #[test]
1313 fn package_name_rejects_control_chars() {
1314 for raw in ["foo\u{0000}bar", "foo\u{0007}bar", "foo\u{007F}bar"] {
1315 assert!(PackageName::new(raw).is_err(), "{raw:?} should be rejected");
1316 }
1317 }
1318
1319 #[test]
1320 fn target_name_rejects_empty() {
1321 assert_eq!(
1322 TargetName::new("").unwrap_err(),
1323 ValidationError::EmptyTargetName
1324 );
1325 }
1326
1327 #[test]
1328 fn target_name_rejects_whitespace() {
1329 let err = TargetName::new("a b").unwrap_err();
1330 assert!(matches!(
1331 err,
1332 ValidationError::TargetNameContainsWhitespace(_)
1333 ));
1334 }
1335
1336 /// Symmetric with `package_name_rejects_leading_dash`. Target
1337 /// names eventually thread into argv (cargo flags, archiver
1338 /// inputs); a leading `-` would be ambiguous with a flag.
1339 /// Post-tightening this case is reported as `UnsafeTargetName`
1340 /// because the path-safe predicate rejects leading dashes as
1341 /// part of the same rule that excludes path separators.
1342 #[test]
1343 fn target_name_rejects_leading_dash() {
1344 for raw in ["-foo", "--release", "-"] {
1345 assert!(
1346 matches!(
1347 TargetName::new(raw).unwrap_err(),
1348 ValidationError::UnsafeTargetName(_)
1349 ),
1350 "{raw:?} must be rejected because of the leading `-`"
1351 );
1352 }
1353 // Embedded `-` is still fine.
1354 assert!(TargetName::new("foo-bar").is_ok());
1355 }
1356
1357 /// Target names are joined into object, executable, and Cargo
1358 /// target directory paths by the build planner. A manifest like
1359 /// `[target."/tmp/out"]` would otherwise let an attacker write
1360 /// build artifacts outside the selected `--build-dir`. Reject
1361 /// the full path-component grammar: path separators, parent
1362 /// references, leading dots, absolute paths, drive letters,
1363 /// and non-ASCII bytes.
1364 #[test]
1365 fn target_name_rejects_path_unsafe_values() {
1366 for raw in [
1367 "/foo",
1368 "foo/bar",
1369 "\\foo",
1370 "foo\\bar",
1371 "..",
1372 "../evil",
1373 ".",
1374 ".hidden",
1375 "/tmp/out",
1376 "C:foo",
1377 "foo\u{00E9}bar",
1378 "foo\u{0000}bar",
1379 ] {
1380 assert!(
1381 matches!(
1382 TargetName::new(raw).unwrap_err(),
1383 ValidationError::UnsafeTargetName(_)
1384 ),
1385 "{raw:?} should be rejected as path-unsafe"
1386 );
1387 }
1388 }
1389
1390 #[test]
1391 fn target_name_accepts_path_safe_values() {
1392 for raw in ["foo", "foo-bar", "foo_bar", "foo.bar", "lib1", "a"] {
1393 assert!(TargetName::new(raw).is_ok(), "{raw:?} should be accepted");
1394 }
1395 }
1396
1397 #[test]
1398 fn project_accepts_valid_targets() {
1399 let package = Package::new(
1400 pkg("hello"),
1401 version(),
1402 vec![
1403 target("lib", TargetKind::Library, &[]),
1404 target("exe", TargetKind::Executable, &["lib"]),
1405 ],
1406 Vec::new(),
1407 )
1408 .unwrap();
1409 assert_eq!(package.targets.len(), 2);
1410 assert!(package.dependencies.is_empty());
1411 }
1412
1413 #[test]
1414 fn project_rejects_duplicate_targets() {
1415 let err = Package::new(
1416 pkg("hello"),
1417 version(),
1418 vec![
1419 target("a", TargetKind::Library, &[]),
1420 target("a", TargetKind::Executable, &[]),
1421 ],
1422 Vec::new(),
1423 )
1424 .unwrap_err();
1425 assert_eq!(err, ValidationError::DuplicateTargetName("a".into()));
1426 }
1427
1428 #[test]
1429 fn project_accepts_unknown_target_dep_for_planner_resolution() {
1430 // target-dep existence is resolved by cabin-build against
1431 // the full package graph, so cabin-core no longer rejects unknown
1432 // names here.
1433 let package = Package::new(
1434 pkg("hello"),
1435 version(),
1436 vec![target("exe", TargetKind::Executable, &["external"])],
1437 Vec::new(),
1438 )
1439 .unwrap();
1440 assert_eq!(package.targets[0].deps[0], TargetDep::private("external"));
1441 }
1442
1443 #[test]
1444 fn target_dep_serde_round_trips_both_shapes() {
1445 // A private edge keeps the bare-string shape (existing
1446 // manifests and the `cabin metadata` JSON view are
1447 // unchanged); a public edge serializes as the table form.
1448 let private = TargetDep::private("fmt");
1449 assert_eq!(serde_json::to_string(&private).unwrap(), "\"fmt\"");
1450 let public = TargetDep {
1451 reference: "fmt:core".to_owned(),
1452 public: true,
1453 };
1454 assert_eq!(
1455 serde_json::to_string(&public).unwrap(),
1456 r#"{"name":"fmt:core","public":true}"#
1457 );
1458 for dep in [private, public] {
1459 let json = serde_json::to_string(&dep).unwrap();
1460 assert_eq!(serde_json::from_str::<TargetDep>(&json).unwrap(), dep);
1461 }
1462 }
1463
1464 #[test]
1465 fn project_rejects_required_feature_not_declared() {
1466 let mut gated = target("tls", TargetKind::Library, &[]);
1467 gated.required_features = vec!["ssl".into()];
1468 let err = Package::with_config(PackageConfigInput {
1469 name: pkg("hello"),
1470 version: version(),
1471 targets: vec![gated],
1472 dependencies: Vec::new(),
1473 system_dependencies: Vec::new(),
1474 features: Features::default(),
1475 })
1476 .unwrap_err();
1477 assert_eq!(
1478 err,
1479 ValidationError::UnknownRequiredFeature {
1480 target: "tls".into(),
1481 feature: "ssl".into(),
1482 }
1483 );
1484 }
1485
1486 #[test]
1487 fn project_accepts_required_feature_declared_in_features_table() {
1488 let mut gated = target("tls", TargetKind::Library, &[]);
1489 gated.required_features = vec!["ssl".into()];
1490 let features = Features::new(
1491 Vec::new(),
1492 [("ssl".to_owned(), Vec::new())].into_iter().collect(),
1493 )
1494 .unwrap();
1495 let package = Package::with_config(PackageConfigInput {
1496 name: pkg("hello"),
1497 version: version(),
1498 targets: vec![gated],
1499 dependencies: Vec::new(),
1500 system_dependencies: Vec::new(),
1501 features,
1502 })
1503 .unwrap();
1504 assert_eq!(package.targets[0].required_features, vec!["ssl"]);
1505 }
1506
1507 #[test]
1508 fn project_rejects_required_feature_with_invalid_grammar() {
1509 // `dep:` / `pkg/feature` entry forms are feature-list
1510 // syntax, not feature names; `required-features` only
1511 // accepts local feature identifiers.
1512 let mut gated = target("tls", TargetKind::Library, &[]);
1513 gated.required_features = vec!["dep:openssl".into()];
1514 let err = Package::with_config(PackageConfigInput {
1515 name: pkg("hello"),
1516 version: version(),
1517 targets: vec![gated],
1518 dependencies: Vec::new(),
1519 system_dependencies: Vec::new(),
1520 features: Features::default(),
1521 })
1522 .unwrap_err();
1523 assert_eq!(
1524 err,
1525 ValidationError::InvalidConfigName {
1526 kind: "feature",
1527 value: "dep:openssl".into(),
1528 }
1529 );
1530 }
1531
1532 #[test]
1533 fn missing_required_features_reports_unmet_subset_in_order() {
1534 let mut gated = target("tls", TargetKind::Library, &[]);
1535 gated.required_features = vec!["ssl".into(), "net".into()];
1536 let enabled: std::collections::BTreeSet<String> = ["net".to_owned()].into();
1537 assert_eq!(gated.missing_required_features(&enabled), vec!["ssl"]);
1538 let both: std::collections::BTreeSet<String> = ["net".to_owned(), "ssl".to_owned()].into();
1539 assert!(gated.missing_required_features(&both).is_empty());
1540 }
1541
1542 fn dep(name: &str, kind: DependencyKind) -> Dependency {
1543 Dependency {
1544 name: pkg(name),
1545 source: DependencySource::Path(Utf8PathBuf::from("../somewhere")),
1546 kind,
1547 optional: false,
1548 features: Vec::new(),
1549 default_features: true,
1550 condition: None,
1551 ignore_interface_standard: false,
1552 }
1553 }
1554
1555 #[test]
1556 fn project_rejects_duplicate_dependencies_within_a_kind() {
1557 let err = Package::new(
1558 pkg("hello"),
1559 version(),
1560 Vec::new(),
1561 vec![
1562 dep("greet", DependencyKind::Normal),
1563 dep("greet", DependencyKind::Normal),
1564 ],
1565 )
1566 .unwrap_err();
1567 assert_eq!(
1568 err,
1569 ValidationError::DuplicateDependency {
1570 name: "greet".into(),
1571 kind: DependencyKind::Normal,
1572 }
1573 );
1574 }
1575
1576 #[test]
1577 fn project_accepts_same_name_across_different_kinds() {
1578 // The same package may appear under multiple dependency
1579 // kind sections - that is the documented duplicate policy.
1580 let package = Package::new(
1581 pkg("hello"),
1582 version(),
1583 Vec::new(),
1584 vec![
1585 dep("fmt", DependencyKind::Normal),
1586 dep("fmt", DependencyKind::Dev),
1587 ],
1588 )
1589 .expect("same name across distinct kinds is allowed");
1590 assert_eq!(package.dependencies.len(), 2);
1591 }
1592
1593 #[test]
1594 fn project_rejects_duplicate_system_dependencies() {
1595 let sys = |n: &str| SystemDependency {
1596 name: pkg(n),
1597 version: ">=1".into(),
1598 kind: DependencyKind::Normal,
1599 condition: None,
1600 };
1601 let err = Package::with_config(PackageConfigInput {
1602 name: pkg("hello"),
1603 version: version(),
1604 targets: Vec::new(),
1605 dependencies: Vec::new(),
1606 system_dependencies: vec![sys("zlib"), sys("zlib")],
1607 features: Features::default(),
1608 })
1609 .unwrap_err();
1610 assert_eq!(
1611 err,
1612 ValidationError::DuplicateSystemDependency("zlib".into())
1613 );
1614 }
1615
1616 #[test]
1617 fn dependency_kind_lists_are_consistent() {
1618 // `all()` covers every variant.
1619 let all = DependencyKind::all();
1620 assert_eq!(all.len(), 2);
1621 // Resolution policy: dev is excluded by default.
1622 assert!(DependencyKind::Normal.is_resolved_by_default());
1623 assert!(!DependencyKind::Dev.is_resolved_by_default());
1624 }
1625
1626 #[test]
1627 fn target_kind_str_round_trip() {
1628 for kind in TargetKind::all() {
1629 assert_eq!(kind.to_string(), kind.as_str());
1630 }
1631 }
1632
1633 #[test]
1634 fn target_kind_classification_matches_documented_policy() {
1635 // `library` / `executable` are the production surface
1636 // that `cabin build` enumerates by default.
1637 for kind in [TargetKind::Library, TargetKind::Executable] {
1638 assert!(
1639 kind.is_default_buildable(),
1640 "{kind} must be default-buildable"
1641 );
1642 assert!(!kind.is_dev_only(), "{kind} must not be dev-only");
1643 assert!(!kind.is_test(), "{kind} must not be classed as a test");
1644 }
1645 // The dev-only kinds: `cabin build` ignores them; `cabin
1646 // test` runs `test` only.
1647 for kind in [TargetKind::Test, TargetKind::Example] {
1648 assert!(
1649 !kind.is_default_buildable(),
1650 "{kind} must NOT be default-buildable"
1651 );
1652 assert!(kind.is_dev_only(), "{kind} must be dev-only");
1653 assert!(kind.produces_executable(), "{kind} produces an executable");
1654 }
1655 assert!(TargetKind::Test.is_test());
1656 assert!(!TargetKind::Example.is_test());
1657 }
1658
1659 #[test]
1660 fn produces_executable_matches_kind_intent() {
1661 assert!(!TargetKind::Library.produces_executable());
1662 assert!(!TargetKind::HeaderOnly.produces_executable());
1663 assert!(TargetKind::Executable.produces_executable());
1664 assert!(TargetKind::Test.produces_executable());
1665 assert!(TargetKind::Example.produces_executable());
1666 }
1667
1668 #[test]
1669 fn header_only_is_default_buildable_but_produces_nothing() {
1670 // Header-only is included in the default selection so the
1671 // dep-closure walk reaches it, but the planner emits no
1672 // compile / archive / link actions for it.
1673 assert!(TargetKind::HeaderOnly.is_default_buildable());
1674 assert!(TargetKind::HeaderOnly.is_header_only());
1675 assert!(!TargetKind::HeaderOnly.produces_archive());
1676 assert!(!TargetKind::HeaderOnly.produces_executable());
1677 }
1678}