rustdoc_json/
builder.rs

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