Skip to main content

codehelion_core/discovery/
build_config.rs

1//! What a compiler was told, in the form an identity is decided on.
2//!
3//! Two compilations of the same text are the same program only if the compiler
4//! was told the same things. A define changes which branch of a header exists,
5//! a feature changes which type a name resolves to, an optimization level
6//! changes what the artifact contains. So the arguments are part of the variant
7//! rather than context beside it, and a run that resolved them carries a
8//! [`BuildConfiguration`].
9//!
10//! # What counts towards identity
11//!
12//! Everything, minus a short explicit exclusion list. The rule is deliberately
13//! that way round: a flag wrongly included splits one variant into two, which
14//! costs recall and is visible; a flag wrongly excluded merges two programs
15//! into one identity, which produces confident findings about code that was
16//! never compiled the same way and is not visible at all. The exclusion list
17//! ([`EXCLUDED`], [`EXCLUDED_WITH_VALUE`]) is therefore short, and grows only
18//! for arguments that provably cannot change what a compiler resolves —
19//! diagnostic presentation, dependency-file bookkeeping, and the output path,
20//! which would otherwise give every translation unit a variant of its own and
21//! leave nothing to partition.
22//!
23//! # Where order is normalized, and where it is not
24//!
25//! Only where order provably does not matter. Macro settings are reduced to one
26//! entry per macro — the state its last mention left it in, so that `-DX -UX`
27//! and `-UX -DX` stay different — and then sorted by name, which makes the
28//! order they were written in irrelevant. Include directories are a search
29//! path, so their order is meaning and is preserved; sorting them would merge
30//! two builds that find different headers under the same name. Remaining flags
31//! keep their order too, because last-one-wins options like `-O1 -O2` are
32//! common and nothing here can tell which flags those are.
33//!
34//! # Encoding
35//!
36//! The canonical form is length-prefixed rather than delimiter-separated.
37//! Compiler arguments are arbitrary text and routinely contain the punctuation
38//! a delimiter scheme would use: `-Dpair=a,b` and `-Dpair=a -Db` are different
39//! builds that any comma-joined encoding reports as the same one. Prefixing
40//! each value with its length makes the encoding injective whatever the values
41//! contain.
42//!
43//! # One list, read two ways
44//!
45//! A configuration says what it was told once, as [`Setting`]s, and the
46//! canonical form is a fold over that list. Anything that wants the fields
47//! themselves — an audit database recording what a stored variant was built
48//! with — reads the same list. Keeping the identity and the record derived from
49//! one enumeration is what stops them drifting: a field added to the encoding
50//! but forgotten in the record would leave two variants that differ in the
51//! database by nothing but a hash, which is precisely a difference nobody can
52//! act on.
53
54use std::collections::{BTreeMap, BTreeSet};
55use std::path::Path;
56
57/// Arguments dropped from a compilation before it becomes an identity.
58///
59/// Each is either a statement about how to present diagnostics or about where
60/// to write bookkeeping files. None can change what the compiler resolves.
61pub const EXCLUDED: [&str; 8] = [
62    "-c",
63    "-M",
64    "-MM",
65    "-MD",
66    "-MMD",
67    "-MP",
68    "-fcolor-diagnostics",
69    "-fno-color-diagnostics",
70];
71
72/// Arguments dropped together with the value that follows them.
73///
74/// `-o` is here for a second reason: an object path is unique per translation
75/// unit, so keeping it would give every unit its own variant and leave the
76/// partition with one member each.
77pub const EXCLUDED_WITH_VALUE: [&str; 4] = ["-o", "-MF", "-MT", "-MQ"];
78
79/// A stable hex hash of a file's contents, for the build inputs that are
80/// identified by what they say rather than by where they are.
81#[must_use]
82pub fn content_hash(text: &str) -> String {
83    blake3::hash(text.as_bytes()).to_hex().to_string()
84}
85
86/// One thing a compiler was told, under the name it is recorded by.
87///
88/// The name is part of the record and outlives the release that wrote it, so it
89/// is chosen once and not renamed with the field it comes from.
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct Setting {
92    /// What it is called wherever it is stored.
93    pub name: &'static str,
94    /// Its value, in the shape the setting has.
95    pub shape: Shape,
96}
97
98/// The three shapes a build setting comes in.
99///
100/// They are distinguished because they encode differently, and they encode
101/// differently because they mean different things: a value nobody resolved is
102/// not an empty value, and a sequence of one is not a scalar.
103#[derive(Debug, Clone, PartialEq, Eq)]
104pub enum Shape {
105    /// A value every build has.
106    Given(String),
107    /// A value only something that looked it up can supply.
108    Resolved(Option<String>),
109    /// A sequence, in the order it was given.
110    Ordered(Vec<String>),
111}
112
113impl Shape {
114    /// The values worth recording, in order.
115    ///
116    /// An unresolved setting yields nothing: what was never looked up is
117    /// absent from the record rather than present and empty.
118    #[must_use]
119    pub fn values(&self) -> Vec<&str> {
120        match self {
121            Self::Given(value) => vec![value.as_str()],
122            Self::Resolved(value) => value.as_deref().into_iter().collect(),
123            Self::Ordered(values) => values.iter().map(String::as_str).collect(),
124        }
125    }
126}
127
128fn given(name: &'static str, value: &str) -> Setting {
129    Setting {
130        name,
131        shape: Shape::Given(value.to_string()),
132    }
133}
134
135fn resolved(name: &'static str, value: Option<&str>) -> Setting {
136    Setting {
137        name,
138        shape: Shape::Resolved(value.map(ToString::to_string)),
139    }
140}
141
142fn ordered(name: &'static str, values: &[String]) -> Setting {
143    Setting {
144        name,
145        shape: Shape::Ordered(values.to_vec()),
146    }
147}
148
149/// What a C or C++ translation unit was compiled with.
150#[derive(Debug, Clone, PartialEq, Eq, Default)]
151pub struct CppBuild {
152    /// The compiler as the database spells it.
153    pub compiler: String,
154    /// Its version, which only something that ran it can know.
155    pub compiler_version: Option<String>,
156    /// The linker, for the variants that reach a link step.
157    pub linker: Option<String>,
158    /// One entry per macro, in the flag form its last mention left it in
159    /// (`-DNAME=value` or `-UNAME`), sorted by macro name.
160    pub macros: Vec<String>,
161    /// Include directories in search order, without the `-I`.
162    pub include_paths: Vec<String>,
163    /// Everything else that was passed, in the order it was passed.
164    pub flags: Vec<String>,
165    /// A hash of the compilation database this came from.
166    pub database_hash: Option<String>,
167    /// Post-processing tools declared for this build, in invocation order.
168    ///
169    /// This is deliberately declarative. Source analysis never runs a tool in
170    /// this list; artifact analysis may consume the recorded fact later.
171    pub post_processing_tools: Vec<String>,
172}
173
174impl CppBuild {
175    /// The identity of one entry of a compilation database.
176    ///
177    /// `file` is the translation unit's own source, which is dropped: it says
178    /// which unit this is, not which variant it belongs to.
179    #[must_use]
180    pub fn from_command(arguments: &[String], file: &Path) -> Self {
181        Self::from_command_in_directory(arguments, file, None)
182    }
183
184    /// The identity of one entry of a compilation database whose command ran
185    /// from `directory`.
186    ///
187    /// Relative input arguments are resolved from that directory before they
188    /// are compared with `file`, so translation-unit paths do not accidentally
189    /// become variant settings.
190    #[must_use]
191    pub fn from_command_in_directory(
192        arguments: &[String],
193        file: &Path,
194        directory: Option<&Path>,
195    ) -> Self {
196        let mut build = Self {
197            compiler: arguments.first().cloned().unwrap_or_default(),
198            ..Self::default()
199        };
200        let mut macros = Vec::new();
201        let mut index = 1;
202        while index < arguments.len() {
203            let argument = arguments[index].as_str();
204            index += 1;
205            if source_argument_matches(argument, file, directory) {
206                continue;
207            }
208            if EXCLUDED_WITH_VALUE.contains(&argument) {
209                index += 1;
210                continue;
211            }
212            if EXCLUDED.contains(&argument) || argument.starts_with("-fdiagnostics-color") {
213                continue;
214            }
215            match separated(argument, arguments.get(index).map(String::as_str)) {
216                Some(Separated::Macro(setting, consumed)) => {
217                    macros.push(setting);
218                    index += usize::from(consumed);
219                }
220                Some(Separated::Include(path, consumed)) => {
221                    build.include_paths.push(path);
222                    index += usize::from(consumed);
223                }
224                None => build.flags.push(argument.to_string()),
225            }
226        }
227        build.macros = last_mention_wins(macros);
228        build
229    }
230
231    /// The macros left defined, without the `-D`.
232    #[must_use]
233    pub fn defines(&self) -> Vec<&str> {
234        self.macros
235            .iter()
236            .filter_map(|setting| setting.strip_prefix("-D"))
237            .collect()
238    }
239
240    /// Everything this build was told, in the order the identity encodes it.
241    #[must_use]
242    pub fn settings(&self) -> Vec<Setting> {
243        vec![
244            given("compiler", &self.compiler),
245            resolved("compiler_version", self.compiler_version.as_deref()),
246            resolved("linker", self.linker.as_deref()),
247            ordered("macros", &self.macros),
248            ordered("includes", &self.include_paths),
249            ordered("flags", &self.flags),
250            resolved("database", self.database_hash.as_deref()),
251            ordered("post_processing_tools", &self.post_processing_tools),
252        ]
253    }
254}
255
256fn source_argument_matches(argument: &str, file: &Path, directory: Option<&Path>) -> bool {
257    let argument = Path::new(argument);
258    let resolved = if argument.is_relative() {
259        directory.map_or_else(
260            || argument.to_path_buf(),
261            |directory| directory.join(argument),
262        )
263    } else {
264        argument.to_path_buf()
265    };
266    normalize_path(&resolved) == normalize_path(file)
267}
268
269fn normalize_path(path: &Path) -> std::path::PathBuf {
270    crate::paths::canonical(path).unwrap_or_else(|_| path.to_path_buf())
271}
272
273/// What a Rust crate was built with.
274///
275/// Recorded rather than assumed: a helper analyses with the compiler it holds,
276/// which need not be the one the project builds with, so the version here is
277/// the one that produced the answers.
278#[derive(Debug, Clone, PartialEq, Eq, Default)]
279pub struct RustBuild {
280    /// The target triple.
281    pub target: String,
282    /// Enabled cargo features, deduplicated and sorted: they are a set, and
283    /// nothing about the order they were requested in reaches the compiler.
284    ///
285    /// Each names the package it belongs to, because a feature is declared per
286    /// package: one package's `derive` and another's are unrelated, and a bare
287    /// list would let either stand for both.
288    pub features: Vec<String>,
289    /// `--cfg` settings, deduplicated and sorted for the same reason.
290    pub cfgs: Vec<String>,
291    /// The compiler that produced the answers.
292    pub compiler_version: String,
293    /// Optimization level, as cargo spells it.
294    pub opt_level: String,
295    /// Link-time optimization setting.
296    pub lto: String,
297    /// Codegen units, when pinned.
298    pub codegen_units: Option<u32>,
299    /// Panic strategy.
300    pub panic: String,
301    /// A hash of `Cargo.lock`: the dependency versions are part of what the
302    /// source means, and the lockfile is the only place that records them all.
303    pub lockfile_hash: Option<String>,
304    /// A hash of the command the build was requested with.
305    pub build_command_hash: Option<String>,
306    /// Post-processing tools declared for this build, in invocation order.
307    ///
308    /// This records build context only. The source scanner never executes a
309    /// declared tool.
310    pub post_processing_tools: Vec<String>,
311    /// The classes of execution the run was permitted, sorted and named as a
312    /// person types them.
313    ///
314    /// Part of the identity because a run allowed to run build scripts reads a
315    /// program the refused run cannot see: types that only exist after a script
316    /// has written them resolve in one and not the other. What was *permitted*
317    /// rather than what turned out to run — the second is a prediction this
318    /// side would have to make before doing the work, and a prediction that
319    /// came out wrong would file the answers under conditions that did not
320    /// hold.
321    pub permitted_execution: Vec<String>,
322}
323
324impl RustBuild {
325    /// The same build with its features and cfgs reduced to sets.
326    #[must_use]
327    pub fn normalized(mut self) -> Self {
328        self.features = self
329            .features
330            .into_iter()
331            .collect::<BTreeSet<_>>()
332            .into_iter()
333            .collect();
334        self.cfgs = self
335            .cfgs
336            .into_iter()
337            .collect::<BTreeSet<_>>()
338            .into_iter()
339            .collect();
340        self
341    }
342
343    /// Everything this build was told, in the order the identity encodes it.
344    #[must_use]
345    pub fn settings(&self) -> Vec<Setting> {
346        vec![
347            given("target", &self.target),
348            ordered("features", &self.features),
349            ordered("cfgs", &self.cfgs),
350            given("compiler_version", &self.compiler_version),
351            given("opt_level", &self.opt_level),
352            given("lto", &self.lto),
353            resolved(
354                "codegen_units",
355                self.codegen_units.map(|units| units.to_string()).as_deref(),
356            ),
357            given("panic", &self.panic),
358            resolved("lockfile", self.lockfile_hash.as_deref()),
359            resolved("build_command", self.build_command_hash.as_deref()),
360            ordered("post_processing_tools", &self.post_processing_tools),
361            ordered("permitted_execution", &self.permitted_execution),
362        ]
363    }
364}
365
366/// The build configuration a variant was resolved under.
367#[derive(Debug, Clone, PartialEq, Eq)]
368pub enum BuildConfiguration {
369    /// A Rust crate.
370    Rust(Box<RustBuild>),
371    /// A C or C++ translation unit.
372    Cpp(Box<CppBuild>),
373}
374
375impl BuildConfiguration {
376    /// Which language's build this is.
377    ///
378    /// Part of the identity in its own right: the two languages' settings are
379    /// named differently, but nothing stops them lining up field for field, and
380    /// two builds that share an encoding are not the same program.
381    #[must_use]
382    pub const fn language(&self) -> &'static str {
383        match self {
384            Self::Rust(_) => "rust",
385            Self::Cpp(_) => "cpp",
386        }
387    }
388
389    /// Everything this build was told, in the order the identity encodes it.
390    #[must_use]
391    pub fn settings(&self) -> Vec<Setting> {
392        match self {
393            Self::Rust(build) => build.settings(),
394            Self::Cpp(build) => build.settings(),
395        }
396    }
397
398    /// The canonical, injective encoding of this configuration.
399    ///
400    /// Two configurations produce the same string exactly when they are equal,
401    /// whatever punctuation their arguments contain.
402    #[must_use]
403    pub fn canonical(&self) -> String {
404        let mut out = String::new();
405        scalar(&mut out, "language", self.language());
406        for setting in self.settings() {
407            match &setting.shape {
408                Shape::Given(value) => scalar(&mut out, setting.name, value),
409                Shape::Resolved(value) => optional(&mut out, setting.name, value.as_deref()),
410                Shape::Ordered(values) => list(&mut out, setting.name, values),
411            }
412        }
413        out
414    }
415
416    /// A stable hex fingerprint of the canonical form.
417    #[must_use]
418    pub fn fingerprint(&self) -> String {
419        blake3::hash(self.canonical().as_bytes())
420            .to_hex()
421            .to_string()
422    }
423}
424
425/// An argument that may carry its value in the next position.
426enum Separated {
427    /// A macro setting, and whether the next argument was consumed.
428    Macro(String, bool),
429    /// An include directory, and whether the next argument was consumed.
430    Include(String, bool),
431}
432
433/// Classifies `argument`, reading `next` only for the separated spellings
434/// (`-D NAME` beside `-DNAME`), which both compilers accept.
435fn separated(argument: &str, next: Option<&str>) -> Option<Separated> {
436    for prefix in ["-D", "-U"] {
437        if let Some(rest) = argument.strip_prefix(prefix) {
438            return Some(if rest.is_empty() {
439                Separated::Macro(format!("{prefix}{}", next.unwrap_or_default()), true)
440            } else {
441                Separated::Macro(argument.to_string(), false)
442            });
443        }
444    }
445    if let Some(rest) = argument.strip_prefix("-I") {
446        return Some(if rest.is_empty() {
447            Separated::Include(next.unwrap_or_default().to_string(), true)
448        } else {
449            Separated::Include(rest.to_string(), false)
450        });
451    }
452    None
453}
454
455/// One entry per macro, keeping the last mention and sorting by name.
456///
457/// Last mention rather than first because that is what the preprocessor does,
458/// and keeping `-D` and `-U` in the same reduction is what makes `-DX -UX`
459/// and `-UX -DX` two identities rather than one.
460fn last_mention_wins(settings: Vec<String>) -> Vec<String> {
461    let mut latest: BTreeMap<String, String> = BTreeMap::new();
462    for setting in settings {
463        let name = setting
464            .trim_start_matches("-D")
465            .trim_start_matches("-U")
466            .split('=')
467            .next()
468            .unwrap_or_default()
469            .to_string();
470        latest.insert(name, setting);
471    }
472    latest.into_values().collect()
473}
474
475fn scalar(out: &mut String, name: &str, value: &str) {
476    out.push_str(name);
477    out.push('=');
478    push_sized(out, value);
479    out.push(';');
480}
481
482fn optional(out: &mut String, name: &str, value: Option<&str>) {
483    out.push_str(name);
484    out.push('=');
485    match value {
486        Some(value) => {
487            out.push_str("some");
488            push_sized(out, value);
489        }
490        // Distinct from a present empty value, which is a different claim.
491        None => out.push_str("none"),
492    }
493    out.push(';');
494}
495
496fn list(out: &mut String, name: &str, values: &[String]) {
497    out.push_str(name);
498    out.push('=');
499    out.push_str(&values.len().to_string());
500    out.push('[');
501    for value in values {
502        push_sized(out, value);
503    }
504    out.push_str("];");
505}
506
507fn push_sized(out: &mut String, value: &str) {
508    out.push_str(&value.len().to_string());
509    out.push(':');
510    out.push_str(value);
511}
512
513#[cfg(test)]
514#[allow(clippy::unwrap_used, clippy::expect_used)]
515mod tests {
516    use super::*;
517
518    fn command(arguments: &[&str]) -> Vec<String> {
519        arguments.iter().map(|a| (*a).to_string()).collect()
520    }
521
522    fn cpp(arguments: &[&str], file: &str) -> CppBuild {
523        CppBuild::from_command(&command(arguments), Path::new(file))
524    }
525
526    #[test]
527    fn the_compiler_and_what_it_was_told_are_read_off_the_command() {
528        let build = cpp(
529            &[
530                "clang++",
531                "-std=c++17",
532                "-DACCUM_WIDTH=64",
533                "-I/w/include",
534                "-c",
535                "-o",
536                "wide.o",
537                "/w/src/wide.cpp",
538            ],
539            "/w/src/wide.cpp",
540        );
541        assert_eq!(build.compiler, "clang++");
542        assert_eq!(build.macros, vec!["-DACCUM_WIDTH=64"]);
543        assert_eq!(build.include_paths, vec!["/w/include"]);
544        assert_eq!(build.flags, vec!["-std=c++17"]);
545        assert_eq!(build.defines(), vec!["ACCUM_WIDTH=64"]);
546    }
547
548    /// The output path is unique per unit. Keeping it would give every
549    /// translation unit its own variant, which is the same as having none.
550    #[test]
551    fn the_object_path_does_not_become_part_of_the_identity() {
552        let narrow = cpp(&["cc", "-O2", "-o", "a/narrow.o", "-c", "/w/a.c"], "/w/a.c");
553        let wide = cpp(&["cc", "-O2", "-o", "b/wide.o", "-c", "/w/a.c"], "/w/a.c");
554        assert_eq!(narrow, wide);
555        assert!(narrow.flags.iter().all(|flag| !flag.contains("narrow.o")));
556    }
557
558    #[test]
559    fn dependency_bookkeeping_and_diagnostic_colour_are_not_identity() {
560        let plain = cpp(&["cc", "-O2", "/w/a.c"], "/w/a.c");
561        let noisy = cpp(
562            &[
563                "cc",
564                "-O2",
565                "-MD",
566                "-MF",
567                "a.d",
568                "-MT",
569                "a.o",
570                "-fcolor-diagnostics",
571                "-fdiagnostics-color=always",
572                "/w/a.c",
573            ],
574            "/w/a.c",
575        );
576        assert_eq!(plain, noisy);
577    }
578
579    /// An unrecognised flag is kept. The exclusion list is the whole of what is
580    /// dropped, because a flag wrongly dropped merges two programs into one
581    /// identity and nothing downstream can notice.
582    #[test]
583    fn an_unrecognised_flag_counts_towards_identity() {
584        let plain = cpp(&["cc", "/w/a.c"], "/w/a.c");
585        let odd = cpp(&["cc", "-fsomething-nobody-here-knows", "/w/a.c"], "/w/a.c");
586        assert_ne!(plain, odd);
587        assert_eq!(odd.flags, vec!["-fsomething-nobody-here-knows"]);
588    }
589
590    #[test]
591    fn the_separated_spellings_mean_the_same_as_the_joined_ones() {
592        let joined = cpp(&["cc", "-DWIDTH=64", "-I/w/inc", "/w/a.c"], "/w/a.c");
593        let separated = cpp(
594            &["cc", "-D", "WIDTH=64", "-I", "/w/inc", "/w/a.c"],
595            "/w/a.c",
596        );
597        assert_eq!(joined, separated);
598    }
599
600    /// Macro order is not meaning, so it is normalized away — but only after
601    /// the last mention has won, which is what the preprocessor does.
602    #[test]
603    fn macros_are_sorted_but_the_last_mention_still_decides() {
604        let one = cpp(&["cc", "-DB=2", "-DA=1", "/w/a.c"], "/w/a.c");
605        let other = cpp(&["cc", "-DA=1", "-DB=2", "/w/a.c"], "/w/a.c");
606        assert_eq!(one, other);
607        assert_eq!(one.macros, vec!["-DA=1", "-DB=2"]);
608
609        let redefined = cpp(&["cc", "-DA=1", "-DA=2", "/w/a.c"], "/w/a.c");
610        assert_eq!(redefined.macros, vec!["-DA=2"]);
611    }
612
613    /// Sorting a define beside an undefine of the same macro would lose which
614    /// one the compiler saw last, and those are two different programs.
615    #[test]
616    fn defining_then_undefining_is_not_the_same_as_the_reverse() {
617        let defined_last = cpp(&["cc", "-UA", "-DA=1", "/w/a.c"], "/w/a.c");
618        let undefined_last = cpp(&["cc", "-DA=1", "-UA", "/w/a.c"], "/w/a.c");
619        assert_ne!(defined_last, undefined_last);
620        assert_eq!(defined_last.macros, vec!["-DA=1"]);
621        assert_eq!(undefined_last.macros, vec!["-UA"]);
622    }
623
624    /// Include directories are a search order. Two builds that reach different
625    /// headers under the same name are not one variant.
626    #[test]
627    fn include_order_is_meaning_and_is_kept() {
628        let vendor_first = cpp(&["cc", "-I/vendor", "-I/local", "/w/a.c"], "/w/a.c");
629        let local_first = cpp(&["cc", "-I/local", "-I/vendor", "/w/a.c"], "/w/a.c");
630        assert_ne!(vendor_first, local_first);
631        assert_eq!(vendor_first.include_paths, vec!["/vendor", "/local"]);
632    }
633
634    /// A delimiter-joined encoding reports these two as the same build. The
635    /// length prefix is what keeps them apart.
636    #[test]
637    fn punctuation_inside_an_argument_cannot_forge_another_argument() {
638        let one = cpp(&["cc", "-Dpair=a,b", "/w/a.c"], "/w/a.c");
639        let two = cpp(&["cc", "-Dpair=a", "-Db", "/w/a.c"], "/w/a.c");
640        let one = BuildConfiguration::Cpp(Box::new(one));
641        let two = BuildConfiguration::Cpp(Box::new(two));
642        assert_ne!(one.canonical(), two.canonical());
643        assert_ne!(one.fingerprint(), two.fingerprint());
644    }
645
646    #[test]
647    fn an_absent_value_is_not_an_empty_one() {
648        let absent = BuildConfiguration::Cpp(Box::new(CppBuild {
649            compiler: "cc".into(),
650            compiler_version: None,
651            ..CppBuild::default()
652        }));
653        let empty = BuildConfiguration::Cpp(Box::new(CppBuild {
654            compiler: "cc".into(),
655            compiler_version: Some(String::new()),
656            ..CppBuild::default()
657        }));
658        assert_ne!(absent.fingerprint(), empty.fingerprint());
659    }
660
661    #[test]
662    fn the_fingerprint_is_a_function_of_the_configuration_alone() {
663        let build = || {
664            BuildConfiguration::Cpp(Box::new(cpp(
665                &["clang++", "-std=c++17", "-DA=1", "/w/a.c"],
666                "/w/a.c",
667            )))
668        };
669        assert_eq!(build().fingerprint(), build().fingerprint());
670    }
671
672    #[test]
673    fn rust_features_are_a_set_and_are_ordered_like_one() {
674        let one = RustBuild {
675            features: vec!["wide".into(), "serde".into(), "wide".into()],
676            ..RustBuild::default()
677        }
678        .normalized();
679        let other = RustBuild {
680            features: vec!["serde".into(), "wide".into()],
681            ..RustBuild::default()
682        }
683        .normalized();
684        assert_eq!(one, other);
685        assert_eq!(one.features, vec!["serde", "wide"]);
686    }
687
688    /// Two runs of the same source under different dependency versions are not
689    /// comparable, and the lockfile is the only record of what those were.
690    #[test]
691    fn a_different_lockfile_is_a_different_build() {
692        let base = RustBuild {
693            target: "aarch64-apple-darwin".into(),
694            compiler_version: "rustc 1.85.0".into(),
695            lockfile_hash: Some(content_hash("one")),
696            ..RustBuild::default()
697        };
698        let moved = RustBuild {
699            lockfile_hash: Some(content_hash("another")),
700            ..base.clone()
701        };
702        assert_ne!(
703            BuildConfiguration::Rust(Box::new(base)).fingerprint(),
704            BuildConfiguration::Rust(Box::new(moved)).fingerprint()
705        );
706    }
707
708    /// A Rust build and a C++ build cannot collide however their fields line
709    /// up, because the language is part of what is hashed.
710    #[test]
711    fn the_two_languages_are_in_different_identity_spaces() {
712        let rust = BuildConfiguration::Rust(Box::default());
713        let cpp = BuildConfiguration::Cpp(Box::default());
714        assert_ne!(rust.fingerprint(), cpp.fingerprint());
715    }
716
717    /// The canonical form is what stored variants are identified by, so it is
718    /// pinned here in full: a refactor that reorders or renames a setting would
719    /// otherwise silently stop an audit database from lining up with the runs
720    /// that follow it.
721    #[test]
722    fn the_encoding_of_a_configuration_is_fixed() {
723        let build = BuildConfiguration::Cpp(Box::new(CppBuild {
724            compiler: "cc".into(),
725            macros: vec!["-DA=1".into()],
726            include_paths: vec!["/inc".into()],
727            ..CppBuild::default()
728        }));
729        assert_eq!(
730            build.canonical(),
731            "language=3:cpp;compiler=2:cc;compiler_version=none;linker=none;\
732             macros=1[5:-DA=1];includes=1[4:/inc];flags=0[];database=none;\
733             post_processing_tools=0[];"
734        );
735        let build = BuildConfiguration::Rust(Box::new(RustBuild {
736            features: vec!["ledger/std".into()],
737            cfgs: vec!["unix".into()],
738            compiler_version: "rust-analyzer 0.0.344".into(),
739            permitted_execution: vec!["build-script".into()],
740            ..RustBuild::default()
741        }));
742        assert_eq!(
743            build.canonical(),
744            "language=4:rust;target=0:;features=1[10:ledger/std];cfgs=1[4:unix];\
745             compiler_version=21:rust-analyzer 0.0.344;opt_level=0:;lto=0:;\
746             codegen_units=none;panic=0:;lockfile=none;build_command=none;\
747             post_processing_tools=0[];permitted_execution=1[12:build-script];"
748        );
749    }
750
751    /// Whatever a field is worth to the identity, it is worth the same to the
752    /// record: a field that moved the fingerprint but not the settings would
753    /// leave two stored variants differing by a hash and nothing a reader could
754    /// name.
755    #[test]
756    fn every_field_that_moves_the_identity_is_one_of_the_settings() {
757        let cpp = |change: fn(&mut CppBuild)| {
758            let mut build = CppBuild {
759                compiler: "cc".into(),
760                compiler_version: Some("18".into()),
761                linker: Some("ld".into()),
762                macros: vec!["-DA=1".into()],
763                include_paths: vec!["/inc".into()],
764                flags: vec!["-O2".into()],
765                database_hash: Some("db".into()),
766                post_processing_tools: vec!["strip".into()],
767            };
768            change(&mut build);
769            BuildConfiguration::Cpp(Box::new(build))
770        };
771        let changes: [fn(&mut CppBuild); 8] = [
772            |b| b.compiler = "c++".into(),
773            |b| b.compiler_version = None,
774            |b| b.linker = Some("lld".into()),
775            |b| b.macros.push("-DB=2".into()),
776            |b| b.include_paths.clear(),
777            |b| b.flags = vec!["-O0".into()],
778            |b| b.database_hash = None,
779            |b| b.post_processing_tools.push("objcopy".into()),
780        ];
781        let base = cpp(|_| {});
782        for change in changes {
783            let moved = cpp(change);
784            assert_ne!(base.fingerprint(), moved.fingerprint());
785            assert_ne!(base.settings(), moved.settings());
786        }
787
788        let rust = |change: fn(&mut RustBuild)| {
789            let mut build = RustBuild {
790                target: "aarch64-apple-darwin".into(),
791                features: vec!["serde".into()],
792                cfgs: vec!["unix".into()],
793                compiler_version: "rustc 1.85.0".into(),
794                opt_level: "3".into(),
795                lto: "thin".into(),
796                codegen_units: Some(16),
797                panic: "unwind".into(),
798                lockfile_hash: Some("lock".into()),
799                build_command_hash: Some("cmd".into()),
800                post_processing_tools: vec!["strip".into()],
801                permitted_execution: Vec::new(),
802            };
803            change(&mut build);
804            BuildConfiguration::Rust(Box::new(build))
805        };
806        let changes: [fn(&mut RustBuild); 12] = [
807            |b| b.target = "x86_64-unknown-linux-gnu".into(),
808            |b| b.features.clear(),
809            |b| b.cfgs.push("windows".into()),
810            |b| b.compiler_version = "rustc 1.86.0".into(),
811            |b| b.opt_level = "0".into(),
812            |b| b.lto = "fat".into(),
813            |b| b.codegen_units = None,
814            |b| b.panic = "abort".into(),
815            |b| b.lockfile_hash = None,
816            |b| b.build_command_hash = Some("other".into()),
817            |b| b.post_processing_tools.push("objcopy".into()),
818            |b| b.permitted_execution = vec!["build-script".into()],
819        ];
820        let base = rust(|_| {});
821        for change in changes {
822            let moved = rust(change);
823            assert_ne!(base.fingerprint(), moved.fingerprint());
824            assert_ne!(base.settings(), moved.settings());
825        }
826    }
827
828    /// A value nobody looked up is left out of the record, rather than written
829    /// down as an empty one — the same distinction the encoding makes.
830    #[test]
831    fn an_unresolved_setting_records_nothing_and_an_empty_one_records_a_value() {
832        assert!(Shape::Resolved(None).values().is_empty());
833        assert_eq!(Shape::Resolved(Some(String::new())).values(), vec![""]);
834        assert_eq!(Shape::Given("cc".into()).values(), vec!["cc"]);
835        assert_eq!(
836            Shape::Ordered(vec!["/a".into(), "/b".into()]).values(),
837            vec!["/a", "/b"]
838        );
839    }
840}