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.as_str().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    hasher.update(b"link-libs\n");
651    for a in &build_flags.link_libs {
652        hasher.update(a.as_bytes());
653        hasher.update(b"\n");
654    }
655    crate::hash::hex_digest(&hasher.finalize())
656}
657
658/// Identifier grammar for feature names.
659fn validate_identifier(name: &str) -> Result<(), ValidationError> {
660    if name.is_empty() {
661        return Err(ValidationError::EmptyConfigName("feature"));
662    }
663    let bad = name.chars().any(|c| {
664        !(c.is_ascii_alphanumeric() || c == '_' || c == '-')
665            || c.is_whitespace()
666            || matches!(c, '/' | '.' | ':')
667    });
668    if bad {
669        return Err(ValidationError::InvalidConfigName {
670            kind: "feature",
671            value: name.to_owned(),
672        });
673    }
674    Ok(())
675}
676
677#[cfg(test)]
678mod tests {
679    use super::*;
680    use crate::profile::{
681        ProfileDefinition, ProfileName, ProfileSelection, ResolvedProfile, resolve_profile,
682    };
683    use camino::Utf8PathBuf;
684
685    fn dev() -> ResolvedProfile {
686        resolve_profile(
687            &ProfileSelection::default_dev(),
688            &BTreeMap::<ProfileName, ProfileDefinition>::new(),
689        )
690        .expect("built-in dev resolves")
691    }
692
693    fn feats(default: &[&str], pairs: &[(&str, &[&str])]) -> Features {
694        let mut features = BTreeMap::new();
695        for (k, vs) in pairs {
696            features.insert(
697                (*k).to_owned(),
698                vs.iter().map(|s| (*s).to_owned()).collect(),
699            );
700        }
701        Features {
702            default: default.iter().map(|s| (*s).to_owned()).collect(),
703            features,
704        }
705    }
706
707    #[test]
708    fn features_validate_ok_for_simple_decls() {
709        feats(&["simd"], &[("simd", &[]), ("ssl", &[])])
710            .validate()
711            .unwrap();
712    }
713
714    #[test]
715    fn features_reject_reserved_default_key() {
716        let mut f = feats(&[], &[]);
717        f.features.insert("default".into(), vec![]);
718        match f.validate().unwrap_err() {
719            ValidationError::ReservedFeatureName(n) => assert_eq!(n, "default"),
720            other => panic!("expected ReservedFeatureName, got {other:?}"),
721        }
722    }
723
724    #[test]
725    fn features_reject_unknown_default_reference() {
726        match feats(&["nope"], &[("simd", &[])]).validate().unwrap_err() {
727            ValidationError::UnknownFeatureReference { referenced, .. } => {
728                assert_eq!(referenced, "nope");
729            }
730            other => panic!("unexpected: {other:?}"),
731        }
732    }
733
734    #[test]
735    fn features_reject_internal_unknown_reference() {
736        match feats(&[], &[("full", &["ssl"])]).validate().unwrap_err() {
737            ValidationError::UnknownFeatureReference {
738                referrer,
739                referenced,
740            } => {
741                assert_eq!(referrer, "full");
742                assert_eq!(referenced, "ssl");
743            }
744            other => panic!("unexpected: {other:?}"),
745        }
746    }
747
748    #[test]
749    fn features_reject_cycles() {
750        let f = feats(&[], &[("a", &["b"]), ("b", &["a"])]);
751        match f.validate().unwrap_err() {
752            ValidationError::FeatureCycle(cycle) => {
753                assert!(cycle.iter().any(|n| n == "a"));
754                assert!(cycle.iter().any(|n| n == "b"));
755            }
756            other => panic!("unexpected: {other:?}"),
757        }
758    }
759
760    #[test]
761    fn features_reject_invalid_name() {
762        let f = feats(&[], &[("foo/bar", &[])]);
763        match f.validate().unwrap_err() {
764            ValidationError::InvalidConfigName { kind, value } => {
765                assert_eq!(kind, "feature");
766                assert_eq!(value, "foo/bar");
767            }
768            other => panic!("unexpected: {other:?}"),
769        }
770    }
771
772    #[test]
773    fn features_expand_default_set() {
774        let f = feats(
775            &["full"],
776            &[("simd", &[]), ("ssl", &[]), ("full", &["simd", "ssl"])],
777        );
778        f.validate().unwrap();
779        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
780            package: "demo",
781            features: &f,
782            request: &SelectionRequest::default(),
783            profile: dev(),
784            toolchain: ToolchainSummary::default(),
785            build_flags: ResolvedProfileFlags::default(),
786        })
787        .unwrap();
788        let v: Vec<&str> = cfg.enabled_features.iter().map(String::as_str).collect();
789        assert_eq!(v, vec!["full", "simd", "ssl"]);
790    }
791
792    #[test]
793    fn no_default_features_drops_defaults() {
794        let f = feats(&["simd"], &[("simd", &[]), ("ssl", &[])]);
795        f.validate().unwrap();
796        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
797            package: "demo",
798            features: &f,
799            request: &SelectionRequest {
800                no_default_features: true,
801                ..Default::default()
802            },
803            profile: dev(),
804            toolchain: ToolchainSummary::default(),
805            build_flags: ResolvedProfileFlags::default(),
806        })
807        .unwrap();
808        assert!(cfg.enabled_features.is_empty());
809    }
810
811    #[test]
812    fn explicit_features_are_added() {
813        let f = feats(&[], &[("simd", &[]), ("ssl", &[])]);
814        f.validate().unwrap();
815        let mut req = SelectionRequest::default();
816        req.features.insert("ssl".into());
817        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
818            package: "demo",
819            features: &f,
820            request: &req,
821            profile: dev(),
822            toolchain: ToolchainSummary::default(),
823            build_flags: ResolvedProfileFlags::default(),
824        })
825        .unwrap();
826        let v: Vec<&str> = cfg.enabled_features.iter().map(String::as_str).collect();
827        assert_eq!(v, vec!["ssl"]);
828    }
829
830    #[test]
831    fn all_features_enables_every_declared_feature() {
832        let f = feats(&[], &[("simd", &[]), ("ssl", &[])]);
833        f.validate().unwrap();
834        let cfg = BuildConfiguration::resolve(BuildConfigurationInput {
835            package: "demo",
836            features: &f,
837            request: &SelectionRequest {
838                all_features: true,
839                ..Default::default()
840            },
841            profile: dev(),
842            toolchain: ToolchainSummary::default(),
843            build_flags: ResolvedProfileFlags::default(),
844        })
845        .unwrap();
846        let v: Vec<&str> = cfg.enabled_features.iter().map(String::as_str).collect();
847        assert_eq!(v, vec!["simd", "ssl"]);
848    }
849
850    #[test]
851    fn unknown_feature_in_request_errors() {
852        let f = feats(&[], &[("simd", &[])]);
853        let mut req = SelectionRequest::default();
854        req.features.insert("missing".into());
855        match BuildConfiguration::resolve(BuildConfigurationInput {
856            package: "demo",
857            features: &f,
858            request: &req,
859            profile: dev(),
860            toolchain: ToolchainSummary::default(),
861            build_flags: ResolvedProfileFlags::default(),
862        })
863        .unwrap_err()
864        {
865            ValidationError::UnknownFeature { feature, .. } => assert_eq!(feature, "missing"),
866            other => panic!("unexpected: {other:?}"),
867        }
868    }
869
870    #[test]
871    fn fingerprint_is_stable_for_same_inputs() {
872        let f = feats(&["simd"], &[("simd", &[]), ("ssl", &[])]);
873        f.validate().unwrap();
874        let cfg1 = BuildConfiguration::resolve(BuildConfigurationInput {
875            package: "demo",
876            features: &f,
877            request: &SelectionRequest::default(),
878            profile: dev(),
879            toolchain: ToolchainSummary::default(),
880            build_flags: ResolvedProfileFlags::default(),
881        })
882        .unwrap();
883        let cfg2 = BuildConfiguration::resolve(BuildConfigurationInput {
884            package: "demo",
885            features: &f,
886            request: &SelectionRequest::default(),
887            profile: dev(),
888            toolchain: ToolchainSummary::default(),
889            build_flags: ResolvedProfileFlags::default(),
890        })
891        .unwrap();
892        assert_eq!(cfg1.fingerprint, cfg2.fingerprint);
893        assert_eq!(cfg1.fingerprint.len(), 64);
894    }
895
896    #[test]
897    fn fingerprint_differs_when_features_change() {
898        let f = feats(&[], &[("simd", &[]), ("ssl", &[])]);
899        f.validate().unwrap();
900        let mut req = SelectionRequest::default();
901        let cfg_empty = BuildConfiguration::resolve(BuildConfigurationInput {
902            package: "demo",
903            features: &f,
904            request: &req,
905            profile: dev(),
906            toolchain: ToolchainSummary::default(),
907            build_flags: ResolvedProfileFlags::default(),
908        })
909        .unwrap();
910        req.features.insert("simd".into());
911        let cfg_simd = BuildConfiguration::resolve(BuildConfigurationInput {
912            package: "demo",
913            features: &f,
914            request: &req,
915            profile: dev(),
916            toolchain: ToolchainSummary::default(),
917            build_flags: ResolvedProfileFlags::default(),
918        })
919        .unwrap();
920        assert_ne!(cfg_empty.fingerprint, cfg_simd.fingerprint);
921    }
922    /// Helper: resolve a `BuildConfiguration` with the supplied
923    /// build flags. Every other input is the boring default so
924    /// the only difference between two calls is the `flags` arg
925    /// — used for the fingerprint-input regression tests below.
926    fn resolve_with_flags(flags: ResolvedProfileFlags) -> BuildConfiguration {
927        BuildConfiguration::resolve(BuildConfigurationInput {
928            package: "demo",
929            features: &Features::default(),
930            request: &SelectionRequest::default(),
931            profile: dev(),
932            toolchain: ToolchainSummary::default(),
933            build_flags: flags,
934        })
935        .unwrap()
936    }
937
938    #[test]
939    fn fingerprint_differs_when_defines_change() {
940        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
941        let added = resolve_with_flags(ResolvedProfileFlags {
942            defines: vec!["FOO=1".to_owned()],
943            ..ResolvedProfileFlags::default()
944        });
945        assert_ne!(baseline.fingerprint, added.fingerprint);
946    }
947
948    #[test]
949    fn fingerprint_differs_when_include_dirs_change() {
950        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
951        let added = resolve_with_flags(ResolvedProfileFlags {
952            include_dirs: vec![Utf8PathBuf::from("include")],
953            ..ResolvedProfileFlags::default()
954        });
955        assert_ne!(baseline.fingerprint, added.fingerprint);
956    }
957
958    #[test]
959    fn fingerprint_differs_when_extra_compile_args_change() {
960        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
961        let added = resolve_with_flags(ResolvedProfileFlags {
962            extra_compile_args: vec!["-Wall".to_owned()],
963            ..ResolvedProfileFlags::default()
964        });
965        assert_ne!(baseline.fingerprint, added.fingerprint);
966    }
967
968    #[test]
969    fn fingerprint_differs_when_cflags_change() {
970        // The per-language escape hatches must each contribute
971        // their own fingerprint bucket. A C compile command's
972        // argv changes when this slot changes, which means the
973        // resulting `.o` bytes can change too — a future on-disk
974        // artifact cache *must* see a different fingerprint or it
975        // would silently reuse a stale object. The fingerprint
976        // must move.
977        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
978        let added = resolve_with_flags(ResolvedProfileFlags {
979            cflags: vec!["-std=c99".to_owned()],
980            ..ResolvedProfileFlags::default()
981        });
982        assert_ne!(baseline.fingerprint, added.fingerprint);
983    }
984
985    #[test]
986    fn fingerprint_differs_when_cxxflags_change() {
987        // Mirror of the C-only test: a C++ compile command's
988        // argv must move the fingerprint too.
989        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
990        let added = resolve_with_flags(ResolvedProfileFlags {
991            cxxflags: vec!["-fno-rtti".to_owned()],
992            ..ResolvedProfileFlags::default()
993        });
994        assert_ne!(baseline.fingerprint, added.fingerprint);
995    }
996
997    #[test]
998    fn fingerprint_distinguishes_c_only_from_cxx_only_extra_args() {
999        // Belt-and-suspenders: putting the *same* flag string in
1000        // the C-only slot vs. the C++-only slot must produce
1001        // different fingerprints because the two slots route to
1002        // different compile commands. Without this guarantee,
1003        // future cache logic could accidentally serve a C-only
1004        // object for a C++-only request that happens to share an
1005        // argv string.
1006        let c_only = resolve_with_flags(ResolvedProfileFlags {
1007            cflags: vec!["-Wsome-warning".to_owned()],
1008            ..ResolvedProfileFlags::default()
1009        });
1010        let cxx_only = resolve_with_flags(ResolvedProfileFlags {
1011            cxxflags: vec!["-Wsome-warning".to_owned()],
1012            ..ResolvedProfileFlags::default()
1013        });
1014        assert_ne!(c_only.fingerprint, cxx_only.fingerprint);
1015    }
1016
1017    #[test]
1018    fn fingerprint_differs_when_ldflags_change() {
1019        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
1020        let added = resolve_with_flags(ResolvedProfileFlags {
1021            ldflags: vec!["-Wl,--as-needed".to_owned()],
1022            ..ResolvedProfileFlags::default()
1023        });
1024        assert_ne!(baseline.fingerprint, added.fingerprint);
1025    }
1026
1027    #[test]
1028    fn fingerprint_differs_when_link_libs_change() {
1029        // `link-libs` change the generated link command, so two
1030        // configurations differing only in their system libraries must
1031        // not share a build fingerprint.
1032        let baseline = resolve_with_flags(ResolvedProfileFlags::default());
1033        let added = resolve_with_flags(ResolvedProfileFlags {
1034            link_libs: vec!["pthread".to_owned()],
1035            ..ResolvedProfileFlags::default()
1036        });
1037        assert_ne!(baseline.fingerprint, added.fingerprint);
1038    }
1039
1040    #[test]
1041    fn fingerprint_is_stable_for_same_build_flags() {
1042        // Determinism: identical inputs produce identical
1043        // fingerprints. The fingerprint serialiser sorts every
1044        // map / set; this test pins that contract.
1045        let flags = ResolvedProfileFlags {
1046            defines: vec!["FOO=1".to_owned(), "BAR=2".to_owned()],
1047            include_dirs: vec![
1048                Utf8PathBuf::from("include"),
1049                Utf8PathBuf::from("vendor/include"),
1050            ],
1051            extra_compile_args: vec!["-Wall".to_owned()],
1052            cflags: vec!["-std=c99".to_owned()],
1053            cxxflags: vec!["-fno-rtti".to_owned()],
1054            ldflags: vec!["-Wl,--as-needed".to_owned()],
1055            link_libs: vec!["pthread".to_owned()],
1056        };
1057        let a = resolve_with_flags(flags.clone());
1058        let b = resolve_with_flags(flags);
1059        assert_eq!(a.fingerprint, b.fingerprint);
1060        assert_eq!(a.fingerprint.len(), 64, "sha256 hex digest is 64 chars");
1061    }
1062
1063    fn release() -> ResolvedProfile {
1064        use crate::profile::{ProfileDefinition, ProfileName, ProfileSelection, resolve_profile};
1065        resolve_profile(
1066            &ProfileSelection::release_alias(),
1067            &BTreeMap::<ProfileName, ProfileDefinition>::new(),
1068        )
1069        .expect("built-in release resolves")
1070    }
1071
1072    #[test]
1073    fn fingerprint_differs_when_profile_changes() {
1074        let dev_cfg = BuildConfiguration::resolve(BuildConfigurationInput {
1075            package: "demo",
1076            features: &Features::default(),
1077            request: &SelectionRequest::default(),
1078            profile: dev(),
1079            toolchain: ToolchainSummary::default(),
1080            build_flags: ResolvedProfileFlags::default(),
1081        })
1082        .unwrap();
1083        let release_cfg = BuildConfiguration::resolve(BuildConfigurationInput {
1084            package: "demo",
1085            features: &Features::default(),
1086            request: &SelectionRequest::default(),
1087            profile: release(),
1088            toolchain: ToolchainSummary::default(),
1089            build_flags: ResolvedProfileFlags::default(),
1090        })
1091        .unwrap();
1092        // Built-in dev and release differ in opt-level, debug,
1093        // assertions, and name — every field participates in
1094        // the fingerprint, so the digest must move.
1095        assert_ne!(dev_cfg.fingerprint, release_cfg.fingerprint);
1096    }
1097
1098    #[test]
1099    fn fingerprint_differs_when_toolchain_summary_changes() {
1100        let mut tc_a = ToolchainSummary::default();
1101        tc_a.tools.insert("cxx".to_owned(), "g++".to_owned());
1102        let mut tc_b = ToolchainSummary::default();
1103        tc_b.tools.insert("cxx".to_owned(), "clang++".to_owned());
1104        let cfg_a = BuildConfiguration::resolve(BuildConfigurationInput {
1105            package: "demo",
1106            features: &Features::default(),
1107            request: &SelectionRequest::default(),
1108            profile: dev(),
1109            toolchain: tc_a,
1110            build_flags: ResolvedProfileFlags::default(),
1111        })
1112        .unwrap();
1113        let cfg_b = BuildConfiguration::resolve(BuildConfigurationInput {
1114            package: "demo",
1115            features: &Features::default(),
1116            request: &SelectionRequest::default(),
1117            profile: dev(),
1118            toolchain: tc_b,
1119            build_flags: ResolvedProfileFlags::default(),
1120        })
1121        .unwrap();
1122        assert_ne!(cfg_a.fingerprint, cfg_b.fingerprint);
1123    }
1124
1125    #[test]
1126    fn fingerprint_differs_when_compiler_wrapper_changes() {
1127        let no_wrapper = ToolchainSummary::default();
1128        let with_wrapper = ToolchainSummary {
1129            compiler_wrapper: Some(CompilerWrapperSummary {
1130                kind: "ccache".into(),
1131                spec: "ccache".into(),
1132                source: "cli".into(),
1133                version: Some("4.8.0".into()),
1134            }),
1135            ..ToolchainSummary::default()
1136        };
1137        let cfg_a = BuildConfiguration::resolve(BuildConfigurationInput {
1138            package: "demo",
1139            features: &Features::default(),
1140            request: &SelectionRequest::default(),
1141            profile: dev(),
1142            toolchain: no_wrapper,
1143            build_flags: ResolvedProfileFlags::default(),
1144        })
1145        .unwrap();
1146        let cfg_b = BuildConfiguration::resolve(BuildConfigurationInput {
1147            package: "demo",
1148            features: &Features::default(),
1149            request: &SelectionRequest::default(),
1150            profile: dev(),
1151            toolchain: with_wrapper,
1152            build_flags: ResolvedProfileFlags::default(),
1153        })
1154        .unwrap();
1155        assert_ne!(cfg_a.fingerprint, cfg_b.fingerprint);
1156    }
1157}