cargo-gamma-lib 0.1.0

Internal library for cargo-gamma
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.

use core::time::Duration;

use crate::error::error;

/// Replaces lockfile promises Gamma cannot honor with their offline half.
fn adjust_lock_flags(args: &[String]) -> Vec<String> {
    let mut adjusted = Vec::with_capacity(args.len());

    for arg in args {
        let replacement = if matches!(arg.as_str(), "--locked" | "--frozen") {
            "--offline"
        } else {
            arg
        };

        if replacement != "--offline" || !adjusted.iter().any(|kept| kept == "--offline") {
            adjusted.push(replacement.to_owned());
        }
    }

    adjusted
}

/// How cargo and the test binaries are invoked.
///
/// A run builds once and then executes that build thousands of times, so these are the settings
/// that decide what gets compiled and what the compiled thing is asked to do.
#[derive(Debug, Clone, Default)]
pub struct CargoOptions {
    /// Feature arguments, already rendered in the form cargo accepts.
    pub features: Vec<String>,

    /// The cargo profile to build with.
    pub profile: Option<String>,

    /// Extra arguments appended to every cargo invocation.
    pub extra: Vec<String>,

    /// Extra arguments appended to every test binary's command line.
    pub test_args: Vec<String>,

    /// Whether cargo should color its own output.
    ///
    /// Cargo's stdio is always a pipe here, so it would otherwise decide "no" every time — and the
    /// progress bar gamma borrows from it would arrive as plain text even on a terminal that had
    /// asked for color everywhere else.
    pub color: bool,
}

impl CargoOptions {
    /// Refuses Cargo configuration that gamma cannot apply while discovering the build.
    ///
    /// Cargo accepts both an inline TOML value and a path after `--config`. Those settings can
    /// change the target, flags and profiles before rustc sees a source file, while gamma's cfg
    /// discovery and cache provenance deliberately resolve configuration from the workspace. Do
    /// not let the two builds silently diverge; supporting this needs to model Cargo's full
    /// configuration precedence, so it is rejected before either discovery or Cargo starts.
    pub fn validate(&self) -> crate::Result<()> {
        if let Some(argument) = self
            .extra
            .iter()
            .find(|argument| argument.as_str() == "--config" || argument.starts_with("--config="))
        {
            return Err(error!(
                "pass-through Cargo configuration `{argument}` is not supported; put the setting in a Cargo configuration file gamma can inspect"
            )
            .usage());
        }

        Ok(())
    }

    /// Describes the compilation these options ask for, for a workspace at `root`.
    ///
    /// Discovery evaluates `#[cfg(...)]` against the build that will actually be run, and this is
    /// where it learns what that build is: the profile decides `debug_assertions`, the passthrough
    /// arguments can carry `--target`, and the environment and cargo configuration carry the rest.
    /// Derived from these options rather than resolved independently, so the tree that is surveyed
    /// and the tree that is compiled cannot describe different builds.
    #[must_use]
    pub fn cfg_build(&self, root: &camino::Utf8Path) -> crate::cfg::Build {
        crate::cfg::Build::resolve(root, self.profile.as_deref(), &self.extra)
    }

    /// Appends the build-shaping arguments to a cargo command line.
    ///
    /// The flags that promise the lockfile will not change are the one thing not passed through as
    /// written. Gamma adds the guard runtime to the manifest before it builds, so the lockfile
    /// *will* change; `--locked` would fail the build before a single mutant ran, which is a worse
    /// answer than the honest one.
    pub fn extend_build_args(&self, args: &mut Vec<String>) {
        args.extend(self.features.iter().cloned());

        if let Some(profile) = self.profile.as_ref() {
            args.push("--profile".to_owned());
            args.push(profile.clone());
        }

        args.extend(adjust_lock_flags(&self.extra));
    }

    /// Appends the build-shaping arguments in nextest's spelling.
    pub fn extend_nextest_args(&self, args: &mut Vec<String>) {
        args.extend(self.features.iter().cloned());

        if let Some(profile) = self.profile.as_ref() {
            args.push("--cargo-profile".to_owned());
            args.push(profile.clone());
        }

        args.extend(adjust_lock_flags(&self.extra));
    }
}

/// How many build-and-withdraw rounds are allowed before a run gives up.
///
/// Some mutants are speculative — replacing a function body with `Some(Default::default())` only
/// compiles when the type happens to implement `Default` — and rustc reports only the errors it
/// reaches before it stops, so a large tree can need many rounds to converge. The cost of a round is
/// a rebuild of a tree that is already warm, whereas the cost of stopping too early is a run that
/// cannot complete at all, so the limit is deliberately lopsided.
pub const DEFAULT_ROLLBACK_ROUNDS: u32 = 256;

/// Limits on how long the build may take.
#[derive(Debug, Clone, Copy, Default)]
pub struct BuildLimits {
    /// A fixed budget for the build, whatever it turns out to cost.
    pub timeout: Option<Duration>,

    /// The multiple of the first round's duration a later rollback round is allowed.
    ///
    /// Rollback rounds recompile the same tree with strictly fewer live mutants, so a round that
    /// takes far longer than the first is not converging.
    pub multiplier: Option<f64>,

    /// How many build-and-withdraw rounds are allowed before the run gives up.
    ///
    /// Zero means the built-in default, so that a caller that does not care about rollback does not
    /// have to know what the default is.
    pub rollback_rounds: u32,
}

impl BuildLimits {
    /// Returns how many build-and-withdraw rounds are allowed.
    #[must_use]
    pub const fn rounds(&self) -> u32 {
        if self.rollback_rounds == 0 {
            DEFAULT_ROLLBACK_ROUNDS
        } else {
            self.rollback_rounds
        }
    }

    /// Returns the budget for a round, given how long the first round took.
    #[must_use]
    pub fn budget(&self, first: Option<Duration>) -> Option<Duration> {
        let scaled = self
            .multiplier
            .zip(first)
            .map(|(multiplier, first)| first.mul_f64(multiplier).max(MINIMUM_BUILD_BUDGET));

        match (self.timeout, scaled) {
            (Some(fixed), Some(scaled)) => Some(fixed.min(scaled)),
            (fixed, scaled) => fixed.or(scaled),
        }
    }
}

/// Floor under a scaled build budget, so a first round that finished instantly cannot produce one
/// that the next round trips over for reasons of scheduling alone.
const MINIMUM_BUILD_BUDGET: Duration = Duration::from_secs(30);

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn zero_rollback_rounds_means_the_default() {
        // A caller that does not care about rollback should not have to know what the default is,
        // and a build that allowed zero rounds could never even try once.
        assert_eq!(BuildLimits::default().rounds(), DEFAULT_ROLLBACK_ROUNDS);
        assert_eq!(
            BuildLimits {
                rollback_rounds: 7,
                ..BuildLimits::default()
            }
            .rounds(),
            7
        );
    }

    #[test]
    fn no_limits_means_no_budget() {
        assert_eq!(BuildLimits::default().budget(Some(Duration::from_secs(10))), None);
    }

    #[test]
    fn a_fixed_timeout_applies_from_the_first_round() {
        let limits = BuildLimits {
            timeout: Some(Duration::from_mins(1)),
            multiplier: None,
            rollback_rounds: 0,
        };

        assert_eq!(limits.budget(None), Some(Duration::from_mins(1)));
    }

    #[test]
    fn a_multiplier_needs_a_first_round_to_scale_from() {
        let limits = BuildLimits {
            timeout: None,
            multiplier: Some(2.0),
            rollback_rounds: 0,
        };

        assert_eq!(limits.budget(None), None);
        assert_eq!(limits.budget(Some(Duration::from_secs(100))), Some(Duration::from_secs(200)));
    }

    #[test]
    fn a_scaled_budget_never_falls_below_the_floor() {
        let limits = BuildLimits {
            timeout: None,
            multiplier: Some(2.0),
            rollback_rounds: 0,
        };

        assert_eq!(limits.budget(Some(Duration::from_secs(1))), Some(MINIMUM_BUILD_BUDGET));
    }

    #[test]
    fn the_tighter_of_the_two_wins() {
        let limits = BuildLimits {
            timeout: Some(Duration::from_mins(1)),
            multiplier: Some(2.0),
            rollback_rounds: 0,
        };

        assert_eq!(limits.budget(Some(Duration::from_secs(100))), Some(Duration::from_mins(1)));
    }

    #[test]
    fn build_args_are_rendered_in_cargo_order() {
        let options = CargoOptions {
            features: vec!["--all-features".to_owned()],
            profile: Some("release".to_owned()),
            extra: vec!["--offline".to_owned()],
            test_args: Vec::new(),
            color: false,
        };

        let mut args = Vec::new();

        options.extend_build_args(&mut args);

        assert_eq!(args, vec!["--all-features", "--profile", "release", "--offline"]);
    }

    #[test]
    fn lockfile_promises_become_one_offline_flag() {
        let options = CargoOptions {
            extra: vec![
                "--locked".to_owned(),
                "--frozen".to_owned(),
                "--offline".to_owned(),
                "--verbose".to_owned(),
            ],
            ..CargoOptions::default()
        };
        let mut args = Vec::new();

        options.extend_build_args(&mut args);

        assert_eq!(args, ["--offline", "--verbose"]);
    }

    /// The build discovery evaluates predicates against has to be the one these options describe.
    ///
    /// Not merely a delegation: if this ever ignored the profile or passthrough arguments, a run
    /// built with `--profile release --target …` would be surveyed as a different build, and every
    /// item behind a gate those settings decide would be misjudged.
    #[test]
    #[cfg(not(miri))]
    fn the_described_build_carries_the_profile_and_the_passthrough_target() {
        let options = CargoOptions {
            profile: Some("release".to_owned()),
            extra: vec!["--target".to_owned(), "x86_64-pc-solaris".to_owned()],
            ..CargoOptions::default()
        };

        let build = options.cfg_build(camino::Utf8Path::new("."));

        assert_eq!(build.target.as_deref(), Some("x86_64-pc-solaris"));
        assert_eq!(
            build,
            crate::cfg::Build::resolve(camino::Utf8Path::new("."), Some("release"), &options.extra)
        );
    }

    #[test]
    fn inline_and_file_pass_through_cargo_configuration_are_refused() {
        for extra in [
            vec!["--config".to_owned(), "build.target = \"wasm32-wasip1\"".to_owned()],
            vec!["--config=extra.toml".to_owned()],
        ] {
            let failure = CargoOptions {
                extra,
                ..CargoOptions::default()
            }
            .validate()
            .expect_err("unmodelled Cargo configuration must stop before discovery");

            assert!(failure.is_usage(), "{failure}");
            assert!(failure.to_string().contains("--config"), "{failure}");
        }
    }

    #[test]
    fn nextest_args_use_its_cargo_profile_spelling() {
        let options = CargoOptions {
            features: vec!["--all-features".to_owned()],
            profile: Some("mutants".to_owned()),
            extra: vec!["--offline".to_owned()],
            ..CargoOptions::default()
        };
        let mut args = Vec::new();

        options.extend_nextest_args(&mut args);

        assert_eq!(args, vec!["--all-features", "--cargo-profile", "mutants", "--offline"]);
    }

    /// The bug this guards: `--locked` was accepted and then failed the build, because gamma adds
    /// the guard runtime to the manifest and that forces the lockfile to be written. The flag is
    /// substituted rather than obeyed or refused, and the run gets as far as it would have.
    #[test]
    fn a_lockfile_promise_gamma_cannot_keep_becomes_the_half_it_can() {
        crate::notes::alone(|| {
            let options = CargoOptions {
                extra: vec!["--locked".to_owned(), "--verbose".to_owned()],
                ..CargoOptions::default()
            };

            let mut args = Vec::new();

            options.extend_build_args(&mut args);

            assert_eq!(args, vec!["--offline", "--verbose"]);
        });
    }

    /// `--frozen` is `--locked` plus `--offline`, so what survives it is the substitute itself.
    #[test]
    fn a_frozen_lockfile_is_treated_the_same_way() {
        crate::notes::alone(|| {
            let options = CargoOptions {
                extra: vec!["--frozen".to_owned()],
                ..CargoOptions::default()
            };

            let mut args = Vec::new();

            options.extend_build_args(&mut args);

            assert_eq!(args, vec!["--offline"]);
        });
    }

    /// Lock-flag substitution is an implementation detail and does not emit user-facing output.
    #[test]
    fn substituting_a_lock_flag_is_silent() {
        crate::notes::alone(|| {
            let options = CargoOptions {
                extra: vec!["--locked".to_owned()],
                ..CargoOptions::default()
            };

            // Once per build the run does: the check build, the test build, every rollback round.
            for _round in 0..3 {
                options.extend_build_args(&mut Vec::new());
            }
            let raised = crate::notes::drain();

            assert!(raised.is_empty(), "{raised:?}");
        });
    }

    /// A command line with nothing substituted has nothing to say about it.
    #[test]
    fn a_lockfile_promise_gamma_can_keep_is_announced_not_at_all() {
        crate::notes::alone(|| {
            let options = CargoOptions {
                extra: vec!["--offline".to_owned()],
                ..CargoOptions::default()
            };

            options.extend_build_args(&mut Vec::new());

            assert!(crate::notes::drain().is_empty());
        });
    }
}