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::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}