Skip to main content

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 serde::Deserialize;
20use std::{
21    collections::{BTreeMap, HashSet},
22    io,
23};
24use tracing::{debug, warn};
25
26/// A Rust test binary built by Cargo.
27#[derive(Clone, Debug)]
28pub struct RustTestBinary {
29    /// A unique ID.
30    pub id: RustBinaryId,
31    /// The path to the binary artifact.
32    pub path: Utf8PathBuf,
33    /// The package this artifact belongs to.
34    pub package_id: String,
35    /// The kind of Rust test binary this is.
36    pub kind: RustTestBinaryKind,
37    /// The unique binary name defined in `Cargo.toml` or inferred by the filename.
38    pub name: String,
39    /// Platform for which this binary was built.
40    /// (Proc-macro tests are built for the host.)
41    pub build_platform: BuildPlatform,
42}
43
44/// The list of Rust test binaries built by Cargo.
45#[derive(Clone, Debug)]
46pub struct BinaryList {
47    /// Rust-related metadata.
48    pub rust_build_meta: RustBuildMeta<BinaryListState>,
49
50    /// The list of test binaries.
51    pub rust_binaries: Vec<RustTestBinary>,
52}
53
54impl BinaryList {
55    /// Parses Cargo messages from the given `BufRead` and returns a list of test binaries.
56    pub fn from_messages(
57        reader: impl io::BufRead,
58        graph: &PackageGraph,
59        build_platforms: BuildPlatforms,
60    ) -> Result<Self, FromMessagesError> {
61        let mut builder = BinaryListBuilder::new(graph, build_platforms);
62
63        for message in Message::parse_stream(reader) {
64            let message = message.map_err(FromMessagesError::ReadMessages)?;
65            builder.process_message(message)?;
66        }
67
68        Ok(builder.finish())
69    }
70
71    /// Constructs the list from its summary format
72    pub fn from_summary(summary: BinaryListSummary) -> Result<Self, RustBuildMetaParseError> {
73        let rust_binaries = summary
74            .rust_binaries
75            .into_values()
76            .map(|bin| RustTestBinary {
77                name: bin.binary_name,
78                path: bin.binary_path,
79                package_id: bin.package_id,
80                kind: bin.kind,
81                id: bin.binary_id,
82                build_platform: bin.build_platform,
83            })
84            .collect();
85        Ok(Self {
86            rust_build_meta: RustBuildMeta::from_summary(summary.rust_build_meta)?,
87            rust_binaries,
88        })
89    }
90
91    /// Outputs this list to the given writer.
92    pub fn write(
93        &self,
94        output_format: OutputFormat,
95        writer: &mut dyn WriteStr,
96        colorize: bool,
97    ) -> Result<(), WriteTestListError> {
98        match output_format {
99            OutputFormat::Human { verbose } => self
100                .write_human(writer, verbose, colorize)
101                .map_err(WriteTestListError::Io),
102            OutputFormat::Oneline { verbose } => self
103                .write_oneline(writer, verbose, colorize)
104                .map_err(WriteTestListError::Io),
105            OutputFormat::Serializable(format) => format.to_writer(&self.to_summary(), writer),
106        }
107    }
108
109    fn to_summary(&self) -> BinaryListSummary {
110        BinaryListSummary {
111            rust_build_meta: self.rust_build_meta.to_summary(),
112            rust_binaries: self.binary_summaries(),
113        }
114    }
115
116    /// Produces a summary suitable for archive metadata.
117    ///
118    /// * `build_directory` is omitted so it defaults to `target_directory` on
119    ///   extraction.
120    /// * Binary paths under `build_directory` are remapped to `target_directory`
121    ///   so the `PathMapper` can remap them correctly on extraction.
122    pub(crate) fn to_archive_summary(&self) -> BinaryListSummary {
123        let target_dir = &self.rust_build_meta.target_directory;
124        let build_directory = &self.rust_build_meta.build_directory;
125
126        let rust_binaries = self
127            .rust_binaries
128            .iter()
129            .map(|bin| {
130                // In the archive, test binaries are stored under target/.
131                // Remap paths from build_directory to target_directory so the PathMapper
132                // can relocate them on extraction.
133                let binary_path = target_dir.join(
134                    bin.path
135                        .strip_prefix(build_directory)
136                        .expect("test binary paths must be within the build directory"),
137                );
138                let summary = RustTestBinarySummary {
139                    binary_name: bin.name.clone(),
140                    package_id: bin.package_id.clone(),
141                    kind: bin.kind.clone(),
142                    binary_path,
143                    binary_id: bin.id.clone(),
144                    build_platform: bin.build_platform,
145                };
146                (bin.id.clone(), summary)
147            })
148            .collect();
149
150        BinaryListSummary {
151            rust_build_meta: self.rust_build_meta.to_archive_summary(),
152            rust_binaries,
153        }
154    }
155
156    fn binary_summaries(&self) -> BTreeMap<RustBinaryId, RustTestBinarySummary> {
157        self.rust_binaries
158            .iter()
159            .map(|bin| {
160                let summary = RustTestBinarySummary {
161                    binary_name: bin.name.clone(),
162                    package_id: bin.package_id.clone(),
163                    kind: bin.kind.clone(),
164                    binary_path: bin.path.clone(),
165                    binary_id: bin.id.clone(),
166                    build_platform: bin.build_platform,
167                };
168                (bin.id.clone(), summary)
169            })
170            .collect()
171    }
172
173    fn write_human(
174        &self,
175        writer: &mut dyn WriteStr,
176        verbose: bool,
177        colorize: bool,
178    ) -> io::Result<()> {
179        let mut styles = Styles::default();
180        if colorize {
181            styles.colorize();
182        }
183        for bin in &self.rust_binaries {
184            if verbose {
185                writeln!(writer, "{}:", bin.id.style(styles.binary_id))?;
186                writeln!(writer, "  {} {}", "bin:".style(styles.field), bin.path)?;
187                writeln!(
188                    writer,
189                    "  {} {}",
190                    "build platform:".style(styles.field),
191                    bin.build_platform,
192                )?;
193            } else {
194                writeln!(writer, "{}", bin.id.style(styles.binary_id))?;
195            }
196        }
197        Ok(())
198    }
199
200    fn write_oneline(
201        &self,
202        writer: &mut dyn WriteStr,
203        verbose: bool,
204        colorize: bool,
205    ) -> io::Result<()> {
206        let mut styles = Styles::default();
207        if colorize {
208            styles.colorize();
209        }
210        for bin in &self.rust_binaries {
211            write!(writer, "{}", bin.id.style(styles.binary_id))?;
212            if verbose {
213                write!(
214                    writer,
215                    " [{}{}] [{}{}]",
216                    "bin: ".style(styles.field),
217                    bin.path,
218                    "build platform: ".style(styles.field),
219                    bin.build_platform,
220                )?;
221            }
222            writeln!(writer)?;
223        }
224        Ok(())
225    }
226
227    /// Outputs this list as a string with the given format.
228    pub fn to_string(&self, output_format: OutputFormat) -> Result<String, WriteTestListError> {
229        let mut s = String::with_capacity(1024);
230        self.write(output_format, &mut s, false)?;
231        Ok(s)
232    }
233}
234
235/// Incrementally builds a [`BinaryList`] from Cargo messages.
236#[derive(Debug)]
237pub struct BinaryListBuilder<'g> {
238    state: BinaryListBuildState<'g>,
239}
240
241impl<'g> BinaryListBuilder<'g> {
242    /// Creates a new builder for Cargo messages.
243    pub fn new(graph: &'g PackageGraph, build_platforms: BuildPlatforms) -> Self {
244        Self {
245            state: BinaryListBuildState::new(graph, build_platforms),
246        }
247    }
248
249    /// Processes a single Cargo message.
250    pub fn process_message(&mut self, message: Message) -> Result<(), FromMessagesError> {
251        self.state.process_message(message)
252    }
253
254    /// Processes a single line of Cargo output.
255    ///
256    /// This uses the same single-line parsing behavior as
257    /// [`cargo_metadata::Message::parse_stream`].
258    pub fn process_message_line(&mut self, line: &str) -> Result<(), FromMessagesError> {
259        self.process_message(parse_message_line(line))
260    }
261
262    /// Finishes building the binary list.
263    pub fn finish(self) -> BinaryList {
264        self.state.finish()
265    }
266}
267
268// Adapted from cargo_metadata::MessageIter::next (cargo_metadata 0.23.1).
269fn parse_message_line(line: &str) -> Message {
270    let mut deserializer = serde_json::Deserializer::from_str(line);
271    deserializer.disable_recursion_limit();
272    Message::deserialize(&mut deserializer).unwrap_or_else(|_| Message::TextLine(line.to_owned()))
273}
274
275#[derive(Debug)]
276struct BinaryListBuildState<'g> {
277    graph: &'g PackageGraph,
278    rust_binaries: Vec<RustTestBinary>,
279    rust_build_meta: RustBuildMeta<BinaryListState>,
280    alt_target_dir: Option<Utf8PathBuf>,
281}
282
283impl<'g> BinaryListBuildState<'g> {
284    fn new(graph: &'g PackageGraph, build_platforms: BuildPlatforms) -> Self {
285        let rust_target_dir = graph.workspace().target_directory().to_path_buf();
286        // Use the build directory if on Cargo 1.91 or newer. Fall back to
287        // the target directory for older Cargo versions.
288        let build_directory = graph
289            .workspace()
290            .build_directory()
291            .unwrap_or_else(|| graph.workspace().target_directory())
292            .to_path_buf();
293        // For testing only, not part of the public API.
294        let alt_target_dir = std::env::var("__NEXTEST_ALT_TARGET_DIR")
295            .ok()
296            .map(Utf8PathBuf::from);
297
298        Self {
299            graph,
300            rust_binaries: vec![],
301            rust_build_meta: RustBuildMeta::new(rust_target_dir, build_directory, build_platforms),
302            alt_target_dir,
303        }
304    }
305
306    fn process_message(&mut self, message: Message) -> Result<(), FromMessagesError> {
307        match message {
308            Message::CompilerArtifact(artifact) => {
309                self.process_artifact(artifact)?;
310            }
311            Message::BuildScriptExecuted(build_script) => {
312                self.process_build_script(build_script)?;
313            }
314            _ => {
315                // Ignore all other messages.
316            }
317        }
318
319        Ok(())
320    }
321
322    fn process_artifact(&mut self, artifact: Artifact) -> Result<(), FromMessagesError> {
323        if let Some(path) = artifact.executable {
324            self.detect_base_output_dir(&path);
325
326            if artifact.profile.test {
327                let package_id = artifact.package_id.repr;
328
329                // Look up the executable by package ID.
330
331                let name = artifact.target.name;
332
333                let package = self
334                    .graph
335                    .metadata(&guppy::PackageId::new(package_id.clone()))
336                    .map_err(FromMessagesError::PackageGraph)?;
337
338                let kind = artifact.target.kind;
339                if kind.is_empty() {
340                    return Err(FromMessagesError::MissingTargetKind {
341                        package_name: package.name().to_owned(),
342                        binary_name: name.clone(),
343                    });
344                }
345
346                let (computed_kind, platform) = if kind.iter().any(|k| {
347                    // https://doc.rust-lang.org/nightly/cargo/reference/cargo-targets.html#the-crate-type-field
348                    matches!(
349                        k,
350                        TargetKind::Lib
351                            | TargetKind::RLib
352                            | TargetKind::DyLib
353                            | TargetKind::CDyLib
354                            | TargetKind::StaticLib
355                    )
356                }) {
357                    (RustTestBinaryKind::LIB, BuildPlatform::Target)
358                } else if let Some(TargetKind::ProcMacro) = kind.first() {
359                    (RustTestBinaryKind::PROC_MACRO, BuildPlatform::Host)
360                } else {
361                    // Non-lib kinds should always have just one element. Grab the first one.
362                    (
363                        RustTestBinaryKind::new(
364                            kind.into_iter()
365                                .next()
366                                .expect("already checked that kind is non-empty")
367                                .to_string(),
368                        ),
369                        BuildPlatform::Target,
370                    )
371                };
372
373                // Construct the binary ID from the package and build target.
374                let id = RustBinaryId::from_parts(package.name(), &computed_kind, &name);
375
376                self.rust_binaries.push(RustTestBinary {
377                    path,
378                    package_id,
379                    kind: computed_kind,
380                    name,
381                    id,
382                    build_platform: platform,
383                });
384            } else if artifact
385                .target
386                .kind
387                .iter()
388                .any(|x| matches!(x, TargetKind::Bin))
389            {
390                // This is a non-test binary -- add it to the map.
391                // Error case here implies that the returned path wasn't in the target directory -- ignore it
392                // since it shouldn't happen in normal use.
393                if let Ok(rel_path) = path.strip_prefix(&self.rust_build_meta.target_directory) {
394                    let non_test_binary = RustNonTestBinarySummary {
395                        name: artifact.target.name,
396                        kind: RustNonTestBinaryKind::BIN_EXE,
397                        path: convert_rel_path_to_forward_slash(rel_path),
398                        build_platform: self.non_test_build_platform(&path),
399                    };
400
401                    self.rust_build_meta.non_test_binaries.insert(
402                        guppy::PackageId::new(artifact.package_id.repr),
403                        non_test_binary,
404                    );
405                };
406            }
407        } else if artifact
408            .target
409            .kind
410            .iter()
411            .any(|x| matches!(x, TargetKind::DyLib | TargetKind::CDyLib))
412        {
413            // Also look for and grab dynamic libraries to store in archives.
414            for filename in artifact.filenames {
415                if let Ok(rel_path) = filename.strip_prefix(&self.rust_build_meta.target_directory)
416                {
417                    let non_test_binary = RustNonTestBinarySummary {
418                        name: artifact.target.name.clone(),
419                        kind: RustNonTestBinaryKind::DYLIB,
420                        path: convert_rel_path_to_forward_slash(rel_path),
421                        build_platform: self.non_test_build_platform(&filename),
422                    };
423                    self.rust_build_meta.non_test_binaries.insert(
424                        guppy::PackageId::new(artifact.package_id.repr.clone()),
425                        non_test_binary,
426                    );
427                }
428            }
429        }
430
431        Ok(())
432    }
433
434    /// Records the base output directory an artifact was built into.
435    ///
436    /// A base output directory is the `[<triple>/]<profile>` prefix of an
437    /// artifact's path, relative to the build directory. Only these shapes are
438    /// recognized:
439    ///
440    /// * the legacy layout: `debug/deps/test-binary`
441    /// * the legacy layout, for `[[example]]` targets: `debug/examples/test-binary`
442    /// * the build-dir layout v2: `debug/build/my-package/f3694e28990a9310/out/test-binary`
443    ///
444    /// In each case, the base output directory is `debug`. For each one, nextest
445    /// adds two directories to the dynamic library path:
446    ///
447    /// * the Cargo artifact directory, where Cargo uplifts final artifacts to
448    ///   (the base output directory under the *target* directory)
449    /// * the legacy `deps` directory (under the *build* directory)
450    ///
451    /// The `Option` in the return value is to let ? work.
452    fn detect_base_output_dir(&mut self, artifact_path: &Utf8Path) -> Option<()> {
453        // Artifact paths must be relative to the build directory (which
454        // equals the target directory unless Cargo's build.build-dir is
455        // configured).
456        //
457        // Unlike `artifact_build_platform`, which picks whichever is the
458        // innermost of the build and target directories, this resolves against
459        // the build directory and nothing else, because its result goes into
460        // `base_output_directories`, which is relative to the build directory.
461        let rel_path = match artifact_path.strip_prefix(&self.rust_build_meta.build_directory) {
462            Ok(rel) => rel,
463            Err(_) => {
464                debug!(
465                    target: "nextest-runner::list",
466                    "artifact path `{}` is not within the build directory `{}`, \
467                     skipping base output directory detection",
468                    artifact_path, self.rust_build_meta.build_directory,
469                );
470                return None;
471            }
472        };
473
474        let base = base_output_dir(rel_path)?;
475        if !self.rust_build_meta.base_output_directories.contains(base) {
476            self.rust_build_meta
477                .base_output_directories
478                .insert(convert_rel_path_to_forward_slash(base));
479        }
480        Some(())
481    }
482
483    fn non_test_build_platform(&self, artifact_path: &Utf8Path) -> Option<BuildPlatform> {
484        artifact_build_platform(
485            artifact_path,
486            &self.rust_build_meta.target_directory,
487            &self.rust_build_meta.build_directory,
488            self.rust_build_meta
489                .build_platforms
490                .target
491                .as_ref()
492                .map(|target| target.triple.platform.triple_str()),
493        )
494    }
495
496    fn process_build_script(&mut self, build_script: BuildScript) -> Result<(), FromMessagesError> {
497        for path in build_script.linked_paths {
498            self.detect_linked_path(&build_script.package_id, &path);
499        }
500
501        // We only care about build scripts for workspace packages.
502        let package_id = guppy::PackageId::new(build_script.package_id.repr);
503        let in_workspace = self.graph.metadata(&package_id).map_or_else(
504            |_| {
505                // Warn about processing a package that isn't in the package graph.
506                warn!(
507                    target: "nextest-runner::list",
508                    "warning: saw package ID `{}` which wasn't produced by cargo metadata",
509                    package_id
510                );
511                false
512            },
513            |p| p.in_workspace(),
514        );
515        if in_workspace {
516            // Build script out_dirs are relative to the build directory.
517            match build_script
518                .out_dir
519                .strip_prefix(&self.rust_build_meta.build_directory)
520            {
521                Ok(rel_out_dir) => {
522                    self.rust_build_meta.build_script_out_dirs.insert(
523                        package_id.repr().to_owned(),
524                        convert_rel_path_to_forward_slash(rel_out_dir),
525                    );
526                }
527                Err(_) => {
528                    debug!(
529                        target: "nextest-runner::list",
530                        "build script out_dir `{}` for package `{}` is not within \
531                         the build directory `{}`, skipping",
532                        build_script.out_dir, package_id,
533                        self.rust_build_meta.build_directory,
534                    );
535                }
536            }
537
538            // Capture build script environment variables from the structured
539            // cargo message, avoiding the need to parse the raw output file.
540            if !build_script.env.is_empty() {
541                self.rust_build_meta
542                    .build_script_info
543                    .get_or_insert_with(BTreeMap::new)
544                    .entry(package_id.repr().to_owned())
545                    .or_default()
546                    .envs = build_script.env.into_iter().collect();
547            }
548        }
549
550        Ok(())
551    }
552
553    /// The `Option` in the return value is to let ? work.
554    fn detect_linked_path(&mut self, package_id: &PackageId, path: &Utf8Path) -> Option<()> {
555        // Remove anything up to the first "=" (e.g. "native=").
556        let actual_path = match path.as_str().split_once('=') {
557            Some((_, p)) => p.into(),
558            None => path,
559        };
560
561        let rel_path = match actual_path.strip_prefix(&self.rust_build_meta.build_directory) {
562            Ok(rel) => rel,
563            Err(_) => {
564                // For a seeded build (like in our test suite), Cargo will
565                // return:
566                //
567                // * the new path if the linked path exists
568                // * the original path if the linked path does not exist
569                //
570                // Linked paths not existing is not an ordinary condition, but
571                // we want to test it within nextest. We filter out paths if
572                // they're not a subdirectory of the target directory. With
573                // __NEXTEST_ALT_TARGET_DIR, we can simulate that for an
574                // alternate target directory.
575                if let Some(alt_target_dir) = &self.alt_target_dir {
576                    actual_path.strip_prefix(alt_target_dir).ok()?
577                } else {
578                    return None;
579                }
580            }
581        };
582
583        self.rust_build_meta
584            .linked_paths
585            .entry(convert_rel_path_to_forward_slash(rel_path))
586            .or_default()
587            .insert(package_id.repr.clone());
588
589        Some(())
590    }
591
592    fn finish(mut self) -> BinaryList {
593        self.rust_binaries.sort_by(|b1, b2| b1.id.cmp(&b2.id));
594
595        // Clean out any build script output directories for which there's no corresponding binary.
596        let relevant_package_ids = self
597            .rust_binaries
598            .iter()
599            .map(|bin| bin.package_id.clone())
600            .collect::<HashSet<_>>();
601
602        self.rust_build_meta
603            .build_script_out_dirs
604            .retain(|package_id, _| relevant_package_ids.contains(package_id));
605        if let Some(info) = &mut self.rust_build_meta.build_script_info {
606            info.retain(|package_id, _| relevant_package_ids.contains(package_id));
607        }
608
609        // All test binaries live inside a base output dir under both Cargo
610        // layouts, so an empty set suggests that we didn't recognize the
611        // layout. It's worth warning about this.
612        if !self.rust_binaries.is_empty() && self.rust_build_meta.base_output_directories.is_empty()
613        {
614            warn!(
615                target: "nextest-runner::list",
616                "failed to detect any base output directories under the build directory `{}`; \
617                 tests that link against dynamic libraries may fail to start. \
618                 This usually means Cargo's build directory layout changed -- \
619                 please report it at https://github.com/nextest-rs/nextest/issues/new",
620                self.rust_build_meta.build_directory,
621            );
622        }
623
624        BinaryList {
625            rust_build_meta: self.rust_build_meta,
626            rust_binaries: self.rust_binaries,
627        }
628    }
629}
630
631/// Determines the build platform for a particular artifact.
632///
633/// This is a heuristic due to Cargo's lack of platform information in build
634/// message output. See <https://github.com/rust-lang/cargo/issues/12869> for
635/// the Cargo issue.
636fn artifact_build_platform(
637    artifact_path: &Utf8Path,
638    target_directory: &Utf8Path,
639    build_directory: &Utf8Path,
640    target_triple: Option<&str>,
641) -> Option<BuildPlatform> {
642    // If we're not cross compiling, we must be building for the target.
643    let Some(target_triple) = target_triple else {
644        return Some(BuildPlatform::Target);
645    };
646
647    // As of this writing (2026-08), callers ensure that the artifact path is
648    // under either the target directory or the build directory. But we can
649    // reasonably return None here if that assumption doesn't hold.
650    let Some(rel_path) = [target_directory, build_directory]
651        .into_iter()
652        .filter_map(|root| artifact_path.strip_prefix(root).ok())
653        .min_by_key(|rel_path| rel_path.as_str().len())
654    else {
655        debug!(
656            target: "nextest-runner::list",
657            "artifact path `{}` is under neither the target directory `{}` nor the build \
658             directory `{}`, recording its build platform as unknown",
659            artifact_path, target_directory, build_directory,
660        );
661        return None;
662    };
663
664    if rel_path.starts_with(target_triple) {
665        Some(BuildPlatform::Target)
666    } else {
667        Some(BuildPlatform::Host)
668    }
669}
670
671fn base_output_dir(rel_path: &Utf8Path) -> Option<&Utf8Path> {
672    let parent = rel_path.parent()?;
673    let base = match parent.file_name()? {
674        // The legacy layout.
675        //
676        // Test binaries built from `[[example]]` targets go to `examples`
677        // rather than `deps` -- see Cargo's `CompilationFiles::output_dir`.
678        // (Under the v2 layout they go to the `out` directory below, like every
679        // other compilation unit.)
680        "deps" | "examples" => parent.parent()?,
681        // The build-dir v2 layout.
682        "out" => parent
683            .ancestors()
684            // There is a subtle point here: we want to restrict this branch to
685            // the v2 layout.
686            //
687            // With the legacy layout, build script out dirs are of the form
688            // `<base>/build/<package>-<hash>/out`. `nth(3)` would return
689            // `<base>`, whose last component is the profile's directory name.
690            // That name can never be `build`, because:
691            //
692            // * Cargo does not allow profiles to have the name `build`
693            // * `profile.<name>.dir-name`, the only potential way to decouple a
694            //   profile's directory name from the profile name, is currently
695            //   disallowed as of 2026-07.
696            //
697            // So the filter below discards legacy build script out dirs without
698            // also discarding any real base output directory.
699            //
700            // With the v2 layout, out dirs are of the form
701            // `<base>/build/<package>/<hash>/out`. `nth(3)` returns `build`.
702            // This is also the shape of build script out dirs under v2, which
703            // is harmless: they resolve to the same `<base>`.
704            .nth(3)
705            .filter(|dir| dir.file_name() == Some("build"))?
706            .parent()?,
707        _ => return None,
708    };
709
710    // A base output dir is always `[<triple>/]<profile>`, so an empty one means
711    // the path didn't have the shape we expected.
712    (!base.as_str().is_empty()).then_some(base)
713}
714
715#[cfg(test)]
716mod tests {
717    use super::*;
718    use crate::{
719        cargo_config::{TargetDefinitionLocation, TargetTriple, TargetTripleSource},
720        list::{
721            SerializableFormat,
722            test_helpers::{PACKAGE_GRAPH_FIXTURE, PACKAGE_METADATA_ID, package_metadata},
723        },
724        platform::{HostPlatform, PlatformLibdir, TargetPlatform},
725    };
726    use indoc::indoc;
727    use maplit::btreeset;
728    use nextest_metadata::PlatformLibdirUnavailable;
729    use pretty_assertions::assert_eq;
730    use serde_json::json;
731    use target_spec::{Platform, TargetFeatures};
732
733    #[test]
734    fn test_parse_binary_list() {
735        let fake_bin_test = RustTestBinary {
736            id: "fake-package::bin/fake-binary".into(),
737            path: "/fake/binary".into(),
738            package_id: "fake-package 0.1.0 (path+file:///Users/fakeuser/project/fake-package)"
739                .to_owned(),
740            kind: RustTestBinaryKind::LIB,
741            name: "fake-binary".to_owned(),
742            build_platform: BuildPlatform::Target,
743        };
744        let fake_macro_test = RustTestBinary {
745            id: "fake-macro::proc-macro/fake-macro".into(),
746            path: "/fake/macro".into(),
747            package_id: "fake-macro 0.1.0 (path+file:///Users/fakeuser/project/fake-macro)"
748                .to_owned(),
749            kind: RustTestBinaryKind::PROC_MACRO,
750            name: "fake-macro".to_owned(),
751            build_platform: BuildPlatform::Host,
752        };
753
754        let fake_triple = TargetTriple {
755            platform: Platform::new("aarch64-unknown-linux-gnu", TargetFeatures::Unknown).unwrap(),
756            source: TargetTripleSource::CliOption,
757            location: TargetDefinitionLocation::Builtin,
758        };
759        let fake_host_libdir = "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib";
760        let build_platforms = BuildPlatforms {
761            host: HostPlatform {
762                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
763                libdir: PlatformLibdir::Available(Utf8PathBuf::from(fake_host_libdir)),
764            },
765            target: Some(TargetPlatform {
766                triple: fake_triple,
767                // Test out the error case for unavailable libdirs.
768                libdir: PlatformLibdir::Unavailable(PlatformLibdirUnavailable::RUSTC_OUTPUT_ERROR),
769            }),
770        };
771
772        let mut rust_build_meta =
773            RustBuildMeta::new("/fake/target", "/fake/target", build_platforms);
774        // With a target triple set, a binary built for the target platform
775        // lives under `<triple>/<profile>`, otherwise just under `<profile>`.
776        rust_build_meta
777            .base_output_directories
778            .insert("aarch64-unknown-linux-gnu/my-profile".into());
779        rust_build_meta
780            .base_output_directories
781            .insert("my-profile".into());
782        for non_test_binary in [
783            RustNonTestBinarySummary {
784                name: "my-name".into(),
785                kind: RustNonTestBinaryKind::BIN_EXE,
786                path: "aarch64-unknown-linux-gnu/my-profile/my-name".into(),
787                build_platform: Some(BuildPlatform::Target),
788            },
789            RustNonTestBinarySummary {
790                name: "your-name".into(),
791                kind: RustNonTestBinaryKind::DYLIB,
792                path: "my-profile/your-name.dll".into(),
793                build_platform: Some(BuildPlatform::Host),
794            },
795            RustNonTestBinarySummary {
796                name: "your-name".into(),
797                kind: RustNonTestBinaryKind::DYLIB,
798                path: "my-profile/your-name.exp".into(),
799                build_platform: Some(BuildPlatform::Host),
800            },
801        ] {
802            rust_build_meta
803                .non_test_binaries
804                .insert(guppy::PackageId::new("my-package-id"), non_test_binary);
805        }
806
807        let binary_list = BinaryList {
808            rust_build_meta,
809            rust_binaries: vec![fake_bin_test, fake_macro_test],
810        };
811
812        // Check that the expected outputs are valid.
813        static EXPECTED_HUMAN: &str = indoc! {"
814        fake-package::bin/fake-binary
815        fake-macro::proc-macro/fake-macro
816        "};
817        static EXPECTED_HUMAN_VERBOSE: &str = indoc! {r"
818        fake-package::bin/fake-binary:
819          bin: /fake/binary
820          build platform: target
821        fake-macro::proc-macro/fake-macro:
822          bin: /fake/macro
823          build platform: host
824        "};
825        static EXPECTED_JSON_PRETTY: &str = indoc! {r#"
826        {
827          "rust-build-meta": {
828            "target-directory": "/fake/target",
829            "build-directory": "/fake/target",
830            "base-output-directories": [
831              "aarch64-unknown-linux-gnu/my-profile",
832              "my-profile"
833            ],
834            "non-test-binaries": {
835              "my-package-id": [
836                {
837                  "name": "my-name",
838                  "kind": "bin-exe",
839                  "path": "aarch64-unknown-linux-gnu/my-profile/my-name",
840                  "build-platform": "target"
841                },
842                {
843                  "name": "your-name",
844                  "kind": "dylib",
845                  "path": "my-profile/your-name.dll",
846                  "build-platform": "host"
847                },
848                {
849                  "name": "your-name",
850                  "kind": "dylib",
851                  "path": "my-profile/your-name.exp",
852                  "build-platform": "host"
853                }
854              ]
855            },
856            "build-script-out-dirs": {},
857            "build-script-info": {},
858            "linked-paths": [],
859            "platforms": {
860              "host": {
861                "platform": {
862                  "triple": "x86_64-unknown-linux-gnu",
863                  "target-features": "unknown"
864                },
865                "libdir": {
866                  "status": "available",
867                  "path": "/home/fake/.rustup/toolchains/stable-x86_64-unknown-linux-gnu/lib/rustlib/x86_64-unknown-linux-gnu/lib"
868                }
869              },
870              "targets": [
871                {
872                  "platform": {
873                    "triple": "aarch64-unknown-linux-gnu",
874                    "target-features": "unknown"
875                  },
876                  "libdir": {
877                    "status": "unavailable",
878                    "reason": "rustc-output-error"
879                  }
880                }
881              ]
882            },
883            "target-platforms": [
884              {
885                "triple": "aarch64-unknown-linux-gnu",
886                "target-features": "unknown"
887              }
888            ],
889            "target-platform": "aarch64-unknown-linux-gnu"
890          },
891          "rust-binaries": {
892            "fake-macro::proc-macro/fake-macro": {
893              "binary-id": "fake-macro::proc-macro/fake-macro",
894              "binary-name": "fake-macro",
895              "package-id": "fake-macro 0.1.0 (path+file:///Users/fakeuser/project/fake-macro)",
896              "kind": "proc-macro",
897              "binary-path": "/fake/macro",
898              "build-platform": "host"
899            },
900            "fake-package::bin/fake-binary": {
901              "binary-id": "fake-package::bin/fake-binary",
902              "binary-name": "fake-binary",
903              "package-id": "fake-package 0.1.0 (path+file:///Users/fakeuser/project/fake-package)",
904              "kind": "lib",
905              "binary-path": "/fake/binary",
906              "build-platform": "target"
907            }
908          }
909        }"#};
910        // Non-verbose oneline is the same as non-verbose human.
911        static EXPECTED_ONELINE: &str = indoc! {"
912            fake-package::bin/fake-binary
913            fake-macro::proc-macro/fake-macro
914        "};
915        static EXPECTED_ONELINE_VERBOSE: &str = indoc! {r"
916            fake-package::bin/fake-binary [bin: /fake/binary] [build platform: target]
917            fake-macro::proc-macro/fake-macro [bin: /fake/macro] [build platform: host]
918        "};
919
920        assert_eq!(
921            binary_list
922                .to_string(OutputFormat::Human { verbose: false })
923                .expect("human succeeded"),
924            EXPECTED_HUMAN
925        );
926        assert_eq!(
927            binary_list
928                .to_string(OutputFormat::Human { verbose: true })
929                .expect("human succeeded"),
930            EXPECTED_HUMAN_VERBOSE
931        );
932        assert_eq!(
933            binary_list
934                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
935                .expect("json-pretty succeeded"),
936            EXPECTED_JSON_PRETTY
937        );
938        assert_eq!(
939            binary_list
940                .to_string(OutputFormat::Oneline { verbose: false })
941                .expect("oneline succeeded"),
942            EXPECTED_ONELINE
943        );
944        assert_eq!(
945            binary_list
946                .to_string(OutputFormat::Oneline { verbose: true })
947                .expect("oneline verbose succeeded"),
948            EXPECTED_ONELINE_VERBOSE
949        );
950    }
951
952    #[test]
953    fn test_parse_binary_list_from_message_lines() {
954        let build_platforms = BuildPlatforms {
955            host: HostPlatform {
956                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
957                libdir: PlatformLibdir::Available("/fake/libdir".into()),
958            },
959            target: None,
960        };
961        let package = package_metadata();
962        // The fixture sets build_directory separately from target_directory, so
963        // an artifact resolved against the wrong root fails to produce a base
964        // output directory.
965        let artifact_path = PACKAGE_GRAPH_FIXTURE
966            .workspace()
967            .build_directory()
968            .expect("fixture sets build_directory")
969            .join("debug/deps/metadata_helper-test");
970        let compiler_artifact = artifact_json(
971            package.name(),
972            &["lib"],
973            std::slice::from_ref(&artifact_path),
974            Some(&artifact_path),
975            TestTarget::Yes,
976        );
977        let input = format!("this is not JSON\n{}\n\n", compiler_artifact);
978
979        let from_messages = BinaryList::from_messages(
980            input.as_bytes(),
981            &PACKAGE_GRAPH_FIXTURE,
982            build_platforms.clone(),
983        )
984        .expect("parsing from messages succeeds");
985
986        let mut builder = BinaryListBuilder::new(&PACKAGE_GRAPH_FIXTURE, build_platforms);
987        for line in input.lines() {
988            builder
989                .process_message_line(line)
990                .expect("processing line succeeds");
991        }
992        let from_lines = builder.finish();
993
994        assert_eq!(
995            from_lines.rust_build_meta.base_output_directories,
996            btreeset! { Utf8PathBuf::from("debug") },
997            "base output directory detected from the artifact's executable path"
998        );
999
1000        assert_eq!(
1001            from_lines
1002                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
1003                .expect("json-pretty succeeds"),
1004            from_messages
1005                .to_string(OutputFormat::Serializable(SerializableFormat::JsonPretty))
1006                .expect("json-pretty succeeds")
1007        );
1008    }
1009
1010    #[test]
1011    fn test_artifact_build_platform() {
1012        static TARGET_DIR: &str = "/w/target";
1013        static TRIPLE: &str = "aarch64-unknown-linux-gnu";
1014
1015        struct Case {
1016            artifact_path: &'static str,
1017            // None here means Cargo's `build.build-dir` is unset, so it equals
1018            // the target directory.
1019            build_directory: Option<&'static str>,
1020            target_triple: Option<&'static str>,
1021            expected: Option<BuildPlatform>,
1022            description: &'static str,
1023        }
1024
1025        let cases = [
1026            Case {
1027                artifact_path: "/w/target/debug/libfoo.so",
1028                build_directory: None,
1029                target_triple: None,
1030                expected: Some(BuildPlatform::Target),
1031                description: "with no target platform the host and the target coincide, and \
1032                    nextest reports target",
1033            },
1034            Case {
1035                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/libfoo.so",
1036                build_directory: None,
1037                target_triple: Some(TRIPLE),
1038                expected: Some(BuildPlatform::Target),
1039                description: "a dylib uplifted into the target platform's artifact directory",
1040            },
1041            Case {
1042                artifact_path: "/w/target/debug/libfoo.so",
1043                build_directory: None,
1044                target_triple: Some(TRIPLE),
1045                expected: Some(BuildPlatform::Host),
1046                description: "the same dylib, built for the host as a build dependency",
1047            },
1048            Case {
1049                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/deps/libfoo.rlib",
1050                build_directory: None,
1051                target_triple: Some(TRIPLE),
1052                expected: Some(BuildPlatform::Target),
1053                description: "an rlib that was never uplifted, legacy layout",
1054            },
1055            Case {
1056                artifact_path: "/w/target/debug/deps/libfoo.rlib",
1057                build_directory: None,
1058                target_triple: Some(TRIPLE),
1059                expected: Some(BuildPlatform::Host),
1060                description: "the same rlib built for the host, legacy layout",
1061            },
1062            Case {
1063                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/build/foo/9e6e6f7b/out/libfoo.rlib",
1064                build_directory: None,
1065                target_triple: Some(TRIPLE),
1066                expected: Some(BuildPlatform::Target),
1067                description: "an rlib that was never uplifted, build-dir layout v2",
1068            },
1069            Case {
1070                artifact_path: "/w/target/debug/build/foo/9e6e6f7b/out/libfoo.rlib",
1071                build_directory: None,
1072                target_triple: Some(TRIPLE),
1073                expected: Some(BuildPlatform::Host),
1074                description: "the same rlib built for the host, build-dir layout v2",
1075            },
1076            Case {
1077                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/libfoo.so",
1078                build_directory: Some("/w/build"),
1079                target_triple: Some(TRIPLE),
1080                expected: Some(BuildPlatform::Target),
1081                description: "a build directory beside the target directory: uplifts are unaffected",
1082            },
1083            Case {
1084                artifact_path: "/w/target/build/aarch64-unknown-linux-gnu/debug/deps/libfoo.rlib",
1085                build_directory: Some("/w/target/build"),
1086                target_triple: Some(TRIPLE),
1087                expected: Some(BuildPlatform::Target),
1088                description: "a build directory inside the target directory: the build directory is the \
1089                    innermost root, so the triple is still the first component under it",
1090            },
1091            Case {
1092                artifact_path: "/w/target/build/debug/deps/libfoo.rlib",
1093                build_directory: Some("/w/target/build"),
1094                target_triple: Some(TRIPLE),
1095                expected: Some(BuildPlatform::Host),
1096                description: "a build directory inside the target directory, host artifact",
1097            },
1098            Case {
1099                artifact_path: "/w/target/build/aarch64-unknown-linux-gnu/debug/build/foo/9e6e6f7b/out/libfoo.rlib",
1100                build_directory: Some("/w/target/build"),
1101                target_triple: Some(TRIPLE),
1102                expected: Some(BuildPlatform::Target),
1103                description: "the shape Cargo emits under the build-dir v2 layout for \
1104                    `build.build-dir = target/build` plus --target: nesting and v2 at once. \
1105                    Verified by hand against a real nightly build",
1106            },
1107            Case {
1108                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/libfoo.so",
1109                build_directory: Some("/w/target/build"),
1110                target_triple: Some(TRIPLE),
1111                expected: Some(BuildPlatform::Target),
1112                description: "a build directory inside the target directory: uplifts still go to the \
1113                    target directory, which is the innermost root containing them",
1114            },
1115            Case {
1116                artifact_path: "/w/target/debug/libfoo.so",
1117                build_directory: Some("/w/target/build"),
1118                target_triple: Some(TRIPLE),
1119                expected: Some(BuildPlatform::Host),
1120                description: "a host dylib uplifted alongside a nested build directory. The \
1121                    nested build directory does not contain it, so dropping the target directory \
1122                    from the candidate roots would leave nothing to strip",
1123            },
1124            Case {
1125                artifact_path: "/w/target/debug/deps/aarch64-unknown-linux-gnu/libfoo.so",
1126                build_directory: None,
1127                target_triple: Some(TRIPLE),
1128                expected: Some(BuildPlatform::Host),
1129                description: "only the first component is the triple slot; a triple-named \
1130                    directory deeper in the path means nothing",
1131            },
1132            Case {
1133                artifact_path: "/w/target/debug/libfoo.so",
1134                build_directory: Some("/w"),
1135                target_triple: Some(TRIPLE),
1136                expected: Some(BuildPlatform::Host),
1137                description: "the reverse nesting: `build.build-dir` and `build.target-dir` are \
1138                    independent, so the target directory can sit inside the build directory. It \
1139                    is then the innermost root",
1140            },
1141            Case {
1142                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/libfoo.so",
1143                build_directory: Some("/w"),
1144                target_triple: Some(TRIPLE),
1145                expected: Some(BuildPlatform::Target),
1146                description: "the reverse nesting, target artifact: resolving against the outer \
1147                    build directory would see `target` first and misreport this as host",
1148            },
1149            Case {
1150                artifact_path: "/w/target/aarch64-unknown-linux-gnu-ilp32/debug/libfoo.so",
1151                build_directory: None,
1152                target_triple: Some(TRIPLE),
1153                expected: Some(BuildPlatform::Host),
1154                description: "a directory whose name merely starts with the triple is not the triple",
1155            },
1156            Case {
1157                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/libfoo.so",
1158                build_directory: Some("/w/target/aarch"),
1159                target_triple: Some(TRIPLE),
1160                expected: Some(BuildPlatform::Target),
1161                description: "root selection is component-wise, so a build directory that is only \
1162                    a string prefix of the path never wins; a byte-wise prefix test would strip \
1163                    `/w/target/aarch` here and misread the artifact as host",
1164            },
1165            Case {
1166                artifact_path: "/w/target/my-custom-target/debug/libfoo.so",
1167                build_directory: None,
1168                target_triple: Some("my-custom-target"),
1169                expected: Some(BuildPlatform::Target),
1170                description: "a custom JSON target's directory is its file stem, which is also its triple \
1171                    string in nextest",
1172            },
1173            Case {
1174                artifact_path: "/w/target/aarch64-unknown-linux-gnu/libfoo.so",
1175                build_directory: None,
1176                target_triple: Some(TRIPLE),
1177                expected: Some(BuildPlatform::Target),
1178                description: "KNOWN WRONG, pinned so the limitation stays visible: a custom \
1179                    profile named exactly after the triple puts host uplifts at \
1180                    `<profile>/libfoo.so` under the target directory, and the profile component \
1181                    occupies the `<triple>` slot, so a host artifact reads as target. \
1182                    Self-inflicted",
1183            },
1184            Case {
1185                artifact_path: "/w/target/aarch64-unknown-linux-gnu/debug/libfoo.so",
1186                build_directory: Some("/w/target/aarch64-unknown-linux-gnu"),
1187                target_triple: Some(TRIPLE),
1188                expected: Some(BuildPlatform::Host),
1189                description: "KNOWN WRONG, pinned so the limitation stays visible: a build \
1190                    directory nested in the target directory and named after the triple becomes \
1191                    the innermost root and swallows the triple component, so an uplifted target \
1192                    artifact reads as host. Self-inflicted, like the triple-named custom profile \
1193                    above",
1194            },
1195            Case {
1196                artifact_path: "/elsewhere/debug/libfoo.so",
1197                build_directory: None,
1198                target_triple: Some(TRIPLE),
1199                expected: None,
1200                description: "a path under neither root has no `<triple>` slot to read, so the \
1201                    platform is unknown",
1202            },
1203        ];
1204
1205        for case in cases {
1206            let build_directory = case.build_directory.unwrap_or(TARGET_DIR);
1207            assert_eq!(
1208                artifact_build_platform(
1209                    Utf8Path::new(case.artifact_path),
1210                    Utf8Path::new(TARGET_DIR),
1211                    Utf8Path::new(build_directory),
1212                    case.target_triple,
1213                ),
1214                case.expected,
1215                "{}: build platform for {} (build directory {build_directory})",
1216                case.description,
1217                case.artifact_path,
1218            );
1219        }
1220    }
1221
1222    fn cross_build_platforms() -> BuildPlatforms {
1223        let target_triple = TargetTriple {
1224            platform: Platform::new("aarch64-unknown-linux-gnu", TargetFeatures::Unknown)
1225                .expect("aarch64-unknown-linux-gnu is a builtin triple"),
1226            source: TargetTripleSource::CliOption,
1227            location: TargetDefinitionLocation::Builtin,
1228        };
1229        BuildPlatforms {
1230            host: HostPlatform {
1231                platform: TargetTriple::x86_64_unknown_linux_gnu().platform,
1232                libdir: PlatformLibdir::Available("/fake/host/libdir".into()),
1233            },
1234            target: Some(TargetPlatform::new(
1235                target_triple,
1236                PlatformLibdir::Available("/fake/target/libdir".into()),
1237            )),
1238        }
1239    }
1240
1241    #[test]
1242    fn test_non_test_build_platform_resolves_against_the_build_directory() {
1243        let mut state = BinaryListBuildState::new(&PACKAGE_GRAPH_FIXTURE, cross_build_platforms());
1244        state.rust_build_meta.target_directory = "/w/target".into();
1245        state.rust_build_meta.build_directory = "/w/target/build".into();
1246
1247        assert_eq!(
1248            state.non_test_build_platform(Utf8Path::new(
1249                "/w/target/build/aarch64-unknown-linux-gnu/debug/deps/libfoo.rlib"
1250            )),
1251            Some(BuildPlatform::Target),
1252            "the nested build directory is the innermost root, so the triple is the first \
1253             component under it; resolving against the target directory instead would see \
1254             `build` first and misreport this as host"
1255        );
1256    }
1257
1258    #[test]
1259    fn test_non_test_binaries_record_the_build_platform() {
1260        let build_platforms = cross_build_platforms();
1261
1262        let workspace = PACKAGE_GRAPH_FIXTURE.workspace();
1263        let target_dir = workspace.target_directory();
1264        let build_dir = workspace
1265            .build_directory()
1266            .expect("fixture sets build_directory");
1267
1268        // This simulates `cargo test --no-run --target
1269        // aarch64-unknown-linux-gnu` for a crate with `crate-type = ["dylib",
1270        // "rlib"]` reachable through both a build dependency (i.e. host) and a
1271        // regular dependency (target). Cargo compiles such libraries twice, and
1272        // uplifts each build's dylib into the build's artifact directory, but
1273        // leaves the rlibs in the build directory.
1274        let input = [
1275            dylib_artifact_json(
1276                "shared",
1277                &[
1278                    target_dir.join("debug/libshared.so"),
1279                    build_dir.join("debug/deps/libshared.rlib"),
1280                ],
1281            ),
1282            dylib_artifact_json(
1283                "shared",
1284                &[
1285                    target_dir.join("aarch64-unknown-linux-gnu/debug/libshared.so"),
1286                    build_dir.join("aarch64-unknown-linux-gnu/debug/deps/libshared.rlib"),
1287                ],
1288            ),
1289            bin_artifact_json(
1290                "mainbin",
1291                &target_dir.join("aarch64-unknown-linux-gnu/debug/mainbin"),
1292            ),
1293        ]
1294        .map(|artifact| artifact.to_string())
1295        .join("\n");
1296
1297        let binary_list =
1298            BinaryList::from_messages(input.as_bytes(), &PACKAGE_GRAPH_FIXTURE, build_platforms)
1299                .expect("parsing from messages succeeds");
1300
1301        assert_eq!(
1302            binary_list.rust_build_meta.non_test_binaries.to_summary(),
1303            BTreeMap::from([(
1304                PACKAGE_METADATA_ID.to_owned(),
1305                btreeset! {
1306                    RustNonTestBinarySummary {
1307                        name: "mainbin".to_owned(),
1308                        kind: RustNonTestBinaryKind::BIN_EXE,
1309                        path: "aarch64-unknown-linux-gnu/debug/mainbin".into(),
1310                        build_platform: Some(BuildPlatform::Target),
1311                    },
1312                    RustNonTestBinarySummary {
1313                        name: "shared".to_owned(),
1314                        kind: RustNonTestBinaryKind::DYLIB,
1315                        path: "aarch64-unknown-linux-gnu/debug/libshared.so".into(),
1316                        build_platform: Some(BuildPlatform::Target),
1317                    },
1318                    RustNonTestBinarySummary {
1319                        name: "shared".to_owned(),
1320                        kind: RustNonTestBinaryKind::DYLIB,
1321                        path: "debug/libshared.so".into(),
1322                        build_platform: Some(BuildPlatform::Host),
1323                    },
1324                },
1325            )]),
1326            "the two builds of one dylib target are told apart by the triple component of their \
1327             uplifted paths; the rlibs live in the fixture's build directory, which is not under \
1328             the target directory, so they are not recorded at all"
1329        );
1330    }
1331
1332    #[derive(Clone, Copy)]
1333    enum TestTarget {
1334        Yes,
1335        No,
1336    }
1337
1338    impl TestTarget {
1339        fn as_bool(self) -> bool {
1340            match self {
1341                Self::Yes => true,
1342                Self::No => false,
1343            }
1344        }
1345    }
1346
1347    fn bin_artifact_json(name: &str, path: &Utf8Path) -> serde_json::Value {
1348        artifact_json(
1349            name,
1350            &["bin"],
1351            &[path.to_owned()],
1352            Some(path),
1353            TestTarget::No,
1354        )
1355    }
1356
1357    fn dylib_artifact_json(name: &str, filenames: &[Utf8PathBuf]) -> serde_json::Value {
1358        artifact_json(name, &["dylib", "rlib"], filenames, None, TestTarget::No)
1359    }
1360
1361    fn artifact_json(
1362        name: &str,
1363        kind: &[&str],
1364        filenames: &[Utf8PathBuf],
1365        executable: Option<&Utf8Path>,
1366        test_target: TestTarget,
1367    ) -> serde_json::Value {
1368        let package = package_metadata();
1369        let src_path = package
1370            .manifest_path()
1371            .parent()
1372            .expect("manifest path has a parent")
1373            .join("src/lib.rs");
1374        let is_test = test_target.as_bool();
1375
1376        json!({
1377            "reason": "compiler-artifact",
1378            "package_id": PACKAGE_METADATA_ID,
1379            "manifest_path": package.manifest_path(),
1380            "target": {
1381                "name": name,
1382                "kind": kind,
1383                "crate_types": kind,
1384                "required-features": [],
1385                "src_path": src_path,
1386                "edition": "2021",
1387                "doctest": is_test,
1388                "test": is_test,
1389                "doc": is_test
1390            },
1391            "profile": {
1392                "opt_level": "0",
1393                "debuginfo": 0,
1394                "debug_assertions": true,
1395                "overflow_checks": true,
1396                "test": is_test
1397            },
1398            "features": [],
1399            "filenames": filenames,
1400            "executable": executable,
1401            "fresh": false
1402        })
1403    }
1404
1405    #[test]
1406    fn test_base_output_dir() {
1407        let cases: &[(&str, Option<&str>, &str)] = &[
1408            ("debug/deps/foo-9e6e6f7b", Some("debug"), "legacy layout"),
1409            (
1410                "aarch64-unknown-linux-gnu/debug/deps/foo-9e6e6f7b",
1411                Some("aarch64-unknown-linux-gnu/debug"),
1412                "legacy layout with a target triple",
1413            ),
1414            (
1415                "debug/build/metadata-helper/9e6e6f7b/out/foo",
1416                Some("debug"),
1417                "v2 layout",
1418            ),
1419            (
1420                "aarch64-unknown-linux-gnu/debug/build/metadata-helper/9e6e6f7b/out/foo",
1421                Some("aarch64-unknown-linux-gnu/debug"),
1422                "v2 layout with a target triple",
1423            ),
1424            (
1425                "debug/build/build/9e6e6f7b/out/foo",
1426                Some("debug"),
1427                "v2 layout, package named build",
1428            ),
1429            (
1430                "debug/examples/foo-9e6e6f7b",
1431                Some("debug"),
1432                "legacy layout, example test binary",
1433            ),
1434            (
1435                "aarch64-unknown-linux-gnu/debug/examples/foo-9e6e6f7b",
1436                Some("aarch64-unknown-linux-gnu/debug"),
1437                "legacy layout, example test binary with a target triple",
1438            ),
1439            (
1440                "debug/build/metadata-helper-9e6e6f7b/out/foo",
1441                None,
1442                "legacy build script out dir",
1443            ),
1444            (
1445                "aarch64-unknown-linux-gnu/debug/build/metadata-helper-9e6e6f7b/out/foo",
1446                None,
1447                "legacy build script out dir with a target triple",
1448            ),
1449            ("debug/foo", None, "uplifted into the profile directory"),
1450            (
1451                "out/foo",
1452                None,
1453                "out directory directly under the build directory",
1454            ),
1455            (
1456                "deps/foo",
1457                None,
1458                "deps directory directly under the build directory",
1459            ),
1460            (
1461                "examples/foo",
1462                None,
1463                "examples directory directly under the build directory",
1464            ),
1465            (
1466                "build/metadata-helper/9e6e6f7b/out/foo",
1467                None,
1468                "v2 build directory directly under the build directory",
1469            ),
1470        ];
1471
1472        for (rel_artifact_path, expected, description) in cases {
1473            assert_eq!(
1474                base_output_dir(Utf8Path::new(rel_artifact_path)),
1475                expected.map(Utf8Path::new),
1476                "{description}: base output dir for artifact path {rel_artifact_path}"
1477            );
1478        }
1479    }
1480}