Skip to content

Commit 490aa14

Browse files
committed
cargo-rbmt: add API break whitelist feature
Add allow_breaking_changes configuration option to filter out intentional breaking changes from API check failures.
1 parent 849cbbd commit 490aa14

File tree

2 files changed

+113
-22
lines changed

2 files changed

+113
-22
lines changed

cargo-rbmt/README.md

Lines changed: 25 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,8 +9,10 @@ Maintainer tools for Rust-based projects in the Bitcoin domain. Built with [xshe
99
- [Lint](#lint)
1010
- [Test](#test)
1111
- [Integration](#integration)
12+
- [Fuzz](#fuzz)
1213
- [Prerelease](#prerelease)
1314
- [Lock Files](#lock-files)
15+
- [API Checking](#api-checking)
1416
- [Workspace Integration](#workspace-integration)
1517
- [1. Install on system](#1-install-on-system)
1618
- [2. Add as a dev-dependency](#2-add-as-a-dev-dependency)
@@ -91,6 +93,10 @@ package = "bitcoind-tests"
9193
versions = ["29_0", "28_2", "27_2"]
9294
```
9395

96+
## Fuzz
97+
98+
The `fuzz` command assumes there is a package in a workspace which defines fuzz targets.
99+
94100
## Prerelease
95101

96102
The `prerelease` command performs readiness checks before releasing a package. By default, all packages are checked unless they explicitly opt-out.
@@ -135,7 +141,7 @@ When you specify `--lock-file`, the tool copies that lock file to `Cargo.lock` b
135141

136142
## API Checking
137143

138-
The `api` command helps maintain API stability by generating public API snapshots and checking for breaking changes. It uses the [public-api](https://github.com/Enselic/cargo-public-api) crate to analyze a crate's public interface.
144+
The `api` command helps maintain API stability by generating public API snapshots and checking for breaking changes. The generated API files are stored in `api/<package-name>/`.
139145

140146
```bash
141147
cargo rbmt api
@@ -145,14 +151,30 @@ cargo rbmt api
145151
2. Validates that features are additive (enabling features only adds to the API, never removes).
146152
3. Checks for uncommitted changes to API files.
147153

148-
The generated API files are stored in `api/<package-name>/`.
149-
150154
```bash
151155
cargo rbmt api --baseline v0.1.0
152156
```
153157

154158
Compares the current API against a baseline git reference (tag, branch, or commit) to detect breaking changes.
155159

160+
### Whitelisting Breaking Changes
161+
162+
The `api` command checks for two types of breaking changes.
163+
164+
* **Additivity** compares a package's `no-features` API vs. its `all-features` API to ensure its features are additive.
165+
* **Semver** compares some baseline version of a package's API vs the current API to ensure no breaking changes.
166+
167+
Sometimes breaking changes are intentional and safe (e.g., replacing a specific impl with a more general blanket impl for sealed traits). Configure the `[api]` section a your package's `rbmt.toml` to whitelist specific changes:
168+
169+
```toml
170+
[api]
171+
# Items that can be removed or changed without triggering errors.
172+
allow_breaking_changes = [
173+
"impl MyTrait for SpecificType",
174+
"pub fn deprecated_function()",
175+
]
176+
```
177+
156178
## Workspace Integration
157179

158180
`cargo-rbmt` can simply be installed globally on a system or added as a dev-dependency to a package.

cargo-rbmt/src/api.rs

Lines changed: 88 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ use std::collections::HashMap;
22
use std::fs;
33
use std::path::{Path, PathBuf};
44

5+
use serde::Deserialize;
56
use xshell::Shell;
67

78
use crate::{environment, quiet_cmd, toolchain};
@@ -59,6 +60,38 @@ impl FeatureConfig {
5960
}
6061
}
6162

63+
/// API configuration loaded from rbmt.toml.
64+
#[derive(Debug, Deserialize, Default)]
65+
#[serde(default)]
66+
struct Config {
67+
api: ApiConfig,
68+
}
69+
70+
/// API-specific configuration for a package.
71+
#[derive(Debug, Deserialize, Default)]
72+
#[serde(default)]
73+
struct ApiConfig {
74+
/// List of API items that are allowed to be removed or changed without causing
75+
/// semver or additivity check failures.
76+
allow_breaking_changes: Vec<String>,
77+
}
78+
79+
impl ApiConfig {
80+
/// Load API configuration from the package directory.
81+
fn load(_sh: &Shell, package_dir: &Path) -> Result<Self, Box<dyn std::error::Error>> {
82+
let config_path = package_dir.join(environment::CONFIG_FILE_PATH);
83+
84+
if !config_path.exists() {
85+
// Return empty config if file doesn't exist.
86+
return Ok(Self { allow_breaking_changes: Vec::new() });
87+
}
88+
89+
let contents = fs::read_to_string(&config_path)?;
90+
let config: Config = toml::from_str(&contents)?;
91+
Ok(config.api)
92+
}
93+
}
94+
6295
/// Run the API check task.
6396
///
6497
/// This command checks for changes to the public API of workspace packages by generating
@@ -131,6 +164,44 @@ fn get_package_apis(
131164
Ok(apis)
132165
}
133166

167+
/// Check for breaking changes in a diff, respecting the whitelist.
168+
///
169+
/// Returns a list of breaking change descriptions (empty if no breaks).
170+
fn check_for_breaking_changes(
171+
diff: &public_api::diff::PublicApiDiff,
172+
allow_breaking: &[String],
173+
) -> Vec<String> {
174+
let mut breaking = Vec::new();
175+
176+
for item in &diff.removed {
177+
let item_str = item.to_string();
178+
if allow_breaking.contains(&item_str) {
179+
environment::quiet_println(&format!(
180+
"Warning: Allowing removal of '{}' (whitelisted)",
181+
item_str
182+
));
183+
} else {
184+
breaking.push(format!("Removed: {}", item_str));
185+
}
186+
}
187+
188+
for change in &diff.changed {
189+
let old_str = change.old.to_string();
190+
let new_str = change.new.to_string();
191+
192+
if allow_breaking.contains(&old_str) || allow_breaking.contains(&new_str) {
193+
environment::quiet_println(&format!(
194+
"Warning: Allowing change to '{}' (whitelisted)",
195+
old_str
196+
));
197+
} else {
198+
breaking.push(format!("Changed:\n old: {}\n new: {}", old_str, new_str));
199+
}
200+
}
201+
202+
breaking
203+
}
204+
134205
/// Check API files for all packages.
135206
///
136207
/// For each package, generates public API files for different feature configurations,
@@ -160,24 +231,14 @@ fn check_apis(
160231

161232
let diff = public_api::diff::PublicApiDiff::between(no_features, all_features);
162233

163-
if !diff.removed.is_empty() || !diff.changed.is_empty() {
164-
eprintln!("Non-additive features detected in {}:", package_name);
234+
let config = ApiConfig::load(sh, package_dir)?;
235+
let breaking_changes = check_for_breaking_changes(&diff, &config.allow_breaking_changes);
165236

166-
if !diff.removed.is_empty() {
167-
eprintln!(" Items removed when enabling features:");
168-
for item in &diff.removed {
169-
eprintln!(" - {}", item);
170-
}
171-
}
172-
173-
if !diff.changed.is_empty() {
174-
eprintln!(" Items changed when enabling features:");
175-
for item in &diff.changed {
176-
eprintln!(" - old: {}", item.old);
177-
eprintln!(" new: {}", item.new);
178-
}
237+
if !breaking_changes.is_empty() {
238+
eprintln!("Non-additive features detected in {}:", package_name);
239+
for change in breaking_changes {
240+
eprintln!(" {}", change);
179241
}
180-
181242
return Err("Non-additive features detected".into());
182243
}
183244
}
@@ -240,7 +301,7 @@ fn check_semver(
240301
quiet_cmd!(sh, "git switch {current_ref}").run()?;
241302

242303
// Check for breaking changes in each package.
243-
for package_name in package_info.iter().map(|(name, _)| name) {
304+
for (package_name, package_dir) in package_info {
244305
let Some(mut baseline) = baseline_apis.remove(package_name) else {
245306
environment::quiet_println(&format!(
246307
"Warning: Package '{}' not found in baseline - skipping comparison",
@@ -257,14 +318,22 @@ fn check_semver(
257318
continue;
258319
};
259320

321+
let api_config = ApiConfig::load(sh, package_dir)?;
322+
260323
for config in [FeatureConfig::None, FeatureConfig::Alloc, FeatureConfig::All] {
261324
let baseline_api = baseline.remove(&config).ok_or("Config not found in baseline")?;
262325
let current_api = current.remove(&config).ok_or("Config not found in current")?;
263326

264327
let diff = public_api::diff::PublicApiDiff::between(baseline_api, current_api);
265328

266-
if !diff.removed.is_empty() || !diff.changed.is_empty() {
267-
eprintln!("API changes detected in {} ({})", package_name, config.display_name());
329+
let breaking_changes =
330+
check_for_breaking_changes(&diff, &api_config.allow_breaking_changes);
331+
332+
if !breaking_changes.is_empty() {
333+
eprintln!("Breaking changes in {} ({}):", package_name, config.display_name());
334+
for change in breaking_changes {
335+
eprintln!(" {}", change);
336+
}
268337
return Err("Semver compatibility check failed: breaking changes detected".into());
269338
}
270339
}

0 commit comments

Comments
 (0)