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