From d1b2d29528b7794b41e66fc2136e395a02f8529b Mon Sep 17 00:00:00 2001 From: Daniel Baumann Date: Thu, 30 May 2024 05:59:35 +0200 Subject: Merging upstream version 1.73.0+dfsg1. Signed-off-by: Daniel Baumann --- src/tools/cargo/crates/cargo-platform/Cargo.toml | 6 +- src/tools/cargo/crates/cargo-test-macro/Cargo.toml | 4 +- .../cargo/crates/cargo-test-support/Cargo.toml | 4 +- .../cargo-test-support/containers/sshd/Dockerfile | 2 +- .../cargo/crates/cargo-test-support/src/compare.rs | 1 + .../crates/cargo-test-support/src/registry.rs | 36 ++ src/tools/cargo/crates/cargo-util/Cargo.toml | 8 +- src/tools/cargo/crates/cargo-util/src/paths.rs | 20 +- .../cargo/crates/cargo-util/src/process_builder.rs | 4 +- src/tools/cargo/crates/crates-io/Cargo.toml | 8 +- src/tools/cargo/crates/crates-io/lib.rs | 167 ++++---- src/tools/cargo/crates/home/Cargo.toml | 7 +- src/tools/cargo/crates/mdman/Cargo.toml | 4 +- src/tools/cargo/crates/resolver-tests/Cargo.toml | 2 +- src/tools/cargo/crates/semver-check/Cargo.toml | 2 +- src/tools/cargo/crates/semver-check/src/main.rs | 13 +- src/tools/cargo/crates/xtask-build-man/Cargo.toml | 2 +- src/tools/cargo/crates/xtask-bump-check/Cargo.toml | 14 + .../cargo/crates/xtask-bump-check/src/main.rs | 27 ++ .../cargo/crates/xtask-bump-check/src/xtask.rs | 423 +++++++++++++++++++++ .../cargo/crates/xtask-stale-label/Cargo.toml | 2 +- .../cargo/crates/xtask-stale-label/src/main.rs | 2 +- .../cargo/crates/xtask-unpublished/Cargo.toml | 12 - .../cargo/crates/xtask-unpublished/src/main.rs | 15 - .../cargo/crates/xtask-unpublished/src/xtask.rs | 200 ---------- 25 files changed, 630 insertions(+), 355 deletions(-) create mode 100644 src/tools/cargo/crates/xtask-bump-check/Cargo.toml create mode 100644 src/tools/cargo/crates/xtask-bump-check/src/main.rs create mode 100644 src/tools/cargo/crates/xtask-bump-check/src/xtask.rs delete mode 100644 src/tools/cargo/crates/xtask-unpublished/Cargo.toml delete mode 100644 src/tools/cargo/crates/xtask-unpublished/src/main.rs delete mode 100644 src/tools/cargo/crates/xtask-unpublished/src/xtask.rs (limited to 'src/tools/cargo/crates') diff --git a/src/tools/cargo/crates/cargo-platform/Cargo.toml b/src/tools/cargo/crates/cargo-platform/Cargo.toml index 423cf491d..e7f22cf87 100644 --- a/src/tools/cargo/crates/cargo-platform/Cargo.toml +++ b/src/tools/cargo/crates/cargo-platform/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-platform" -version = "0.1.3" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.1.4" +edition.workspace = true +license.workspace = true homepage = "https://github.com/rust-lang/cargo" repository = "https://github.com/rust-lang/cargo" documentation = "https://docs.rs/cargo-platform" diff --git a/src/tools/cargo/crates/cargo-test-macro/Cargo.toml b/src/tools/cargo/crates/cargo-test-macro/Cargo.toml index e40602ae3..b5da0522f 100644 --- a/src/tools/cargo/crates/cargo-test-macro/Cargo.toml +++ b/src/tools/cargo/crates/cargo-test-macro/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-test-macro" version = "0.1.0" -edition = "2021" -license = "MIT OR Apache-2.0" +edition.workspace = true +license.workspace = true homepage = "https://github.com/rust-lang/cargo" repository = "https://github.com/rust-lang/cargo" documentation = "https://github.com/rust-lang/cargo" diff --git a/src/tools/cargo/crates/cargo-test-support/Cargo.toml b/src/tools/cargo/crates/cargo-test-support/Cargo.toml index 305c809a8..085041aff 100644 --- a/src/tools/cargo/crates/cargo-test-support/Cargo.toml +++ b/src/tools/cargo/crates/cargo-test-support/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-test-support" version = "0.1.0" -license = "MIT OR Apache-2.0" -edition = "2021" +license.workspace = true +edition.workspace = true publish = false [lib] diff --git a/src/tools/cargo/crates/cargo-test-support/containers/sshd/Dockerfile b/src/tools/cargo/crates/cargo-test-support/containers/sshd/Dockerfile index b52eefbad..f25212770 100644 --- a/src/tools/cargo/crates/cargo-test-support/containers/sshd/Dockerfile +++ b/src/tools/cargo/crates/cargo-test-support/containers/sshd/Dockerfile @@ -1,4 +1,4 @@ -FROM alpine:3.17 +FROM alpine:3.18 RUN apk add --no-cache openssh git RUN ssh-keygen -A diff --git a/src/tools/cargo/crates/cargo-test-support/src/compare.rs b/src/tools/cargo/crates/cargo-test-support/src/compare.rs index 96ce52afc..21eb64d28 100644 --- a/src/tools/cargo/crates/cargo-test-support/src/compare.rs +++ b/src/tools/cargo/crates/cargo-test-support/src/compare.rs @@ -192,6 +192,7 @@ fn substitute_macros(input: &str) -> String { ("[CHECKING]", " Checking"), ("[COMPLETED]", " Completed"), ("[CREATED]", " Created"), + ("[CREDENTIAL]", " Credential"), ("[DOWNGRADING]", " Downgrading"), ("[FINISHED]", " Finished"), ("[ERROR]", "error:"), diff --git a/src/tools/cargo/crates/cargo-test-support/src/registry.rs b/src/tools/cargo/crates/cargo-test-support/src/registry.rs index 910f95bfa..27c319656 100644 --- a/src/tools/cargo/crates/cargo-test-support/src/registry.rs +++ b/src/tools/cargo/crates/cargo-test-support/src/registry.rs @@ -104,6 +104,8 @@ pub struct RegistryBuilder { not_found_handler: RequestCallback, /// If nonzero, the git index update to be delayed by the given number of seconds. delayed_index_update: usize, + /// Credential provider in configuration + credential_provider: Option, } pub struct TestRegistry { @@ -172,6 +174,7 @@ impl RegistryBuilder { custom_responders: HashMap::new(), not_found_handler: Box::new(not_found), delayed_index_update: 0, + credential_provider: None, } } @@ -266,6 +269,13 @@ impl RegistryBuilder { self } + /// The credential provider to configure for this registry. + #[must_use] + pub fn credential_provider(mut self, provider: &[&str]) -> Self { + self.credential_provider = Some(format!("['{}']", provider.join("','"))); + self + } + /// Initializes the registry. #[must_use] pub fn build(self) -> TestRegistry { @@ -336,6 +346,18 @@ impl RegistryBuilder { .as_bytes(), ) .unwrap(); + if let Some(p) = &self.credential_provider { + append( + &config_path, + &format!( + " + credential-provider = {p} + " + ) + .as_bytes(), + ) + .unwrap() + } } else { append( &config_path, @@ -351,6 +373,20 @@ impl RegistryBuilder { .as_bytes(), ) .unwrap(); + + if let Some(p) = &self.credential_provider { + append( + &config_path, + &format!( + " + [registry] + credential-provider = {p} + " + ) + .as_bytes(), + ) + .unwrap() + } } } diff --git a/src/tools/cargo/crates/cargo-util/Cargo.toml b/src/tools/cargo/crates/cargo-util/Cargo.toml index 614581037..99a59422d 100644 --- a/src/tools/cargo/crates/cargo-util/Cargo.toml +++ b/src/tools/cargo/crates/cargo-util/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "cargo-util" -version = "0.2.5" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.2.6" +edition.workspace = true +license.workspace = true homepage = "https://github.com/rust-lang/cargo" repository = "https://github.com/rust-lang/cargo" description = "Miscellaneous support code used by Cargo." @@ -14,10 +14,10 @@ filetime.workspace = true hex.workspace = true jobserver.workspace = true libc.workspace = true -log.workspace = true same-file.workspace = true shell-escape.workspace = true tempfile.workspace = true +tracing.workspace = true walkdir.workspace = true [target.'cfg(target_os = "macos")'.dependencies] diff --git a/src/tools/cargo/crates/cargo-util/src/paths.rs b/src/tools/cargo/crates/cargo-util/src/paths.rs index 4a917821b..ce6755859 100644 --- a/src/tools/cargo/crates/cargo-util/src/paths.rs +++ b/src/tools/cargo/crates/cargo-util/src/paths.rs @@ -237,7 +237,7 @@ pub fn mtime_recursive(path: &Path) -> Result { Err(e) => { // Ignore errors while walking. If Cargo can't access it, the // build script probably can't access it, either. - log::debug!("failed to determine mtime while walking directory: {}", e); + tracing::debug!("failed to determine mtime while walking directory: {}", e); None } }) @@ -252,7 +252,7 @@ pub fn mtime_recursive(path: &Path) -> Result { // I'm not sure when this is really possible (maybe a // race with unlinking?). Regardless, if Cargo can't // read it, the build script probably can't either. - log::debug!( + tracing::debug!( "failed to determine mtime while fetching symlink metadata of {}: {}", e.path().display(), err @@ -271,7 +271,7 @@ pub fn mtime_recursive(path: &Path) -> Result { // Can't access the symlink target. If Cargo can't // access it, the build script probably can't access // it either. - log::debug!( + tracing::debug!( "failed to determine mtime of symlink target for {}: {}", e.path().display(), err @@ -286,7 +286,7 @@ pub fn mtime_recursive(path: &Path) -> Result { // I'm not sure when this is really possible (maybe a // race with unlinking?). Regardless, if Cargo can't // read it, the build script probably can't either. - log::debug!( + tracing::debug!( "failed to determine mtime while fetching metadata of {}: {}", e.path().display(), err @@ -314,7 +314,7 @@ pub fn set_invocation_time(path: &Path) -> Result { "This file has an mtime of when this was started.", )?; let ft = mtime(×tamp)?; - log::debug!("invocation time for {:?} is {}", path, ft); + tracing::debug!("invocation time for {:?} is {}", path, ft); Ok(ft) } @@ -508,7 +508,7 @@ pub fn link_or_copy(src: impl AsRef, dst: impl AsRef) -> Result<()> } fn _link_or_copy(src: &Path, dst: &Path) -> Result<()> { - log::debug!("linking {} to {}", src.display(), dst.display()); + tracing::debug!("linking {} to {}", src.display(), dst.display()); if same_file::is_same_file(src, dst).unwrap_or(false) { return Ok(()); } @@ -567,7 +567,7 @@ fn _link_or_copy(src: &Path, dst: &Path) -> Result<()> { }; link_result .or_else(|err| { - log::debug!("link failed {}. falling back to fs::copy", err); + tracing::debug!("link failed {}. falling back to fs::copy", err); fs::copy(src, dst).map(|_| ()) }) .with_context(|| { @@ -598,8 +598,8 @@ pub fn copy, Q: AsRef>(from: P, to: Q) -> Result { pub fn set_file_time_no_err>(path: P, time: FileTime) { let path = path.as_ref(); match filetime::set_file_times(path, time, time) { - Ok(()) => log::debug!("set file mtime {} to {}", path.display(), time), - Err(e) => log::warn!( + Ok(()) => tracing::debug!("set file mtime {} to {}", path.display(), time), + Err(e) => tracing::warn!( "could not set mtime of {} to {}: {:?}", path.display(), time, @@ -621,7 +621,7 @@ pub fn strip_prefix_canonical>( let safe_canonicalize = |path: &Path| match path.canonicalize() { Ok(p) => p, Err(e) => { - log::warn!("cannot canonicalize {:?}: {:?}", path, e); + tracing::warn!("cannot canonicalize {:?}: {:?}", path, e); path.to_path_buf() } }; diff --git a/src/tools/cargo/crates/cargo-util/src/process_builder.rs b/src/tools/cargo/crates/cargo-util/src/process_builder.rs index 76392f256..b197b95b1 100644 --- a/src/tools/cargo/crates/cargo-util/src/process_builder.rs +++ b/src/tools/cargo/crates/cargo-util/src/process_builder.rs @@ -449,7 +449,7 @@ impl ProcessBuilder { arg.push(tmp.path()); let mut cmd = self.build_command_without_args(); cmd.arg(arg); - log::debug!("created argfile at {} for {self}", tmp.path().display()); + tracing::debug!("created argfile at {} for {self}", tmp.path().display()); let cap = self.get_args().map(|arg| arg.len() + 1).sum::(); let mut buf = Vec::with_capacity(cap); @@ -558,7 +558,7 @@ fn piped(cmd: &mut Command, pipe_stdin: bool) -> &mut Command { fn close_tempfile_and_log_error(file: NamedTempFile) { file.close().unwrap_or_else(|e| { - log::warn!("failed to close temporary file: {e}"); + tracing::warn!("failed to close temporary file: {e}"); }); } diff --git a/src/tools/cargo/crates/crates-io/Cargo.toml b/src/tools/cargo/crates/crates-io/Cargo.toml index 034c2fca5..139b8aa97 100644 --- a/src/tools/cargo/crates/crates-io/Cargo.toml +++ b/src/tools/cargo/crates/crates-io/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "crates-io" -version = "0.37.0" -edition = "2021" -license = "MIT OR Apache-2.0" +version = "0.38.0" +edition.workspace = true +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = """ Helpers for interacting with crates.io @@ -13,9 +13,9 @@ name = "crates_io" path = "lib.rs" [dependencies] -anyhow.workspace = true curl.workspace = true percent-encoding.workspace = true serde = { workspace = true, features = ["derive"] } serde_json.workspace = true +thiserror.workspace = true url.workspace = true diff --git a/src/tools/cargo/crates/crates-io/lib.rs b/src/tools/cargo/crates/crates-io/lib.rs index 243808098..6ce39cefd 100644 --- a/src/tools/cargo/crates/crates-io/lib.rs +++ b/src/tools/cargo/crates/crates-io/lib.rs @@ -1,18 +1,18 @@ #![allow(clippy::all)] use std::collections::BTreeMap; -use std::fmt; use std::fs::File; use std::io::prelude::*; use std::io::{Cursor, SeekFrom}; use std::time::Instant; -use anyhow::{bail, format_err, Context, Result}; use curl::easy::{Easy, List}; use percent_encoding::{percent_encode, NON_ALPHANUMERIC}; use serde::{Deserialize, Serialize}; use url::Url; +pub type Result = std::result::Result; + pub struct Registry { /// The base URL for issuing API requests. host: String, @@ -125,67 +125,62 @@ struct Crates { meta: TotalCrates, } -#[derive(Debug)] -pub enum ResponseError { - Curl(curl::Error), +/// Error returned when interacting with a registry. +#[derive(Debug, thiserror::Error)] +pub enum Error { + /// Error from libcurl. + #[error(transparent)] + Curl(#[from] curl::Error), + + /// Error from seriailzing the request payload and deserialzing the + /// response body (like response body didn't match expected structure). + #[error(transparent)] + Json(#[from] serde_json::Error), + + /// Error from IO. Mostly from reading the tarball to upload. + #[error("failed to seek tarball")] + Io(#[from] std::io::Error), + + /// Response body was not valid utf8. + #[error("invalid response body from server")] + Utf8(#[from] std::string::FromUtf8Error), + + /// Error from API response containing JSON field `errors.details`. + #[error( + "the remote server responded with an error{}: {}", + status(*code), + errors.join(", "), + )] Api { code: u32, + headers: Vec, errors: Vec, }, + + /// Error from API response which didn't have pre-programmed `errors.details`. + #[error( + "failed to get a 200 OK response, got {code}\nheaders:\n\t{}\nbody:\n{body}", + headers.join("\n\t"), + )] Code { code: u32, headers: Vec, body: String, }, - Other(anyhow::Error), -} - -impl std::error::Error for ResponseError { - fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { - match self { - ResponseError::Curl(..) => None, - ResponseError::Api { .. } => None, - ResponseError::Code { .. } => None, - ResponseError::Other(e) => Some(e.as_ref()), - } - } -} -impl fmt::Display for ResponseError { - fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { - match self { - ResponseError::Curl(e) => write!(f, "{}", e), - ResponseError::Api { code, errors } => { - f.write_str("the remote server responded with an error")?; - if *code != 200 { - write!(f, " (status {} {})", code, reason(*code))?; - }; - write!(f, ": {}", errors.join(", ")) - } - ResponseError::Code { - code, - headers, - body, - } => write!( - f, - "failed to get a 200 OK response, got {}\n\ - headers:\n\ - \t{}\n\ - body:\n\ - {}", - code, - headers.join("\n\t"), - body - ), - ResponseError::Other(..) => write!(f, "invalid response from server"), - } - } -} - -impl From for ResponseError { - fn from(error: curl::Error) -> Self { - ResponseError::Curl(error) - } + /// Reason why the token was invalid. + #[error("{0}")] + InvalidToken(&'static str), + + /// Server was unavailable and timeouted. Happened when uploading a way + /// too large tarball to crates.io. + #[error( + "Request timed out after 30 seconds. If you're trying to \ + upload a crate it may be too large. If the crate is under \ + 10MB in size, you can email help@crates.io for assistance.\n\ + Total size was {0}." + )] + Timeout(u64), } impl Registry { @@ -221,10 +216,9 @@ impl Registry { } fn token(&self) -> Result<&str> { - let token = match self.token.as_ref() { - Some(s) => s, - None => bail!("no upload token found, please run `cargo login`"), - }; + let token = self.token.as_ref().ok_or_else(|| { + Error::InvalidToken("no upload token found, please run `cargo login`") + })?; check_token(token)?; Ok(token) } @@ -270,12 +264,8 @@ impl Registry { // This checks the length using seeking instead of metadata, because // on some filesystems, getting the metadata will fail because // the file was renamed in ops::package. - let tarball_len = tarball - .seek(SeekFrom::End(0)) - .with_context(|| "failed to seek tarball")?; - tarball - .seek(SeekFrom::Start(0)) - .with_context(|| "failed to seek tarball")?; + let tarball_len = tarball.seek(SeekFrom::End(0))?; + tarball.seek(SeekFrom::Start(0))?; let header = { let mut w = Vec::new(); w.extend(&(json.len() as u32).to_le_bytes()); @@ -300,18 +290,12 @@ impl Registry { let body = self .handle(&mut |buf| body.read(buf).unwrap_or(0)) .map_err(|e| match e { - ResponseError::Code { code, .. } + Error::Code { code, .. } if code == 503 && started.elapsed().as_secs() >= 29 && self.host_is_crates_io() => { - format_err!( - "Request timed out after 30 seconds. If you're trying to \ - upload a crate it may be too large. If the crate is under \ - 10MB in size, you can email help@crates.io for assistance.\n\ - Total size was {}.", - tarball_len - ) + Error::Timeout(tarball_len) } _ => e.into(), })?; @@ -410,10 +394,7 @@ impl Registry { } } - fn handle( - &mut self, - read: &mut dyn FnMut(&mut [u8]) -> usize, - ) -> std::result::Result { + fn handle(&mut self, read: &mut dyn FnMut(&mut [u8]) -> usize) -> Result { let mut headers = Vec::new(); let mut body = Vec::new(); { @@ -427,28 +408,29 @@ impl Registry { // Headers contain trailing \r\n, trim them to make it easier // to work with. let s = String::from_utf8_lossy(data).trim().to_string(); + // Don't let server sneak extra lines anywhere. + if s.contains('\n') { + return true; + } headers.push(s); true })?; handle.perform()?; } - let body = match String::from_utf8(body) { - Ok(body) => body, - Err(..) => { - return Err(ResponseError::Other(format_err!( - "response body was not valid utf-8" - ))) - } - }; + let body = String::from_utf8(body)?; let errors = serde_json::from_str::(&body) .ok() .map(|s| s.errors.into_iter().map(|s| s.detail).collect::>()); match (self.handle.response_code()?, errors) { (0, None) | (200, None) => Ok(body), - (code, Some(errors)) => Err(ResponseError::Api { code, errors }), - (code, None) => Err(ResponseError::Code { + (code, Some(errors)) => Err(Error::Api { + code, + headers, + errors, + }), + (code, None) => Err(Error::Code { code, headers, body, @@ -457,6 +439,15 @@ impl Registry { } } +fn status(code: u32) -> String { + if code == 200 { + String::new() + } else { + let reason = reason(code); + format!(" (status {code} {reason})") + } +} + fn reason(code: u32) -> &'static str { // Taken from https://developer.mozilla.org/en-US/docs/Web/HTTP/Status match code { @@ -520,7 +511,7 @@ pub fn is_url_crates_io(url: &str) -> bool { /// registries only create tokens in that format so that is as less restricted as possible. pub fn check_token(token: &str) -> Result<()> { if token.is_empty() { - bail!("please provide a non-empty token"); + return Err(Error::InvalidToken("please provide a non-empty token")); } if token.bytes().all(|b| { // This is essentially the US-ASCII limitation of @@ -531,9 +522,9 @@ pub fn check_token(token: &str) -> Result<()> { }) { Ok(()) } else { - Err(anyhow::anyhow!( + Err(Error::InvalidToken( "token contains invalid characters.\nOnly printable ISO-8859-1 characters \ - are allowed as it is sent in a HTTPS header." + are allowed as it is sent in a HTTPS header.", )) } } diff --git a/src/tools/cargo/crates/home/Cargo.toml b/src/tools/cargo/crates/home/Cargo.toml index 6c65ecc18..03bd555a2 100644 --- a/src/tools/cargo/crates/home/Cargo.toml +++ b/src/tools/cargo/crates/home/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "home" -version = "0.5.6" # also update `html_root_url` in `src/lib.rs` +version = "0.5.7" # also update `html_root_url` in `src/lib.rs` authors = ["Brian Anderson "] documentation = "https://docs.rs/home" -edition = "2018" +edition.workspace = true include = [ "/src", "/Cargo.toml", @@ -11,8 +11,7 @@ include = [ "/LICENSE-*", "/README.md", ] -license = "MIT OR Apache-2.0" -readme = "README.md" +license.workspace = true repository = "https://github.com/rust-lang/cargo" description = "Shared definitions of home directories." diff --git a/src/tools/cargo/crates/mdman/Cargo.toml b/src/tools/cargo/crates/mdman/Cargo.toml index 812f1393a..ba1d4b462 100644 --- a/src/tools/cargo/crates/mdman/Cargo.toml +++ b/src/tools/cargo/crates/mdman/Cargo.toml @@ -1,8 +1,8 @@ [package] name = "mdman" version = "0.0.0" -edition = "2021" -license = "MIT OR Apache-2.0" +edition.workspace = true +license.workspace = true description = "Creates a man page page from markdown." publish = false diff --git a/src/tools/cargo/crates/resolver-tests/Cargo.toml b/src/tools/cargo/crates/resolver-tests/Cargo.toml index e0efb9b6d..5e69d7367 100644 --- a/src/tools/cargo/crates/resolver-tests/Cargo.toml +++ b/src/tools/cargo/crates/resolver-tests/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "resolver-tests" version = "0.0.0" -edition = "2018" +edition.workspace = true publish = false [dependencies] diff --git a/src/tools/cargo/crates/semver-check/Cargo.toml b/src/tools/cargo/crates/semver-check/Cargo.toml index f7b8c7d48..17e696566 100644 --- a/src/tools/cargo/crates/semver-check/Cargo.toml +++ b/src/tools/cargo/crates/semver-check/Cargo.toml @@ -2,7 +2,7 @@ name = "semver-check" version = "0.0.0" authors = ["Eric Huss"] -edition = "2021" +edition.workspace = true publish = false # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html diff --git a/src/tools/cargo/crates/semver-check/src/main.rs b/src/tools/cargo/crates/semver-check/src/main.rs index fa4639eb7..1ba405f57 100644 --- a/src/tools/cargo/crates/semver-check/src/main.rs +++ b/src/tools/cargo/crates/semver-check/src/main.rs @@ -7,6 +7,11 @@ //! An example with the word "MINOR" at the top is expected to successfully //! build against the before and after. Otherwise it should fail. A comment of //! "// Error:" will check that the given message appears in the error output. +//! +//! The code block can also include the annotations: +//! - `run-fail`: The test should fail at runtime, not compiletime. +//! - `dont-deny`: By default tests have a `#![deny(warnings)]`. This option +//! avoids this attribute. Note that `#![allow(unused)]` is always added. use std::error::Error; use std::fs; @@ -57,7 +62,13 @@ fn doit() -> Result<(), Box> { if line.trim() == "```" { break; } - block.push(line); + // Support rustdoc/mdbook hidden lines. + let line = line.strip_prefix("# ").unwrap_or(line); + if line == "#" { + block.push(""); + } else { + block.push(line); + } } None => { return Err(format!( diff --git a/src/tools/cargo/crates/xtask-build-man/Cargo.toml b/src/tools/cargo/crates/xtask-build-man/Cargo.toml index 6d02aa2c3..bec10c48c 100644 --- a/src/tools/cargo/crates/xtask-build-man/Cargo.toml +++ b/src/tools/cargo/crates/xtask-build-man/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "xtask-build-man" version = "0.0.0" -edition = "2021" +edition.workspace = true publish = false [dependencies] diff --git a/src/tools/cargo/crates/xtask-bump-check/Cargo.toml b/src/tools/cargo/crates/xtask-bump-check/Cargo.toml new file mode 100644 index 000000000..e965ad09e --- /dev/null +++ b/src/tools/cargo/crates/xtask-bump-check/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "xtask-bump-check" +version = "0.0.0" +edition.workspace = true +publish = false + +[dependencies] +anyhow.workspace = true +cargo.workspace = true +cargo-util.workspace = true +clap.workspace = true +git2.workspace = true +tracing.workspace = true +tracing-subscriber.workspace = true diff --git a/src/tools/cargo/crates/xtask-bump-check/src/main.rs b/src/tools/cargo/crates/xtask-bump-check/src/main.rs new file mode 100644 index 000000000..0461ab91a --- /dev/null +++ b/src/tools/cargo/crates/xtask-bump-check/src/main.rs @@ -0,0 +1,27 @@ +mod xtask; + +fn main() { + setup_logger(); + + let cli = xtask::cli(); + let matches = cli.get_matches(); + + let mut config = cargo::util::config::Config::default().unwrap_or_else(|e| { + let mut eval = cargo::core::shell::Shell::new(); + cargo::exit_with_error(e.into(), &mut eval) + }); + if let Err(e) = xtask::exec(&matches, &mut config) { + cargo::exit_with_error(e, &mut config.shell()) + } +} + +// In sync with `src/bin/cargo/main.rs@setup_logger`. +fn setup_logger() { + let env = tracing_subscriber::EnvFilter::from_env("CARGO_LOG"); + + tracing_subscriber::fmt() + .with_ansi(std::io::IsTerminal::is_terminal(&std::io::stderr())) + .with_writer(std::io::stderr) + .with_env_filter(env) + .init(); +} diff --git a/src/tools/cargo/crates/xtask-bump-check/src/xtask.rs b/src/tools/cargo/crates/xtask-bump-check/src/xtask.rs new file mode 100644 index 000000000..f89152331 --- /dev/null +++ b/src/tools/cargo/crates/xtask-bump-check/src/xtask.rs @@ -0,0 +1,423 @@ +//! ```text +//! NAME +//! xtask-bump-check +//! +//! SYNOPSIS +//! xtask-bump-check --base-rev --head-rev +//! +//! DESCRIPTION +//! Checks if there is any member got changed since a base commit +//! but forgot to bump its version. +//! ``` + +use std::collections::HashMap; +use std::fmt::Write; +use std::fs; +use std::task; + +use cargo::core::dependency::Dependency; +use cargo::core::registry::PackageRegistry; +use cargo::core::Package; +use cargo::core::QueryKind; +use cargo::core::Registry; +use cargo::core::SourceId; +use cargo::core::Workspace; +use cargo::util::command_prelude::*; +use cargo::util::ToSemver; +use cargo::CargoResult; +use cargo_util::ProcessBuilder; + +const UPSTREAM_BRANCH: &str = "master"; +const STATUS: &str = "BumpCheck"; + +pub fn cli() -> clap::Command { + clap::Command::new("xtask-bump-check") + .arg( + opt( + "verbose", + "Use verbose output (-vv very verbose/build.rs output)", + ) + .short('v') + .action(ArgAction::Count) + .global(true), + ) + .arg_quiet() + .arg( + opt("color", "Coloring: auto, always, never") + .value_name("WHEN") + .global(true), + ) + .arg(opt("base-rev", "Git revision to lookup for a baseline")) + .arg(opt("head-rev", "Git revision with changes")) + .arg(flag("frozen", "Require Cargo.lock and cache are up to date").global(true)) + .arg(flag("locked", "Require Cargo.lock is up to date").global(true)) + .arg(flag("offline", "Run without accessing the network").global(true)) + .arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true)) + .arg( + Arg::new("unstable-features") + .help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details") + .short('Z') + .value_name("FLAG") + .action(ArgAction::Append) + .global(true), + ) +} + +pub fn exec(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult { + config_configure(config, args)?; + + bump_check(args, config)?; + + Ok(()) +} + +fn config_configure(config: &mut Config, args: &ArgMatches) -> CliResult { + let verbose = args.verbose(); + // quiet is unusual because it is redefined in some subcommands in order + // to provide custom help text. + let quiet = args.flag("quiet"); + let color = args.get_one::("color").map(String::as_str); + let frozen = args.flag("frozen"); + let locked = args.flag("locked"); + let offline = args.flag("offline"); + let mut unstable_flags = vec![]; + if let Some(values) = args.get_many::("unstable-features") { + unstable_flags.extend(values.cloned()); + } + let mut config_args = vec![]; + if let Some(values) = args.get_many::("config") { + config_args.extend(values.cloned()); + } + config.configure( + verbose, + quiet, + color, + frozen, + locked, + offline, + &None, + &unstable_flags, + &config_args, + )?; + Ok(()) +} + +/// Main entry of `xtask-bump-check`. +/// +/// Assumption: version number are incremental. We never have point release for old versions. +fn bump_check(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> CargoResult<()> { + let ws = args.workspace(config)?; + let repo = git2::Repository::open(ws.root())?; + let base_commit = get_base_commit(config, args, &repo)?; + let head_commit = get_head_commit(args, &repo)?; + let referenced_commit = get_referenced_commit(&repo, &base_commit)?; + let changed_members = changed(&ws, &repo, &base_commit, &head_commit)?; + let status = |msg: &str| config.shell().status(STATUS, msg); + + status(&format!("base commit `{}`", base_commit.id()))?; + status(&format!("head commit `{}`", head_commit.id()))?; + + let mut needs_bump = Vec::new(); + + check_crates_io(config, &changed_members, &mut needs_bump)?; + + if let Some(referenced_commit) = referenced_commit.as_ref() { + status(&format!("compare against `{}`", referenced_commit.id()))?; + for referenced_member in checkout_ws(&ws, &repo, referenced_commit)?.members() { + let pkg_name = referenced_member.name().as_str(); + let Some(changed_member) = changed_members.get(pkg_name) else { + tracing::trace!("skipping {pkg_name}, may be removed or not published"); + continue; + }; + + if changed_member.version() <= referenced_member.version() { + needs_bump.push(*changed_member); + } + } + } + + if !needs_bump.is_empty() { + needs_bump.sort(); + needs_bump.dedup(); + let mut msg = String::new(); + msg.push_str("Detected changes in these crates but no version bump found:\n"); + for pkg in needs_bump { + writeln!(&mut msg, " {}@{}", pkg.name(), pkg.version())?; + } + msg.push_str("\nPlease bump at least one patch version in each corresponding Cargo.toml."); + anyhow::bail!(msg) + } + + // Tracked by https://github.com/obi1kenobi/cargo-semver-checks/issues/511 + let exclude_args = [ + "--exclude", + "cargo-credential-1password", + "--exclude", + "cargo-credential-libsecret", + "--exclude", + "cargo-credential-macos-keychain", + "--exclude", + "cargo-credential-wincred", + ]; + + // Even when we test against baseline-rev, we still need to make sure a + // change doesn't violate SemVer rules aginst crates.io releases. The + // possibility of this happening is nearly zero but no harm to check twice. + let mut cmd = ProcessBuilder::new("cargo"); + cmd.arg("semver-checks") + .arg("check-release") + .arg("--workspace") + .args(&exclude_args); + config.shell().status("Running", &cmd)?; + cmd.exec()?; + + if let Some(referenced_commit) = referenced_commit.as_ref() { + let mut cmd = ProcessBuilder::new("cargo"); + cmd.arg("semver-checks") + .arg("--workspace") + .arg("--baseline-rev") + .arg(referenced_commit.id().to_string()) + .args(&exclude_args); + config.shell().status("Running", &cmd)?; + cmd.exec()?; + } + + status("no version bump needed for member crates.")?; + + return Ok(()); +} + +/// Returns the commit of upstream `master` branch if `base-rev` is missing. +fn get_base_commit<'a>( + config: &Config, + args: &clap::ArgMatches, + repo: &'a git2::Repository, +) -> CargoResult> { + let base_commit = match args.get_one::("base-rev") { + Some(sha) => { + let obj = repo.revparse_single(sha)?; + obj.peel_to_commit()? + } + None => { + let upstream_branches = repo + .branches(Some(git2::BranchType::Remote))? + .filter_map(|r| r.ok()) + .filter(|(b, _)| { + b.name() + .ok() + .flatten() + .unwrap_or_default() + .ends_with(&format!("/{UPSTREAM_BRANCH}")) + }) + .map(|(b, _)| b) + .collect::>(); + if upstream_branches.is_empty() { + anyhow::bail!( + "could not find `base-sha` for `{UPSTREAM_BRANCH}`, pass it in directly" + ); + } + let upstream_ref = upstream_branches[0].get(); + if upstream_branches.len() > 1 { + let name = upstream_ref.name().expect("name is valid UTF-8"); + let _ = config.shell().warn(format!( + "multiple `{UPSTREAM_BRANCH}` found, picking {name}" + )); + } + upstream_ref.peel_to_commit()? + } + }; + Ok(base_commit) +} + +/// Returns `HEAD` of the Git repository if `head-rev` is missing. +fn get_head_commit<'a>( + args: &clap::ArgMatches, + repo: &'a git2::Repository, +) -> CargoResult> { + let head_commit = match args.get_one::("head-rev") { + Some(sha) => { + let head_obj = repo.revparse_single(sha)?; + head_obj.peel_to_commit()? + } + None => { + let head_ref = repo.head()?; + head_ref.peel_to_commit()? + } + }; + Ok(head_commit) +} + +/// Gets the referenced commit to compare if version bump needed. +/// +/// * When merging into nightly, check the version with beta branch +/// * When merging into beta, check the version with stable branch +/// * When merging into stable, check against crates.io registry directly +fn get_referenced_commit<'a>( + repo: &'a git2::Repository, + base: &git2::Commit<'a>, +) -> CargoResult>> { + let [beta, stable] = beta_and_stable_branch(&repo)?; + let rev_id = base.id(); + let stable_commit = stable.get().peel_to_commit()?; + let beta_commit = beta.get().peel_to_commit()?; + + let referenced_commit = if rev_id == stable_commit.id() { + None + } else if rev_id == beta_commit.id() { + tracing::trace!("stable branch from `{}`", stable.name().unwrap().unwrap()); + Some(stable_commit) + } else { + tracing::trace!("beta branch from `{}`", beta.name().unwrap().unwrap()); + Some(beta_commit) + }; + + Ok(referenced_commit) +} + +/// Get the current beta and stable branch in cargo repository. +/// +/// Assumptions: +/// +/// * The repository contains the full history of `/rust-1.*.0` branches. +/// * The version part of `/rust-1.*.0` always ends with a zero. +/// * The maximum version is for beta channel, and the second one is for stable. +fn beta_and_stable_branch(repo: &git2::Repository) -> CargoResult<[git2::Branch<'_>; 2]> { + let mut release_branches = Vec::new(); + for branch in repo.branches(Some(git2::BranchType::Remote))? { + let (branch, _) = branch?; + let name = branch.name()?.unwrap(); + let Some((_, version)) = name.split_once("/rust-") else { + tracing::trace!("branch `{name}` is not in the format of `/rust-`"); + continue; + }; + let Ok(version) = version.to_semver() else { + tracing::trace!("branch `{name}` is not a valid semver: `{version}`"); + continue; + }; + release_branches.push((version, branch)); + } + release_branches.sort_unstable_by(|a, b| a.0.cmp(&b.0)); + release_branches.dedup_by(|a, b| a.0 == b.0); + + let beta = release_branches.pop().unwrap(); + let stable = release_branches.pop().unwrap(); + + assert_eq!(beta.0.major, 1); + assert_eq!(beta.0.patch, 0); + assert_eq!(stable.0.major, 1); + assert_eq!(stable.0.patch, 0); + assert_ne!(beta.0.minor, stable.0.minor); + + Ok([beta.1, stable.1]) +} + +/// Lists all changed workspace members between two commits. +fn changed<'r, 'ws>( + ws: &'ws Workspace<'_>, + repo: &'r git2::Repository, + base_commit: &git2::Commit<'r>, + head: &git2::Commit<'r>, +) -> CargoResult> { + let root_pkg_name = ws.current()?.name(); // `cargo` crate. + let ws_members = ws + .members() + .filter(|pkg| pkg.name() != root_pkg_name) // Only take care of sub crates here. + .filter(|pkg| pkg.publish() != &Some(vec![])) // filter out `publish = false` + .map(|pkg| { + // Having relative package root path so that we can compare with + // paths of changed files to determine which package has changed. + let relative_pkg_root = pkg.root().strip_prefix(ws.root()).unwrap(); + (relative_pkg_root, pkg) + }) + .collect::>(); + let base_tree = base_commit.as_object().peel_to_tree()?; + let head_tree = head.as_object().peel_to_tree()?; + let diff = repo.diff_tree_to_tree(Some(&base_tree), Some(&head_tree), Default::default())?; + + let mut changed_members = HashMap::new(); + + for delta in diff.deltas() { + let old = delta.old_file().path().unwrap(); + let new = delta.new_file().path().unwrap(); + for (ref pkg_root, pkg) in ws_members.iter() { + if old.starts_with(pkg_root) || new.starts_with(pkg_root) { + changed_members.insert(pkg.name().as_str(), *pkg); + break; + } + } + } + + tracing::trace!("changed_members: {:?}", changed_members.keys()); + Ok(changed_members) +} + +/// Compares version against published crates on crates.io. +/// +/// Assumption: We always release a version larger than all existing versions. +fn check_crates_io<'a>( + config: &Config, + changed_members: &HashMap<&'a str, &'a Package>, + needs_bump: &mut Vec<&'a Package>, +) -> CargoResult<()> { + let source_id = SourceId::crates_io(config)?; + let mut registry = PackageRegistry::new(config)?; + let _lock = config.acquire_package_cache_lock()?; + registry.lock_patches(); + config.shell().status( + STATUS, + format_args!("compare against `{}`", source_id.display_registry_name()), + )?; + for (name, member) in changed_members { + let current = member.version(); + let version_req = format!(">={current}"); + let query = Dependency::parse(*name, Some(&version_req), source_id)?; + let possibilities = loop { + // Exact to avoid returning all for path/git + match registry.query_vec(&query, QueryKind::Exact) { + task::Poll::Ready(res) => { + break res?; + } + task::Poll::Pending => registry.block_until_ready()?, + } + }; + if possibilities.is_empty() { + tracing::trace!("dep `{name}` has no version greater than or equal to `{current}`"); + } else { + tracing::trace!( + "`{name}@{current}` needs a bump because its should have a version newer than crates.io: {:?}`", + possibilities + .iter() + .map(|s| format!("{}@{}", s.name(), s.version())) + .collect::>(), + ); + needs_bump.push(member); + } + } + + Ok(()) +} + +/// Checkouts a temporary workspace to do further version comparsions. +fn checkout_ws<'cfg, 'a>( + ws: &Workspace<'cfg>, + repo: &'a git2::Repository, + referenced_commit: &git2::Commit<'a>, +) -> CargoResult> { + let repo_path = repo.path().as_os_str().to_str().unwrap(); + // Put it under `target/cargo-` + let short_id = &referenced_commit.id().to_string()[..7]; + let checkout_path = ws.target_dir().join(format!("cargo-{short_id}")); + let checkout_path = checkout_path.as_path_unlocked(); + let _ = fs::remove_dir_all(checkout_path); + let new_repo = git2::build::RepoBuilder::new() + .clone_local(git2::build::CloneLocal::Local) + .clone(repo_path, checkout_path)?; + let obj = new_repo.find_object(referenced_commit.id(), None)?; + new_repo.reset(&obj, git2::ResetType::Hard, None)?; + Workspace::new(&checkout_path.join("Cargo.toml"), ws.config()) +} + +#[test] +fn verify_cli() { + cli().debug_assert(); +} diff --git a/src/tools/cargo/crates/xtask-stale-label/Cargo.toml b/src/tools/cargo/crates/xtask-stale-label/Cargo.toml index af3218e96..b1f54a2f1 100644 --- a/src/tools/cargo/crates/xtask-stale-label/Cargo.toml +++ b/src/tools/cargo/crates/xtask-stale-label/Cargo.toml @@ -1,7 +1,7 @@ [package] name = "xtask-stale-label" version = "0.0.0" -edition = "2021" +edition.workspace = true publish = false [dependencies] diff --git a/src/tools/cargo/crates/xtask-stale-label/src/main.rs b/src/tools/cargo/crates/xtask-stale-label/src/main.rs index 37675979c..88c044b5b 100644 --- a/src/tools/cargo/crates/xtask-stale-label/src/main.rs +++ b/src/tools/cargo/crates/xtask-stale-label/src/main.rs @@ -34,7 +34,7 @@ fn main() { for (label, value) in autolabel.iter() { let Some(trigger_files) = value.get("trigger_files") else { - continue + continue; }; let trigger_files = trigger_files.as_array().expect("an array"); let missing_files: Vec<_> = trigger_files diff --git a/src/tools/cargo/crates/xtask-unpublished/Cargo.toml b/src/tools/cargo/crates/xtask-unpublished/Cargo.toml deleted file mode 100644 index 541a34dea..000000000 --- a/src/tools/cargo/crates/xtask-unpublished/Cargo.toml +++ /dev/null @@ -1,12 +0,0 @@ -[package] -name = "xtask-unpublished" -version = "0.0.0" -edition = "2021" -publish = false - -[dependencies] -anyhow.workspace = true -cargo.workspace = true -clap.workspace = true -env_logger.workspace = true -log.workspace = true diff --git a/src/tools/cargo/crates/xtask-unpublished/src/main.rs b/src/tools/cargo/crates/xtask-unpublished/src/main.rs deleted file mode 100644 index 1942a3621..000000000 --- a/src/tools/cargo/crates/xtask-unpublished/src/main.rs +++ /dev/null @@ -1,15 +0,0 @@ -mod xtask; - -fn main() { - env_logger::init_from_env("CARGO_LOG"); - let cli = xtask::cli(); - let matches = cli.get_matches(); - - let mut config = cargo::util::config::Config::default().unwrap_or_else(|e| { - let mut eval = cargo::core::shell::Shell::new(); - cargo::exit_with_error(e.into(), &mut eval) - }); - if let Err(e) = xtask::exec(&matches, &mut config) { - cargo::exit_with_error(e, &mut config.shell()) - } -} diff --git a/src/tools/cargo/crates/xtask-unpublished/src/xtask.rs b/src/tools/cargo/crates/xtask-unpublished/src/xtask.rs deleted file mode 100644 index f1086951f..000000000 --- a/src/tools/cargo/crates/xtask-unpublished/src/xtask.rs +++ /dev/null @@ -1,200 +0,0 @@ -//! `xtask-unpublished` outputs a table with publish status --- a local version -//! and a version on crates.io for comparisons. -//! -//! This aims to help developers check if there is any crate required a new -//! publish, as well as detect if a version bump is needed in CI pipeline. - -use std::collections::HashSet; - -use cargo::core::registry::PackageRegistry; -use cargo::core::QueryKind; -use cargo::core::Registry; -use cargo::core::SourceId; -use cargo::ops::Packages; -use cargo::util::command_prelude::*; - -pub fn cli() -> clap::Command { - clap::Command::new("xtask-unpublished") - .arg_package_spec_simple("Package to inspect the published status") - .arg( - opt( - "verbose", - "Use verbose output (-vv very verbose/build.rs output)", - ) - .short('v') - .action(ArgAction::Count) - .global(true), - ) - .arg_quiet() - .arg( - opt("color", "Coloring: auto, always, never") - .value_name("WHEN") - .global(true), - ) - .arg(flag("frozen", "Require Cargo.lock and cache are up to date").global(true)) - .arg(flag("locked", "Require Cargo.lock is up to date").global(true)) - .arg(flag("offline", "Run without accessing the network").global(true)) - .arg(multi_opt("config", "KEY=VALUE", "Override a configuration value").global(true)) - .arg( - Arg::new("unstable-features") - .help("Unstable (nightly-only) flags to Cargo, see 'cargo -Z help' for details") - .short('Z') - .value_name("FLAG") - .action(ArgAction::Append) - .global(true), - ) -} - -pub fn exec(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult { - config_configure(config, args)?; - - unpublished(args, config)?; - - Ok(()) -} - -fn config_configure(config: &mut Config, args: &ArgMatches) -> CliResult { - let verbose = args.verbose(); - // quiet is unusual because it is redefined in some subcommands in order - // to provide custom help text. - let quiet = args.flag("quiet"); - let color = args.get_one::("color").map(String::as_str); - let frozen = args.flag("frozen"); - let locked = args.flag("locked"); - let offline = args.flag("offline"); - let mut unstable_flags = vec![]; - if let Some(values) = args.get_many::("unstable-features") { - unstable_flags.extend(values.cloned()); - } - let mut config_args = vec![]; - if let Some(values) = args.get_many::("config") { - config_args.extend(values.cloned()); - } - config.configure( - verbose, - quiet, - color, - frozen, - locked, - offline, - &None, - &unstable_flags, - &config_args, - )?; - Ok(()) -} - -fn unpublished(args: &clap::ArgMatches, config: &mut cargo::util::Config) -> cargo::CliResult { - let ws = args.workspace(config)?; - - let members_to_inspect: HashSet<_> = { - let pkgs = args.packages_from_flags()?; - if let Packages::Packages(_) = pkgs { - HashSet::from_iter(pkgs.get_packages(&ws)?) - } else { - HashSet::from_iter(ws.members()) - } - }; - - let mut results = Vec::new(); - { - let mut registry = PackageRegistry::new(config)?; - let _lock = config.acquire_package_cache_lock()?; - registry.lock_patches(); - let source_id = SourceId::crates_io(config)?; - - for member in members_to_inspect { - let name = member.name(); - let current = member.version(); - if member.publish() == &Some(vec![]) { - log::trace!("skipping {name}, `publish = false`"); - continue; - } - - let version_req = format!("<={current}"); - let query = - cargo::core::dependency::Dependency::parse(name, Some(&version_req), source_id)?; - let possibilities = loop { - // Exact to avoid returning all for path/git - match registry.query_vec(&query, QueryKind::Exact) { - std::task::Poll::Ready(res) => { - break res?; - } - std::task::Poll::Pending => registry.block_until_ready()?, - } - }; - let (last, published) = possibilities - .iter() - .map(|s| s.version()) - .max() - .map(|last| (last.to_string(), last == current)) - .unwrap_or(("-".to_string(), false)); - - results.push(vec![ - name.to_string(), - last, - current.to_string(), - if published { "yes" } else { "no" }.to_string(), - ]); - } - } - results.sort(); - - if results.is_empty() { - return Ok(()); - } - - results.insert( - 0, - vec![ - "name".to_owned(), - "crates.io".to_owned(), - "local".to_owned(), - "published?".to_owned(), - ], - ); - - output_table(results); - - Ok(()) -} - -/// Outputs a markdown table like this. -/// -/// ```text -/// | name | crates.io | local | published? | -/// |------------------|-----------|--------|------------| -/// | cargo | 0.70.1 | 0.72.0 | no | -/// | cargo-platform | 0.1.2 | 0.1.2 | yes | -/// | cargo-util | - | 0.2.4 | no | -/// | crates-io | 0.36.0 | 0.36.0 | yes | -/// | home | - | 0.5.6 | no | -/// ``` -fn output_table(table: Vec>) { - let header = table.first().unwrap(); - let paddings = table.iter().fold(vec![0; header.len()], |mut widths, row| { - for (width, field) in widths.iter_mut().zip(row) { - *width = usize::max(*width, field.len()); - } - widths - }); - - let print = |row: &[_]| { - for (field, pad) in row.iter().zip(&paddings) { - print!("| {field:pad$} "); - } - println!("|"); - }; - - print(header); - - paddings.iter().for_each(|fill| print!("|-{:-