nextest_runner/list/
binary_list.rs

1// Copyright (c) The nextest Contributors
2// SPDX-License-Identifier: MIT OR Apache-2.0
3
4use crate::{
5    errors::{FromMessagesError, RustBuildMetaParseError, WriteTestListError},
6    helpers::convert_rel_path_to_forward_slash,
7    list::{BinaryListState, OutputFormat, RustBuildMeta, Styles},
8    platform::BuildPlatforms,
9    write_str::WriteStr,
10};
11use camino::{Utf8Path, Utf8PathBuf};
12use cargo_metadata::{Artifact, BuildScript, Message, PackageId, TargetKind};
13use guppy::graph::PackageGraph;
14use nextest_metadata::{
15    BinaryListSummary, BuildPlatform, RustBinaryId, RustNonTestBinaryKind,
16    RustNonTestBinarySummary, RustTestBinaryKind, RustTestBinarySummary,
17};
18use owo_colors::OwoColorize;
19use std::{collections::HashSet, io};
20use tracing::warn;
21
22/// A Rust test binary built by Cargo.
23#[derive(Clone, Debug)]
24pub struct RustTestBinary {
25    /// A unique ID.
26    pub id: RustBinaryId,
27    /// The path to the binary artifact.
28    pub path: Utf8PathBuf,
29    /// The package this artifact belongs to.
30    pub package_id: String,
31    /// The kind of Rust test binary this is.
32    pub kind: RustTestBinaryKind,
33    /// The unique binary name defined in `Cargo.toml` or inferred by the filename.
34    pub name: String,
35    /// Platform for which this binary was built.
36    /// (Proc-macro tests are built for the host.)
37    pub build_platform: BuildPlatform,
38}
39
40/// The list of Rust test binaries built by Cargo.
41#[derive(Clone, Debug)]
42pub struct BinaryList {
43    /// Rust-related metadata.
44    pub rust_build_meta: RustBuildMeta<BinaryListState>,
45
46    /// The list of test binaries.
47    pub rust_binaries: Vec<RustTestBinary>,
48}
49
50impl BinaryList {
51    /// Parses Cargo messages from the given `BufRead` and returns a list of test binaries.
52    pub fn from_messages(
53        reader: impl io::BufRead,
54        graph: &PackageGraph,
55        build_platforms: BuildPlatforms,
56    ) -> Result<Self, FromMessagesError> {
57        let mut state = BinaryListBuildState::new(graph, build_platforms);
58
59        for message in Message::parse_stream(reader) {
60            let message = message.map_err(FromMessagesError::ReadMessages)?;
61            state.process_message(message)?;
62        }
63
64        Ok(state.finish())
65    }
66
67    /// Constructs the list from its summary format
68    pub fn from_summary(summary: BinaryListSummary) -> Result<Self, RustBuildMetaParseError> {
69        let rust_binaries = summary
70            .rust_binaries
71            .into_values()
72            .map(|bin| RustTestBinary {
73                name: bin.binary_name,
74                path: bin.binary_path,
75                package_id: bin.package_id,
76                kind: bin.kind,
77                id: bin.binary_id,
78                build_platform: bin.build_platform,
79            })
80            .collect();
81        Ok(Self {
82            rust_build_meta: RustBuildMeta::from_summary(summary.rust_build_meta)?,
83            rust_binaries,
84        })
85    }
86
87    /// Outputs this list to the given writer.
88    pub fn write(
89        &self,
90        output_format: OutputFormat,
91        writer: &mut dyn WriteStr,
92        colorize: bool,
93    ) -> Result<(), WriteTestListError> {
94        match output_format {
95            OutputFormat::Human { verbose } => self
96                .write_human(writer, verbose, colorize)
97                .map_err(WriteTestListError::Io),
98            OutputFormat::Serializable(format) => format.to_writer(&self.to_summary(), writer),
99        }
100    }
101
102    fn to_summary(&self) -> BinaryListSummary {
103        let rust_binaries = self
104            .rust_binaries
105            .iter()
106            .map(|bin| {
107                let summary = RustTestBinarySummary {
108                    binary_name: bin.name.clone(),
109                    package_id: bin.package_id.clone(),
110                    kind: bin.kind.clone(),
111                    binary_path: bin.path.clone(),
112                    binary_id: bin.id.clone(),
113                    build_platform: bin.build_platform,
114                };
115                (bin.id.clone(), summary)
116            })
117            .collect();
118
119        BinaryListSummary {
120            rust_build_meta: self.rust_build_meta.to_summary(),
121            rust_binaries,
122        }
123    }
124
125    fn write_human(
126        &self,
127        writer: &mut dyn WriteStr,
128        verbose: bool,
129        colorize: bool,
130    ) -> io::Result<()> {
131        let mut styles = Styles::default();
132        if colorize {
133            styles.colorize();
134        }
135        for bin in &self.rust_binaries {
136            if verbose {
137                writeln!(writer, "{}:", bin.id.style(styles.binary_id))?;
138                writeln!(writer, "  {} {}", "bin:".style(styles.field), bin.path)?;
139                writeln!(
140                    writer,
141                    "  {} {}",
142                    "build platform:".style(styles.field),
143                    bin.build_platform,
144                )?;
145            } else {
146                writeln!(writer, "{}", bin.id.style(styles.binary_id))?;
147            }
148        }
149        Ok(())
150    }
151
152    /// Outputs this list as a string with the given format.
153    pub fn to_string(&self, output_format: OutputFormat) -> Result<String, WriteTestListError> {
154        let mut s = String::with_capacity(1024);
155        self.write(output_format, &mut s, false)?;
156        Ok(s)
157    }
158}
159
160#[derive(Debug)]
161struct BinaryListBuildState<'g> {
162    graph: &'g PackageGraph,
163    rust_binaries: Vec<RustTestBinary>,
164    rust_build_meta: RustBuildMeta<BinaryListState>,
165    alt_target_dir: Option<Utf8PathBuf>,
166}
167
168impl<'g> BinaryListBuildState<'g> {
169    fn new(graph: &'g PackageGraph, build_platforms: BuildPlatforms) -> Self {
170        let rust_target_dir = graph.workspace().target_directory().to_path_buf();
171        // For testing only, not part of the public API.
172        let alt_target_dir = std::env::var("__NEXTEST_ALT_TARGET_DIR")
173            .ok()
174            .map(Utf8PathBuf::from);
175
176        Self {
177            graph,
178            rust_binaries: vec![],
179            rust_build_meta: RustBuildMeta::new(rust_target_dir, build_platforms),
180            alt_target_dir,
181        }
182    }
183
184    fn process_message(&mut self, message: Message) -> Result<(), FromMessagesError> {
185        match message {
186            Message::CompilerArtifact(artifact) => {
187                self.process_artifact(artifact)?;
188            }
189            Message::BuildScriptExecuted(build_script) => {
190                self.process_build_script(build_script)?;
191            }
192            _ => {
193                // Ignore all other messages.
194            }
195        }
196
197        Ok(())
198    }
199
200    fn process_artifact(&mut self, artifact: Artifact) -> Result<(), FromMessagesError> {
201        if let Some(path) = artifact.executable {
202            self.detect_base_output_dir(&path);
203
204            if artifact.profile.test {
205                let package_id = artifact.package_id.repr;
206
207                // Look up the executable by package ID.
208
209                let name = artifact.target.name;
210
211                let package = self
212                    .graph
213                    .metadata(&guppy::PackageId::new(package_id.clone()))
214                    .map_err(FromMessagesError::PackageGraph)?;
215
216                let kind = artifact.target.kind;
217                if kind.is_empty() {
218                    return Err(FromMessagesError::MissingTargetKind {
219                        package_name: package.name().to_owned(),
220                        binary_name: name.clone(),
221                    });
222                }
223
224                let (computed_kind, platform) = if kind.iter().any(|k| {
225                    // https://doc.rust-lang.org/nightly/cargo/reference/cargo-targets.html#the-crate-type-field
226                    matches!(
227                        k,
228                        TargetKind::Lib
229                            | TargetKind::RLib
230                            | TargetKind::DyLib
231                            | TargetKind::CDyLib
232                            | TargetKind::StaticLib
233                    )
234                }) {
235                    (RustTestBinaryKind::LIB, BuildPlatform::Target)
236                } else if let Some(TargetKind::ProcMacro) = kind.first() {
237                    (RustTestBinaryKind::PROC_MACRO, BuildPlatform::Host)
238                } else {
239                    // Non-lib kinds should always have just one element. Grab the first one.
240                    (
241                        RustTestBinaryKind::new(
242                            kind.into_iter()
243                                .next()
244                                .expect("already checked that kind is non-empty")
245                                .to_string(),
246                        ),
247                        BuildPlatform::Target,
248                    )
249                };
250
251                // Construct the binary ID from the package and build target.
252                let id = RustBinaryId::from_parts(package.name(), &computed_kind, &name);
253
254                self.rust_binaries.push(RustTestBinary {
255                    path,
256                    package_id,
257                    kind: computed_kind,
258                    name,
259                    id,
260                    build_platform: platform,
261                });
262            } else if artifact
263                .target
264                .kind
265                .iter()
266                .any(|x| matches!(x, TargetKind::Bin))
267            {
268                // This is a non-test binary -- add it to the map.
269                // Error case here implies that the returned path wasn't in the target directory -- ignore it
270                // since it shouldn't happen in normal use.
271                if let Ok(rel_path) = path.strip_prefix(&self.rust_build_meta.target_directory) {
272                    let non_test_binary = RustNonTestBinarySummary {
273                        name: artifact.target.name,
274                        kind: RustNonTestBinaryKind::BIN_EXE,
275                        path: convert_rel_path_to_forward_slash(rel_path),
276                    };
277
278                    self.rust_build_meta
279                        .non_test_binaries
280                        .entry(artifact.package_id.repr)
281                        .or_default()
282                        .insert(non_test_binary);
283                };
284            }
285        } else if artifact
286            .target
287            .kind
288            .iter()
289            .any(|x| matches!(x, TargetKind::DyLib | TargetKind::CDyLib))
290        {
291            // Also look for and grab dynamic libraries to store in archives.
292            for filename in artifact.filenames {
293                if let Ok(rel_path) = filename.strip_prefix(&self.rust_build_meta.target_directory)
294                {
295                    let non_test_binary = RustNonTestBinarySummary {
296                        name: artifact.target.name.clone(),
297                        kind: RustNonTestBinaryKind::DYLIB,
298                        path: convert_rel_path_to_forward_slash(rel_path),
299                    };
300                    self.rust_build_meta
301                        .non_test_binaries
302                        .entry(artifact.package_id.repr.clone())
303                        .or_default()
304                        .insert(non_test_binary);
305                }
306            }
307        }
308
309        Ok(())
310    }
311
312    /// Look for paths that contain "deps" in their second-to-last component,
313    /// and are descendants of the target directory.
314    /// The paths without "deps" are base output directories.
315    ///
316    /// e.g. path/to/repo/target/debug/deps/test-binary => add "debug"
317    /// to base output dirs.
318    ///
319    /// Note that test binaries are always present in "deps", so we should always
320    /// have a match.
321    ///
322    /// The `Option` in the return value is to let ? work.
323    fn detect_base_output_dir(&mut self, artifact_path: &Utf8Path) -> Option<()> {
324        // Artifact paths must be relative to the target directory.
325        let rel_path = artifact_path
326            .strip_prefix(&self.rust_build_meta.target_directory)
327            .ok()?;
328        let parent = rel_path.parent()?;
329        if parent.file_name() == Some("deps") {
330            let base = parent.parent()?;
331            if !self.rust_build_meta.base_output_directories.contains(base) {
332                self.rust_build_meta
333                    .base_output_directories
334                    .insert(convert_rel_path_to_forward_slash(base));
335            }
336        }
337        Some(())
338    }
339
340    fn process_build_script(&mut self, build_script: BuildScript) -> Result<(), FromMessagesError> {
341        for path in build_script.linked_paths {
342            self.detect_linked_path(&build_script.package_id, &path);
343        }
344
345        // We only care about build scripts for workspace packages.
346        let package_id = guppy::PackageId::new(build_script.package_id.repr);
347        let in_workspace = self.graph.metadata(&package_id).map_or_else(
348            |_| {
349                // Warn about processing a package that isn't in the package graph.
350                warn!(
351                    target: "nextest-runner::list",
352                    "warning: saw package ID `{}` which wasn't produced by cargo metadata",
353                    package_id
354                );
355                false
356            },
357            |p| p.in_workspace(),
358        );
359        if in_workspace {
360            // Ignore this build script if it's not in the target directory.
361            if let Ok(rel_out_dir) = build_script
362                .out_dir
363                .strip_prefix(&self.rust_build_meta.target_directory)
364            {
365                self.rust_build_meta.build_script_out_dirs.insert(
366                    package_id.repr().to_owned(),
367                    convert_rel_path_to_forward_slash(rel_out_dir),
368                );
369            }
370        }
371
372        Ok(())
373    }
374
375    /// The `Option` in the return value is to let ? work.
376    fn detect_linked_path(&mut self, package_id: &PackageId, path: &Utf8Path) -> Option<()> {
377        // Remove anything up to the first "=" (e.g. "native=").
378        let actual_path = match path.as_str().split_once('=') {
379            Some((_, p)) => p.into(),
380            None => path,
381        };
382
383        let rel_path = match actual_path.strip_prefix(&self.rust_build_meta.target_directory) {
384            Ok(rel) => rel,
385            Err(_) => {
386                // For a seeded build (like in our test suite), Cargo will
387                // return:
388                //
389                // * the new path if the linked path exists
390                // * the original path if the linked path does not exist
391                //
392                // Linked paths not existing is not an ordinary condition, but
393                // we want to test it within nextest. We filter out paths if
394                // they're not a subdirectory of the target directory. With
395                // __NEXTEST_ALT_TARGET_DIR, we can simulate that for an
396                // alternate target directory.
397                if let Some(alt_target_dir) = &self.alt_target_dir {
398                    actual_path.strip_prefix(alt_target_dir).ok()?
399                } else {
400                    return None;
401                }
402            }
403        };
404
405        self.rust_build_meta
406            .linked_paths
407            .entry(convert_rel_path_to_forward_slash(rel_path))
408            .or_default()
409            .insert(package_id.repr.clone());
410
411        Some(())
412    }
413
414    fn finish(mut self) -> BinaryList {
415        self.rust_binaries.sort_by(|b1, b2| b1.id.cmp(&b2.id));
416
417        // Clean out any build script output directories for which there's no corresponding binary.
418        let relevant_package_ids = self
419            .rust_binaries
420            .iter()
421            .map(|bin| bin.package_id.clone())
422            .collect::<HashSet<_>>();
423
424        self.rust_build_meta
425            .build_script_out_dirs
426            .retain(|package_id, _| relevant_package_ids.contains(package_id));
427
428        BinaryList {
429            rust_build_meta: self.rust_build_meta,
430            rust_binaries: self.rust_binaries,
431        }
432    }
433}
434
435#[cfg(test)]
436mod tests {
437    use super::*;
438    use crate::{
439        cargo_config::{TargetDefinitionLocation, TargetTriple, TargetTripleSource},
440        list::SerializableFormat,
441        platform::{HostPlatform, PlatformLibdir, TargetPlatform},
442    };
443    use indoc::indoc;
444    use maplit::btreeset;
445    use nextest_metadata::PlatformLibdirUnavailable;
446    use pretty_assertions::assert_eq;
447    use target_spec::{Platform, TargetFeatures};
448
449    #[test]
450    fn test_parse_binary_list() {
451        let fake_bin_test = RustTestBinary {
452            id: "fake-package::bin/fake-binary".into(),
453            path: "/fake/binary".into(),
454            package_id: "fake-package 0.1.0 (path+file:///Users/fakeuser/project/fake-package)"
455                .to_owned(),
456            kind: RustTestBinaryKind::LIB,
457            name: "fake-binary".to_owned(),
458            build_platform: BuildPlatform::Target,
459        };
460        let fake_macro_test = RustTestBinary {
461            id: "fake-macro::proc-macro/fake-macro".into(),
462            path: "/fake/macro".into(),
463            package_id: "fake-macro 0.1.0 (path+file:///Users/fakeuser/project/fake-macro)"
464                .to_owned(),
465            kind: RustTestBinaryKind::PROC_MACRO,
466            name: "fake-macro".to_owned(),
467            build_platform: BuildPlatform::Host,
468        };
469
470        let fake_triple = TargetTriple {
471            platform: Platform::new("aarch64-unknown-linux-gnu", TargetFeatures::Unknown).unwrap(),
472            source: TargetTripleSource::CliOption,
473            location: TargetDefinitionLocation::Builtin,
474        };
475        let fake_host_libdir = "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib";
476        let build_platforms = BuildPlatforms {
477            host: HostPlatform {
478                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
479                libdir: PlatformLibdir::Available(Utf8PathBuf::from(fake_host_libdir)),
480            },
481            target: Some(TargetPlatform {
482                triple: fake_triple,
483                // Test out the error case for unavailable libdirs.
484                libdir: PlatformLibdir::Unavailable(PlatformLibdirUnavailable::RUSTC_OUTPUT_ERROR),
485            }),
486        };
487
488        let mut rust_build_meta = RustBuildMeta::new("/fake/target", build_platforms);
489        rust_build_meta
490            .base_output_directories
491            .insert("my-profile".into());
492        rust_build_meta.non_test_binaries.insert(
493            "my-package-id".into(),
494            btreeset! {
495                RustNonTestBinarySummary {
496                    name: "my-name".into(),
497                    kind: RustNonTestBinaryKind::BIN_EXE,
498                    path: "my-profile/my-name".into(),
499                },
500                RustNonTestBinarySummary {
501                    name: "your-name".into(),
502                    kind: RustNonTestBinaryKind::DYLIB,
503                    path: "my-profile/your-name.dll".into(),
504                },
505                RustNonTestBinarySummary {
506                    name: "your-name".into(),
507                    kind: RustNonTestBinaryKind::DYLIB,
508                    path: "my-profile/your-name.exp".into(),
509                },
510            },
511        );
512
513        let binary_list = BinaryList {
514            rust_build_meta,
515            rust_binaries: vec![fake_bin_test, fake_macro_test],
516        };
517
518        // Check that the expected outputs are valid.
519        static EXPECTED_HUMAN: &str = indoc! {"
520        fake-package::bin/fake-binary
521        fake-macro::proc-macro/fake-macro
522        "};
523        static EXPECTED_HUMAN_VERBOSE: &str = indoc! {r"
524        fake-package::bin/fake-binary:
525          bin: /fake/binary
526          build platform: target
527        fake-macro::proc-macro/fake-macro:
528          bin: /fake/macro
529          build platform: host
530        "};
531        static EXPECTED_JSON_PRETTY: &str = indoc! {r#"
532        {
533          "rust-build-meta": {
534            "target-directory": "/fake/target",
535            "base-output-directories": [
536              "my-profile"
537            ],
538            "non-test-binaries": {
539              "my-package-id": [
540                {
541                  "name": "my-name",
542                  "kind": "bin-exe",
543                  "path": "my-profile/my-name"
544                },
545                {
546                  "name": "your-name",
547                  "kind": "dylib",
548                  "path": "my-profile/your-name.dll"
549                },
550                {
551                  "name": "your-name",
552                  "kind": "dylib",
553                  "path": "my-profile/your-name.exp"
554                }
555              ]
556            },
557            "build-script-out-dirs": {},
558            "linked-paths": [],
559            "platforms": {
560              "host": {
561                "platform": {
562                  "triple": "x86_64-unknown-linux-gnu",
563                  "target-features": "unknown"
564                },
565                "libdir": {
566                  "status": "available",
567                  "path": "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib"
568                }
569              },
570              "targets": [
571                {
572                  "platform": {
573                    "triple": "aarch64-unknown-linux-gnu",
574                    "target-features": "unknown"
575                  },
576                  "libdir": {
577                    "status": "unavailable",
578                    "reason": "rustc-output-error"
579                  }
580                }
581              ]
582            },
583            "target-platforms": [
584              {
585                "triple": "aarch64-unknown-linux-gnu",
586                "target-features": "unknown"
587              }
588            ],
589            "target-platform": "aarch64-unknown-linux-gnu"
590          },
591          "rust-binaries": {
592            "fake-macro::proc-macro/fake-macro": {
593              "binary-id": "fake-macro::proc-macro/fake-macro",
594              "binary-name": "fake-macro",
595              "package-id": "fake-macro 0.1.0 (path+file:///Users/fakeuser/project/fake-macro)",
596              "kind": "proc-macro",
597              "binary-path": "/fake/macro",
598              "build-platform": "host"
599            },
600            "fake-package::bin/fake-binary": {
601              "binary-id": "fake-package::bin/fake-binary",
602              "binary-name": "fake-binary",
603              "package-id": "fake-package 0.1.0 (path+file:///Users/fakeuser/project/fake-package)",
604              "kind": "lib",
605              "binary-path": "/fake/binary",
606              "build-platform": "target"
607            }
608          }
609        }"#};
610
611        assert_eq!(
612            binary_list
613                .to_string(OutputFormat::Human { verbose: false })
614                .expect("human succeeded"),
615            EXPECTED_HUMAN
616        );
617        assert_eq!(
618            binary_list
619                .to_string(OutputFormat::Human { verbose: true })
620                .expect("human succeeded"),
621            EXPECTED_HUMAN_VERBOSE
622        );
623        assert_eq!(
624            binary_list
625                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
626                .expect("json-pretty succeeded"),
627            EXPECTED_JSON_PRETTY
628        );
629    }
630}