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 literally `.` 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}`")]
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`] and is passed to
537///   [`crate::build_flags::resolve_build_flags`] downstream so
538///   the package's `[profile]` / `[target.'cfg(...)'.profile]`
539///   layers sit beneath the chain-merged profile flags.
540///
541/// # Errors
542/// Returns a [`ProfileResolutionError`] when the definitions or selection are
543/// invalid: an `inherits` cycle ([`ProfileResolutionError::InheritanceCycle`]),
544/// a reference to an unknown profile ([`ProfileResolutionError::UnknownProfile`])
545/// or unknown parent ([`ProfileResolutionError::UnknownInheritedProfile`]), or a
546/// validation failure surfaced by `validate_definitions`
547/// ([`ProfileResolutionError::BuiltinCannotInherit`],
548/// [`ProfileResolutionError::CustomMissingInherits`]).
549///
550/// # Panics
551/// Panics if the inheritance walk somehow produces an empty chain, which cannot
552/// happen because the selected name is always pushed before the loop can break.
553/// The two `unreachable!` arms (a built-in with `inherits`, or a custom profile
554/// without it) are likewise excluded up front by `validate_definitions`.
555pub fn resolve_profile(
556    selection: &ProfileSelection,
557    definitions: &BTreeMap<ProfileName, ProfileDefinition>,
558) -> Result<ResolvedProfile, ProfileResolutionError> {
559    validate_definitions(definitions)?;
560
561    let mut chain: Vec<ProfileName> = Vec::new();
562    let mut seen: BTreeSet<ProfileName> = BTreeSet::new();
563    let mut cursor = selection.name.clone();
564
565    // Walk inheritance up to a built-in root. The chain ends as
566    // soon as either (a) `cursor` names a built-in or (b) `cursor`
567    // names a manifest definition that has no `inherits` (which is
568    // only legal for built-in overrides).
569    loop {
570        if !seen.insert(cursor.clone()) {
571            // Cycle: render the chain ending at the offending name.
572            let mut display: Vec<String> = chain.iter().map(|n| n.as_str().to_owned()).collect();
573            display.push(cursor.as_str().to_owned());
574            return Err(ProfileResolutionError::InheritanceCycle { chain: display });
575        }
576        chain.push(cursor.clone());
577
578        if let Some(def) = definitions.get(&cursor) {
579            match (cursor.as_builtin(), &def.inherits) {
580                (Some(_), None) => break,
581                (None, Some(parent)) => {
582                    if !definitions.contains_key(parent) && parent.as_builtin().is_none() {
583                        return Err(ProfileResolutionError::UnknownInheritedProfile {
584                            profile: cursor.as_str().to_owned(),
585                            parent: parent.as_str().to_owned(),
586                        });
587                    }
588                    cursor = parent.clone();
589                    continue;
590                }
591                (Some(_), Some(_)) => {
592                    unreachable!("validate_definitions rejects `inherits` on built-ins")
593                }
594                (None, None) => {
595                    unreachable!("validate_definitions rejects custom profiles without `inherits`")
596                }
597            }
598        }
599
600        if cursor.as_builtin().is_some() {
601            break;
602        }
603
604        return Err(ProfileResolutionError::UnknownProfile {
605            name: cursor.as_str().to_owned(),
606        });
607    }
608
609    // `chain` is selected -> ... -> root. Reverse so we merge
610    // root-first.
611    chain.reverse();
612
613    let root_name = chain.first().expect("chain is non-empty after walk");
614    let builtin = root_name
615        .as_builtin()
616        .ok_or_else(|| ProfileResolutionError::UnknownProfile {
617            name: root_name.as_str().to_owned(),
618        })?;
619    let defaults = builtin.defaults();
620
621    let mut debug = defaults.debug;
622    let mut opt_level = defaults.opt_level;
623    let mut assertions = defaults.assertions;
624    // Per-profile flag arrays merge with **append** semantics
625    // across the inherits chain — root → selected. Scalars
626    // above use replacement (later wins); arrays here use
627    // accumulation. The merge stays out of `as_json` so the
628    // cabin-metadata schema is unchanged.
629    let mut merged_build: Option<crate::build_flags::ProfileFlags> = None;
630    for step in &chain {
631        if let Some(def) = definitions.get(step) {
632            if let Some(d) = def.debug {
633                debug = d;
634            }
635            if let Some(o) = def.opt_level {
636                opt_level = o;
637            }
638            if let Some(a) = def.assertions {
639                assertions = a;
640            }
641            if let Some(layer) = def.build.as_ref() {
642                let acc =
643                    merged_build.get_or_insert_with(crate::build_flags::ProfileFlags::default);
644                acc.append_layer(layer);
645            }
646        }
647    }
648
649    let final_name = selection.name.clone();
650    let source = match (
651        final_name.as_builtin(),
652        definitions.contains_key(&final_name),
653    ) {
654        (Some(_), true) => ProfileSource::BuiltinOverridden,
655        (Some(_), false) => ProfileSource::Builtin,
656        (None, _) => ProfileSource::Custom,
657    };
658
659    Ok(ResolvedProfile {
660        name: final_name,
661        debug,
662        opt_level,
663        assertions,
664        source,
665        inherits_chain: chain,
666        build: merged_build,
667    })
668}
669
670/// Whole-table validation: every custom profile declares
671/// `inherits`, no built-in declares it, and inherits-targets are
672/// known. Cycles are caught in [`resolve_profile`] when the chain
673/// is walked.
674fn validate_definitions(
675    definitions: &BTreeMap<ProfileName, ProfileDefinition>,
676) -> Result<(), ProfileResolutionError> {
677    for (name, def) in definitions {
678        match (name.as_builtin(), &def.inherits) {
679            (Some(_), Some(_)) => {
680                return Err(ProfileResolutionError::BuiltinCannotInherit {
681                    name: name.as_str().to_owned(),
682                });
683            }
684            (None, None) => {
685                return Err(ProfileResolutionError::CustomMissingInherits {
686                    name: name.as_str().to_owned(),
687                });
688            }
689            (None, Some(parent)) => {
690                if !definitions.contains_key(parent) && parent.as_builtin().is_none() {
691                    return Err(ProfileResolutionError::UnknownInheritedProfile {
692                        profile: name.as_str().to_owned(),
693                        parent: parent.as_str().to_owned(),
694                    });
695                }
696            }
697            (Some(_), None) => {}
698        }
699    }
700    Ok(())
701}
702
703/// Enumerate every profile name reachable for a given definition
704/// set: the two built-ins plus every manifest-declared name.
705/// Useful for `cabin metadata --profile-list`-style consumers
706/// without forcing each caller to special-case built-ins.
707pub fn available_profile_names(
708    definitions: &BTreeMap<ProfileName, ProfileDefinition>,
709) -> Vec<ProfileName> {
710    let mut names: BTreeSet<ProfileName> = BTreeSet::new();
711    for builtin in BuiltinProfile::all() {
712        names.insert(ProfileName::builtin(builtin));
713    }
714    for k in definitions.keys() {
715        names.insert(k.clone());
716    }
717    names.into_iter().collect()
718}
719
720#[cfg(test)]
721mod tests {
722    use super::*;
723
724    fn name(s: &str) -> ProfileName {
725        ProfileName::new(s).unwrap()
726    }
727
728    fn def(
729        n: &str,
730        inherits: Option<&str>,
731        debug: Option<bool>,
732        opt: Option<OptLevel>,
733        assertions: Option<bool>,
734    ) -> (ProfileName, ProfileDefinition) {
735        let profile_name = name(n);
736        let def = ProfileDefinition {
737            name: profile_name.clone(),
738            inherits: inherits.map(name),
739            debug,
740            opt_level: opt,
741            assertions,
742            build: None,
743        };
744        (profile_name, def)
745    }
746
747    fn defs(
748        items: Vec<(ProfileName, ProfileDefinition)>,
749    ) -> BTreeMap<ProfileName, ProfileDefinition> {
750        items.into_iter().collect()
751    }
752
753    #[test]
754    fn dev_default_is_built_in_and_unmodified() {
755        let r = resolve_profile(&ProfileSelection::default_dev(), &BTreeMap::new()).unwrap();
756        assert_eq!(r.name.as_str(), "dev");
757        assert!(r.debug);
758        assert_eq!(r.opt_level, OptLevel::O0);
759        assert!(r.assertions);
760        assert_eq!(r.source, ProfileSource::Builtin);
761        assert_eq!(r.inherits_chain.len(), 1);
762    }
763
764    #[test]
765    fn release_default_is_built_in_and_unmodified() {
766        let r = resolve_profile(&ProfileSelection::release_alias(), &BTreeMap::new()).unwrap();
767        assert_eq!(r.name.as_str(), "release");
768        assert!(!r.debug);
769        assert_eq!(r.opt_level, OptLevel::O3);
770        assert!(!r.assertions);
771        assert_eq!(r.source, ProfileSource::Builtin);
772    }
773
774    #[test]
775    fn dev_override_marks_source_builtin_overridden() {
776        let d = defs(vec![def(
777            "dev",
778            None,
779            Some(false),
780            Some(OptLevel::O2),
781            None,
782        )]);
783        let r = resolve_profile(&ProfileSelection::default_dev(), &d).unwrap();
784        assert_eq!(r.opt_level, OptLevel::O2);
785        assert!(!r.debug);
786        // Assertions inherits from the built-in default.
787        assert!(r.assertions);
788        assert_eq!(r.source, ProfileSource::BuiltinOverridden);
789    }
790
791    #[test]
792    fn release_override_keeps_unaffected_fields() {
793        let d = defs(vec![def("release", None, Some(true), None, None)]);
794        let r = resolve_profile(&ProfileSelection::release_alias(), &d).unwrap();
795        assert!(r.debug);
796        assert_eq!(r.opt_level, OptLevel::O3);
797        assert!(!r.assertions);
798        assert_eq!(r.source, ProfileSource::BuiltinOverridden);
799    }
800
801    #[test]
802    fn custom_profile_inherits_from_release_then_overrides_debug() {
803        let d = defs(vec![def(
804            "relwithdebinfo",
805            Some("release"),
806            Some(true),
807            None,
808            None,
809        )]);
810        let r = resolve_profile(&ProfileSelection::from_name(name("relwithdebinfo")), &d).unwrap();
811        assert!(r.debug);
812        assert_eq!(r.opt_level, OptLevel::O3);
813        assert!(!r.assertions);
814        assert_eq!(r.source, ProfileSource::Custom);
815        let chain: Vec<&str> = r
816            .inherits_chain
817            .iter()
818            .map(super::ProfileName::as_str)
819            .collect();
820        assert_eq!(chain, vec!["release", "relwithdebinfo"]);
821    }
822
823    #[test]
824    fn custom_chain_through_another_custom_resolves_deterministically() {
825        let d = defs(vec![
826            def(
827                "intermediate",
828                Some("release"),
829                None,
830                Some(OptLevel::O2),
831                None,
832            ),
833            def("ci", Some("intermediate"), Some(true), None, Some(true)),
834        ]);
835        let r = resolve_profile(&ProfileSelection::from_name(name("ci")), &d).unwrap();
836        assert!(r.debug);
837        assert_eq!(r.opt_level, OptLevel::O2);
838        assert!(r.assertions);
839        let chain: Vec<&str> = r
840            .inherits_chain
841            .iter()
842            .map(super::ProfileName::as_str)
843            .collect();
844        assert_eq!(chain, vec!["release", "intermediate", "ci"]);
845    }
846
847    fn def_full(
848        n: &str,
849        inherits: Option<&str>,
850        debug: Option<bool>,
851        opt: Option<OptLevel>,
852        assertions: Option<bool>,
853        build: Option<crate::build_flags::ProfileFlags>,
854    ) -> (ProfileName, ProfileDefinition) {
855        let profile_name = name(n);
856        let def = ProfileDefinition {
857            name: profile_name.clone(),
858            inherits: inherits.map(name),
859            debug,
860            opt_level: opt,
861            assertions,
862            build,
863        };
864        (profile_name, def)
865    }
866
867    fn flags_cxx(values: &[&str]) -> crate::build_flags::ProfileFlags {
868        crate::build_flags::ProfileFlags {
869            cxxflags: values.iter().map(|s| (*s).to_owned()).collect(),
870            ..Default::default()
871        }
872    }
873
874    #[test]
875    fn cxxflags_append_across_inheritance() {
876        let d = defs(vec![
877            def_full("release", None, None, None, None, Some(flags_cxx(&["-O3"]))),
878            def_full(
879                "bench",
880                Some("release"),
881                None,
882                None,
883                None,
884                Some(flags_cxx(&["-pg"])),
885            ),
886        ]);
887        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
888        let build = r
889            .build
890            .expect("merged build is some when chain contributes");
891        assert_eq!(build.cxxflags, vec!["-O3".to_owned(), "-pg".to_owned()]);
892    }
893
894    #[test]
895    fn parent_build_inherited_when_leaf_has_no_build() {
896        let d = defs(vec![
897            def_full("release", None, None, None, None, Some(flags_cxx(&["-O3"]))),
898            def_full("bench", Some("release"), None, None, None, None),
899        ]);
900        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
901        let build = r.build.expect("parent build survives leaf having no build");
902        assert_eq!(build.cxxflags, vec!["-O3".to_owned()]);
903    }
904
905    #[test]
906    fn include_dirs_dedup_across_inheritance() {
907        use std::path::PathBuf;
908        let parent_flags = crate::build_flags::ProfileFlags {
909            include_dirs: vec![PathBuf::from("include"), PathBuf::from("vendor/include")],
910            ..Default::default()
911        };
912        let leaf_flags = crate::build_flags::ProfileFlags {
913            include_dirs: vec![PathBuf::from("include"), PathBuf::from("third_party")],
914            ..Default::default()
915        };
916        let d = defs(vec![
917            def_full("release", None, None, None, None, Some(parent_flags)),
918            def_full("bench", Some("release"), None, None, None, Some(leaf_flags)),
919        ]);
920        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
921        let build = r.build.expect("merged build is some");
922        assert_eq!(
923            build.include_dirs,
924            vec![
925                PathBuf::from("include"),
926                PathBuf::from("vendor/include"),
927                PathBuf::from("third_party"),
928            ],
929        );
930    }
931
932    #[test]
933    fn scalar_fields_replace_across_inheritance() {
934        let d = defs(vec![
935            def_full(
936                "release",
937                None,
938                Some(false),
939                Some(OptLevel::O3),
940                Some(false),
941                Some(flags_cxx(&["-O3"])),
942            ),
943            def_full(
944                "bench",
945                Some("release"),
946                Some(true),
947                Some(OptLevel::O2),
948                Some(true),
949                Some(flags_cxx(&["-pg"])),
950            ),
951        ]);
952        let r = resolve_profile(&ProfileSelection::from_name(name("bench")), &d).unwrap();
953        assert!(r.debug, "leaf debug=true replaces parent debug=false");
954        assert_eq!(r.opt_level, OptLevel::O2, "leaf opt-level replaces parent");
955        assert!(r.assertions, "leaf assertions replaces parent");
956        let build = r.build.expect("merged build is some");
957        assert_eq!(
958            build.cxxflags,
959            vec!["-O3".to_owned(), "-pg".to_owned()],
960            "arrays still append even though scalars replace",
961        );
962    }
963
964    #[test]
965    fn build_is_none_when_no_chain_step_sets_build() {
966        let d = defs(vec![
967            def_full("ci", Some("release"), Some(true), None, None, None),
968            def_full(
969                "ci-strict",
970                Some("ci"),
971                None,
972                Some(OptLevel::O2),
973                None,
974                None,
975            ),
976        ]);
977        let r = resolve_profile(&ProfileSelection::from_name(name("ci-strict")), &d).unwrap();
978        assert!(
979            r.build.is_none(),
980            "build stays None when no chain step contributed flags",
981        );
982    }
983
984    #[test]
985    fn unknown_profile_selection_errors() {
986        let err = resolve_profile(
987            &ProfileSelection::from_name(name("fastdebug")),
988            &BTreeMap::new(),
989        )
990        .unwrap_err();
991        assert!(matches!(
992            err,
993            ProfileResolutionError::UnknownProfile { ref name } if name == "fastdebug"
994        ));
995    }
996
997    #[test]
998    fn custom_without_inherits_is_rejected() {
999        let d = defs(vec![def("ci", None, Some(true), None, None)]);
1000        let err = resolve_profile(&ProfileSelection::from_name(name("ci")), &d).unwrap_err();
1001        assert!(matches!(
1002            err,
1003            ProfileResolutionError::CustomMissingInherits { ref name } if name == "ci"
1004        ));
1005    }
1006
1007    #[test]
1008    fn builtin_with_inherits_is_rejected() {
1009        let d = defs(vec![def("dev", Some("release"), None, None, None)]);
1010        let err = resolve_profile(&ProfileSelection::default_dev(), &d).unwrap_err();
1011        assert!(matches!(
1012            err,
1013            ProfileResolutionError::BuiltinCannotInherit { ref name } if name == "dev"
1014        ));
1015    }
1016
1017    #[test]
1018    fn unknown_inherited_profile_errors() {
1019        let d = defs(vec![def("ci", Some("fast"), None, None, None)]);
1020        let err = resolve_profile(&ProfileSelection::from_name(name("ci")), &d).unwrap_err();
1021        match err {
1022            ProfileResolutionError::UnknownInheritedProfile { profile, parent } => {
1023                assert_eq!(profile, "ci");
1024                assert_eq!(parent, "fast");
1025            }
1026            other => panic!("unexpected: {other:?}"),
1027        }
1028    }
1029
1030    #[test]
1031    fn inheritance_cycle_is_detected() {
1032        let d = defs(vec![
1033            def("a", Some("b"), None, None, None),
1034            def("b", Some("a"), None, None, None),
1035        ]);
1036        let err = resolve_profile(&ProfileSelection::from_name(name("a")), &d).unwrap_err();
1037        match err {
1038            ProfileResolutionError::InheritanceCycle { chain } => {
1039                assert!(chain.contains(&"a".to_owned()));
1040                assert!(chain.contains(&"b".to_owned()));
1041            }
1042            other => panic!("unexpected: {other:?}"),
1043        }
1044    }
1045
1046    #[test]
1047    fn invalid_profile_name_is_rejected_at_construction() {
1048        for bad in [
1049            ".release",
1050            "..",
1051            "",
1052            "release/x",
1053            "release\\x",
1054            "release ",
1055            "rel?",
1056        ] {
1057            assert!(ProfileName::new(bad).is_err(), "{bad:?} should be invalid");
1058        }
1059        for good in [
1060            "dev",
1061            "release",
1062            "rel-with-debug-info",
1063            "ci.fast",
1064            "0",
1065            "ci_2",
1066        ] {
1067            assert!(ProfileName::new(good).is_ok(), "{good:?} should be valid");
1068        }
1069    }
1070
1071    #[test]
1072    fn opt_level_parse_round_trips_and_rejects_unknown() {
1073        for (raw, expected) in [
1074            ("0", OptLevel::O0),
1075            ("1", OptLevel::O1),
1076            ("2", OptLevel::O2),
1077            ("3", OptLevel::O3),
1078            ("s", OptLevel::S),
1079            ("z", OptLevel::Z),
1080        ] {
1081            assert_eq!(OptLevel::parse(raw).unwrap(), expected);
1082            assert_eq!(expected.as_str(), raw);
1083        }
1084        let err = OptLevel::parse("fast").unwrap_err();
1085        assert!(err.contains("invalid opt-level"));
1086        assert!(err.contains("\"fast\""));
1087    }
1088
1089    #[test]
1090    fn compile_flags_are_deterministic_and_drop_ndebug_when_assertions_on() {
1091        let r = ResolvedProfile {
1092            name: name("dev"),
1093            debug: true,
1094            opt_level: OptLevel::O0,
1095            assertions: true,
1096            source: ProfileSource::Builtin,
1097            inherits_chain: vec![name("dev")],
1098            build: None,
1099        };
1100        assert_eq!(r.compile_flags(), vec!["-O0", "-g"]);
1101
1102        let r = ResolvedProfile {
1103            name: name("release"),
1104            debug: false,
1105            opt_level: OptLevel::O3,
1106            assertions: false,
1107            source: ProfileSource::Builtin,
1108            inherits_chain: vec![name("release")],
1109            build: None,
1110        };
1111        assert_eq!(r.compile_flags(), vec!["-O3", "-DNDEBUG"]);
1112    }
1113
1114    #[test]
1115    fn compile_flags_are_language_neutral_profile_flags() {
1116        let r = ResolvedProfile {
1117            name: name("dev"),
1118            debug: true,
1119            opt_level: OptLevel::O2,
1120            assertions: false,
1121            source: ProfileSource::Builtin,
1122            inherits_chain: vec![name("dev")],
1123            build: None,
1124        };
1125        assert_eq!(r.compile_flags(), vec!["-O2", "-g", "-DNDEBUG"]);
1126    }
1127
1128    #[test]
1129    fn available_profile_names_includes_built_ins_and_custom() {
1130        let d = defs(vec![def("ci", Some("release"), None, None, None)]);
1131        let names: Vec<String> = available_profile_names(&d)
1132            .into_iter()
1133            .map(|n| n.as_str().to_owned())
1134            .collect();
1135        assert_eq!(
1136            names,
1137            vec!["ci".to_owned(), "dev".to_owned(), "release".to_owned()]
1138        );
1139    }
1140}