Skip to main content

cabin_core/
config.rs

1//! Features — public, additive, named-boolean capabilities used
2//! to gate optional dependencies and per-edge feature requests.
3//!
4//! A Feature may be enabled by the package's user or by a
5//! downstream consumer. Feature implication arrows form a directed
6//! graph; the resolver expands defaults plus user requests by
7//! transitive closure. Feature entries can enable optional
8//! dependencies (`dep:foo`) and request features on dependency
9//! packages (`crate/feature`).
10//!
11//! All declarations live on `cabin_core::Package`. Selection happens
12//! through [`BuildConfiguration::resolve`], which consumes the
13//! declarations plus a [`SelectionRequest`] (typically built from CLI
14//! flags by `cabin`).
15
16use std::collections::{BTreeMap, BTreeSet};
17
18use serde::{Deserialize, Serialize};
19use sha2::{Digest, Sha256};
20
21use crate::build_flags::ResolvedProfileFlags;
22use crate::compiler_wrapper::{CompilerWrapperSummary, ResolvedCompilerWrapper};
23use crate::error::ValidationError;
24use crate::profile::ResolvedProfile;
25use crate::toolchain::ResolvedToolchain;
26
27/// The reserved feature group name. The list of names mapped to this
28/// key in `[features]` is the package's "default" feature set: the
29/// Features Cabin enables when the user does not pass
30/// `--no-default-features`.
31pub const DEFAULT_FEATURE_KEY: &str = "default";
32
33/// `[features]` declarations for a package.
34///
35/// Feature names are stable identifiers. The `default` group lists
36/// which features are enabled by default; other entries declare
37/// individual features and what enabling them implies.
38///
39/// Each entry on the right-hand side of a feature is a string in
40/// one of three documented forms (parsed lazily into
41/// [`FeatureEntry`] by the feature resolver):
42///
43/// - `"feature_name"` — enables another local feature on the same
44///   package (transitive feature implication).
45/// - `"dep:dependency_name"` — enables an optional Cabin package
46///   dependency declared by this package's `[dependencies]`
47///   table.
48/// - `"dependency_name/feature_name"` — requests a specific
49///   feature on a Cabin package dependency. If the dependency is
50///   optional, this form also enables it.
51///
52/// The on-disk shape stays a flat list of strings so older
53/// readers and the canonical metadata format remain
54/// byte-identical for packages that only use the local-feature
55/// form.
56#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
57pub struct Features {
58    /// Default features. Empty when there is no `default` entry in
59    /// `[features]`.
60    #[serde(default, skip_serializing_if = "Vec::is_empty")]
61    pub default: Vec<String>,
62    /// Declared features and their implication lists. Stored as a
63    /// `BTreeMap` so iteration is deterministic and output is stable.
64    #[serde(default, skip_serializing_if = "BTreeMap::is_empty")]
65    pub features: BTreeMap<String, Vec<String>>,
66}
67
68impl Features {
69    /// Convenience constructor for `Package::new`-style call sites.
70    ///
71    /// # Errors
72    /// Returns a [`ValidationError`] when the resulting feature set fails
73    /// [`Features::validate`] (see that method for the specific conditions).
74    pub fn new(
75        default: Vec<String>,
76        features: BTreeMap<String, Vec<String>>,
77    ) -> Result<Self, ValidationError> {
78        let me = Self { default, features };
79        me.validate()?;
80        Ok(me)
81    }
82
83    /// Validate identifier grammar, the reserved `default` key,
84    /// internal references between local features, and local
85    /// cycles. `dep:` / `dep/feature` entries are validated for
86    /// grammar only; the *feature resolver* checks that the
87    /// referenced dependency exists, that it is optional when
88    /// `dep:` is used, and that the requested feature exists on
89    /// the dependency package — those checks need the package
90    /// graph and therefore happen one layer up.
91    ///
92    /// # Errors
93    /// Returns [`ValidationError::ReservedFeatureName`] when `default` is used
94    /// as a declared feature, [`ValidationError::UnknownFeatureReference`] for a
95    /// default or implication pointing at an undeclared local feature,
96    /// [`ValidationError::InvalidFeatureEntry`] for a malformed implication
97    /// entry, a cycle error from `Self::detect_cycles`, and any identifier
98    /// grammar error from validating a feature name.
99    pub fn validate(&self) -> Result<(), ValidationError> {
100        if self.features.contains_key(DEFAULT_FEATURE_KEY) {
101            return Err(ValidationError::ReservedFeatureName(
102                DEFAULT_FEATURE_KEY.to_owned(),
103            ));
104        }
105        for name in self.features.keys() {
106            validate_identifier(name)?;
107        }
108        for name in &self.default {
109            validate_identifier(name)?;
110            if !self.features.contains_key(name) {
111                return Err(ValidationError::UnknownFeatureReference {
112                    referrer: DEFAULT_FEATURE_KEY.to_owned(),
113                    referenced: name.to_owned(),
114                });
115            }
116        }
117        for (name, implies) in &self.features {
118            for raw in implies {
119                let entry = FeatureEntry::parse(raw).map_err(|kind| {
120                    ValidationError::InvalidFeatureEntry {
121                        referrer: name.clone(),
122                        entry: raw.clone(),
123                        reason: kind,
124                    }
125                })?;
126                match entry {
127                    FeatureEntry::Local(local) => {
128                        if !self.features.contains_key(&local) {
129                            return Err(ValidationError::UnknownFeatureReference {
130                                referrer: name.clone(),
131                                referenced: local,
132                            });
133                        }
134                    }
135                    FeatureEntry::OptionalDep(_) | FeatureEntry::DepFeature { .. } => {
136                        // Dependency-shaped entries are validated by
137                        // the feature resolver, which has access to
138                        // the dep list.
139                    }
140                }
141            }
142        }
143        self.detect_cycles()?;
144        Ok(())
145    }
146
147    fn detect_cycles(&self) -> Result<(), ValidationError> {
148        #[derive(Clone, Copy)]
149        enum Color {
150            Visiting,
151            Done,
152        }
153        fn visit<'a>(
154            node: &'a str,
155            features: &'a BTreeMap<String, Vec<String>>,
156            state: &mut std::collections::HashMap<&'a str, Color>,
157            path: &mut Vec<&'a str>,
158        ) -> Result<(), ValidationError> {
159            match state.get(node) {
160                Some(Color::Done) => return Ok(()),
161                Some(Color::Visiting) => {
162                    let start = path.iter().position(|n| *n == node).unwrap_or(0);
163                    let mut cycle: Vec<String> =
164                        path[start..].iter().map(|s| (*s).to_owned()).collect();
165                    cycle.push(node.to_owned());
166                    return Err(ValidationError::FeatureCycle(cycle));
167                }
168                None => {}
169            }
170            state.insert(node, Color::Visiting);
171            path.push(node);
172            if let Some(implies) = features.get(node) {
173                for r in implies {
174                    // Cycle detection only follows local-feature
175                    // edges. `dep:` / `dep/feature` entries are
176                    // not part of this package's local feature
177                    // graph and never trigger a local cycle here.
178                    // Look up the referenced name in the existing
179                    // `features` keys (so the borrowed slice
180                    // outlives this call) instead of creating a
181                    // new `String` from the parsed entry.
182                    if let Ok(FeatureEntry::Local(local)) = FeatureEntry::parse(r)
183                        && let Some((stored, _)) = features.get_key_value(local.as_str())
184                    {
185                        visit(stored.as_str(), features, state, path)?;
186                    }
187                }
188            }
189            path.pop();
190            state.insert(node, Color::Done);
191            Ok(())
192        }
193        let mut state = std::collections::HashMap::new();
194        let mut path: Vec<&str> = Vec::new();
195        for name in self.features.keys() {
196            visit(name.as_str(), &self.features, &mut state, &mut path)?;
197        }
198        Ok(())
199    }
200
201    /// Expand a set of root feature names by transitive closure
202    /// over the *local* `features` map. Caller is responsible for
203    /// ensuring every root is a declared feature.
204    ///
205    /// Entries that take the form `dep:<name>` or `<dep>/<feature>`
206    /// are skipped: they are package-level effects, not local
207    /// features, and are owned by the cross-package feature
208    /// resolver.
209    pub fn expand(&self, roots: &BTreeSet<String>) -> BTreeSet<String> {
210        let mut out = BTreeSet::new();
211        let mut stack: Vec<String> = roots.iter().cloned().collect();
212        while let Some(name) = stack.pop() {
213            if !out.insert(name.clone()) {
214                continue;
215            }
216            if let Some(implies) = self.features.get(&name) {
217                for raw in implies {
218                    if let Ok(FeatureEntry::Local(local)) = FeatureEntry::parse(raw) {
219                        stack.push(local);
220                    }
221                }
222            }
223        }
224        out
225    }
226}
227
228/// Typed view of a single right-hand-side entry in a `[features]`
229/// list (`feature_name`, `dep:dependency_name`, or
230/// `dependency_name/feature_name`).
231///
232/// `cabin-core` parses the form lazily — the on-disk shape stays
233/// the original string — so older readers are unaffected. The
234/// feature resolver consumes the typed view to decide which
235/// effects an entry has.
236#[derive(Debug, Clone, PartialEq, Eq)]
237pub enum FeatureEntry {
238    /// Enables another local feature on the same package.
239    Local(String),
240    /// Enables an optional Cabin package dependency declared by
241    /// this package. Spelled `dep:<name>` in the manifest.
242    OptionalDep(String),
243    /// Requests `feature` on `dep`. If `dep` is optional, this
244    /// also enables it. Spelled `<dep>/<feature>` in the
245    /// manifest.
246    DepFeature { dep: String, feature: String },
247}
248
249/// Why parsing a feature-list entry failed. Carried inside
250/// [`ValidationError::InvalidFeatureEntry`] so user errors keep
251/// the original string and the structural reason it was
252/// rejected.
253#[derive(Debug, Clone, Copy, PartialEq, Eq)]
254pub enum InvalidFeatureEntryKind {
255    /// The entry was empty.
256    Empty,
257    /// The entry started with `dep:` but the name was empty.
258    EmptyDepName,
259    /// The entry contained a `/` but either side was empty.
260    EmptyDepOrFeature,
261    /// The entry contained more than one `/` separator.
262    MultiplePathSeparators,
263    /// The entry contained a character outside the supported
264    /// alphabet (`A-Z a-z 0-9 _ - .` plus the leading `dep:` or
265    /// single `/` separator).
266    UnsupportedCharacter(char),
267}
268
269impl InvalidFeatureEntryKind {
270    pub fn message(self) -> &'static str {
271        match self {
272            InvalidFeatureEntryKind::Empty => "feature entries must not be empty",
273            InvalidFeatureEntryKind::EmptyDepName => {
274                "`dep:` entries require a non-empty dependency name"
275            }
276            InvalidFeatureEntryKind::EmptyDepOrFeature => {
277                "`<dep>/<feature>` entries require both a dependency name and a feature name"
278            }
279            InvalidFeatureEntryKind::MultiplePathSeparators => {
280                "feature entries may contain at most one `/`"
281            }
282            InvalidFeatureEntryKind::UnsupportedCharacter(_) => {
283                "feature entries may only use ASCII letters, digits, `_`, `-`, `.`, plus the leading `dep:` or single `/` separator"
284            }
285        }
286    }
287}
288
289impl FeatureEntry {
290    /// Parse a single `[features]` value into a typed entry.
291    ///
292    /// # Errors
293    /// Returns [`InvalidFeatureEntryKind::Empty`] for an empty input,
294    /// [`InvalidFeatureEntryKind::EmptyDepName`] for a bare `dep:`,
295    /// [`InvalidFeatureEntryKind::MultiplePathSeparators`] for more than one
296    /// `/`, [`InvalidFeatureEntryKind::EmptyDepOrFeature`] when either side of
297    /// `<dep>/<feature>` is empty, and
298    /// [`InvalidFeatureEntryKind::UnsupportedCharacter`] for a name containing a
299    /// character outside the allowed identifier set.
300    pub fn parse(input: &str) -> Result<Self, InvalidFeatureEntryKind> {
301        if input.is_empty() {
302            return Err(InvalidFeatureEntryKind::Empty);
303        }
304        if let Some(rest) = input.strip_prefix("dep:") {
305            if rest.is_empty() {
306                return Err(InvalidFeatureEntryKind::EmptyDepName);
307            }
308            check_identifier_chars(rest)?;
309            return Ok(FeatureEntry::OptionalDep(rest.to_owned()));
310        }
311        if let Some((dep, feature)) = input.split_once('/') {
312            if feature.contains('/') {
313                return Err(InvalidFeatureEntryKind::MultiplePathSeparators);
314            }
315            if dep.is_empty() || feature.is_empty() {
316                return Err(InvalidFeatureEntryKind::EmptyDepOrFeature);
317            }
318            check_identifier_chars(dep)?;
319            check_identifier_chars(feature)?;
320            return Ok(FeatureEntry::DepFeature {
321                dep: dep.to_owned(),
322                feature: feature.to_owned(),
323            });
324        }
325        check_identifier_chars(input)?;
326        Ok(FeatureEntry::Local(input.to_owned()))
327    }
328}
329
330fn check_identifier_chars(s: &str) -> Result<(), InvalidFeatureEntryKind> {
331    for c in s.chars() {
332        match c {
333            'A'..='Z' | 'a'..='z' | '0'..='9' | '_' | '-' | '.' => {}
334            other => return Err(InvalidFeatureEntryKind::UnsupportedCharacter(other)),
335        }
336    }
337    Ok(())
338}
339
340/// User-supplied flag inputs that select features.
341/// Built by `cabin` from `--features`, `--all-features`, and
342/// `--no-default-features`.
343#[derive(Debug, Clone, Default, PartialEq, Eq)]
344pub struct SelectionRequest {
345    /// Explicit `--features a,b` entries. Order does not matter; the
346    /// Resolver normalizes them.
347    pub features: BTreeSet<String>,
348    pub all_features: bool,
349    pub no_default_features: bool,
350}
351
352/// Resolved, validated build configuration. Drives:
353/// - which features are enabled;
354/// - which profile its compile / link flags come from;
355/// - which toolchain compiled it;
356/// - which semantic build flags applied;
357/// - the deterministic fingerprint that future cache logic can hash on.
358#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
359pub struct BuildConfiguration {
360    pub enabled_features: BTreeSet<String>,
361    /// Resolved profile (e.g. `dev`, `release`, or a custom
362    /// profile inheriting from a built-in). Always populated:
363    /// every build configuration is associated with exactly one
364    /// profile.
365    pub profile: ResolvedProfile,
366    /// Toolchain summary used for fingerprinting and metadata.
367    /// Recorded as the requested spec + tool source per kind so
368    /// the fingerprint is stable across machines that resolve
369    /// `clang++` to different absolute paths.
370    pub toolchain: ToolchainSummary,
371    /// Resolved per-package build flags. The metadata view
372    /// reports this directly; the fingerprint includes a
373    /// deterministic digest of every field.
374    pub build_flags: ResolvedProfileFlags,
375    pub fingerprint: String,
376}
377
378/// Lightweight, non-machine-specific summary of the resolved
379/// toolchain. Stored on every [`BuildConfiguration`] so the
380/// fingerprint reflects "which compiler did this build use" without
381/// pinning the local absolute path.
382#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
383pub struct ToolchainSummary {
384    /// `(kind -> "<spec>") `, sorted alphabetically by tool key.
385    /// Each entry records the user-visible spelling (`clang++`,
386    /// `/opt/llvm/bin/clang++`, …); absolute resolved paths from
387    /// PATH discovery are deliberately omitted.
388    pub tools: BTreeMap<String, String>,
389    /// `(kind -> source label) ` parallel to `tools`. Source
390    /// labels are stable strings (`cli`, `env`, `manifest`,
391    /// `manifest-conditional`, `default`).
392    pub sources: BTreeMap<String, String>,
393    /// Optional compiler-cache wrapper (e.g. `ccache`, `sccache`)
394    /// applied on top of the C++ compiler. `None` when no wrapper
395    /// is selected; otherwise the kind/spec/source/version are
396    /// folded into the configuration fingerprint so a build with a
397    /// different wrapper choice reuses neither cache layer.
398    #[serde(default, skip_serializing_if = "Option::is_none")]
399    pub compiler_wrapper: Option<CompilerWrapperSummary>,
400}
401
402impl ToolchainSummary {
403    /// Build a summary from a `ResolvedToolchain`. Storage is
404    /// deterministic: tools iterate in sorted [`crate::ToolKind`]
405    /// order via [`ResolvedToolchain::iter`].
406    pub fn from_resolved(toolchain: &ResolvedToolchain) -> Self {
407        Self::from_resolved_parts(toolchain, None)
408    }
409
410    /// Build a summary from a `ResolvedToolchain` plus an optional
411    /// compiler-cache wrapper. The wrapper is normalized into a
412    /// [`CompilerWrapperSummary`] so the fingerprint captures the
413    /// requested wrapper without leaking the local absolute path.
414    pub fn from_resolved_parts(
415        toolchain: &ResolvedToolchain,
416        wrapper: Option<&ResolvedCompilerWrapper>,
417    ) -> Self {
418        let mut tools = BTreeMap::new();
419        let mut sources = BTreeMap::new();
420        for tool in toolchain.iter() {
421            let key = tool.kind.as_key().to_owned();
422            tools.insert(key.clone(), tool.spec.display());
423            sources.insert(
424                key,
425                crate::toolchain::tool_source_label(tool.source).to_owned(),
426            );
427        }
428        Self {
429            tools,
430            sources,
431            compiler_wrapper: wrapper.map(CompilerWrapperSummary::from_resolved),
432        }
433    }
434}
435
436/// Bundled inputs for [`BuildConfiguration::resolve`].
437///
438/// `BuildConfiguration` ties together every per-package input the
439/// resolver evaluates (declared features, the requested selection,
440/// and the already-resolved profile / toolchain / build flags).
441/// Threading them through one struct keeps the call signature stable
442/// as new inputs land and stops `cabin metadata` orchestration from
443/// needing to remember a fixed positional order.
444#[derive(Debug)]
445pub struct BuildConfigurationInput<'a> {
446    /// Package name. Used only to render clear validation errors.
447    pub package: &'a str,
448    /// Declared `[features]` table for the package.
449    pub features: &'a Features,
450    /// CLI / config selection request (features).
451    pub request: &'a SelectionRequest,
452    /// Already-resolved profile.
453    pub profile: ResolvedProfile,
454    /// Already-resolved toolchain summary.
455    pub toolchain: ToolchainSummary,
456    /// Already-resolved per-profile build flags.
457    pub build_flags: ResolvedProfileFlags,
458}
459
460impl BuildConfiguration {
461    /// Resolve a [`SelectionRequest`] against a set of declarations.
462    /// `input.package` is used only to make error messages clear.
463    ///
464    /// # Errors
465    /// Returns [`ValidationError::UnknownFeature`] when the request names a
466    /// feature not declared in `input.features`.
467    pub fn resolve(input: BuildConfigurationInput<'_>) -> Result<Self, ValidationError> {
468        let BuildConfigurationInput {
469            package,
470            features,
471            request,
472            profile,
473            toolchain,
474            build_flags,
475        } = input;
476        let enabled_features = resolve_features(package, features, request)?;
477        let fingerprint =
478            compute_fingerprint(&enabled_features, &profile, &toolchain, &build_flags);
479        Ok(Self {
480            enabled_features,
481            profile,
482            toolchain,
483            build_flags,
484            fingerprint,
485        })
486    }
487
488    /// Combined JSON view used to populate the `cabin metadata`
489    /// Configuration block.
490    pub fn as_json(&self) -> serde_json::Value {
491        let compiler_wrapper =
492            self.toolchain
493                .compiler_wrapper
494                .as_ref()
495                .map_or(serde_json::Value::Null, |w| {
496                    let mut obj = serde_json::Map::new();
497                    obj.insert("kind".to_owned(), serde_json::Value::String(w.kind.clone()));
498                    obj.insert("spec".to_owned(), serde_json::Value::String(w.spec.clone()));
499                    obj.insert(
500                        "source".to_owned(),
501                        serde_json::Value::String(w.source.clone()),
502                    );
503                    if let Some(v) = &w.version {
504                        obj.insert("version".to_owned(), serde_json::Value::String(v.clone()));
505                    }
506                    serde_json::Value::Object(obj)
507                });
508        serde_json::json!({
509            "features": self.enabled_features.iter().collect::<Vec<_>>(),
510            "profile": self.profile.as_json(),
511            "toolchain": {
512                "tools": &self.toolchain.tools,
513                "sources": &self.toolchain.sources,
514                "compiler_wrapper": compiler_wrapper,
515            },
516            "build_flags": self.build_flags.as_json(),
517            "fingerprint": self.fingerprint,
518        })
519    }
520}
521
522fn resolve_features(
523    package: &str,
524    features: &Features,
525    request: &SelectionRequest,
526) -> Result<BTreeSet<String>, ValidationError> {
527    // Validate every requested name exists.
528    for name in &request.features {
529        if !features.features.contains_key(name) {
530            return Err(ValidationError::UnknownFeature {
531                package: package.to_owned(),
532                feature: name.clone(),
533            });
534        }
535    }
536
537    let mut roots: BTreeSet<String> = BTreeSet::new();
538    if request.all_features {
539        for name in features.features.keys() {
540            roots.insert(name.clone());
541        }
542    } else {
543        if !request.no_default_features {
544            for name in &features.default {
545                roots.insert(name.clone());
546            }
547        }
548        for name in &request.features {
549            roots.insert(name.clone());
550        }
551    }
552    Ok(features.expand(&roots))
553}
554
555fn bool_bytes(b: bool) -> &'static [u8] {
556    if b { b"true" } else { b"false" }
557}
558
559fn compute_fingerprint(
560    features: &BTreeSet<String>,
561    profile: &ResolvedProfile,
562    toolchain: &ToolchainSummary,
563    build_flags: &ResolvedProfileFlags,
564) -> String {
565    // Hash a stable, line-based serialization rather than JSON so the
566    // fingerprint is independent of serialiser whitespace choices.
567    let mut hasher = Sha256::new();
568    hasher.update(b"features\n");
569    for f in features {
570        hasher.update(f.as_bytes());
571        hasher.update(b"\n");
572    }
573    hasher.update(b"profile\n");
574    hasher.update(b"name=");
575    hasher.update(profile.name.as_str().as_bytes());
576    hasher.update(b"\n");
577    hasher.update(b"debug=");
578    hasher.update(bool_bytes(profile.debug));
579    hasher.update(b"\n");
580    hasher.update(b"opt-level=");
581    hasher.update(profile.opt_level.as_str().as_bytes());
582    hasher.update(b"\n");
583    hasher.update(b"assertions=");
584    hasher.update(bool_bytes(profile.assertions));
585    hasher.update(b"\n");
586    hasher.update(b"toolchain\n");
587    for (kind, spec) in &toolchain.tools {
588        hasher.update(kind.as_bytes());
589        hasher.update(b"=");
590        hasher.update(spec.as_bytes());
591        hasher.update(b"\n");
592    }
593    hasher.update(b"compiler-wrapper\n");
594    match &toolchain.compiler_wrapper {
595        Some(wrapper) => {
596            hasher.update(b"kind=");
597            hasher.update(wrapper.kind.as_bytes());
598            hasher.update(b"\n");
599            hasher.update(b"spec=");
600            hasher.update(wrapper.spec.as_bytes());
601            hasher.update(b"\n");
602            if let Some(version) = wrapper.version.as_deref() {
603                hasher.update(b"version=");
604                hasher.update(version.as_bytes());
605                hasher.update(b"\n");
606            }
607        }
608        None => {
609            hasher.update(b"kind=none\n");
610        }
611    }
612    hasher.update(b"build-flags\n");
613    hasher.update(b"defines\n");
614    for d in &build_flags.defines {
615        hasher.update(d.as_bytes());
616        hasher.update(b"\n");
617    }
618    hasher.update(b"include-dirs\n");
619    for inc in &build_flags.include_dirs {
620        hasher.update(inc.to_string_lossy().as_bytes());
621        hasher.update(b"\n");
622    }
623    hasher.update(b"language-neutral-compile-args\n");
624    for a in &build_flags.extra_compile_args {
625        hasher.update(a.as_bytes());
626        hasher.update(b"\n");
627    }
628    // The C-only and C++-only escape hatches change the
629    // generated compile commands and the resulting object
630    // contents, so they must move the fingerprint. Each section
631    // is anchored by a labeled header so a future addition
632    // (e.g. extra-asm-compile-args) cannot accidentally collide
633    // with one of the existing buckets and produce the same
634    // fingerprint as a different input.
635    hasher.update(b"cflags\n");
636    for a in &build_flags.cflags {
637        hasher.update(a.as_bytes());
638        hasher.update(b"\n");
639    }
640    hasher.update(b"cxxflags\n");
641    for a in &build_flags.cxxflags {
642        hasher.update(a.as_bytes());
643        hasher.update(b"\n");
644    }
645    hasher.update(b"ldflags\n");
646    for a in &build_flags.ldflags {
647        hasher.update(a.as_bytes());
648        hasher.update(b"\n");
649    }
650    crate::hash::hex_digest(&hasher.finalize())
651}
652
653/// Identifier grammar for feature names.
654fn validate_identifier(name: &str) -> Result<(), ValidationError> {
655    if name.is_empty() {
656        return Err(ValidationError::EmptyConfigName("feature"));
657    }
658    let bad = name.chars().any(|c| {
659        !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
660            || c.is_whitespace()
661            || matches!(c, '/' | '.' | ':')
662    });
663    if bad {
664        return Err(ValidationError::InvalidConfigName {
665            kind: "feature",
666            value: name.to_owned(),
667        });
668    }
669    Ok(())
670}
671
672#[cfg(test)]
673mod tests {
674    use super::*;
675    use crate::profile::{
676        ProfileDefinition, ProfileName, ProfileSelection, ResolvedProfile, resolve_profile,
677    };
678    use std::path::PathBuf;
679
680    fn dev() -> ResolvedProfile {
681        resolve_profile(
682            &ProfileSelection::default_dev(),
683            &BTreeMap::<ProfileName, ProfileDefinition>::new(),
684        )
685        .expect("built-in dev resolves")
686    }
687
688    fn feats(default: &[&str], pairs: &[(&str, &[&str])]) -> Features {
689        let mut features = BTreeMap::new();
690        for (k, vs) in pairs {
691            features.insert(
692                (*k).to_owned(),
693                vs.iter().map(|s| (*s).to_owned()).collect(),
694            );
695        }
696        Features {
697            default: default.iter().map(|s| (*s).to_owned()).collect(),
698            features,
699        }
700    }
701
702    #[test]
703    fn features_validate_ok_for_simple_decls() {
704        feats(&["simd"], &[("simd", &[]), ("ssl", &[])])
705            .validate()
706            .unwrap();
707    }
708
709    #[test]
710    fn features_reject_reserved_default_key() {
711        let mut f = feats(&[], &[]);
712        f.features.insert("default".into(), vec![]);
713        match f.validate().unwrap_err() {
714            ValidationError::ReservedFeatureName(n) => assert_eq!(n, "default"),
715            other => panic!("expected ReservedFeatureName, got {other:?}"),
716        }
717    }
718
719    #[test]
720    fn features_reject_unknown_default_reference() {
721        match feats(&["nope"], &[("simd", &[])]).validate().unwrap_err() {
722            ValidationError::UnknownFeatureReference { referenced, .. } => {
723                assert_eq!(referenced, "nope");
724            }
725            other => panic!("unexpected: {other:?}"),
726        }
727    }
728
729    #[test]
730    fn features_reject_internal_unknown_reference() {
731        match feats(&[], &[("full", &["ssl"])]).validate().unwrap_err() {
732            ValidationError::UnknownFeatureReference {
733                referrer,
734                referenced,
735            } => {
736                assert_eq!(referrer, "full");
737                assert_eq!(referenced, "ssl");
738            }
739            other => panic!("unexpected: {other:?}"),
740        }
741    }
742
743    #[test]
744    fn features_reject_cycles() {
745        let f = feats(&[], &[("a", &["b"]), ("b", &["a"])]);
746        match f.validate().unwrap_err() {
747            ValidationError::FeatureCycle(cycle) => {
748                assert!(cycle.iter().any(|n| n == "a"));
749                assert!(cycle.iter().any(|n| n == "b"));
750            }
751            other => panic!("unexpected: {other:?}"),
752        }
753    }
754
755    #[test]
756    fn features_reject_invalid_name() {
757        let f = feats(&[], &[("foo/bar", &[])]);
758        match f.validate().unwrap_err() {
759            ValidationError::InvalidConfigName { kind, value } => {
760                assert_eq!(kind, "feature");
761                assert_eq!(value, "foo/bar");
762            }
763            other => panic!("unexpected: {other:?}"),
764        }
765    }
766
767    #[test]
768    fn features_expand_default_set() {
769        let f = feats(
770            &["full"],
771            &[("simd", &[]), ("ssl", &[]), ("full", &["simd", "ssl"])],
772        );
773        f.validate().unwrap();
774        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
775            package: "demo",
776            features: &f,
777            request: &SelectionRequest::default(),
778            profile: dev(),
779            toolchain: ToolchainSummary::default(),
780            build_flags: ResolvedProfileFlags::default(),
781        })
782        .unwrap();
783        let v: Vec<&str> = cfg.enabled_features.iter().map(String::as_str).collect();
784        assert_eq!(v, vec!["full", "simd", "ssl"]);
785    }
786
787    #[test]
788    fn no_default_features_drops_defaults() {
789        let f = feats(&["simd"], &[("simd", &[]), ("ssl", &[])]);
790        f.validate().unwrap();
791        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
792            package: "demo",
793            features: &f,
794            request: &SelectionRequest {
795                no_default_features: true,
796                ..Default::default()
797            },
798            profile: dev(),
799            toolchain: ToolchainSummary::default(),
800            build_flags: ResolvedProfileFlags::default(),
801        })
802        .unwrap();
803        assert!(cfg.enabled_features.is_empty());
804    }
805
806    #[test]
807    fn explicit_features_are_added() {
808        let f = feats(&[], &[("simd", &[]), ("ssl", &[])]);
809        f.validate().unwrap();
810        let mut req = SelectionRequest::default();
811        req.features.insert("ssl".into());
812        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
813            package: "demo",
814            features: &f,
815            request: &req,
816            profile: dev(),
817            toolchain: ToolchainSummary::default(),
818            build_flags: ResolvedProfileFlags::default(),
819        })
820        .unwrap();
821        let v: Vec<&str> = cfg.enabled_features.iter().map(String::as_str).collect();
822        assert_eq!(v, vec!["ssl"]);
823    }
824
825    #[test]
826    fn all_features_enables_every_declared_feature() {
827        let f = feats(&[], &[("simd", &[]), ("ssl", &[])]);
828        f.validate().unwrap();
829        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
830            package: "demo",
831            features: &f,
832            request: &SelectionRequest {
833                all_features: true,
834                ..Default::default()
835            },
836            profile: dev(),
837            toolchain: ToolchainSummary::default(),
838            build_flags: ResolvedProfileFlags::default(),
839        })
840        .unwrap();
841        let v: Vec<&str> = cfg.enabled_features.iter().map(String::as_str).collect();
842        assert_eq!(v, vec!["simd", "ssl"]);
843    }
844
845    #[test]
846    fn unknown_feature_in_request_errors() {
847        let f = feats(&[], &[("simd", &[])]);
848        let mut req = SelectionRequest::default();
849        req.features.insert("missing".into());
850        match BuildConfiguration::resolve(BuildConfigurationInput {
851            package: "demo",
852            features: &f,
853            request: &req,
854            profile: dev(),
855            toolchain: ToolchainSummary::default(),
856            build_flags: ResolvedProfileFlags::default(),
857        })
858        .unwrap_err()
859        {
860            ValidationError::UnknownFeature { feature, .. } => assert_eq!(feature, "missing"),
861            other => panic!("unexpected: {other:?}"),
862        }
863    }
864
865    #[test]
866    fn fingerprint_is_stable_for_same_inputs() {
867        let f = feats(&["simd"], &[("simd", &[]), ("ssl", &[])]);
868        f.validate().unwrap();
869        let cfg1 = BuildConfiguration::resolve(BuildConfigurationInput {
870            package: "demo",
871            features: &f,
872            request: &SelectionRequest::default(),
873            profile: dev(),
874            toolchain: ToolchainSummary::default(),
875            build_flags: ResolvedProfileFlags::default(),
876        })
877        .unwrap();
878        let cfg2 = BuildConfiguration::resolve(BuildConfigurationInput {
879            package: "demo",
880            features: &f,
881            request: &SelectionRequest::default(),
882            profile: dev(),
883            toolchain: ToolchainSummary::default(),
884            build_flags: ResolvedProfileFlags::default(),
885        })
886        .unwrap();
887        assert_eq!(cfg1.fingerprint, cfg2.fingerprint);
888        assert_eq!(cfg1.fingerprint.len(), 64);
889    }
890
891    #[test]
892    fn fingerprint_differs_when_features_change() {
893        let f = feats(&[], &[("simd", &[]), ("ssl", &[])]);
894        f.validate().unwrap();
895        let mut req = SelectionRequest::default();
896        let cfg_empty = BuildConfiguration::resolve(BuildConfigurationInput {
897            package: "demo",
898            features: &f,
899            request: &req,
900            profile: dev(),
901            toolchain: ToolchainSummary::default(),
902            build_flags: ResolvedProfileFlags::default(),
903        })
904        .unwrap();
905        req.features.insert("simd".into());
906        let cfg_simd = BuildConfiguration::resolve(BuildConfigurationInput {
907            package: "demo",
908            features: &f,
909            request: &req,
910            profile: dev(),
911            toolchain: ToolchainSummary::default(),
912            build_flags: ResolvedProfileFlags::default(),
913        })
914        .unwrap();
915        assert_ne!(cfg_empty.fingerprint, cfg_simd.fingerprint);
916    }
917    /// Helper: resolve a `BuildConfiguration` with the supplied
918    /// build flags. Every other input is the boring default so
919    /// the only difference between two calls is the `flags` arg
920    /// — used for the fingerprint-input regression tests below.
921    fn resolve_with_flags(flags: ResolvedProfileFlags) -> BuildConfiguration {
922        BuildConfiguration::resolve(BuildConfigurationInput {
923            package: "demo",
924            features: &Features::default(),
925            request: &SelectionRequest::default(),
926            profile: dev(),
927            toolchain: ToolchainSummary::default(),
928            build_flags: flags,
929        })
930        .unwrap()
931    }
932
933    #[test]
934    fn fingerprint_differs_when_defines_change() {
935        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
936        let added = resolve_with_flags(ResolvedProfileFlags {
937            defines: vec!["FOO=1".to_owned()],
938            ..ResolvedProfileFlags::default()
939        });
940        assert_ne!(baseline.fingerprint, added.fingerprint);
941    }
942
943    #[test]
944    fn fingerprint_differs_when_include_dirs_change() {
945        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
946        let added = resolve_with_flags(ResolvedProfileFlags {
947            include_dirs: vec![PathBuf::from("include")],
948            ..ResolvedProfileFlags::default()
949        });
950        assert_ne!(baseline.fingerprint, added.fingerprint);
951    }
952
953    #[test]
954    fn fingerprint_differs_when_extra_compile_args_change() {
955        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
956        let added = resolve_with_flags(ResolvedProfileFlags {
957            extra_compile_args: vec!["-Wall".to_owned()],
958            ..ResolvedProfileFlags::default()
959        });
960        assert_ne!(baseline.fingerprint, added.fingerprint);
961    }
962
963    #[test]
964    fn fingerprint_differs_when_cflags_change() {
965        // The per-language escape hatches must each contribute
966        // their own fingerprint bucket. A C compile command's
967        // argv changes when this slot changes, which means the
968        // resulting `.o` bytes can change too — a future on-disk
969        // artifact cache *must* see a different fingerprint or it
970        // would silently reuse a stale object. The fingerprint
971        // must move.
972        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
973        let added = resolve_with_flags(ResolvedProfileFlags {
974            cflags: vec!["-std=c99".to_owned()],
975            ..ResolvedProfileFlags::default()
976        });
977        assert_ne!(baseline.fingerprint, added.fingerprint);
978    }
979
980    #[test]
981    fn fingerprint_differs_when_cxxflags_change() {
982        // Mirror of the C-only test: a C++ compile command's
983        // argv must move the fingerprint too.
984        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
985        let added = resolve_with_flags(ResolvedProfileFlags {
986            cxxflags: vec!["-fno-rtti".to_owned()],
987            ..ResolvedProfileFlags::default()
988        });
989        assert_ne!(baseline.fingerprint, added.fingerprint);
990    }
991
992    #[test]
993    fn fingerprint_distinguishes_c_only_from_cxx_only_extra_args() {
994        // Belt-and-suspenders: putting the *same* flag string in
995        // the C-only slot vs. the C++-only slot must produce
996        // different fingerprints because the two slots route to
997        // different compile commands. Without this guarantee,
998        // future cache logic could accidentally serve a C-only
999        // object for a C++-only request that happens to share an
1000        // argv string.
1001        let c_only = resolve_with_flags(ResolvedProfileFlags {
1002            cflags: vec!["-Wsome-warning".to_owned()],
1003            ..ResolvedProfileFlags::default()
1004        });
1005        let cxx_only = resolve_with_flags(ResolvedProfileFlags {
1006            cxxflags: vec!["-Wsome-warning".to_owned()],
1007            ..ResolvedProfileFlags::default()
1008        });
1009        assert_ne!(c_only.fingerprint, cxx_only.fingerprint);
1010    }
1011
1012    #[test]
1013    fn fingerprint_differs_when_ldflags_change() {
1014        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
1015        let added = resolve_with_flags(ResolvedProfileFlags {
1016            ldflags: vec!["-Wl,--as-needed".to_owned()],
1017            ..ResolvedProfileFlags::default()
1018        });
1019        assert_ne!(baseline.fingerprint, added.fingerprint);
1020    }
1021
1022    #[test]
1023    fn fingerprint_is_stable_for_same_build_flags() {
1024        // Determinism: identical inputs produce identical
1025        // fingerprints. The fingerprint serialiser sorts every
1026        // map / set; this test pins that contract.
1027        let flags = ResolvedProfileFlags {
1028            defines: vec!["FOO=1".to_owned(), "BAR=2".to_owned()],
1029            include_dirs: vec![PathBuf::from("include"), PathBuf::from("vendor/include")],
1030            extra_compile_args: vec!["-Wall".to_owned()],
1031            cflags: vec!["-std=c99".to_owned()],
1032            cxxflags: vec!["-fno-rtti".to_owned()],
1033            ldflags: vec!["-Wl,--as-needed".to_owned()],
1034        };
1035        let a = resolve_with_flags(flags.clone());
1036        let b = resolve_with_flags(flags);
1037        assert_eq!(a.fingerprint, b.fingerprint);
1038        assert_eq!(a.fingerprint.len(), 64, "sha256 hex digest is 64 chars");
1039    }
1040
1041    fn release() -> ResolvedProfile {
1042        use crate::profile::{ProfileDefinition, ProfileName, ProfileSelection, resolve_profile};
1043        resolve_profile(
1044            &ProfileSelection::release_alias(),
1045            &BTreeMap::<ProfileName, ProfileDefinition>::new(),
1046        )
1047        .expect("built-in release resolves")
1048    }
1049
1050    #[test]
1051    fn fingerprint_differs_when_profile_changes() {
1052        let dev_cfg = BuildConfiguration::resolve(BuildConfigurationInput {
1053            package: "demo",
1054            features: &Features::default(),
1055            request: &SelectionRequest::default(),
1056            profile: dev(),
1057            toolchain: ToolchainSummary::default(),
1058            build_flags: ResolvedProfileFlags::default(),
1059        })
1060        .unwrap();
1061        let release_cfg = BuildConfiguration::resolve(BuildConfigurationInput {
1062            package: "demo",
1063            features: &Features::default(),
1064            request: &SelectionRequest::default(),
1065            profile: release(),
1066            toolchain: ToolchainSummary::default(),
1067            build_flags: ResolvedProfileFlags::default(),
1068        })
1069        .unwrap();
1070        // Built-in dev and release differ in opt-level, debug,
1071        // assertions, and name — every field participates in
1072        // the fingerprint, so the digest must move.
1073        assert_ne!(dev_cfg.fingerprint, release_cfg.fingerprint);
1074    }
1075
1076    #[test]
1077    fn fingerprint_differs_when_toolchain_summary_changes() {
1078        let mut tc_a = ToolchainSummary::default();
1079        tc_a.tools.insert("cxx".to_owned(), "g++".to_owned());
1080        let mut tc_b = ToolchainSummary::default();
1081        tc_b.tools.insert("cxx".to_owned(), "clang++".to_owned());
1082        let cfg_a = BuildConfiguration::resolve(BuildConfigurationInput {
1083            package: "demo",
1084            features: &Features::default(),
1085            request: &SelectionRequest::default(),
1086            profile: dev(),
1087            toolchain: tc_a,
1088            build_flags: ResolvedProfileFlags::default(),
1089        })
1090        .unwrap();
1091        let cfg_b = BuildConfiguration::resolve(BuildConfigurationInput {
1092            package: "demo",
1093            features: &Features::default(),
1094            request: &SelectionRequest::default(),
1095            profile: dev(),
1096            toolchain: tc_b,
1097            build_flags: ResolvedProfileFlags::default(),
1098        })
1099        .unwrap();
1100        assert_ne!(cfg_a.fingerprint, cfg_b.fingerprint);
1101    }
1102
1103    #[test]
1104    fn fingerprint_differs_when_compiler_wrapper_changes() {
1105        let no_wrapper = ToolchainSummary::default();
1106        let with_wrapper = ToolchainSummary {
1107            compiler_wrapper: Some(CompilerWrapperSummary {
1108                kind: "ccache".into(),
1109                spec: "ccache".into(),
1110                source: "cli".into(),
1111                version: Some("4.8.0".into()),
1112            }),
1113            ..ToolchainSummary::default()
1114        };
1115        let cfg_a = BuildConfiguration::resolve(BuildConfigurationInput {
1116            package: "demo",
1117            features: &Features::default(),
1118            request: &SelectionRequest::default(),
1119            profile: dev(),
1120            toolchain: no_wrapper,
1121            build_flags: ResolvedProfileFlags::default(),
1122        })
1123        .unwrap();
1124        let cfg_b = BuildConfiguration::resolve(BuildConfigurationInput {
1125            package: "demo",
1126            features: &Features::default(),
1127            request: &SelectionRequest::default(),
1128            profile: dev(),
1129            toolchain: with_wrapper,
1130            build_flags: ResolvedProfileFlags::default(),
1131        })
1132        .unwrap();
1133        assert_ne!(cfg_a.fingerprint, cfg_b.fingerprint);
1134    }
1135}