rustdoc_json/
builder.rs

1use super::BuildError;
2use cargo_metadata::TargetKind;
3use tracing::*;
4
5use std::io::Write;
6use std::{
7    path::{Path, PathBuf},
8    process::Command,
9};
10
11/// For development purposes only. Sometimes when you work on this project you
12/// want to quickly use a different toolchain to build rustdoc JSON. You can
13/// specify what toolchain, by temporarily changing this.
14const OVERRIDDEN_TOOLCHAIN: Option<&str> = option_env!("RUSTDOC_JSON_OVERRIDDEN_TOOLCHAIN_HACK"); // Some("nightly-2022-07-16");
15
16struct CaptureOutput<O, E> {
17    stdout: O,
18    stderr: E,
19}
20
21fn run_cargo_rustdoc<O, E>(
22    options: Builder,
23    capture_output: Option<CaptureOutput<O, E>>,
24) -> Result<PathBuf, BuildError>
25where
26    O: Write,
27    E: Write,
28{
29    let mut cmd = cargo_rustdoc_command(&options)?;
30    info!("Running {cmd:?}");
31
32    let status = match capture_output {
33        Some(CaptureOutput {
34            mut stdout,
35            mut stderr,
36        }) => {
37            let output = cmd.output().map_err(|e| {
38                BuildError::CommandExecutionError(format!("Failed to run `{cmd:?}`: {e}"))
39            })?;
40            stdout.write_all(&output.stdout).map_err(|e| {
41                BuildError::CapturedOutputError(format!("Failed to write stdout: {e}"))
42            })?;
43            stderr.write_all(&output.stderr).map_err(|e| {
44                BuildError::CapturedOutputError(format!("Failed to write stderr: {e}"))
45            })?;
46            output.status
47        }
48        None => cmd.status().map_err(|e| {
49            BuildError::CommandExecutionError(format!("Failed to run `{cmd:?}`: {e}"))
50        })?,
51    };
52
53    if status.success() {
54        rustdoc_json_path_for_manifest_path(
55            &options.manifest_path,
56            options.package.as_deref(),
57            &options.package_target,
58            options.target_dir.as_deref(),
59            options.target.as_deref(),
60        )
61    } else {
62        let manifest = cargo_manifest::Manifest::from_path(&options.manifest_path)?;
63        if manifest.package.is_none() && manifest.workspace.is_some() {
64            Err(BuildError::VirtualManifest(options.manifest_path))
65        } else {
66            Err(BuildError::BuildRustdocJsonError)
67        }
68    }
69}
70
71/// Construct the `cargo rustdoc` command to use for building rustdoc JSON. The
72/// command typically ends up looks something like this:
73/// ```bash
74/// cargo +nightly rustdoc --lib --manifest-path Cargo.toml -- -Z unstable-options --output-format json --cap-lints warn
75/// ```
76fn cargo_rustdoc_command(options: &Builder) -> Result<Command, BuildError> {
77    let Builder {
78        toolchain: requested_toolchain,
79        manifest_path,
80        target_dir,
81        target,
82        quiet,
83        silent,
84        color,
85        no_default_features,
86        all_features,
87        features,
88        package,
89        package_target,
90        document_private_items,
91        cap_lints,
92    } = options;
93
94    let mut command = match OVERRIDDEN_TOOLCHAIN.or(requested_toolchain.as_deref()) {
95        None => Command::new("cargo"),
96        Some(toolchain) => {
97            if !rustup_installed() {
98                return Err(BuildError::General(String::from(
99                    "required program rustup not found in PATH. Is it installed?",
100                )));
101            }
102            let mut cmd = Command::new("rustup");
103            cmd.args(["run", toolchain, "cargo"]);
104            cmd
105        }
106    };
107
108    command.arg("rustdoc");
109    match package_target {
110        PackageTarget::Lib => command.arg("--lib"),
111        PackageTarget::Bin(target) => command.args(["--bin", target]),
112        PackageTarget::Example(target) => command.args(["--example", target]),
113        PackageTarget::Test(target) => command.args(["--test", target]),
114        PackageTarget::Bench(target) => command.args(["--bench", target]),
115    };
116    if let Some(target_dir) = target_dir {
117        command.arg("--target-dir");
118        command.arg(target_dir);
119    }
120    if *quiet {
121        command.arg("--quiet");
122    }
123    if *silent {
124        command.stdout(std::process::Stdio::null());
125        command.stderr(std::process::Stdio::null());
126    }
127    match *color {
128        Color::Always => command.arg("--color").arg("always"),
129        Color::Never => command.arg("--color").arg("never"),
130        Color::Auto => command.arg("--color").arg("auto"),
131    };
132    command.arg("--manifest-path");
133    command.arg(manifest_path);
134    if let Some(target) = target {
135        command.arg("--target");
136        command.arg(target);
137    }
138    if *no_default_features {
139        command.arg("--no-default-features");
140    }
141    if *all_features {
142        command.arg("--all-features");
143    }
144    for feature in features {
145        command.args(["--features", feature]);
146    }
147    if let Some(package) = package {
148        command.args(["--package", package]);
149    }
150    command.arg("--");
151    command.args(["-Z", "unstable-options"]);
152    command.args(["--output-format", "json"]);
153    if *document_private_items {
154        command.arg("--document-private-items");
155    }
156    if let Some(cap_lints) = cap_lints {
157        command.args(["--cap-lints", cap_lints]);
158    }
159    Ok(command)
160}
161
162/// Returns `./target/doc/crate_name.json`. Also takes care of transforming
163/// `crate-name` to `crate_name`. Also handles `[lib] name = "foo"`.
164#[instrument(ret(level = Level::DEBUG))]
165fn rustdoc_json_path_for_manifest_path(
166    manifest_path: &Path,
167    package: Option<&str>,
168    package_target: &PackageTarget,
169    target_dir: Option<&Path>,
170    target: Option<&str>,
171) -> Result<PathBuf, BuildError> {
172    let target_dir = match target_dir {
173        Some(target_dir) => target_dir.to_owned(),
174        None => target_directory(manifest_path)?,
175    };
176
177    // get the name of the crate/binary/example/test/bench
178    let package_target_name = match package_target {
179        PackageTarget::Lib => library_name(manifest_path, package)?,
180        PackageTarget::Bin(name)
181        | PackageTarget::Example(name)
182        | PackageTarget::Test(name)
183        | PackageTarget::Bench(name) => name.clone(),
184    }
185    .replace('-', "_");
186
187    let mut rustdoc_json_path = target_dir;
188    // if one has specified a target explicitly then Cargo appends that target triple name as a subfolder
189    if let Some(target) = target {
190        rustdoc_json_path.push(target);
191    }
192    rustdoc_json_path.push("doc");
193    rustdoc_json_path.push(package_target_name);
194    rustdoc_json_path.set_extension("json");
195    Ok(rustdoc_json_path)
196}
197
198/// Checks if the `rustup` program can be found in `PATH`.
199pub fn rustup_installed() -> bool {
200    let mut check_rustup = std::process::Command::new("rustup");
201    check_rustup.arg("--version");
202    check_rustup.stdout(std::process::Stdio::null());
203    check_rustup.stderr(std::process::Stdio::null());
204    check_rustup.status().map(|s| s.success()).unwrap_or(false)
205}
206
207/// Typically returns the absolute path to the regular cargo `./target`
208/// directory. But also handles packages part of workspaces.
209fn target_directory(manifest_path: impl AsRef<Path>) -> Result<PathBuf, BuildError> {
210    let mut metadata_cmd = cargo_metadata::MetadataCommand::new();
211    metadata_cmd.manifest_path(manifest_path.as_ref());
212    let metadata = metadata_cmd.exec()?;
213    Ok(metadata.target_directory.as_std_path().to_owned())
214}
215
216/// Figures out the name of the library crate corresponding to the given
217/// `Cargo.toml` and `package_name` (in case Cargo.toml is a workspace root).
218fn library_name(
219    manifest_path: impl AsRef<Path>,
220    package_name: Option<&str>,
221) -> Result<String, BuildError> {
222    let package_name = if let Some(package_name) = package_name {
223        package_name.to_owned()
224    } else {
225        // We must figure out the package name ourselves from the manifest.
226        let manifest = cargo_manifest::Manifest::from_path(manifest_path.as_ref())?;
227        manifest
228            .package
229            .ok_or_else(|| BuildError::VirtualManifest(manifest_path.as_ref().to_owned()))?
230            .name
231            .to_owned()
232    };
233
234    let mut metadata_cmd = cargo_metadata::MetadataCommand::new();
235    metadata_cmd.manifest_path(manifest_path.as_ref());
236    let metadata = metadata_cmd.exec()?;
237
238    let package = metadata
239        .packages
240        .iter()
241        .find(|p| p.name == package_name)
242        .ok_or_else(|| BuildError::VirtualManifest(manifest_path.as_ref().to_owned()))?;
243
244    for target in &package.targets {
245        if target.kind.contains(&TargetKind::Lib) {
246            return Ok(target.name.to_owned());
247        }
248    }
249
250    Ok(package.name.clone())
251}
252
253/// Color configuration for the output of `cargo rustdoc`.
254#[derive(Clone, Copy, Debug)]
255pub enum Color {
256    /// Always output colors.
257    Always,
258    /// Never output colors.
259    Never,
260    /// Cargo will decide whether to output colors based on the tty type.
261    Auto,
262}
263
264/// Builds rustdoc JSON. There are many build options. Refer to the docs to
265/// learn about them all. See [top-level docs](crate) for an example on how to use this builder.
266#[derive(Clone, Debug)]
267pub struct Builder {
268    toolchain: Option<String>,
269    manifest_path: PathBuf,
270    target_dir: Option<PathBuf>,
271    target: Option<String>,
272    quiet: bool,
273    silent: bool,
274    color: Color,
275    no_default_features: bool,
276    all_features: bool,
277    features: Vec<String>,
278    package: Option<String>,
279    package_target: PackageTarget,
280    document_private_items: bool,
281    cap_lints: Option<String>,
282}
283
284impl Default for Builder {
285    fn default() -> Self {
286        Self {
287            toolchain: None,
288            manifest_path: PathBuf::from("Cargo.toml"),
289            target_dir: None,
290            target: None,
291            quiet: false,
292            silent: false,
293            color: Color::Auto,
294            no_default_features: false,
295            all_features: false,
296            features: vec![],
297            package: None,
298            package_target: PackageTarget::default(),
299            document_private_items: false,
300            cap_lints: Some(String::from("warn")),
301        }
302    }
303}
304
305impl Builder {
306    /// Set the toolchain. Default: `None`.
307    /// Until rustdoc JSON has stabilized, you will want to set this to
308    /// be `"nightly"` or similar.
309    ///
310    /// If the toolchain is set as `None`, the current active toolchain will be used.
311    ///
312    /// # Notes
313    ///
314    /// The currently active toolchain is typically specified by the
315    /// `RUSTUP_TOOLCHAIN` environment variable, which the rustup proxy
316    /// mechanism sets. See <https://rust-lang.github.io/rustup/overrides.html>
317    /// for more info on how the active toolchain is determined.
318    #[must_use]
319    pub fn toolchain(mut self, toolchain: impl Into<String>) -> Self {
320        self.toolchain = Some(toolchain.into());
321        self
322    }
323
324    /// Clear a toolchain previously set with [`Self::toolchain`].
325    #[must_use]
326    pub fn clear_toolchain(mut self) -> Self {
327        self.toolchain = None;
328        self
329    }
330
331    /// Set the relative or absolute path to `Cargo.toml`. Default: `Cargo.toml`
332    #[must_use]
333    pub fn manifest_path(mut self, manifest_path: impl AsRef<Path>) -> Self {
334        manifest_path.as_ref().clone_into(&mut self.manifest_path);
335        self
336    }
337
338    /// Set what `--target-dir` to pass to `cargo`. Typically only needed if you
339    /// want to be able to build rustdoc JSON for the same crate concurrently,
340    /// for example to parallelize regression tests.
341    #[must_use]
342    pub fn target_dir(mut self, target_dir: impl AsRef<Path>) -> Self {
343        self.target_dir = Some(target_dir.as_ref().to_owned());
344        self
345    }
346
347    /// Clear a target dir previously set with [`Self::target_dir`].
348    #[must_use]
349    pub fn clear_target_dir(mut self) -> Self {
350        self.target_dir = None;
351        self
352    }
353
354    /// Whether or not to pass `--quiet` to `cargo rustdoc`. Default: `false`
355    #[must_use]
356    pub const fn quiet(mut self, quiet: bool) -> Self {
357        self.quiet = quiet;
358        self
359    }
360
361    /// Whether or not to redirect stdout and stderr to /dev/null. Default: `false`
362    #[must_use]
363    pub const fn silent(mut self, silent: bool) -> Self {
364        self.silent = silent;
365        self
366    }
367
368    /// Color configuration for the output of `cargo rustdoc`.
369    #[must_use]
370    pub const fn color(mut self, color: Color) -> Self {
371        self.color = color;
372        self
373    }
374
375    /// Whether or not to pass `--target` to `cargo rustdoc`. Default: `None`
376    #[must_use]
377    pub fn target(mut self, target: String) -> Self {
378        self.target = Some(target);
379        self
380    }
381
382    /// Whether to pass `--no-default-features` to `cargo rustdoc`. Default: `false`
383    #[must_use]
384    pub const fn no_default_features(mut self, no_default_features: bool) -> Self {
385        self.no_default_features = no_default_features;
386        self
387    }
388
389    /// Whether to pass `--all-features` to `cargo rustdoc`. Default: `false`
390    #[must_use]
391    pub const fn all_features(mut self, all_features: bool) -> Self {
392        self.all_features = all_features;
393        self
394    }
395
396    /// Features to pass to `cargo rustdoc` via `--features`. Default to an empty vector
397    #[must_use]
398    pub fn features<I: IntoIterator<Item = S>, S: AsRef<str>>(mut self, features: I) -> Self {
399        self.features = features
400            .into_iter()
401            .map(|item| item.as_ref().to_owned())
402            .collect();
403        self
404    }
405
406    /// Package to use for `cargo rustdoc` via `-p`. Default: `None`
407    #[must_use]
408    pub fn package(mut self, package: impl AsRef<str>) -> Self {
409        self.package = Some(package.as_ref().to_owned());
410        self
411    }
412
413    /// What part of the package to document. Default: `PackageTarget::Lib`
414    #[must_use]
415    pub fn package_target(mut self, package_target: PackageTarget) -> Self {
416        self.package_target = package_target;
417        self
418    }
419
420    /// Whether to pass `--document-private-items` to `cargo rustdoc`. Default: `false`
421    #[must_use]
422    pub fn document_private_items(mut self, document_private_items: bool) -> Self {
423        self.document_private_items = document_private_items;
424        self
425    }
426
427    /// What to pass as `--cap-lints` to rustdoc JSON build command
428    #[must_use]
429    pub fn cap_lints(mut self, cap_lints: Option<impl AsRef<str>>) -> Self {
430        self.cap_lints = cap_lints.map(|c| c.as_ref().to_owned());
431        self
432    }
433
434    /// Generate rustdoc JSON for a crate. Returns the path to the freshly
435    /// built rustdoc JSON file.
436    ///
437    /// This method will print the stdout and stderr of the `cargo rustdoc` command to the stdout
438    /// and stderr of the calling process. If you want to capture the output, use
439    /// [`Builder::build_with_captured_output()`].
440    ///
441    /// See [top-level docs](crate) for an example on how to use it.
442    ///
443    /// # Errors
444    ///
445    /// E.g. if building the JSON fails or if the manifest path does not exist or is
446    /// invalid.
447    pub fn build(self) -> Result<PathBuf, BuildError> {
448        run_cargo_rustdoc::<std::io::Sink, std::io::Sink>(self, None)
449    }
450
451    /// Generate rustdoc JSON for a crate. This works like [`Builder::build()`], but will
452    /// capture the stdout and stderr of the `cargo rustdoc` command. The output will be written to
453    /// the `stdout` and `stderr` parameters. In particular, potential warnings and errors emitted
454    /// by `cargo rustdoc` will be captured to `stderr`. This can be useful if you want to present
455    /// these errors to the user only when the build failed. Here's an example of how that might
456    /// look like:
457    ///
458    /// ```no_run
459    /// # use std::path::PathBuf;
460    /// # use rustdoc_json::BuildError;
461    /// #
462    /// let mut stderr: Vec<u8> = Vec::new();
463    ///
464    /// let result: Result<PathBuf, BuildError> = rustdoc_json::Builder::default()
465    ///     .toolchain("nightly")
466    ///     .manifest_path("Cargo.toml")
467    ///     .build_with_captured_output(std::io::sink(), &mut stderr);
468    ///
469    /// match result {
470    ///     Err(BuildError::BuildRustdocJsonError) => {
471    ///         eprintln!("Crate failed to build:\n{}", String::from_utf8_lossy(&stderr));
472    ///     }
473    ///     Err(e) => {
474    ///        eprintln!("Error generating the rustdoc json: {}", e);
475    ///     }
476    ///     Ok(json_path) => {
477    ///         // Do something with the json_path.
478    ///     }
479    /// }
480    /// ```
481    pub fn build_with_captured_output(
482        self,
483        stdout: impl Write,
484        stderr: impl Write,
485    ) -> Result<PathBuf, BuildError> {
486        let capture_output = CaptureOutput { stdout, stderr };
487        run_cargo_rustdoc(self, Some(capture_output))
488    }
489}
490
491/// The part of the package to document
492#[derive(Default, Debug, Clone)]
493#[non_exhaustive]
494pub enum PackageTarget {
495    /// Document the package as a library, i.e. pass `--lib`
496    #[default]
497    Lib,
498    /// Document the given binary, i.e. pass `--bin <name>`
499    Bin(String),
500    /// Document the given binary, i.e. pass `--example <name>`
501    Example(String),
502    /// Document the given binary, i.e. pass `--test <name>`
503    Test(String),
504    /// Document the given binary, i.e. pass `--bench <name>`
505    Bench(String),
506}
507
508#[cfg(test)]
509mod tests {
510    use super::*;
511
512    #[test]
513    fn ensure_toolchain_not_overridden() {
514        // The override is only meant to be changed locally, do not git commit!
515        // If the var is set from the env var, that's OK, so skip the check in
516        // that case.
517        if option_env!("RUSTDOC_JSON_OVERRIDDEN_TOOLCHAIN_HACK").is_none() {
518            assert!(OVERRIDDEN_TOOLCHAIN.is_none());
519        }
520    }
521}