use std::ffi::OsString;
use std::io::{stderr, stdout};
use std::path::{Path, PathBuf};
use anyhow::{Result, anyhow, bail};
use api_source::{ApiSource, Commit, CurrentDir, PublishedCrate, RustdocJson};
use arg_types::{Color, DenyMethod, Include, Omit};
use git_utils::current_branch_or_commit;
use plain::Plain;
use public_api::diff::PublicApiDiff;
use clap::{CommandFactory, Parser};
mod api_source;
mod arg_types;
mod error;
mod git_utils;
mod plain;
mod published_crate;
mod toolchain;
#[derive(Parser, Debug)]
#[command(
author,
version,
about = "List and diff the public API of Rust library crates between releases and commits.",
long_about = "List and diff the public API of Rust library crates between releases and commits. Website: https://github.com/cargo-public-api/cargo-public-api",
bin_name = "cargo public-api"
)]
#[command(flatten_help = true)]
pub struct Args {
#[arg(global = true, long, value_name = "PATH", default_value = "Cargo.toml")]
manifest_path: PathBuf,
#[arg(global = true, long, short)]
package: Option<String>,
#[arg(global = true, long, value_enum, value_delimiter = ',')]
omit: Option<Vec<Omit>>,
#[clap(verbatim_doc_comment)]
#[arg(global = true, short, long, action = clap::ArgAction::Count)]
simplified: u8,
#[arg(global = true, long, value_enum, value_delimiter = ',')]
include: Option<Vec<Include>>,
#[clap(verbatim_doc_comment)]
#[arg(global = true, short, long, action = clap::ArgAction::Count)]
verbose: u8,
#[arg(global = true, long, short = 'F')]
features: Vec<String>,
#[arg(global = true, long)]
all_features: bool,
#[arg(global = true, long)]
no_default_features: bool,
#[arg(global = true, long)]
target: Option<String>,
#[arg(global = true, long, value_enum)]
color: Option<Option<Color>>,
#[arg(global = true, long, value_name = "RUSTDOC_JSON_PATH", hide = true)]
rustdoc_json: Option<String>,
#[arg(global = true, long, hide = true)]
debug_processing: bool,
#[arg(global = true, long, hide = true)]
debug_sorting: bool,
#[arg(global = true, long, value_name = "PATH", hide = true)]
target_dir: Option<PathBuf>,
#[arg(global = true, long, hide = true)]
cap_lints: Option<String>,
#[command(subcommand)]
subcommand: Option<Subcommand>,
}
#[derive(Debug)]
struct ArgsAndToolchain {
args: Args,
toolchain: Option<String>,
}
#[derive(Parser, Debug)]
struct DiffArgs {
#[arg(long, value_enum)]
deny: Option<Vec<DenyMethod>>,
#[arg(long)]
force: bool,
#[clap(verbatim_doc_comment)]
args: Vec<String>,
}
#[derive(clap::Subcommand, Debug)]
enum Subcommand {
#[clap(verbatim_doc_comment)]
Diff(DiffArgs),
#[clap(verbatim_doc_comment)]
Completions {
#[arg(value_enum)]
shell: clap_complete_command::Shell,
},
}
enum MainTask {
PrintList {
api: Box<dyn ApiSource>,
},
PrintDiff {
old_api: Box<dyn ApiSource>,
new_api: Box<dyn ApiSource>,
},
GenerateShellCompletionScript(clap_complete_command::Shell),
}
pub enum Action {
CheckDiff {
diff: PublicApiDiff,
deny: Vec<DenyMethod>,
},
RestoreBranch { name: String },
}
const LATEST_VERSION_ARG: &str = "latest";
fn main_() -> Result<()> {
tracing_subscriber::fmt()
.with_env_filter(tracing_subscriber::EnvFilter::from_default_env())
.with_writer(stderr) .init();
let argst = get_args();
let mut final_actions = vec![];
let main_task = main_task(&argst.args)?;
if main_task.changes_commit() {
final_actions.push(Action::RestoreBranch {
name: current_branch_or_commit(argst.args.git_root()?)?,
});
}
let result = match main_task {
MainTask::PrintList { api } => print_public_items(&argst, api.as_ref()),
MainTask::PrintDiff { old_api, new_api } => print_diff(
&argst,
old_api.as_ref(),
new_api.as_ref(),
&mut final_actions,
),
MainTask::GenerateShellCompletionScript(shell) => {
shell.generate(
&mut Args::command().bin_name("cargo-public-api"),
&mut stdout(),
);
Ok(())
}
};
for action in final_actions {
action.perform(&argst.args)?;
}
result
}
fn main_task(args: &Args) -> Result<MainTask> {
match &args.subcommand {
Some(Subcommand::Diff(diff_args)) => main_task_from_diff_args(args, diff_args),
Some(Subcommand::Completions { shell }) => {
Ok(MainTask::GenerateShellCompletionScript(*shell))
}
None => Ok(main_task_from_args(args)),
}
}
fn main_task_from_args(args: &Args) -> MainTask {
if let Some(rustdoc_json) = &args.rustdoc_json {
MainTask::print_list(RustdocJson::new(rustdoc_json.into()).boxed())
} else {
MainTask::print_list(CurrentDir.boxed())
}
}
fn arg_to_api_source(arg: Option<&str>) -> Result<Box<dyn ApiSource>> {
match arg {
Some(arg) if is_json_file(arg) => Ok(RustdocJson::new(arg.into()).boxed()),
Some(arg) if semver::Version::parse(arg).is_ok() => {
Ok(PublishedCrate::new(Some(arg)).boxed())
}
None => Ok(PublishedCrate::new(None).boxed()),
_ => bail!("Use `ref1..ref2` syntax to diff git commits"),
}
}
fn main_task_from_diff_args(args: &Args, diff_args: &DiffArgs) -> Result<MainTask> {
if diff_args.args.len() > 2 {
bail!(
"Expected 1 or 2 arguments, but got {}",
diff_args.args.len()
)
}
let first_arg = diff_args.args.first();
let second_arg = diff_args.args.get(1);
let main_task = match (first_arg, second_arg) {
(Some(first), None) if first.contains("...") => {
bail!("Invalid git diff syntax: {first}. Use: rev1..rev2");
}
(Some(first), None) if first.contains("..") => {
let commits: Vec<_> = first.split("..").collect();
if commits.len() != 2 {
bail!("Expected 2 commits, but got {}", commits.len());
}
MainTask::print_diff(
Commit::new(args, commits[0])?.boxed(),
Commit::new(args, commits[1])?.boxed(),
)
}
(Some(first), None)
if semver::Version::parse(first).is_ok() || first == LATEST_VERSION_ARG =>
{
MainTask::print_diff(PublishedCrate::new(Some(first)).boxed(), CurrentDir.boxed())
}
(Some(first), Some(second)) => MainTask::print_diff(
arg_to_api_source(Some(first))?,
arg_to_api_source(Some(second))?,
),
(None, _) => MainTask::print_diff(PublishedCrate::new(None).boxed(), CurrentDir.boxed()),
(Some(first), None) => {
bail!("Invalid published crate version syntax: {first}");
}
};
Ok(main_task)
}
fn check_diff(deny: &[DenyMethod], diff: &PublicApiDiff) -> Result<()> {
let mut violations = crate::error::Violations::new();
for d in deny {
if d.deny_added() && !diff.added.is_empty() {
violations.extend_added(diff.added.iter().cloned());
}
if d.deny_changed() && !diff.changed.is_empty() {
violations.extend_changed(diff.changed.iter().cloned());
}
if d.deny_removed() && !diff.removed.is_empty() {
violations.extend_removed(diff.removed.iter().cloned());
}
}
if violations.is_empty() {
Ok(())
} else {
Err(anyhow!(error::Error::DiffDenied(violations)))
}
}
fn print_public_items(argst: &ArgsAndToolchain, public_api: &dyn ApiSource) -> Result<()> {
Plain::print_items(
&mut stdout(),
&argst.args,
public_api.obtain_api(argst)?.items(),
)?;
Ok(())
}
fn print_diff(
argst: &ArgsAndToolchain,
old: &dyn ApiSource,
new: &dyn ApiSource,
final_actions: &mut Vec<Action>,
) -> Result<()> {
fn check_diff(deny: &[DenyMethod], diff: PublicApiDiff) -> Action {
Action::CheckDiff {
diff,
deny: deny.to_owned(),
}
}
let old = old.obtain_api(argst)?;
let new = new.obtain_api(argst)?;
let diff = PublicApiDiff::between(old, new);
Plain::print_diff(&mut stdout(), &argst.args, &diff)?;
if let Some(Some(deny)) = argst.args.diff_args().map(|a| &a.deny) {
final_actions.push(check_diff(deny, diff));
}
Ok(())
}
impl MainTask {
fn print_list(api: Box<dyn ApiSource>) -> MainTask {
Self::PrintList { api }
}
fn print_diff(old_api: Box<dyn ApiSource>, new_api: Box<dyn ApiSource>) -> Self {
Self::PrintDiff { old_api, new_api }
}
fn changes_commit(&self) -> bool {
match self {
MainTask::PrintDiff { old_api, new_api } => {
old_api.changes_commit() || new_api.changes_commit()
}
MainTask::PrintList { api } => api.changes_commit(),
MainTask::GenerateShellCompletionScript(_) => false,
}
}
}
impl Action {
fn perform(&self, args: &Args) -> Result<()> {
match self {
Action::CheckDiff { deny, diff } => {
check_diff(deny, diff)?;
}
Action::RestoreBranch { name } => {
git_checkout(args, name)?;
}
};
Ok(())
}
}
impl Args {
fn omit_blanket_impls(&self) -> bool {
self.omits(Omit::BlanketImpls)
}
fn omit_auto_trait_impls(&self) -> bool {
self.omits(Omit::AutoTraitImpls)
}
fn omit_auto_derived_impls(&self) -> bool {
self.omits(Omit::AutoDerivedImpls)
}
fn omits(&self, to_omit: Omit) -> bool {
self.omit.iter().flatten().any(|o| *o == to_omit)
}
fn include_function_parameter_names(&self) -> bool {
self.includes(Include::FunctionParameterNames)
}
fn includes(&self, to_include: Include) -> bool {
self.include.iter().flatten().any(|i| *i == to_include)
}
fn git_root(&self) -> Result<PathBuf> {
git_utils::git_root_from_manifest_path(self.manifest_path.as_path())
}
fn diff_args(&self) -> Option<&DiffArgs> {
match &self.subcommand {
Some(Subcommand::Diff(diff_args)) => Some(diff_args),
_ => None,
}
}
}
fn get_args() -> ArgsAndToolchain {
let subcommand_name = subcommand_name(std::env::args_os().next().unwrap());
let args_os = std::env::args_os()
.enumerate()
.filter(|(index, arg)| *index != 1 || Some(arg) != subcommand_name.as_ref())
.map(|(_, arg)| arg);
let mut args = Args::parse_from(args_os);
resolve_simplified(&mut args);
resolve_verbose(&mut args);
resolve_toolchain(args)
}
fn subcommand_name(bin: OsString) -> Option<OsString> {
Some(
PathBuf::from(bin)
.file_name()?
.to_owned()
.to_string_lossy()
.strip_prefix("cargo-")?
.strip_suffix(std::env::consts::EXE_SUFFIX)?
.to_owned()
.into(),
)
}
fn resolve_toolchain(args: Args) -> ArgsAndToolchain {
let toolchain = if toolchain::is_probably_stable() {
if let Some(toolchain) = toolchain::from_rustup() {
eprintln!(
"Warning: using the `{toolchain}` toolchain for gathering the public api is not possible, switching to `nightly`"
);
}
Some("nightly".to_owned())
} else {
None
};
ArgsAndToolchain { args, toolchain }
}
fn resolve_simplified(args: &mut Args) {
if args.simplified > 0 {
let omit = args.omit.get_or_insert_with(Vec::new);
omit.push(Omit::BlanketImpls);
if args.simplified > 1 {
omit.push(Omit::AutoTraitImpls);
}
if args.simplified > 2 {
omit.push(Omit::AutoDerivedImpls);
}
}
}
fn resolve_verbose(args: &mut Args) {
if args.verbose > 0 {
let include = args.include.get_or_insert_with(Vec::new);
if args.verbose > 0 {
include.push(Include::FunctionParameterNames);
}
}
}
fn is_json_file(file_name: impl AsRef<str>) -> bool {
Path::extension(Path::new(file_name.as_ref())).is_some_and(|a| a.eq_ignore_ascii_case("json"))
}
fn git_checkout(args: &Args, commit: &str) -> Result<()> {
git_utils::git_checkout(
&args.git_root()?,
commit,
!args.debug_processing,
args.diff_args()
.map(|diff_args| diff_args.force)
.unwrap_or_default(),
)
}
fn main() -> Result<()> {
match main_() {
Err(e) => match e.root_cause().downcast_ref::<std::io::Error>() {
Some(io_error) if io_error.kind() == std::io::ErrorKind::BrokenPipe => {
std::process::exit(141)
}
_ => Err(e),
},
result => result,
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn verify_cli() {
use clap::CommandFactory;
Args::command().debug_assert();
}
#[test]
fn test_subcommand_name() {
for test in [
("cargo-public-api", Some("public-api")),
("cargo-public-api-v0.13.0", Some("public-api-v0.13.0")),
("relative/path/cargo-public-api", Some("public-api")),
("relative/cargo-public-api.foo", Some("public-api.foo")),
("cargo-public-api-secondary", Some("public-api-secondary")),
("cargo-something-else", Some("something-else")),
("prefix-cargo-public-api", None),
("/some/abs/path/cargo-public-api", Some("public-api")),
#[cfg(windows)]
("c:\\abs\\cargo-public-api-old", Some("public-api-old")),
] {
assert_eq!(
subcommand_name(OsString::from(&format!(
"{}{}",
test.0,
std::env::consts::EXE_SUFFIX
))),
test.1.map(OsString::from),
"failed to parse \"{}\"",
test.0,
);
}
}
}