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