Skip to content

Commit 1d88466

Browse files
committed
tasks: add the lint task
1 parent 565ba4e commit 1d88466

File tree

8 files changed

+348
-4
lines changed

8 files changed

+348
-4
lines changed

Cargo.lock

Lines changed: 26 additions & 4 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

tasks/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
# Changelog
2+
3+
## [0.1.0] -
4+
5+
* Initial release of `bitcoin-maintainer-tools` (executable: `bmt`) matching functionality of shell scripts.

tasks/Cargo.toml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,3 +5,14 @@ authors = ["Nick Johnson <[email protected]>"]
55
license = "CC0-1.0"
66
edition = "2021"
77
rust-version = "1.74.0"
8+
9+
[[bin]]
10+
name = "bmt"
11+
path = "src/main.rs"
12+
13+
[dependencies]
14+
xshell = "0.2"
15+
clap = { version = "4", features = ["derive"] }
16+
serde = { version = "1.0", features = ["derive"] }
17+
serde_json = "1.0"
18+
toml = "0.8"

tasks/README.md

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,56 @@
11
# Maintainer Tools
22

33
Maintainer tools for Rust-based projects in the Bitcoin domain. Built with [xshell](https://github.com/matklad/xshell).
4+
5+
## Commands
6+
7+
### `lint`
8+
9+
Run comprehensive linting checks across the workspace and crates. Detects duplicate dependencies, but some may be unavoidable (e.g., during dependency updates where transitive dependencies haven't caught up). You can whitelist specific duplicates by creating a `contrib/whitelist_dups.toml` file.
10+
11+
```toml
12+
# Allow specific duplicate dependencies.
13+
allowed_duplicates = [
14+
"syn",
15+
"bitcoin_hashes",
16+
]
17+
```
18+
19+
## Environment Variables
20+
21+
* `BMT_LOG_LEVEL=quiet` - Suppress verbose output and reduce cargo noise.
22+
23+
## Integration
24+
25+
There are a few options to integrate with your workspace.
26+
27+
### 1. Install globally
28+
29+
Install the tool globally on your system with `cargo install`.
30+
31+
```bash
32+
cargo install [email protected]
33+
```
34+
35+
Then run from anywhere in your repository.
36+
37+
```bash
38+
bmt lint
39+
```
40+
41+
### 2. Add as a dev-dependency
42+
43+
Add as a dev-dependency to a workspace member. This pins the tool version in your lockfile for reproducible builds.
44+
45+
```toml
46+
[dev-dependencies]
47+
bitcoin-maintainer-tools = "0.1.0"
48+
```
49+
50+
Then run via cargo.
51+
52+
```bash
53+
cargo run --bin bmt -- lint
54+
```
55+
56+
It might be worth wrapping in an [xtask](https://github.com/matklad/cargo-xtask) package for a clean interface.

tasks/justfile

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
# List available recipes.
2+
_default:
3+
@just --list
4+
5+
# Run tests.
6+
test:
7+
cargo test
8+
9+
# Install bmt from the local path.
10+
install:
11+
cargo install --path .

tasks/src/environment.rs

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
use std::env;
2+
use xshell::{cmd, Shell};
3+
4+
/// Environment variable to control output verbosity.
5+
/// Set to "quiet" to suppress informational messages and reduce cargo output.
6+
/// Any other value (or unset) defaults to verbose mode.
7+
const LOG_LEVEL_ENV_VAR: &str = "BMT_LOG_LEVEL";
8+
9+
/// Helper macro to create commands that respect quiet mode.
10+
///
11+
/// Wraps xshell's `cmd!` macro and automatically adds `.quiet()` when `BMT_LOG_LEVEL=quiet`.
12+
#[macro_export]
13+
macro_rules! quiet_cmd {
14+
($sh:expr, $($arg:tt)*) => {{
15+
let mut cmd = xshell::cmd!($sh, $($arg)*);
16+
if std::env::var("BMT_LOG_LEVEL").map(|v| v == "quiet").unwrap_or(false) {
17+
cmd = cmd.quiet();
18+
}
19+
cmd
20+
}};
21+
}
22+
23+
/// Print a message unless in quiet mode.
24+
pub fn println_verbose(msg: &str) {
25+
if !is_quiet_mode() {
26+
println!("{}", msg);
27+
}
28+
}
29+
30+
/// Check if we're in quiet mode via environment variable.
31+
fn is_quiet_mode() -> bool {
32+
env::var(LOG_LEVEL_ENV_VAR)
33+
.map(|v| v == "quiet")
34+
.unwrap_or(false)
35+
}
36+
37+
/// Configure shell log level and output verbosity.
38+
/// Sets cargo output verbosity based on LOG_LEVEL_ENV_VAR.
39+
pub fn configure_log_level(sh: &Shell) {
40+
if is_quiet_mode() {
41+
sh.set_var("CARGO_TERM_VERBOSE", "false");
42+
sh.set_var("CARGO_TERM_QUIET", "true");
43+
} else {
44+
sh.set_var("CARGO_TERM_VERBOSE", "true");
45+
sh.set_var("CARGO_TERM_QUIET", "false");
46+
}
47+
}
48+
49+
/// Change to the repository root directory.
50+
///
51+
/// # Panics
52+
///
53+
/// Panics if not in a git repository or git command fails.
54+
pub fn change_to_repo_root(sh: &Shell) {
55+
let repo_dir = cmd!(sh, "git rev-parse --show-toplevel")
56+
.read()
57+
.expect("Failed to get repository root, ensure you're in a git repository");
58+
sh.change_dir(&repo_dir);
59+
}
60+
61+
/// Get list of crate directories in the workspace using cargo metadata.
62+
/// Returns fully qualified paths to support various workspace layouts including nested crates.
63+
pub fn get_crate_dirs(sh: &Shell) -> Result<Vec<String>, Box<dyn std::error::Error>> {
64+
let metadata = cmd!(sh, "cargo metadata --no-deps --format-version 1").read()?;
65+
let json: serde_json::Value = serde_json::from_str(&metadata)?;
66+
67+
let crate_dirs: Vec<String> = json["packages"]
68+
.as_array()
69+
.ok_or("Missing 'packages' field in cargo metadata")?
70+
.iter()
71+
.filter_map(|package| {
72+
let manifest_path = package["manifest_path"].as_str()?;
73+
// Extract directory path from the manifest path,
74+
// e.g., "/path/to/repo/releases/Cargo.toml" -> "/path/to/repo/releases".
75+
let dir_path = manifest_path.trim_end_matches("/Cargo.toml");
76+
Some(dir_path.to_string())
77+
})
78+
.collect();
79+
80+
Ok(crate_dirs)
81+
}

tasks/src/lint.rs

Lines changed: 123 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,123 @@
1+
use std::fs;
2+
use xshell::Shell;
3+
4+
use crate::environment::{get_crate_dirs, println_verbose};
5+
use crate::quiet_cmd;
6+
7+
/// Configuration for allowed duplicate dependencies.
8+
#[derive(Debug, serde::Deserialize)]
9+
struct WhitelistConfig {
10+
#[serde(default)]
11+
allowed_duplicates: Vec<String>,
12+
}
13+
14+
/// Run the lint task.
15+
pub fn run(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
16+
println_verbose("Running lint task...");
17+
18+
lint_workspace(sh)?;
19+
lint_crates(sh)?;
20+
check_duplicate_deps(sh)?;
21+
22+
println_verbose("Lint task completed successfully");
23+
Ok(())
24+
}
25+
26+
/// Lint the workspace with clippy.
27+
fn lint_workspace(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
28+
println_verbose("Linting workspace...");
29+
30+
// Run clippy on workspace with all features.
31+
quiet_cmd!(
32+
sh,
33+
"cargo clippy --workspace --all-targets --all-features --keep-going"
34+
)
35+
.args(&["--", "-D", "warnings"])
36+
.run()?;
37+
38+
// Run clippy on workspace without features.
39+
quiet_cmd!(sh, "cargo clippy --workspace --all-targets --keep-going")
40+
.args(&["--", "-D", "warnings"])
41+
.run()?;
42+
43+
Ok(())
44+
}
45+
46+
/// Run extra crate-specific lints.
47+
///
48+
/// # Why run at the crate level?
49+
///
50+
/// When running `cargo clippy --workspace --no-default-features`, cargo resolves
51+
/// features across the entire workspace, which can enable features through dependencies
52+
/// even when a crate's own default features are disabled. Running clippy on each crate
53+
/// individually ensures that each crate truly compiles and passes lints with only its
54+
/// explicitly enabled features.
55+
fn lint_crates(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
56+
println_verbose("Running crate-specific lints...");
57+
58+
let crate_dirs = get_crate_dirs(sh)?;
59+
println_verbose(&format!("Found crates: {}", crate_dirs.join(", ")));
60+
61+
for crate_dir in crate_dirs {
62+
let _old_dir = sh.push_dir(&crate_dir);
63+
64+
// Run clippy without default features.
65+
quiet_cmd!(
66+
sh,
67+
"cargo clippy --all-targets --no-default-features --keep-going"
68+
)
69+
.args(&["--", "-D", "warnings"])
70+
.run()?;
71+
}
72+
73+
Ok(())
74+
}
75+
76+
/// Check for duplicate dependencies.
77+
fn check_duplicate_deps(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
78+
println_verbose("Checking for duplicate dependencies...");
79+
80+
// Load whitelist configuration if it exists.
81+
let whitelist_path = sh.current_dir().join("contrib/whitelist_dups.toml");
82+
let allowed_duplicates = if whitelist_path.exists() {
83+
println_verbose(&format!(
84+
"Loading whitelist from {}",
85+
whitelist_path.display()
86+
));
87+
let contents = fs::read_to_string(&whitelist_path)?;
88+
let config: WhitelistConfig = toml::from_str(&contents)?;
89+
config.allowed_duplicates
90+
} else {
91+
Vec::new()
92+
};
93+
94+
// Run cargo tree to find duplicates.
95+
let output = quiet_cmd!(sh, "cargo tree --target=all --all-features --duplicates")
96+
.ignore_status()
97+
.read()?;
98+
99+
let duplicates: Vec<&str> = output
100+
.lines()
101+
// Filter out non crate names.
102+
.filter(|line| line.chars().next().is_some_and(|c| c.is_alphanumeric()))
103+
// Filter out whitelisted crates.
104+
.filter(|line| {
105+
!allowed_duplicates
106+
.iter()
107+
.any(|allowed| line.contains(allowed))
108+
})
109+
.collect();
110+
111+
if !duplicates.is_empty() {
112+
eprintln!("Error: Found duplicate dependencies in workspace!");
113+
for dup in &duplicates {
114+
eprintln!(" {}", dup);
115+
}
116+
// Show full tree for context.
117+
quiet_cmd!(sh, "cargo tree --target=all --all-features --duplicates").run()?;
118+
return Err("Dependency tree contains duplicates".into());
119+
}
120+
121+
println_verbose("No duplicate dependencies found");
122+
Ok(())
123+
}

tasks/src/main.rs

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
mod environment;
2+
mod lint;
3+
4+
use clap::{Parser, Subcommand};
5+
use std::process;
6+
use xshell::Shell;
7+
8+
use environment::{change_to_repo_root, configure_log_level};
9+
10+
#[derive(Parser)]
11+
#[command(name = "bmt")]
12+
#[command(about = "Bitcoin Maintainer Tools", long_about = None)]
13+
struct Cli {
14+
#[command(subcommand)]
15+
command: Commands,
16+
}
17+
18+
#[derive(Subcommand)]
19+
enum Commands {
20+
/// Run the linter (clippy) for workspace and all crates.
21+
Lint,
22+
}
23+
24+
fn main() {
25+
let cli = Cli::parse();
26+
let sh = Shell::new().unwrap();
27+
configure_log_level(&sh);
28+
change_to_repo_root(&sh);
29+
30+
match cli.command {
31+
Commands::Lint => {
32+
if let Err(e) = lint::run(&sh) {
33+
eprintln!("Error running lint task: {}", e);
34+
process::exit(1);
35+
}
36+
}
37+
}
38+
}

0 commit comments

Comments
 (0)