Skip to main content

cabin_core/
model.rs

1use std::collections::{BTreeMap, HashSet};
2
3use camino::Utf8PathBuf;
4
5use serde::{Deserialize, Serialize};
6
7use crate::build_flags::ProfileSettings;
8use crate::compiler_wrapper::CompilerWrapperManifestSettings;
9use crate::config::Features;
10use crate::error::ValidationError;
11use crate::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}