Skip to main content

cabin_core/
profile.rs

1//! Build profiles.
2//!
3//! A profile is a named preset of build settings that affect how
4//! Cabin compiles a package - debug information, optimization
5//! level, assertions.  Two profiles are built in:
6//!
7//! - `dev` - local development.  Debug info on, no optimization,
8//!   assertions on.
9//! - `release` - optimized builds.  Debug info off, full
10//!   optimization, assertions off.
11//!
12//! Manifests may declare additional `[profile.<name>]` tables to
13//! override the built-in defaults or to add custom presets that
14//! `inherit` from one of the built-ins.  Resolution merges
15//! parents-first and is fully typed; the rest of Cabin
16//! (`cabin-build`, `cabin`, `cabin-package`) consumes a
17//! [`ResolvedProfile`] directly and never sees raw TOML.
18//!
19//! This module owns the *model and the resolver*.  Manifest parsing
20//! lives in `cabin-manifest`; CLI flag handling lives in
21//! `cabin`.
22
23use std::borrow::Borrow;
24use std::collections::{BTreeMap, BTreeSet};
25use std::fmt;
26
27use serde::{Deserialize, Serialize};
28use thiserror::Error;
29
30/// One of the two profiles Cabin always provides without any
31/// manifest declaration.
32#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
33pub enum BuiltinProfile {
34    Dev,
35    Release,
36}
37
38impl BuiltinProfile {
39    /// Iterate over every built-in in a stable order.
40    pub fn all() -> [BuiltinProfile; 2] {
41        [BuiltinProfile::Dev, BuiltinProfile::Release]
42    }
43
44    /// Public name as it appears in `[profile.<name>]` and on the
45    /// CLI.
46    pub fn as_str(self) -> &'static str {
47        match self {
48            BuiltinProfile::Dev => "dev",
49            BuiltinProfile::Release => "release",
50        }
51    }
52
53    /// Default field values for this built-in.
54    pub fn defaults(self) -> ProfileDefaults {
55        match self {
56            BuiltinProfile::Dev => ProfileDefaults {
57                debug: true,
58                opt_level: OptLevel::O0,
59                assertions: true,
60            },
61            BuiltinProfile::Release => ProfileDefaults {
62                debug: false,
63                opt_level: OptLevel::O3,
64                assertions: false,
65            },
66        }
67    }
68
69    /// Look up a built-in by name (case-sensitive).
70    pub fn from_name(name: &str) -> Option<Self> {
71        match name {
72            "dev" => Some(BuiltinProfile::Dev),
73            "release" => Some(BuiltinProfile::Release),
74            _ => None,
75        }
76    }
77}
78
79impl fmt::Display for BuiltinProfile {
80    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
81        f.write_str(self.as_str())
82    }
83}
84
85/// Concrete defaults for one profile.  Used to seed inheritance
86/// before any manifest overrides apply.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct ProfileDefaults {
89    pub debug: bool,
90    pub opt_level: OptLevel,
91    pub assertions: bool,
92}
93
94/// Semantic optimization level.  Mirrors the GCC / Clang `-O`
95/// family without exposing raw flag strings at the manifest
96/// layer; each value maps to a fixed GCC / Clang-style `-O` flag
97/// (see [`OptLevel::as_flag`]) that the build planner appends
98/// verbatim.  There is no per-toolchain flag translation today.
99#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
100pub enum OptLevel {
101    /// `-O0`.  No optimization; the dev profile default.
102    O0,
103    /// `-O1`.  Lightweight optimization.
104    O1,
105    /// `-O2`.  Standard optimization.
106    O2,
107    /// `-O3`.  Aggressive optimization; the release profile default.
108    O3,
109    /// `-Os`.  Optimize for size.
110    S,
111    /// `-Oz`.  Optimize harder for size; emitted verbatim as the
112    /// GCC / Clang `-Oz` spelling with no per-toolchain fallback.
113    Z,
114}
115
116impl OptLevel {
117    /// Compiler flag for this level.  The string form is stable and
118    /// is what the build planner appends to C/C++ compile
119    /// commands.
120    pub fn as_flag(self) -> &'static str {
121        match self {
122            OptLevel::O0 => "-O0",
123            OptLevel::O1 => "-O1",
124            OptLevel::O2 => "-O2",
125            OptLevel::O3 => "-O3",
126            OptLevel::S => "-Os",
127            OptLevel::Z => "-Oz",
128        }
129    }
130
131    /// Value used in JSON / metadata serialization.  Mirrors the
132    /// public manifest key (`opt-level`).
133    pub fn as_str(self) -> &'static str {
134        match self {
135            OptLevel::O0 => "0",
136            OptLevel::O1 => "1",
137            OptLevel::O2 => "2",
138            OptLevel::O3 => "3",
139            OptLevel::S => "s",
140            OptLevel::Z => "z",
141        }
142    }
143}
144
145impl fmt::Display for OptLevel {
146    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
147        f.write_str(self.as_str())
148    }
149}
150
151impl Serialize for OptLevel {
152    fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
153        ser.serialize_str(self.as_str())
154    }
155}
156
157impl<'de> Deserialize<'de> for OptLevel {
158    fn deserialize<D: serde::Deserializer<'de>>(de: D) -> Result<Self, D::Error> {
159        // `opt-level` accepts numeric integers (0..=3) and the
160        // string aliases `"s"` / `"z"`.  The TOML deserialiser hands
161        // the parsed value through one of these channels; both must
162        // reach the same `OptLevel`.
163        struct V;
164        impl serde::de::Visitor<'_> for V {
165            type Value = OptLevel;
166            fn expecting(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
167                f.write_str("0, 1, 2, 3, \"s\", or \"z\"")
168            }
169            fn visit_str<E: serde::de::Error>(self, s: &str) -> Result<OptLevel, E> {
170                OptLevel::parse(s).map_err(serde::de::Error::custom)
171            }
172            fn visit_u64<E: serde::de::Error>(self, v: u64) -> Result<OptLevel, E> {
173                OptLevel::parse(&v.to_string()).map_err(serde::de::Error::custom)
174            }
175            fn visit_i64<E: serde::de::Error>(self, v: i64) -> Result<OptLevel, E> {
176                OptLevel::parse(&v.to_string()).map_err(serde::de::Error::custom)
177            }
178        }
179        de.deserialize_any(V)
180    }
181}
182
183impl OptLevel {
184    /// Parse the public manifest form.  Accepts integers `0..=3`
185    /// and the lowercase letters `"s"` / `"z"` exactly.  Anything
186    /// else returns a stable, user-facing error string.
187    ///
188    /// # Errors
189    /// Returns an error string when `raw` is not one of `0`, `1`, `2`, `3`,
190    /// `"s"`, or `"z"`.
191    pub fn parse(raw: &str) -> Result<Self, String> {
192        match raw {
193            "0" => Ok(OptLevel::O0),
194            "1" => Ok(OptLevel::O1),
195            "2" => Ok(OptLevel::O2),
196            "3" => Ok(OptLevel::O3),
197            "s" => Ok(OptLevel::S),
198            "z" => Ok(OptLevel::Z),
199            other => Err(format!(
200                "invalid opt-level {other:?}; expected 0, 1, 2, 3, \"s\", or \"z\""
201            )),
202        }
203    }
204}
205
206/// Validated profile name.
207///
208/// Profile names appear in three places: the manifest TOML key
209/// (`[profile.<name>]`), the CLI flag (`--profile <name>`), and
210/// the on-disk build directory layout
211/// (`<build_dir>/<profile>/...`).  The grammar below is the
212/// intersection of those three constraints so a single value can
213/// flow through all of them without per-stage re-validation.
214#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)]
215#[serde(try_from = "String", into = "String")]
216pub struct ProfileName(String);
217
218impl ProfileName {
219    /// Construct a [`ProfileName`] after running validation.
220    ///
221    /// A name is valid iff:
222    ///
223    /// - it is non-empty;
224    /// - it consists only of ASCII alphanumerics, `_`, `-`, `.`;
225    /// - it does not start with `.`;
226    /// - it is not equal to `.` or `..`.
227    ///
228    /// # Errors
229    /// Returns [`InvalidProfileName`] when `value` fails the
230    /// `is_path_safe_profile_name` predicate above.
231    pub fn new(value: impl Into<String>) -> Result<Self, InvalidProfileName> {
232        let value = value.into();
233        if !is_path_safe_profile_name(&value) {
234            return Err(InvalidProfileName(value));
235        }
236        Ok(Self(value))
237    }
238
239    /// Construct a [`ProfileName`] for one of Cabin's two built-ins.
240    /// Built-in names are guaranteed valid so this never fails.
241    pub fn builtin(profile: BuiltinProfile) -> Self {
242        Self(profile.as_str().to_owned())
243    }
244
245    pub fn as_str(&self) -> &str {
246        &self.0
247    }
248
249    /// Returns the matching [`BuiltinProfile`] when this name
250    /// refers to a built-in.
251    pub fn as_builtin(&self) -> Option<BuiltinProfile> {
252        BuiltinProfile::from_name(&self.0)
253    }
254}
255
256impl fmt::Display for ProfileName {
257    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258        f.write_str(&self.0)
259    }
260}
261
262impl AsRef<str> for ProfileName {
263    fn as_ref(&self) -> &str {
264        &self.0
265    }
266}
267
268impl Borrow<str> for ProfileName {
269    fn borrow(&self) -> &str {
270        &self.0
271    }
272}
273
274impl From<ProfileName> for String {
275    fn from(name: ProfileName) -> Self {
276        name.0
277    }
278}
279
280impl TryFrom<String> for ProfileName {
281    type Error = InvalidProfileName;
282    fn try_from(value: String) -> Result<Self, Self::Error> {
283        ProfileName::new(value)
284    }
285}
286
287/// Returns whether `name` matches the [`ProfileName`] grammar.
288pub(crate) fn is_path_safe_profile_name(name: &str) -> bool {
289    if name.is_empty() {
290        return false;
291    }
292    if name == "." || name == ".." {
293        return false;
294    }
295    if name.starts_with('.') {
296        return false;
297    }
298    name.bytes()
299        .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-' | b'.'))
300}
301
302/// `cabin.toml`'s public grammar limits which characters profile
303/// names may contain.  The constructor surfaces this error type so
304/// callers (CLI, manifest parser) can format a clear diagnostic
305/// without duplicating the rule.
306#[derive(Debug, Error, Clone, PartialEq, Eq)]
307#[error(
308    "invalid profile name {0:?}; profile names must be non-empty, must not start with `.`, must not be `.` or `..`, and may only contain ASCII alphanumerics, `_`, `-`, or `.`"
309)]
310pub struct InvalidProfileName(pub String);
311
312/// One `[profile.<name>]` declaration as it appeared in
313/// `cabin.toml`, after manifest-level validation but before
314/// inheritance resolution.  Every field except `name` is `Option`
315/// so the resolver can tell "user did not set this" from "user
316/// set this to a value".
317#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
318pub struct ProfileDefinition {
319    pub name: ProfileName,
320    /// Profile this one inherits from.  Required for custom
321    /// profiles; rejected on built-in profiles.
322    #[serde(default, skip_serializing_if = "Option::is_none")]
323    pub inherits: Option<ProfileName>,
324    #[serde(default, skip_serializing_if = "Option::is_none")]
325    pub debug: Option<bool>,
326    #[serde(default, rename = "opt-level", skip_serializing_if = "Option::is_none")]
327    pub opt_level: Option<OptLevel>,
328    #[serde(default, skip_serializing_if = "Option::is_none")]
329    pub assertions: Option<bool>,
330    /// Per-profile flag overrides for `[profile.<name>]` - defines,
331    /// include directories, and extra compile / link arguments that
332    /// apply when this profile is selected.  `None` when the profile
333    /// has no flag overrides.
334    #[serde(default, skip_serializing_if = "Option::is_none")]
335    pub build: Option<crate::build_flags::ProfileFlags>,
336}
337
338/// User-facing profile selection (one CLI invocation picks at
339/// most one profile).  The resolver expands this into a full
340/// [`ResolvedProfile`] against a definition table.
341#[derive(Debug, Clone, PartialEq, Eq)]
342pub struct ProfileSelection {
343    pub name: ProfileName,
344}
345
346impl ProfileSelection {
347    /// Default selection when neither `--profile` nor `--release`
348    /// is supplied - the `dev` built-in.
349    pub fn default_dev() -> Self {
350        Self {
351            name: ProfileName::builtin(BuiltinProfile::Dev),
352        }
353    }
354
355    /// Selection produced by the legacy `--release` flag, kept as
356    /// a compatibility alias for `--profile release`.
357    pub fn release_alias() -> Self {
358        Self {
359            name: ProfileName::builtin(BuiltinProfile::Release),
360        }
361    }
362
363    /// Selection from a user-supplied `--profile <name>` argument.
364    pub fn from_name(name: ProfileName) -> Self {
365        Self { name }
366    }
367}
368
369/// Where a [`ResolvedProfile`] originated.
370#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
371#[serde(rename_all = "kebab-case")]
372pub enum ProfileSource {
373    /// One of `dev` / `release` with no manifest entry.
374    Builtin,
375    /// One of `dev` / `release` with a `[profile.dev]` /
376    /// `[profile.release]` manifest override.
377    BuiltinOverridden,
378    /// A user-defined `[profile.<name>]` inheriting from a
379    /// built-in (directly or transitively).
380    Custom,
381}
382
383/// Fully resolved profile.
384///
385/// Scalar fields are typed and concrete; downstream consumers
386/// (build planner, CLI) read this struct directly. `build` is
387/// the per-profile flag overlay merged root → selected across
388/// `inherits_chain` - see the field docstring.
389#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
390pub struct ResolvedProfile {
391    pub name: ProfileName,
392    pub debug: bool,
393    pub opt_level: OptLevel,
394    pub assertions: bool,
395    pub source: ProfileSource,
396    /// Chain of profile names walked by inheritance, root first.
397    /// For built-ins this is `[name]`; for a custom profile that
398    /// inherits from `release` it is `["release", <custom>]`.
399    /// The chain is also the order used to **append** each
400    /// step's `ProfileDefinition.build` into [`Self::build`].
401    pub inherits_chain: Vec<ProfileName>,
402    /// `[profile.<name>]` per-profile flag overlay, merged
403    /// root-first across `inherits_chain` via
404    /// `ProfileFlags::append_layer`.
405    ///
406    /// `None` means **no** profile in the chain declared
407    /// `build = Some(_)`.  `Some(_)` means at least one step
408    /// contributed profile flags, even if the resulting
409    /// accumulator happens to be empty (uniform shape for
410    /// consumers).
411    ///
412    /// `#[serde(skip)]` because the merged value is computed
413    /// from the inherits-chain walk inside
414    /// [`resolve_profile`] - it isn't part of the on-disk JSON
415    /// schema. `cabin metadata`'s JSON view of a profile comes
416    /// from [`Self::as_json`], which lists fields explicitly;
417    /// the resolved build flags surface under
418    /// `BuildConfiguration.build_flags` in metadata output, not
419    /// here.
420    #[serde(skip)]
421    pub build: Option<crate::build_flags::ProfileFlags>,
422}
423
424impl ResolvedProfile {
425    /// Compact JSON view used by `cabin metadata` and by
426    /// `CABIN_BUILD_CONFIGURATION_JSON`.  Field order matches the
427    /// struct declaration order so the on-disk shape is stable.
428    pub fn as_json(&self) -> serde_json::Value {
429        serde_json::json!({
430            "name": self.name.as_str(),
431            "debug": self.debug,
432            "opt_level": self.opt_level.as_str(),
433            "assertions": self.assertions,
434            "source": match self.source {
435                ProfileSource::Builtin => "builtin",
436                ProfileSource::BuiltinOverridden => "builtin-overridden",
437                ProfileSource::Custom => "custom",
438            },
439            "inherits_chain": self
440                .inherits_chain
441                .iter()
442                .map(ProfileName::as_str)
443                .collect::<Vec<_>>(),
444        })
445    }
446
447    /// Compute the language-neutral compile flags this profile
448    /// contributes.
449    /// The order is fixed: `-O<level>` first, then `-g` when
450    /// debug info is requested, then `-DNDEBUG` when assertions
451    /// are off.  Determinism matters here because the result lands
452    /// in `compile_commands.json`.
453    pub fn compile_flags(&self) -> Vec<&'static str> {
454        let mut out = Vec::with_capacity(3);
455        out.push(self.opt_level.as_flag());
456        if self.debug {
457            out.push("-g");
458        }
459        if !self.assertions {
460            out.push("-DNDEBUG");
461        }
462        out
463    }
464}
465
466/// Errors produced by [`resolve_profile`].
467#[derive(Debug, Error, Clone, PartialEq, Eq)]
468pub enum ProfileResolutionError {
469    /// The user selected a profile that neither matches a built-in
470    /// nor a manifest entry.
471    #[error("unknown profile `{name}`; define it with `[profile.{name}]` and an `inherits` field")]
472    UnknownProfile { name: String },
473
474    /// A custom profile's `inherits =` points at a name that does
475    /// not exist.
476    #[error("profile `{profile}` inherits from unknown profile `{parent}`")]
477    UnknownInheritedProfile { profile: String, parent: String },
478
479    /// The inheritance graph contains a cycle.  The chain is
480    /// rendered with `->` separators so the diagnostic is
481    /// scannable in CI logs.
482    #[error("profile inheritance cycle detected: {}", display_chain(.chain))]
483    InheritanceCycle { chain: Vec<String> },
484
485    /// `[profile.dev]` or `[profile.release]` declared
486    /// `inherits =`, which is not allowed because built-in
487    /// profiles already have implicit defaults.
488    #[error("built-in profile `{name}` cannot declare `inherits`; only custom profiles inherit")]
489    BuiltinCannotInherit { name: String },
490
491    /// A custom profile omitted `inherits =`.  Cabin requires the
492    /// field on every custom profile so the inheritance closure is
493    /// explicit.
494    #[error(
495        "custom profile `{name}` must declare `inherits = \"dev\"` or `inherits = \"release\"` (or another custom profile)"
496    )]
497    CustomMissingInherits { name: String },
498}
499
500fn display_chain(chain: &[String]) -> String {
501    chain.join(" -> ")
502}
503
504/// Resolve a [`ProfileSelection`] against a set of manifest
505/// [`ProfileDefinition`]s.
506///
507/// `definitions` is the workspace-root manifest's
508/// `[profile.<name>]` table set.  Built-in profiles (`dev`,
509/// `release`) do not need to appear in the table; if they do, the
510/// values override the built-in defaults.
511///
512/// Resolution rules:
513///
514/// - if the selection names a built-in and no override exists,
515///   return [`ProfileSource::Builtin`] with the built-in defaults;
516/// - if the selection names a built-in *with* an override, apply
517///   the override on top of the defaults, mark the result as
518///   [`ProfileSource::BuiltinOverridden`];
519/// - if the selection names a custom profile, walk the
520///   `inherits` chain to a built-in root, merge fields
521///   parents-first, mark the result as [`ProfileSource::Custom`];
522/// - the chain is checked for cycles and unknown parents up
523///   front so the merge step never panics.
524///
525/// Merge semantics across the inherits chain:
526///
527/// - **Scalar fields** (`opt-level`, `debug`, `assertions`) use
528///   **replacement** - root first, child later, later wins.
529/// - **Array fields** in
530///   [`ProfileDefinition::build`] (`cflags`, `cxxflags`,
531///   `ldflags`, `defines`, `include-dirs`) use **append**:
532///   each chain step's
533///   layer is folded into the accumulator via
534///   `ProfileFlags::append_layer` in
535///   root → selected order.  The merged result lands on
536///   [`ResolvedProfile::build`]. Build-flag resolution also uses
537///   [`ResolvedProfile::inherits_chain`] and the original definitions
538///   so named target overlays can be interleaved after each step.
539///
540/// # Errors
541/// Returns a [`ProfileResolutionError`] when the definitions or selection are
542/// invalid: an `inherits` cycle ([`ProfileResolutionError::InheritanceCycle`]),
543/// a reference to an unknown profile ([`ProfileResolutionError::UnknownProfile`])
544/// or unknown parent ([`ProfileResolutionError::UnknownInheritedProfile`]), or a
545/// validation failure surfaced by `validate_definitions`
546/// ([`ProfileResolutionError::BuiltinCannotInherit`],
547/// [`ProfileResolutionError::CustomMissingInherits`]).
548///
549/// # Panics
550/// Panics if the inheritance walk somehow produces an empty chain, which cannot
551/// happen because the selected name is always pushed before the loop can break.
552/// The two `unreachable!` arms (a built-in with `inherits`, or a custom profile
553/// without it) are likewise excluded up front by `validate_definitions`.
554pub fn resolve_profile(
555    selection: &ProfileSelection,
556    definitions: &BTreeMap<ProfileName, ProfileDefinition>,
557) -> Result<ResolvedProfile, ProfileResolutionError> {
558    validate_definitions(definitions)?;
559
560    let mut chain: Vec<ProfileName> = Vec::new();
561    let mut seen: BTreeSet<ProfileName> = BTreeSet::new();
562    let mut cursor = selection.name.clone();
563
564    // Walk inheritance up to a built-in root.  The chain ends as
565    // soon as either (a) `cursor` names a built-in or (b) `cursor`
566    // names a manifest definition that has no `inherits` (which is
567    // only legal for built-in overrides).
568    loop {
569        if !seen.insert(cursor.clone()) {
570            // Cycle: render the chain ending at the offending name.
571            let mut display: Vec<String> = chain.iter().map(|n| n.as_str().to_owned()).collect();
572            display.push(cursor.as_str().to_owned());
573            return Err(ProfileResolutionError::InheritanceCycle { chain: display });
574        }
575        chain.push(cursor.clone());
576
577        if let Some(def) = definitions.get(&cursor) {
578            match (cursor.as_builtin(), &def.inherits) {
579                (Some(_), None) => break,
580                (None, Some(parent)) => {
581                    if !definitions.contains_key(parent) && parent.as_builtin().is_none() {
582                        return Err(ProfileResolutionError::UnknownInheritedProfile {
583                            profile: cursor.as_str().to_owned(),
584                            parent: parent.as_str().to_owned(),
585                        });
586                    }
587                    cursor = parent.clone();
588                    continue;
589                }
590                (Some(_), Some(_)) => {
591                    unreachable!("validate_definitions rejects `inherits` on built-ins")
592                }
593                (None, None) => {
594                    unreachable!("validate_definitions rejects custom profiles without `inherits`")
595                }
596            }
597        }
598
599        if cursor.as_builtin().is_some() {
600            break;
601        }
602
603        return Err(ProfileResolutionError::UnknownProfile {
604            name: cursor.as_str().to_owned(),
605        });
606    }
607
608    // `chain` is selected -> ... -> root.  Reverse so we merge
609    // root-first.
610    chain.reverse();
611
612    let root_name = chain.first().expect("chain is non-empty after walk");
613    let builtin = root_name
614        .as_builtin()
615        .ok_or_else(|| ProfileResolutionError::UnknownProfile {
616            name: root_name.as_str().to_owned(),
617        })?;
618    let defaults = builtin.defaults();
619
620    let mut debug = defaults.debug;
621    let mut opt_level = defaults.opt_level;
622    let mut assertions = defaults.assertions;
623    // Per-profile flag arrays merge with **append** semantics
624    // across the inherits chain - root → selected.  Scalars
625    // above use replacement (later wins); arrays here use
626    // accumulation.  The merge stays out of `as_json` so the
627    // cabin-metadata schema is unchanged.
628    let mut merged_build: Option<crate::build_flags::ProfileFlags> = None;
629    for step in &chain {
630        if let Some(def) = definitions.get(step) {
631            if let Some(d) = def.debug {
632                debug = d;
633            }
634            if let Some(o) = def.opt_level {
635                opt_level = o;
636            }
637            if let Some(a) = def.assertions {
638                assertions = a;
639            }
640            if let Some(layer) = def.build.as_ref() {
641                let acc =
642                    merged_build.get_or_insert_with(crate::build_flags::ProfileFlags::default);
643                acc.append_layer(layer);
644            }
645        }
646    }
647
648    let final_name = selection.name.clone();
649    let source = match (
650        final_name.as_builtin(),
651        definitions.contains_key(&final_name),
652    ) {
653        (Some(_), true) => ProfileSource::BuiltinOverridden,
654        (Some(_), false) => ProfileSource::Builtin,
655        (None, _) => ProfileSource::Custom,
656    };
657
658    Ok(ResolvedProfile {
659        name: final_name,
660        debug,
661        opt_level,
662        assertions,
663        source,
664        inherits_chain: chain,
665        build: merged_build,
666    })
667}
668
669/// Whole-table validation: every custom profile declares
670/// `inherits`, no built-in declares it, and inherits-targets are
671/// known.  Cycles are caught in [`resolve_profile`] when the chain
672/// is walked.
673fn validate_definitions(
674    definitions: &BTreeMap<ProfileName, ProfileDefinition>,
675) -> Result<(), ProfileResolutionError> {
676    for (name, def) in definitions {
677        match (name.as_builtin(), &def.inherits) {
678            (Some(_), Some(_)) => {
679                return Err(ProfileResolutionError::BuiltinCannotInherit {
680                    name: name.as_str().to_owned(),
681                });
682            }
683            (None, None) => {
684                return Err(ProfileResolutionError::CustomMissingInherits {
685                    name: name.as_str().to_owned(),
686                });
687            }
688            (None, Some(parent)) => {
689                if !definitions.contains_key(parent) && parent.as_builtin().is_none() {
690                    return Err(ProfileResolutionError::UnknownInheritedProfile {
691                        profile: name.as_str().to_owned(),
692                        parent: parent.as_str().to_owned(),
693                    });
694                }
695            }
696            (Some(_), None) => {}
697        }
698    }
699    Ok(())
700}
701
702/// Enumerate every profile name reachable for a given definition
703/// set: the two built-ins plus every manifest-declared name.
704/// Useful for `cabin metadata --profile-list`-style consumers
705/// without forcing each caller to special-case built-ins.
706pub fn available_profile_names(
707    definitions: &BTreeMap<ProfileName, ProfileDefinition>,
708) -> Vec<ProfileName> {
709    let mut names: BTreeSet<ProfileName> = BTreeSet::new();
710    for builtin in BuiltinProfile::all() {
711        names.insert(ProfileName::builtin(builtin));
712    }
713    for k in definitions.keys() {
714        names.insert(k.clone());
715    }
716    names.into_iter().collect()
717}
718
719#[cfg(test)]
720mod tests {
721    use super::*;
722
723    fn name(s: &str) -> ProfileName {
724        ProfileName::new(s).unwrap()
725    }
726
727    fn def(
728        n: &str,
729        inherits: Option<&str>,
730        debug: Option<bool>,
731        opt: Option<OptLevel>,
732        assertions: Option<bool>,
733    ) -> (ProfileName, ProfileDefinition) {
734        let profile_name = name(n);
735        let def = ProfileDefinition {
736            name: profile_name.clone(),
737            inherits: inherits.map(name),
738            debug,
739            opt_level: opt,
740            assertions,
741            build: None,
742        };
743        (profile_name, def)
744    }
745
746    fn defs(
747        items: Vec<(ProfileName, ProfileDefinition)>,
748    ) -> BTreeMap<ProfileName, ProfileDefinition> {
749        items.into_iter().collect()
750    }
751
752    #[test]
753    fn dev_default_is_built_in_and_unmodified() {
754        let r = resolve_profile(&ProfileSelection::default_dev(), &BTreeMap::new()).unwrap();
755        assert_eq!(r.name.as_str(), "dev");
756        assert!(r.debug);
757        assert_eq!(r.opt_level, OptLevel::O0);
758        assert!(r.assertions);
759        assert_eq!(r.source, ProfileSource::Builtin);
760        assert_eq!(r.inherits_chain.len(), 1);
761    }
762
763    #[test]
764    fn release_default_is_built_in_and_unmodified() {
765        let r = resolve_profile(&ProfileSelection::release_alias(), &BTreeMap::new()).unwrap();
766        assert_eq!(r.name.as_str(), "release");
767        assert!(!r.debug);
768        assert_eq!(r.opt_level, OptLevel::O3);
769        assert!(!r.assertions);
770        assert_eq!(r.source, ProfileSource::Builtin);
771    }
772
773    #[test]
774    fn dev_override_marks_source_builtin_overridden() {
775        let d = defs(vec![def(
776            "dev",
777            None,
778            Some(false),
779            Some(OptLevel::O2),
780            None,
781        )]);
782        let r = resolve_profile(&ProfileSelection::default_dev(), &d).unwrap();
783        assert_eq!(r.opt_level, OptLevel::O2);
784        assert!(!r.debug);
785        // Assertions inherits from the built-in default.
786        assert!(r.assertions);
787        assert_eq!(r.source, ProfileSource::BuiltinOverridden);
788    }
789
790    #[test]
791    fn release_override_keeps_unaffected_fields() {
792        let d = defs(vec![def("release", None, Some(true), None, None)]);
793        let r = resolve_profile(&ProfileSelection::release_alias(), &d).unwrap();
794        assert!(r.debug);
795        assert_eq!(r.opt_level, OptLevel::O3);
796        assert!(!r.assertions);
797        assert_eq!(r.source, ProfileSource::BuiltinOverridden);
798    }
799
800    #[test]
801    fn custom_profile_inherits_from_release_then_overrides_debug() {
802        let d = defs(vec![def(
803            "relwithdebinfo",
804            Some("release"),
805            Some(true),
806            None,
807            None,
808        )]);
809        let r = resolve_profile(&ProfileSelection::from_name(name("relwithdebinfo")), &d).unwrap();
810        assert!(r.debug);
811        assert_eq!(r.opt_level, OptLevel::O3);
812        assert!(!r.assertions);
813        assert_eq!(r.source, ProfileSource::Custom);
814        let chain: Vec<&str> = r
815            .inherits_chain
816            .iter()
817            .map(super::ProfileName::as_str)
818            .collect();
819        assert_eq!(chain, vec!["release", "relwithdebinfo"]);
820    }
821
822    #[test]
823    fn custom_chain_through_another_custom_resolves_deterministically() {
824        let d = defs(vec![
825            def(
826                "intermediate",
827                Some("release"),
828                None,
829                Some(OptLevel::O2),
830                None,
831            ),
832            def("ci", Some("intermediate"), Some(true), None, Some(true)),
833        ]);
834        let r = resolve_profile(&ProfileSelection::from_name(name("ci")), &d).unwrap();
835        assert!(r.debug);
836        assert_eq!(r.opt_level, OptLevel::O2);
837        assert!(r.assertions);
838        let chain: Vec<&str> = r
839            .inherits_chain
840            .iter()
841            .map(super::ProfileName::as_str)
842            .collect();
843        assert_eq!(chain, vec!["release", "intermediate", "ci"]);
844    }
845
846    fn def_full(
847        n: &str,
848        inherits: Option<&str>,
849        debug: Option<bool>,
850        opt: Option<OptLevel>,
851        assertions: Option<bool>,
852        build: Option<crate::build_flags::ProfileFlags>,
853    ) -> (ProfileName, ProfileDefinition) {
854        let profile_name = name(n);
855        let def = ProfileDefinition {
856            name: profile_name.clone(),
857            inherits: inherits.map(name),
858            debug,
859            opt_level: opt,
860            assertions,
861            build,
862        };
863        (profile_name, def)
864    }
865
866    fn flags_cxx(values: &[&str]) -> crate::build_flags::ProfileFlags {
867        crate::build_flags::ProfileFlags {
868            cxxflags: values.iter().map(|s| (*s).to_owned()).collect(),
869            ..Default::default()
870        }
871    }
872
873    #[test]
874    fn cxxflags_append_across_inheritance() {
875        let d = defs(vec![
876            def_full("release", None, None, None, None, Some(flags_cxx(&["-O3"]))),
877            def_full(
878                "bench",
879                Some("release"),
880                None,
881                None,
882                None,
883                Some(flags_cxx(&["-pg"])),
884            ),
885        ]);
886        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
887        let build = r
888            .build
889            .expect("merged build is some when chain contributes");
890        assert_eq!(build.cxxflags, vec!["-O3".to_owned(), "-pg".to_owned()]);
891    }
892
893    #[test]
894    fn parent_build_inherited_when_leaf_has_no_build() {
895        let d = defs(vec![
896            def_full("release", None, None, None, None, Some(flags_cxx(&["-O3"]))),
897            def_full("bench", Some("release"), None, None, None, None),
898        ]);
899        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
900        let build = r.build.expect("parent build survives leaf having no build");
901        assert_eq!(build.cxxflags, vec!["-O3".to_owned()]);
902    }
903
904    #[test]
905    fn include_dirs_dedup_across_inheritance() {
906        use camino::Utf8PathBuf;
907        let parent_flags = crate::build_flags::ProfileFlags {
908            include_dirs: vec![
909                Utf8PathBuf::from("include"),
910                Utf8PathBuf::from("vendor/include"),
911            ],
912            ..Default::default()
913        };
914        let leaf_flags = crate::build_flags::ProfileFlags {
915            include_dirs: vec![
916                Utf8PathBuf::from("include"),
917                Utf8PathBuf::from("third_party"),
918            ],
919            ..Default::default()
920        };
921        let d = defs(vec![
922            def_full("release", None, None, None, None, Some(parent_flags)),
923            def_full("bench", Some("release"), None, None, None, Some(leaf_flags)),
924        ]);
925        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
926        let build = r.build.expect("merged build is some");
927        assert_eq!(
928            build.include_dirs,
929            vec![
930                Utf8PathBuf::from("include"),
931                Utf8PathBuf::from("vendor/include"),
932                Utf8PathBuf::from("third_party"),
933            ],
934        );
935    }
936
937    #[test]
938    fn scalar_fields_replace_across_inheritance() {
939        let d = defs(vec![
940            def_full(
941                "release",
942                None,
943                Some(false),
944                Some(OptLevel::O3),
945                Some(false),
946                Some(flags_cxx(&["-O3"])),
947            ),
948            def_full(
949                "bench",
950                Some("release"),
951                Some(true),
952                Some(OptLevel::O2),
953                Some(true),
954                Some(flags_cxx(&["-pg"])),
955            ),
956        ]);
957        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
958        assert!(r.debug, "leaf debug=true replaces parent debug=false");
959        assert_eq!(r.opt_level, OptLevel::O2, "leaf opt-level replaces parent");
960        assert!(r.assertions, "leaf assertions replaces parent");
961        let build = r.build.expect("merged build is some");
962        assert_eq!(
963            build.cxxflags,
964            vec!["-O3".to_owned(), "-pg".to_owned()],
965            "arrays still append even though scalars replace",
966        );
967    }
968
969    #[test]
970    fn build_is_none_when_no_chain_step_sets_build() {
971        let d = defs(vec![
972            def_full("ci", Some("release"), Some(true), None, None, None),
973            def_full(
974                "ci-strict",
975                Some("ci"),
976                None,
977                Some(OptLevel::O2),
978                None,
979                None,
980            ),
981        ]);
982        let r = resolve_profile(&ProfileSelection::from_name(name("ci-strict")), &d).unwrap();
983        assert!(
984            r.build.is_none(),
985            "build stays None when no chain step contributed flags",
986        );
987    }
988
989    #[test]
990    fn unknown_profile_selection_errors() {
991        let err = resolve_profile(
992            &ProfileSelection::from_name(name("fastdebug")),
993            &BTreeMap::new(),
994        )
995        .unwrap_err();
996        assert!(matches!(
997            err,
998            ProfileResolutionError::UnknownProfile { ref name } if name == "fastdebug"
999        ));
1000        assert_eq!(
1001            err.to_string(),
1002            "unknown profile `fastdebug`; define it with `[profile.fastdebug]` and an `inherits` field",
1003        );
1004    }
1005
1006    #[test]
1007    fn custom_without_inherits_is_rejected() {
1008        let d = defs(vec![def("ci", None, Some(true), None, None)]);
1009        let err = resolve_profile(&ProfileSelection::from_name(name("ci")), &d).unwrap_err();
1010        assert!(matches!(
1011            err,
1012            ProfileResolutionError::CustomMissingInherits { ref name } if name == "ci"
1013        ));
1014    }
1015
1016    #[test]
1017    fn builtin_with_inherits_is_rejected() {
1018        let d = defs(vec![def("dev", Some("release"), None, None, None)]);
1019        let err = resolve_profile(&ProfileSelection::default_dev(), &d).unwrap_err();
1020        assert!(matches!(
1021            err,
1022            ProfileResolutionError::BuiltinCannotInherit { ref name } if name == "dev"
1023        ));
1024    }
1025
1026    #[test]
1027    fn unknown_inherited_profile_errors() {
1028        let d = defs(vec![def("ci", Some("fast"), None, None, None)]);
1029        let err = resolve_profile(&ProfileSelection::from_name(name("ci")), &d).unwrap_err();
1030        match err {
1031            ProfileResolutionError::UnknownInheritedProfile { profile, parent } => {
1032                assert_eq!(profile, "ci");
1033                assert_eq!(parent, "fast");
1034            }
1035            other => panic!("unexpected: {other:?}"),
1036        }
1037    }
1038
1039    #[test]
1040    fn inheritance_cycle_is_detected() {
1041        let d = defs(vec![
1042            def("a", Some("b"), None, None, None),
1043            def("b", Some("a"), None, None, None),
1044        ]);
1045        let err = resolve_profile(&ProfileSelection::from_name(name("a")), &d).unwrap_err();
1046        match err {
1047            ProfileResolutionError::InheritanceCycle { chain } => {
1048                assert!(chain.contains(&"a".to_owned()));
1049                assert!(chain.contains(&"b".to_owned()));
1050            }
1051            other => panic!("unexpected: {other:?}"),
1052        }
1053    }
1054
1055    #[test]
1056    fn invalid_profile_name_is_rejected_at_construction() {
1057        for bad in [
1058            ".release",
1059            "..",
1060            "",
1061            "release/x",
1062            "release\\x",
1063            "release ",
1064            "rel?",
1065        ] {
1066            assert!(ProfileName::new(bad).is_err(), "{bad:?} should be invalid");
1067        }
1068        for good in [
1069            "dev",
1070            "release",
1071            "rel-with-debug-info",
1072            "ci.fast",
1073            "0",
1074            "ci_2",
1075        ] {
1076            assert!(ProfileName::new(good).is_ok(), "{good:?} should be valid");
1077        }
1078    }
1079
1080    #[test]
1081    fn opt_level_parse_round_trips_and_rejects_unknown() {
1082        for (raw, expected) in [
1083            ("0", OptLevel::O0),
1084            ("1", OptLevel::O1),
1085            ("2", OptLevel::O2),
1086            ("3", OptLevel::O3),
1087            ("s", OptLevel::S),
1088            ("z", OptLevel::Z),
1089        ] {
1090            assert_eq!(OptLevel::parse(raw).unwrap(), expected);
1091            assert_eq!(expected.as_str(), raw);
1092        }
1093        let err = OptLevel::parse("fast").unwrap_err();
1094        assert!(err.contains("invalid opt-level"));
1095        assert!(err.contains("\"fast\""));
1096    }
1097
1098    #[test]
1099    fn compile_flags_are_deterministic_and_drop_ndebug_when_assertions_on() {
1100        let r = ResolvedProfile {
1101            name: name("dev"),
1102            debug: true,
1103            opt_level: OptLevel::O0,
1104            assertions: true,
1105            source: ProfileSource::Builtin,
1106            inherits_chain: vec![name("dev")],
1107            build: None,
1108        };
1109        assert_eq!(r.compile_flags(), vec!["-O0", "-g"]);
1110
1111        let r = ResolvedProfile {
1112            name: name("release"),
1113            debug: false,
1114            opt_level: OptLevel::O3,
1115            assertions: false,
1116            source: ProfileSource::Builtin,
1117            inherits_chain: vec![name("release")],
1118            build: None,
1119        };
1120        assert_eq!(r.compile_flags(), vec!["-O3", "-DNDEBUG"]);
1121    }
1122
1123    #[test]
1124    fn compile_flags_are_language_neutral_profile_flags() {
1125        let r = ResolvedProfile {
1126            name: name("dev"),
1127            debug: true,
1128            opt_level: OptLevel::O2,
1129            assertions: false,
1130            source: ProfileSource::Builtin,
1131            inherits_chain: vec![name("dev")],
1132            build: None,
1133        };
1134        assert_eq!(r.compile_flags(), vec!["-O2", "-g", "-DNDEBUG"]);
1135    }
1136
1137    #[test]
1138    fn available_profile_names_includes_built_ins_and_custom() {
1139        let d = defs(vec![def("ci", Some("release"), None, None, None)]);
1140        let names: Vec<String> = available_profile_names(&d)
1141            .into_iter()
1142            .map(|n| n.as_str().to_owned())
1143            .collect();
1144        assert_eq!(
1145            names,
1146            vec!["ci".to_owned(), "dev".to_owned(), "release".to_owned()]
1147        );
1148    }
1149}