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

Issue:
Be able to diff workspace crate against published crate version
It would be great to have an alternative to `--diff-git-checkouts` that instead of syncing a previous commit of the same repo (which can be a quite heavy and complex operation on a large repo) you can compare against a previously published crate version on crates.io. Have there been any thoughts of such an alternative?

So something like `--diff-published-crate x.y.z` which downloads the crate from crates.io (or the registry it was specified to use), unpacks it, and compares it from that.

We have a monorepo that has `rust-toolchain.toml` and lots of forked dependencies so syncing previous git versions and comparing that can be a bit problematic and slow. Also makes it harder if you have pending staged changes etc.

Pull request description:
Support `--diff-published your-crate@1.2.3` to diff against crates.io
Closes #124 

Example usage in our own git (note how it handles a crate part of a workspace) (output shortened for brevity):

```
$ cargo run -- -p rustdoc-json --diff-published rustdoc-json@0.4.2 --verbose         
Processing "/Users/martin/.cargo/cargo-public-api_build-root-for-published-crates/rustdoc-json-0.4.2/target/doc/rustdoc_json.json"
Processing "/Users/martin/src/cargo-public-api/target/doc/rustdoc_json.json"
Removed items from the public API
=================================
-pub const fn rustdoc_json::BuildOptions::all_features(self, all_features: bool) -> Self
-pub fn rustdoc_json::BuildOptions::target_dir(self, target_dir: impl AsRef<Path>) -> Self
-pub fn rustdoc_json::BuildOptions::toolchain(self, toolchain: impl Into<Option<String>>) -> Self

Changed items in the public API
===============================
(none)

Added items to the public API
=============================
+impl Unpin for rustdoc_json::BuildOptions
+impl UnwindSafe for rustdoc_json::BuildOptions
```

Patch:
diff --git a/Cargo.lock b/Cargo.lock
index bcf1831..2488323 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -93,6 +93,7 @@ dependencies = [
  "cargo_metadata",
  "clap",
  "diff",
+ "dirs",
  "nu-ansi-term",
  "predicates",
  "pretty_assertions",
@@ -183,6 +184,26 @@ version = "0.4.0"
 source = "registry+https://github.com/rust-lang/crates.io-index"
 checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8"
 
+[[package]]
+name = "dirs"
+version = "4.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca3aa72a6f96ea37bbc5aa912f6788242832f75369bdfdadcb0e38423f100059"
+dependencies = [
+ "dirs-sys",
+]
+
+[[package]]
+name = "dirs-sys"
+version = "0.3.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "1b1d1d91c932ef41c0f2663aa8b0ca0342d444d842c06914aa0a7e352d0bada6"
+dependencies = [
+ "libc",
+ "redox_users",
+ "winapi",
+]
+
 [[package]]
 name = "doc-comment"
 version = "0.3.3"
@@ -213,6 +234,17 @@ dependencies = [
  "num-traits",
 ]
 
+[[package]]
+name = "getrandom"
+version = "0.2.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c05aeb6a22b8f62540c194aac980f2115af067bfe15a0734d7277a768d396b31"
+dependencies = [
+ "cfg-if",
+ "libc",
+ "wasi",
+]
+
 [[package]]
 name = "hashbag"
 version = "0.1.6"
@@ -282,9 +314,9 @@ checksum = "e2abad23fbc42b3700f2f279844dc832adb2b2eb069b2df918f455c4e18cc646"
 
 [[package]]
 name = "libc"
-version = "0.2.119"
+version = "0.2.137"
 source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1bf2e165bb3457c8e098ea76f3e3bc9db55f87aa90d52d0e6be741470916aaa4"
+checksum = "fc7fcc620a3bff7cdd7a365be3376c97191aeaccc2a603e600951e452615bf89"
 
 [[package]]
 name = "memchr"
@@ -451,6 +483,17 @@ dependencies = [
  "bitflags",
 ]
 
+[[package]]
+name = "redox_users"
+version = "0.4.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b033d837a7cf162d7993aded9304e30a83213c648b6e389db233191f891e5c2b"
+dependencies = [
+ "getrandom",
+ "redox_syscall",
+ "thiserror",
+]
+
 [[package]]
 name = "regex"
 version = "1.5.5"
@@ -660,6 +703,12 @@ dependencies = [
  "libc",
 ]
 
+[[package]]
+name = "wasi"
+version = "0.11.0+wasi-snapshot-preview1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423"
+
 [[package]]
 name = "winapi"
 version = "0.3.9"
diff --git a/README.md b/README.md
index 857a1b0..d74f2da 100644
--- a/README.md
+++ b/README.md
@@ -59,6 +59,14 @@ which will print the diff of your public API changes compared to `origin/main`.
 
 This tool can be put to good use in CI pipelines to e.g. help you make sure your public API is not unexpectedly changed. Please see [CI-EXAMPLES.md](./docs/CI-EXAMPLES.md) for CI job configuration examples and use cases.
 
+### … Against Published Version
+
+Before you `cargo publish` a new version of your crate, you can diff the public API of your local code against the public API of the version you last published. Use the `--diff-published` arg for that. Like this:
+
+```bash
+cargo public-api --diff-published regex@0.2.2
+```
+
 ## Expected Output
 
 Output aims to be character-by-character identical to the textual parts of the regular `cargo doc` HTML output. For example, [this item](https://docs.rs/bat/0.20.0/bat/struct.PrettyPrinter.html#method.input_files) has the following textual representation in the rendered HTML:
diff --git a/cargo-public-api/Cargo.toml b/cargo-public-api/Cargo.toml
index 54388b8..bb846a0 100644
--- a/cargo-public-api/Cargo.toml
+++ b/cargo-public-api/Cargo.toml
@@ -16,6 +16,7 @@ anyhow = "1.0.53"
 atty = "0.2.14"
 clap = { version = "3.1.2", features = ["derive"] }
 diff = "0.1.12"
+dirs = "4.0.0"
 thiserror = "1.0.29"
 
 [dependencies.rustdoc-json]
diff --git a/cargo-public-api/src/main.rs b/cargo-public-api/src/main.rs
index 58f25ca..0d6aba9 100644
--- a/cargo-public-api/src/main.rs
+++ b/cargo-public-api/src/main.rs
@@ -17,6 +17,7 @@ mod arg_types;
 mod error;
 mod git_utils;
 mod plain;
+mod published_crate;
 mod toolchain;
 
 #[derive(Parser, Debug)]
@@ -79,8 +80,15 @@ pub struct Args {
     #[clap(long, min_values = 2, max_values = 2)]
     diff_rustdoc_json: Option<Vec<String>>,
 
+    /// Usage: --diff-published some-crate@1.2.3
+    ///
+    /// Diff the current API against the API in a published version.
+    #[clap(long)]
+    diff_published: Option<String>,
+
     /// Automatically resolves to either `--diff-git-checkouts` or
-    /// `--diff-rustdoc-json` depending on if args ends in `.json` or not.
+    /// `--diff-rustdoc-json` or `--diff-rustdoc-json` depending on if args ends
+    /// in `.json` or not, or if they contain `@`.
     ///
     /// Examples:
     ///
@@ -98,7 +106,15 @@ pub struct Args {
     ///
     ///   cargo public-api --diff-rustdoc-json v0.2.0.json v0.3.0.json
     ///
-    #[clap(long, min_values = 2, max_values = 2)]
+    /// and
+    ///
+    ///   cargo public-api --diff some-crate@1.2.3
+    ///
+    /// resolves to
+    ///
+    ///   cargo public-api --diff-published some-crate@1.2.3
+    ///
+    #[clap(long, min_values = 1, max_values = 2)]
     diff: Option<Vec<String>>,
 
     /// Usage: --rustdoc-json <RUSTDOC_JSON_PATH>
@@ -204,6 +220,12 @@ fn main_() -> Result<()> {
             files.get(0).unwrap(),
             files.get(1).unwrap(),
         )?
+    } else if let Some(package_spec) = &args.diff_published {
+        print_diff_between_two_rustdoc_json_files(
+            &args,
+            &published_crate::build_rustdoc_json(package_spec, &args)?,
+            &rustdoc_json_for_current_dir(&args)?,
+        )?
     } else if let Some(rustdoc_json) = &args.rustdoc_json {
         print_public_items_from_json(&args, rustdoc_json)?
     } else {
@@ -270,7 +292,7 @@ fn print_public_items(
 
 fn print_diff_between_two_commits(args: &Args, commits: &[String]) -> Result<PostProcessing> {
     let old_commit = commits.get(0).expect("clap makes sure first commit exist");
-    let new_commit = commits.get(1).expect("clap makes sure second commit exist");
+    let new_commit = commits.get(1).expect("missing second commit!");
 
     // Validate provided commits and resolve relative refs like HEAD to actual commits
     let old_commit = git_utils::resolve_ref(&args.git_root()?, old_commit)?;
@@ -365,6 +387,8 @@ fn resolve_diff_shorthand(args: &mut Args) {
 
         if diff_args.iter().all(is_json_file) {
             args.diff_rustdoc_json = Some(diff_args);
+        } else if diff_args.iter().any(|a| a.contains('@')) {
+            args.diff_published = diff_args.first().cloned();
         } else {
             args.diff_git_checkouts = Some(diff_args);
         }
diff --git a/cargo-public-api/src/published_crate.rs b/cargo-public-api/src/published_crate.rs
new file mode 100644
index 0000000..8328ae2
--- /dev/null
+++ b/cargo-public-api/src/published_crate.rs
@@ -0,0 +1,110 @@
+//! Creates a dummy project with a dependency on the crate we want to build
+//! rustdoc JSON for. We then build rustdoc JSON for the crate using this dummy
+//! project.
+
+use crate::Args;
+use anyhow::{anyhow, Result};
+use std::{fmt::Display, path::PathBuf};
+
+pub fn build_rustdoc_json(package_spec_str: &str, args: &Args) -> Result<PathBuf> {
+    let spec = PackageSpec::try_from(package_spec_str)?;
+
+    let build_dir = build_dir(&spec);
+    std::fs::create_dir_all(&build_dir)?;
+
+    let write_file = |name: &str, contents: &str| -> std::io::Result<PathBuf> {
+        let mut path = build_dir.clone();
+        path.push(name);
+        std::fs::write(&path, contents)?;
+        Ok(path)
+    };
+
+    write_file("lib.rs", "// empty lib")?;
+    let manifest = write_file("Cargo.toml", &manifest_for(&spec))?;
+
+    let builder = crate::builder_from_args(args)
+        .manifest_path(&manifest)
+        .package(&spec.name);
+    crate::build_rustdoc_json(builder)
+}
+
+/// Prefer a non-temporary dir so repeated builds can be incremental.
+fn build_dir(spec: &PackageSpec) -> PathBuf {
+    let mut build_dir = dirs::cache_dir().unwrap_or_else(std::env::temp_dir);
+    build_dir.push("cargo-public-api");
+    build_dir.push("build-root-for-published-crates");
+    build_dir.push(spec.as_dir_name());
+    build_dir
+}
+
+fn manifest_for(spec: &PackageSpec) -> String {
+    format!(
+        "\
+        [package]\n\
+        name = \"crate-downloader\"\n\
+        version = \"0.1.0\"\n\
+        edition = \"2021\"\n\
+        [lib]\n\
+        path = \"lib.rs\"\n\
+        [dependencies]\n\
+        {} = \"={}\"\n
+        ",
+        spec.name, spec.version
+    )
+}
+
+#[derive(Debug, PartialEq, Eq)]
+struct PackageSpec {
+    name: String,
+    version: String,
+}
+
+impl PackageSpec {
+    fn as_dir_name(&self) -> PathBuf {
+        PathBuf::from(format!("{}-{}", self.name, self.version))
+    }
+}
+
+impl Display for PackageSpec {
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        write!(f, "{}@{}", &self.name, &self.version)
+    }
+}
+
+impl TryFrom<&str> for PackageSpec {
+    type Error = anyhow::Error;
+
+    fn try_from(spec_str: &str) -> Result<Self, Self::Error> {
+        let mut split = spec_str.split('@');
+        let name = split.next().map(str::to_owned);
+        let version = split.next().map(str::to_owned);
+
+        match (name, version) {
+            (Some(name), Some(version)) if !name.is_empty() && !version.is_empty() => Ok(Self {
+                name,
+                version,
+            }),
+            _ => Err(anyhow!("Invalid format of package spec string. Use `crate-name@version`, e.g. `rustdoc-json@0.4.0`")),
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use super::*;
+
+    #[test]
+    fn test_parse_spec() {
+        assert!(PackageSpec::try_from("").is_err());
+        assert!(PackageSpec::try_from("@").is_err());
+        assert!(PackageSpec::try_from("foo@").is_err());
+        assert!(PackageSpec::try_from("@1.0.0").is_err());
+        assert_eq!(
+            PackageSpec::try_from("foo@1.0.0").unwrap(),
+            PackageSpec {
+                name: String::from("foo"),
+                version: String::from("1.0.0")
+            }
+        );
+    }
+}


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 75ca815..7833717 100644
--- a/cargo-public-api/tests/cargo-public-api-bin-tests.rs
+++ b/cargo-public-api/tests/cargo-public-api-bin-tests.rs
@@ -468,6 +468,29 @@ fn diff_public_items_from_files_impl(diff_arg: &str) {
         .success();
 }
 
+#[test]
+fn diff_published() {
+    diff_published_impl("--diff-published");
+}
+
+#[test]
+fn diff_published_smart_diff() {
+    diff_published_impl("--diff");
+}
+
+/// Diff against a published crate. Note that we diff two completely unrelated
+/// libraries. But for testing purposes, we only need to test that there IS a
+/// diff. It does not matter how it looks.
+fn diff_published_impl(diff_arg: &str) {
+    let mut cmd = TestCmd::new();
+    cmd.arg("--color=never");
+    cmd.arg(diff_arg);
+    cmd.arg("rustdoc-json@0.2.0");
+    cmd.assert()
+        .stdout_or_bless("./tests/expected-output/diff_published.txt")
+        .success();
+}
+
 #[test]
 fn list_public_items_from_json_file() {
     let json_file = rustdoc_json_path_for_crate("../test-apis/example_api-v0.3.0");
diff --git a/cargo-public-api/tests/expected-output/diff_published.txt b/cargo-public-api/tests/expected-output/diff_published.txt
new file mode 100644
index 0000000..0dfd411
--- /dev/null
+++ b/cargo-public-api/tests/expected-output/diff_published.txt
@@ -0,0 +1,54 @@
+Removed items from the public API
+=================================
+-impl !RefUnwindSafe for rustdoc_json::BuildError
+-impl !UnwindSafe for rustdoc_json::BuildError
+-impl Debug for rustdoc_json::BuildError
+-impl Display for rustdoc_json::BuildError
+-impl Error for rustdoc_json::BuildError
+-impl From<Error> for rustdoc_json::BuildError
+-impl From<Error> for rustdoc_json::BuildError
+-impl From<Error> for rustdoc_json::BuildError
+-impl Send for rustdoc_json::BuildError
+-impl Sync for rustdoc_json::BuildError
+-impl Unpin for rustdoc_json::BuildError
+-pub enum rustdoc_json::BuildError
+-pub enum variant rustdoc_json::BuildError::CargoMetadataError(cargo_metadata::Error)
+-pub enum variant rustdoc_json::BuildError::CargoTomlError(cargo_toml::Error)
+-pub enum variant rustdoc_json::BuildError::General(String)
+-pub enum variant rustdoc_json::BuildError::IoError(std::io::Error)
+-pub enum variant rustdoc_json::BuildError::VirtualManifest(PathBuf)
+-pub fn rustdoc_json::BuildError::fmt(&self, __formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result
+-pub fn rustdoc_json::BuildError::fmt(&self, f: &mut $crate::fmt::Formatter<'_>) -> $crate::fmt::Result
+-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::build(toolchain: impl AsRef<OsStr>, manifest_path: impl AsRef<Path>) -> Result<PathBuf, rustdoc_json::BuildError>
+-pub fn rustdoc_json::build_quietly(toolchain: impl AsRef<OsStr>, manifest_path: impl AsRef<Path>) -> Result<PathBuf, rustdoc_json::BuildError>
+-pub mod rustdoc_json
+
+Changed items in the public API
+===============================
+(none)
+
+Added items to the public API
+=============================
++#[non_exhaustive] pub struct example_api::Struct
++impl Debug for example_api::Struct
++impl RefUnwindSafe for example_api::Struct
++impl RefUnwindSafe for example_api::StructV2
++impl Send for example_api::Struct
++impl Send for example_api::StructV2
++impl Sync for example_api::Struct
++impl Sync for example_api::StructV2
++impl Unpin for example_api::Struct
++impl Unpin for example_api::StructV2
++impl UnwindSafe for example_api::Struct
++impl UnwindSafe for example_api::StructV2
++pub fn example_api::Struct::fmt(&self, f: &mut $crate::fmt::Formatter<'_>) -> $crate::fmt::Result
++pub mod example_api
++pub struct example_api::StructV2
++pub struct field example_api::Struct::v1_field: usize
++pub struct field example_api::Struct::v2_field: usize
++pub struct field example_api::StructV2::field: usize
+