Repository: Enselic/cargo-public-api (rust)

Issue:
Be able to enable `--features` / `--all-features` / `--no-default-features`
Would it be possible to add support for the Cargo `--features` / `--all-features` / `--no-default-features` options? Pipe them through to the underlying Cargo invocation.

We have multiple crates where we are both interested in the public API with `--all-features` enabled and also some where one wants to check for specific `--features`.

For example our [`puffin`](https://github.com/EmbarkStudios/puffin) crate has a mutually exclusive features (not ideal) and fails to analyse:

```
$ cargo public-api --manifest-path ./puffin/Cargo.toml
    Checking once_cell v1.9.0
    Checking byteorder v1.4.3
 Documenting puffin v0.12.1 (/Users/repi/git/embark/puffin/puffin)
error: Either feature zstd or ruzstd must be enabled
   --> puffin/src/frame_data.rs:491:1
    |
491 | compile_error!("Either feature zstd or ruzstd must be enabled");
    | ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

error: Compilation failed, aborting rustdoc

error: could not document `puffin`

Caused by:
  process didn't exit successfully: `rustdoc --edition=2018 --crate-type lib --crate-name puffin puffin/src/lib.rs -o /Users/repi/git/embark/puffin/target/doc --cfg 'feature="default"' --error-format=json --json=diagnostic-rendered-ansi,artifacts,future-incompat -Z unstable-options --output-format json --cap-lints warn -C metadata=00aac93aa62b9ad4 -L dependency=/Users/repi/git/embark/puffin/target/debug/deps --extern byteorder=/Users/repi/git/embark/puffin/target/debug/deps/libbyteorder-922739cc82a0baa2.rmeta --extern once_cell=/Users/repi/git/embark/puffin/target/debug/deps/libonce_cell-b334803526b5c51d.rmeta --crate-version 0.12.1` (exit status: 1)
Error: Failed to build rustdoc JSON. Stderr: See above
```

Pull request description:
allow specifying features to cargo rustdoc
enables `--features` / `--all-features` / `--no-default-features`

resolves #100


Patch:
diff --git a/Cargo.lock b/Cargo.lock
index ef839f8..a8d57a2 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -100,6 +100,7 @@ dependencies = [
  "anyhow",
  "assert_cmd",
  "atty",
+ "cargo_metadata",
  "clap",
  "diff",
  "predicates",
@@ -528,9 +529,9 @@ dependencies = [
 
 [[package]]
 name = "serde_json"
-version = "1.0.78"
+version = "1.0.85"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d23c1ba4cf0efd44be32017709280b32d1cea5c3f1275c3b6d9e8bc54f758085"
+checksum = "e55a28e3aaef9d5ce0506d0a14dbba8054ddc7e499ef522dd8b26859ec9d4a44"
 dependencies = [
  "itoa",
  "ryu",
diff --git a/Cargo.toml b/Cargo.toml
index 62aa1e9..8144b01 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -27,4 +27,7 @@ exclude = [
 
     # To test that we pass --cap-lints when building rustdoc JSON
     "test-apis/lint_error",
+
+    # Test for feature flags
+    "test-apis/features",
 ]
diff --git a/cargo-public-api/Cargo.toml b/cargo-public-api/Cargo.toml
index f4479c7..8dd3665 100644
--- a/cargo-public-api/Cargo.toml
+++ b/cargo-public-api/Cargo.toml
@@ -30,3 +30,4 @@ version = "0.15.0"
 assert_cmd = "2.0.4"
 predicates = "2.1.1"
 tempfile = "3.3.0"
+cargo_metadata = "0.14.2"
diff --git a/cargo-public-api/src/lib.rs b/cargo-public-api/src/lib.rs
index cacc998..6f525be 100644
--- a/cargo-public-api/src/lib.rs
+++ b/cargo-public-api/src/lib.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 /// For self-testing purposes. Please ignore.
 pub fn for_self_testing_purposes_please_ignore() {
     println!("If you are looking for the library this tool uses, it can be found at https://github.com/Enselic/cargo-public-api/tree/main/public-api");
diff --git a/cargo-public-api/src/main.rs b/cargo-public-api/src/main.rs
index 074a483..6613696 100644
--- a/cargo-public-api/src/main.rs
+++ b/cargo-public-api/src/main.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 use std::io::stdout;
 use std::path::{Path, PathBuf};
 
@@ -24,6 +21,7 @@ mod plain;
 
 #[derive(Parser, Debug)]
 #[clap(author, version, about, long_about = None)]
+#[allow(clippy::struct_excessive_bools)]
 pub struct Args {
     /// Path to `Cargo.toml`.
     #[clap(long, name = "PATH", default_value = "Cargo.toml", parse(from_os_str))]
@@ -120,6 +118,18 @@ pub struct Args {
     /// Build for the target triple
     #[clap(long)]
     target: Option<String>,
+
+    /// Space or comma separated list of features to activate
+    #[clap(long, short = 'F', min_values = 1)]
+    features: Vec<String>,
+
+    #[clap(long)]
+    /// Activate all available features
+    all_features: bool,
+
+    #[clap(long)]
+    /// Do not activate the `default` feature
+    no_default_features: bool,
 }
 
 /// After listing or diffing, we might want to do some extra work. This struct
@@ -301,7 +311,10 @@ fn collect_public_api_from_commit(commit: Option<&str>) -> Result<(PublicApi, Op
 
     let mut build_options = BuildOptions::default()
         .toolchain(&args.toolchain)
-        .manifest_path(&args.manifest_path);
+        .manifest_path(&args.manifest_path)
+        .all_features(args.all_features)
+        .no_default_features(args.no_default_features)
+        .features(&args.features);
     if let Some(target) = &args.target {
         build_options = build_options.target(target.clone());
     }
diff --git a/public-api/src/lib.rs b/public-api/src/lib.rs
index 91bde89..a7a4374 100644
--- a/public-api/src/lib.rs
+++ b/public-api/src/lib.rs
@@ -36,9 +36,6 @@
 //! in the thin binary wrapper around the library, see
 //! <https://github.com/Enselic/cargo-public-api/blob/main/public-api/src/main.rs>.
 
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic, missing_docs)]
-
 mod error;
 mod intermediate_public_item;
 mod item_iterator;
diff --git a/public-api/src/main.rs b/public-api/src/main.rs
index ef60508..897f183 100644
--- a/public-api/src/main.rs
+++ b/public-api/src/main.rs
@@ -1,9 +1,6 @@
 //! Simple wrapper around the library. For a much more sophisticated CLI, see
 //! <https://github.com/Enselic/cargo-public-api/blob/main/cargo-public-api/src/main.rs>.
 
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 use std::io::{stdout, ErrorKind, Write};
 use std::path::{Path, PathBuf};
 
diff --git a/rustdoc-json/public-api.txt b/rustdoc-json/public-api.txt
index bfc96a3..681a931 100644
--- a/rustdoc-json/public-api.txt
+++ b/rustdoc-json/public-api.txt
@@ -10,9 +10,12 @@ pub fn rustdoc_json::BuildError::from(source: cargo_metadata::Error) -> Self
 pub fn rustdoc_json::BuildError::from(source: cargo_toml::Error) -> Self
 pub fn rustdoc_json::BuildError::from(source: std::io::Error) -> Self
 pub fn rustdoc_json::BuildError::source(&self) -> std::option::Option<&(dyn std::error::Error + 'static)>
+pub fn rustdoc_json::BuildOptions::all_features(self, all_features: bool) -> Self
 pub fn rustdoc_json::BuildOptions::default() -> Self
+pub fn rustdoc_json::BuildOptions::features<I: IntoIterator<Item = S>, S: AsRef<str>>(self, features: I) -> Self
 pub fn rustdoc_json::BuildOptions::fmt(&self, f: &mut $crate::fmt::Formatter<'_>) -> $crate::fmt::Result
 pub fn rustdoc_json::BuildOptions::manifest_path(self, manifest_path: impl AsRef<Path>) -> Self
+pub fn rustdoc_json::BuildOptions::no_default_features(self, no_default_features: bool) -> Self
 pub fn rustdoc_json::BuildOptions::quiet(self, quiet: bool) -> Self
 pub fn rustdoc_json::BuildOptions::target(self, target: String) -> Self
 pub fn rustdoc_json::BuildOptions::toolchain(self, toolchain: impl AsRef<OsStr>) -> Self
diff --git a/rustdoc-json/src/build.rs b/rustdoc-json/src/build.rs
index 38a9acb..ca4da57 100644
--- a/rustdoc-json/src/build.rs
+++ b/rustdoc-json/src/build.rs
@@ -15,13 +15,7 @@ const OVERRIDDEN_TOOLCHAIN: Option<&str> = option_env!("RUSTDOC_JSON_OVERRIDDEN_
 /// Run `cargo rustdoc` to produce rustdoc JSON and return the path to the built
 /// file.
 pub(crate) fn run_cargo_rustdoc(options: BuildOptions) -> Result<PathBuf, BuildError> {
-    let status = cargo_rustdoc_command(
-        options.toolchain.as_deref(),
-        &options.manifest_path,
-        options.target.as_deref(),
-        options.quiet,
-    )
-    .status()?;
+    let status = cargo_rustdoc_command(&options).status()?;
     if status.success() {
         rustdoc_json_path_for_manifest_path(options.manifest_path, options.target.as_deref())
     } else {
@@ -39,12 +33,16 @@ pub(crate) fn run_cargo_rustdoc(options: BuildOptions) -> Result<PathBuf, BuildE
 /// ```bash
 /// cargo +nightly rustdoc --lib --manifest-path Cargo.toml -- -Z unstable-options --output-format json --cap-lints warn
 /// ```
-fn cargo_rustdoc_command(
-    requested_toolchain: Option<&OsStr>,
-    manifest_path: impl AsRef<Path>,
-    target: Option<&str>,
-    quiet: bool,
-) -> Command {
+fn cargo_rustdoc_command(options: &BuildOptions) -> Command {
+    let BuildOptions {
+        toolchain: requested_toolchain,
+        manifest_path,
+        target,
+        quiet,
+        no_default_features,
+        all_features,
+        features,
+    } = options;
     let mut command = Command::new("cargo");
 
     // These can override our `+nightly` with `+stable` unless we clear them
@@ -52,21 +50,30 @@ fn cargo_rustdoc_command(
     command.env_remove("RUSTC");
 
     let overridden_toolchain = OVERRIDDEN_TOOLCHAIN.map(OsStr::new);
-    if let Some(toolchain) = overridden_toolchain.or(requested_toolchain) {
+    if let Some(toolchain) = overridden_toolchain.or(requested_toolchain.as_deref()) {
         command.arg(toolchain);
     }
 
     command.arg("rustdoc");
     command.arg("--lib");
-    if quiet {
+    if *quiet {
         command.arg("--quiet");
     }
     command.arg("--manifest-path");
-    command.arg(manifest_path.as_ref());
+    command.arg(manifest_path);
     if let Some(target) = target {
         command.arg("--target");
         command.arg(target);
     }
+    if *no_default_features {
+        command.arg("--no-default-features");
+    }
+    if *all_features {
+        command.arg("--all-features");
+    }
+    for feature in features {
+        command.args(["--features", &*feature]);
+    }
     command.arg("--");
     command.args(["-Z", "unstable-options"]);
     command.args(["--output-format", "json"]);
@@ -120,6 +127,9 @@ impl Default for BuildOptions {
             manifest_path: PathBuf::from("Cargo.toml"),
             target: None,
             quiet: false,
+            no_default_features: false,
+            all_features: false,
+            features: vec![],
         }
     }
 }
@@ -154,6 +164,30 @@ impl BuildOptions {
         self.target = Some(target);
         self
     }
+
+    /// Whether to pass `--no-default-features` to `cargo rustdoc`. Default: `false`
+    #[must_use]
+    pub fn no_default_features(mut self, no_default_features: bool) -> Self {
+        self.no_default_features = no_default_features;
+        self
+    }
+
+    /// Whether to pass `--all-features` to `cargo rustdoc`. Default: `false`
+    #[must_use]
+    pub fn all_features(mut self, all_features: bool) -> Self {
+        self.all_features = all_features;
+        self
+    }
+
+    /// Features to pass to `cargo rustdoc` via `--features`. Default to an empty vector
+    #[must_use]
+    pub fn features<I: IntoIterator<Item = S>, S: AsRef<str>>(mut self, features: I) -> Self {
+        self.features = features
+            .into_iter()
+            .map(|item| item.as_ref().to_owned())
+            .collect();
+        self
+    }
 }
 
 #[cfg(test)]
diff --git a/rustdoc-json/src/lib.rs b/rustdoc-json/src/lib.rs
index 9769347..d9e27f5 100644
--- a/rustdoc-json/src/lib.rs
+++ b/rustdoc-json/src/lib.rs
@@ -18,9 +18,6 @@
 //! A compilable example can be found
 //! [here](https://github.com/Enselic/cargo-public-api/blob/main/rustdoc-json/examples/build-rustdoc-json.rs)
 
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic, missing_docs)]
-
 use std::{ffi::OsString, path::PathBuf};
 
 mod build;
@@ -59,6 +56,9 @@ pub struct BuildOptions {
     manifest_path: std::path::PathBuf,
     target: Option<String>,
     quiet: bool,
+    no_default_features: bool,
+    all_features: bool,
+    features: Vec<String>,
 }
 
 /// Generate rustdoc JSON for a library crate. Returns the path to the freshly


Test patch:
diff --git a/cargo-public-api/tests/cargo-public-api-bin-tests.rs b/cargo-public-api/tests/cargo-public-api-bin-tests.rs
index c8e505e..913611e 100644
--- a/cargo-public-api/tests/cargo-public-api-bin-tests.rs
+++ b/cargo-public-api/tests/cargo-public-api-bin-tests.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 //! To update expected output it is in many cases sufficient to run
 //! ```bash
 //! ./scripts/bless-expected-output-for-tests.sh
@@ -563,6 +560,95 @@ fn initialize_test_repo(dest: &Path) {
     assert!(cmd.spawn().unwrap().wait().unwrap().success());
 }
 
+#[test]
+fn cargo_public_api_with_features() -> Result<(), Box<dyn std::error::Error>> {
+    #[derive(Debug)]
+    struct F<'a> {
+        all: bool,
+        none: bool,
+        features: &'a [&'a str],
+    }
+
+    impl<'a> F<'a> {
+        fn none(mut self) -> Self {
+            self.none = true;
+            self
+        }
+        fn all(mut self) -> Self {
+            self.all = true;
+            self
+        }
+        fn new(features: &'a [&'a str]) -> Self {
+            F {
+                all: false,
+                none: false,
+                features,
+            }
+        }
+    }
+
+    impl std::fmt::Display for F<'_> {
+        fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+            if self.all {
+                write!(f, "all")?;
+            }
+            if self.none {
+                write!(f, "none")?;
+            }
+            for feat in self.features {
+                write!(f, "{feat}")?;
+            }
+            Ok(())
+        }
+    }
+
+    let root = cargo_metadata::MetadataCommand::new()
+        .no_deps()
+        .exec()?
+        .workspace_root;
+
+    for features in [
+        F::new(&[]).all(),
+        F::new(&[]).none(),
+        F::new(&["feature_a", "feature_b", "feature_c"]).none(),
+        F::new(&["feature_b"]).none(),
+        F::new(&["feature_c"]).none(), // includes `feature_b`
+    ] {
+        let expected_file = root.join(format!(
+            "cargo-public-api/tests/expected-output/features-feat{features}.txt"
+        ));
+
+        let mut cmd = Command::cargo_bin("cargo-public-api").unwrap();
+        cmd.current_dir(root.join("test-apis/features"));
+
+        if features.none {
+            cmd.arg("--no-default-features");
+        }
+
+        if features.all {
+            cmd.arg("--all-features");
+        }
+
+        for feature in features.features {
+            cmd.args(["--features", feature]);
+        }
+
+        if std::env::var("BLESS").is_ok() {
+            let out = cmd.output().unwrap();
+            std::fs::write(expected_file, out.stdout).unwrap();
+        } else {
+            // Make into a string to show diff
+            let expected = String::from_utf8(
+                std::fs::read(&expected_file)
+                    .unwrap_or_else(|_| panic!("couldn't read file: {expected_file:?}")),
+            )
+            .unwrap();
+            cmd.assert().stdout(expected).success();
+        }
+    }
+    Ok(())
+}
+
 /// A git repository that lives during the duration of a test. Having each test
 /// have its own git repository to test with makes tests runnable concurrently.
 struct TestRepo {
diff --git a/cargo-public-api/tests/cargo-public-api-readme-tests.rs b/cargo-public-api/tests/cargo-public-api-readme-tests.rs
index 1cd46f6..576065d 100644
--- a/cargo-public-api/tests/cargo-public-api-readme-tests.rs
+++ b/cargo-public-api/tests/cargo-public-api-readme-tests.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 use public_api::MINIMUM_RUSTDOC_JSON_VERSION;
 
 #[test]
diff --git a/cargo-public-api/tests/expected-output/features-featall.txt b/cargo-public-api/tests/expected-output/features-featall.txt
new file mode 100644
index 0000000..c817467
--- /dev/null
+++ b/cargo-public-api/tests/expected-output/features-featall.txt
@@ -0,0 +1,5 @@
+#[non_exhaustive] pub struct features::AStruct
+pub mod features
+pub struct field features::AStruct::feature_a: ()
+pub struct field features::AStruct::feature_b: ()
+pub struct field features::AStruct::feature_c: ()
diff --git a/cargo-public-api/tests/expected-output/features-featnone.txt b/cargo-public-api/tests/expected-output/features-featnone.txt
new file mode 100644
index 0000000..73d9443
--- /dev/null
+++ b/cargo-public-api/tests/expected-output/features-featnone.txt
@@ -0,0 +1,2 @@
+#[non_exhaustive] pub struct features::AStruct
+pub mod features
diff --git a/cargo-public-api/tests/expected-output/features-featnonefeature_afeature_bfeature_c.txt b/cargo-public-api/tests/expected-output/features-featnonefeature_afeature_bfeature_c.txt
new file mode 100644
index 0000000..c817467
--- /dev/null
+++ b/cargo-public-api/tests/expected-output/features-featnonefeature_afeature_bfeature_c.txt
@@ -0,0 +1,5 @@
+#[non_exhaustive] pub struct features::AStruct
+pub mod features
+pub struct field features::AStruct::feature_a: ()
+pub struct field features::AStruct::feature_b: ()
+pub struct field features::AStruct::feature_c: ()
diff --git a/cargo-public-api/tests/expected-output/features-featnonefeature_b.txt b/cargo-public-api/tests/expected-output/features-featnonefeature_b.txt
new file mode 100644
index 0000000..bfc9da2
--- /dev/null
+++ b/cargo-public-api/tests/expected-output/features-featnonefeature_b.txt
@@ -0,0 +1,3 @@
+#[non_exhaustive] pub struct features::AStruct
+pub mod features
+pub struct field features::AStruct::feature_b: ()
diff --git a/cargo-public-api/tests/expected-output/features-featnonefeature_c.txt b/cargo-public-api/tests/expected-output/features-featnonefeature_c.txt
new file mode 100644
index 0000000..26786ca
--- /dev/null
+++ b/cargo-public-api/tests/expected-output/features-featnonefeature_c.txt
@@ -0,0 +1,3 @@
+#[non_exhaustive] pub struct features::AStruct
+pub mod features
+pub struct field features::AStruct::feature_c: ()
diff --git a/public-api/tests/public-api-bin-tests.rs b/public-api/tests/public-api-bin-tests.rs
index bfa4ae4..724255b 100644
--- a/public-api/tests/public-api-bin-tests.rs
+++ b/public-api/tests/public-api-bin-tests.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 use std::{io::BufRead, str::from_utf8};
 
 use assert_cmd::Command;
diff --git a/public-api/tests/public-api-lib-tests.rs b/public-api/tests/public-api-lib-tests.rs
index df7eb16..087ac58 100644
--- a/public-api/tests/public-api-lib-tests.rs
+++ b/public-api/tests/public-api-lib-tests.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 use std::fmt::Display;
 
 use pretty_assertions::assert_eq;
diff --git a/public-api/tests/public-api-readme-tests.rs b/public-api/tests/public-api-readme-tests.rs
index d415eab..88211e6 100644
--- a/public-api/tests/public-api-readme-tests.rs
+++ b/public-api/tests/public-api-readme-tests.rs
@@ -1,6 +1,3 @@
-// deny in CI, only warn here
-#![warn(clippy::all, clippy::pedantic)]
-
 use public_api::MINIMUM_RUSTDOC_JSON_VERSION;
 
 #[test]
diff --git a/scripts/bless-expected-output-for-tests.sh b/scripts/bless-expected-output-for-tests.sh
index 32cf7c3..3812f33 100755
--- a/scripts/bless-expected-output-for-tests.sh
+++ b/scripts/bless-expected-output-for-tests.sh
@@ -12,6 +12,8 @@ for crate in comprehensive_api comprehensive_api_proc_macro; do
     cargo run -p public-api -- "./test-apis/${crate}/target/doc/${crate}.json" > "public-api/tests/expected-output/${crate}.txt"
 done
 
+BLESS=1 RUSTDOC_JSON_OVERRIDDEN_TOOLCHAIN_HACK=${toolchain} cargo test -- cargo_public_api_with_features
+
 RUSTDOC_JSON_OVERRIDDEN_TOOLCHAIN_HACK=${toolchain} cargo run -p public-api -- --with-blanket-implementations "./public-api/tests/rustdoc-json/example_api-v0.2.0.json" > \
       "public-api/tests/expected-output/example_api-v0.2.0-with-blanket-implementations.txt"
 
diff --git a/test-apis/features/Cargo.toml b/test-apis/features/Cargo.toml
new file mode 100644
index 0000000..87158b9
--- /dev/null
+++ b/test-apis/features/Cargo.toml
@@ -0,0 +1,12 @@
+[package]
+name = "features"
+version = "0.1.0"
+edition = "2021"
+
+[dependencies]
+
+[features]
+default = ["feature_a"]
+feature_a = []
+feature_b = []
+feature_c = []
diff --git a/test-apis/features/src/lib.rs b/test-apis/features/src/lib.rs
new file mode 100644
index 0000000..07c4c0e
--- /dev/null
+++ b/test-apis/features/src/lib.rs
@@ -0,0 +1,9 @@
+#[non_exhaustive]
+pub struct AStruct {
+    #[cfg(feature = "feature_a")]
+    pub feature_a: (),
+    #[cfg(feature = "feature_b")]
+    pub feature_b: (),
+    #[cfg(feature = "feature_c")]
+    pub feature_c: (),
+}