Skip to content

Commit 348a992

Browse files
committed
tasks: add the lint task
1 parent fe25b2f commit 348a992

File tree

8 files changed

+339
-1
lines changed

8 files changed

+339
-1
lines changed

Cargo.lock

Lines changed: 22 additions & 0 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 `rust-bitcoin-maintainer-tools` (executable: `rbmt`) 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 = "rbmt"
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: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,54 @@
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+
## Configuration
6+
7+
The `lint` command 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.
8+
9+
```toml
10+
# Allow specific duplicate dependencies.
11+
allowed_duplicates = [
12+
"syn",
13+
"bitcoin_hashes",
14+
]
15+
```
16+
17+
### Environment Variables
18+
19+
* `RBMT_LOG_LEVEL=quiet` - Suppress verbose output and reduce cargo noise.
20+
21+
## Workspace Integration
22+
23+
`rbmt` can simply be installed globally, or as a dev-dependency for more granular control of dependency versions.
24+
25+
### 1. Install globally
26+
27+
Install the tool globally on your system with `cargo install`.
28+
29+
```bash
30+
cargo install [email protected]
31+
```
32+
33+
Then run from anywhere in your repository.
34+
35+
```bash
36+
rbmt lint
37+
```
38+
39+
### 2. Add as a dev-dependency
40+
41+
Add as a dev-dependency to a workspace member. This pins the tool version in your lockfile for reproducible builds.
42+
43+
```toml
44+
[dev-dependencies]
45+
rust-bitcoin-maintainer-tools = "0.1.0"
46+
```
47+
48+
Then run via cargo.
49+
50+
```bash
51+
cargo run --bin rbmt -- lint
52+
```
53+
54+
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 rbmt from the local path.
10+
install:
11+
cargo install --path .

tasks/src/environment.rs

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

tasks/src/lint.rs

Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
use std::fs;
2+
use xshell::Shell;
3+
4+
use crate::environment::{get_crate_dirs, quiet_println};
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+
quiet_println("Running lint task...");
17+
18+
lint_workspace(sh)?;
19+
lint_crates(sh)?;
20+
check_duplicate_deps(sh)?;
21+
22+
quiet_println("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+
quiet_println("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+
quiet_println("Running crate-specific lints...");
57+
58+
let crate_dirs = get_crate_dirs(sh)?;
59+
quiet_println(&format!("Found crates: {}", crate_dirs.join(", ")));
60+
61+
for crate_dir in crate_dirs {
62+
// Returns a RAII guard which reverts the working directory to the old value when dropped.
63+
let _old_dir = sh.push_dir(&crate_dir);
64+
65+
// Run clippy without default features.
66+
quiet_cmd!(
67+
sh,
68+
"cargo clippy --all-targets --no-default-features --keep-going"
69+
)
70+
.args(&["--", "-D", "warnings"])
71+
.run()?;
72+
}
73+
74+
Ok(())
75+
}
76+
77+
/// Check for duplicate dependencies.
78+
fn check_duplicate_deps(sh: &Shell) -> Result<(), Box<dyn std::error::Error>> {
79+
quiet_println("Checking for duplicate dependencies...");
80+
81+
// Load whitelist configuration if it exists.
82+
let whitelist_path = sh.current_dir().join("contrib/whitelist_dups.toml");
83+
let allowed_duplicates = if whitelist_path.exists() {
84+
quiet_println(&format!(
85+
"Loading whitelist from {}",
86+
whitelist_path.display()
87+
));
88+
let contents = fs::read_to_string(&whitelist_path)?;
89+
let config: WhitelistConfig = toml::from_str(&contents)?;
90+
config.allowed_duplicates
91+
} else {
92+
Vec::new()
93+
};
94+
95+
// Run cargo tree to find duplicates.
96+
let output = quiet_cmd!(sh, "cargo tree --target=all --all-features --duplicates")
97+
.ignore_status()
98+
.read()?;
99+
100+
let duplicates: Vec<&str> = output
101+
.lines()
102+
// Filter out non crate names.
103+
.filter(|line| line.chars().next().is_some_and(|c| c.is_alphanumeric()))
104+
// Filter out whitelisted crates.
105+
.filter(|line| {
106+
!allowed_duplicates
107+
.iter()
108+
.any(|allowed| line.contains(allowed))
109+
})
110+
.collect();
111+
112+
if !duplicates.is_empty() {
113+
// Show full tree for context.
114+
quiet_cmd!(sh, "cargo tree --target=all --all-features --duplicates").run()?;
115+
eprintln!("Error: Found duplicate dependencies in workspace!");
116+
for dup in &duplicates {
117+
eprintln!(" {}", dup);
118+
}
119+
return Err("Dependency tree contains duplicates".into());
120+
}
121+
122+
quiet_println("No duplicate dependencies found");
123+
Ok(())
124+
}

tasks/src/main.rs

Lines changed: 38 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1,38 @@
1-
fn main() {}
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 = "rbmt")]
12+
#[command(about = "Rust 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)