Skip to content

Commit d9f1835

Browse files
authored
Merge pull request #2 from kykosic/debug
options for msrv, auto-update, better errors
2 parents 6226812 + 37a2049 commit d9f1835

File tree

3 files changed

+217
-63
lines changed

3 files changed

+217
-63
lines changed

Cargo.lock

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

Cargo.toml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,5 @@ path = "src/main.rs"
1212
anyhow = "1.0.70"
1313
clap = { version = "4.1.13", features = ["derive"] }
1414
glob = "0.3.1"
15+
regex = "1.7.3"
16+
semver = "1.0.17"

src/main.rs

Lines changed: 175 additions & 63 deletions
Original file line numberDiff line numberDiff line change
@@ -2,19 +2,41 @@ use std::collections::HashSet;
22
use std::path::{Path, PathBuf};
33
use std::process::{Command, ExitCode};
44

5-
use anyhow::{bail, Context, Result};
6-
use clap::{Parser, Subcommand};
5+
use anyhow::{anyhow, bail, Context, Error, Result};
6+
use clap::{Args, Parser, Subcommand};
77
use glob::glob;
8+
use regex::Regex;
9+
use semver::Version;
810

911
/// Pre-commit hook for running cargo fmt/check/clippy against a repo.
1012
/// The target repo may contain multiple independent cargo projects or workspaces.
1113
#[derive(Debug, Parser)]
1214
struct Opts {
1315
#[command(subcommand)]
1416
cmd: Cmd,
15-
/// List of chaned files to target
17+
18+
/// List of chaned files to target.
1619
#[clap(global = true)]
1720
files: Vec<PathBuf>,
21+
22+
#[command(flatten)]
23+
cargo_opts: CargoOpts,
24+
}
25+
26+
/// Configuration for cargo toolchain versioning
27+
#[derive(Debug, Args)]
28+
struct CargoOpts {
29+
/// Minimum rustc version, checked before running.
30+
// Alternatively, you can set pre-commit `default_language_version.rust`, and a managed rust
31+
// environment will be created and used at the exact version specified.
32+
#[clap(long, global = true)]
33+
rust_version: Option<Version>,
34+
/// If `rust_version` is specified and an update is needed, automatically run `rustup update`.
35+
#[clap(long, global = true)]
36+
auto_update: bool,
37+
/// Override the error message printed if `cargo` or the command executable is not found.
38+
#[clap(long, global = true)]
39+
not_found_message: Option<String>,
1840
}
1941

2042
#[derive(Debug, Subcommand)]
@@ -38,85 +60,146 @@ enum Cmd {
3860
Clippy,
3961
}
4062

41-
fn main() -> ExitCode {
42-
let opts = Opts::parse();
63+
impl Cmd {
64+
pub fn run(&self, dir: PathBuf) -> Result<()> {
65+
match self {
66+
Cmd::Fmt { config } => {
67+
let mut cmd = Command::new("cargo");
68+
cmd.arg("fmt");
4369

44-
let run_dirs = get_run_dirs(&opts.files);
70+
if let Some(config) = config {
71+
cmd.args(["--", "--config", config]);
72+
}
4573

46-
let err_count = run_dirs
47-
.into_iter()
48-
.map(|dir| match &opts.cmd {
49-
Cmd::Fmt { config } => run_fmt(dir, config),
74+
cmd.current_dir(dir);
75+
let status = cmd.status().context("failed to exec `cargo fmt`")?;
76+
if !status.success() {
77+
bail!("`cargo fmt` found errors");
78+
}
79+
Ok(())
80+
}
5081
Cmd::Check {
5182
features,
5283
all_features,
53-
} => run_check(dir, features, *all_features),
54-
Cmd::Clippy => run_clippy(dir),
55-
})
56-
.filter(|res| match res {
57-
Ok(()) => false,
58-
Err(e) => {
59-
eprintln!("{}", e);
60-
true
61-
}
62-
})
63-
.count();
84+
} => {
85+
let mut cmd = Command::new("cargo");
86+
cmd.arg("check");
6487

65-
if err_count > 0 {
66-
ExitCode::FAILURE
67-
} else {
68-
ExitCode::SUCCESS
88+
if *all_features {
89+
cmd.arg("--all-features");
90+
} else if let Some(features) = features {
91+
cmd.args(["--features", features]);
92+
}
93+
94+
cmd.current_dir(dir);
95+
let status = cmd.status().context("failed to exec `cargo check`")?;
96+
if !status.success() {
97+
bail!("`cargo check` found errors");
98+
}
99+
Ok(())
100+
}
101+
Cmd::Clippy => {
102+
let status = Command::new("cargo")
103+
.args(["clippy", "--", "-D", "warnings"])
104+
.current_dir(dir)
105+
.status()
106+
.context("failed to exec `cargo clippy`")?;
107+
if !status.success() {
108+
bail!("`cargo clippy` found errors");
109+
}
110+
Ok(())
111+
}
112+
}
69113
}
70-
}
71114

72-
const NOT_FOUND: &str = "failed to run 'cargo'";
115+
/// Check the `cargo` subcommand can be run, validating `CargoOpts` are satisfied
116+
pub fn check_subcommand(&self) -> Result<()> {
117+
let sub = match self {
118+
Cmd::Fmt { .. } => "fmt",
119+
Cmd::Check { .. } => "check",
120+
Cmd::Clippy { .. } => "clippy",
121+
};
73122

74-
fn run_fmt(dir: PathBuf, config: &Option<String>) -> Result<()> {
75-
let mut cmd = cargo();
76-
cmd.args(["fmt", "--"]);
123+
let out = Command::new("cargo")
124+
.arg(sub)
125+
.arg("--help")
126+
.output()
127+
.map_err(|_| self.missing())?;
77128

78-
if let Some(config) = config {
79-
cmd.args(["--config", config]);
129+
if !out.status.success() {
130+
Err(self.missing())
131+
} else {
132+
Ok(())
133+
}
80134
}
81135

82-
cmd.current_dir(dir);
83-
let status = cmd.status()?;
84-
if !status.success() {
85-
bail!("cargo fmt modified files");
136+
fn missing(&self) -> Error {
137+
match self {
138+
Cmd::Fmt { .. } => {
139+
anyhow!("Missing `cargo fmt`, try installing with `rustup component add rustfmt`")
140+
}
141+
Cmd::Check { .. } => {
142+
anyhow!("Missing `cargo check`, you may need to update or reinstall rust.")
143+
}
144+
Cmd::Clippy { .. } => {
145+
anyhow!("Missing `cargo clippy`, try installing with `rustup component add clippy`")
146+
}
147+
}
86148
}
87-
Ok(())
88149
}
89150

90-
fn run_check(dir: PathBuf, features: &Option<String>, all_features: bool) -> Result<()> {
91-
let mut cmd = cargo();
92-
cmd.arg("check");
93-
94-
if all_features {
95-
cmd.arg("--all-features");
96-
} else if let Some(features) = features {
97-
cmd.args(["--features", features]);
98-
}
99-
100-
cmd.current_dir(dir);
101-
let status = cmd.status().context(NOT_FOUND)?;
102-
if !status.success() {
103-
bail!("cargo check failed");
151+
/// Verify the cargo/rust toolchain exists and meets the configured requirements
152+
fn check_toolchain(opts: &CargoOpts) -> Result<()> {
153+
match toolchain_version()? {
154+
Some(ver) => {
155+
if let Some(msrv) = &opts.rust_version {
156+
if &ver < msrv {
157+
if opts.auto_update {
158+
eprintln!("Rust toolchain {ver} does not meet minimum required version {msrv}, updating...");
159+
update_rust()?;
160+
} else {
161+
bail!("Rust toolchain {} does not meet minimum required version {}. You may need to run `rustup update`.", ver, msrv);
162+
}
163+
}
164+
}
165+
}
166+
None => {
167+
match &opts.not_found_message {
168+
Some(msg) => bail!("{}", msg),
169+
None => bail!("Could not locate `cargo` binary. See https://www.rust-lang.org/tools/install to install rust"),
170+
}
171+
}
104172
}
105173
Ok(())
106174
}
107175

108-
fn run_clippy(dir: PathBuf) -> Result<()> {
109-
let status = cargo()
110-
.args(["clippy", "--", "-D", "warnings"])
111-
.current_dir(dir)
176+
/// Returns `Ok(None)` if cargo binary is not found / fails to run.
177+
/// Errors when `cargo --version` runs, but the output cannot be parsed.
178+
fn toolchain_version() -> Result<Option<Version>> {
179+
let Ok(out) = Command::new("cargo").arg("--version").output() else { return Ok(None) };
180+
let stdout = String::from_utf8_lossy(&out.stdout);
181+
let version_re = Regex::new(r"cargo (\d+\.\d+\.\S+)").unwrap();
182+
let caps = version_re
183+
.captures(&stdout)
184+
.ok_or_else(|| anyhow!("Unexpected `cargo --version` output: {stdout}"))?;
185+
let version = caps[1]
186+
.parse()
187+
.context(format!("could not parse cargo version: {}", &caps[1]))?;
188+
Ok(Some(version))
189+
}
190+
191+
fn update_rust() -> Result<()> {
192+
let status = Command::new("rustup")
193+
.arg("update")
112194
.status()
113-
.context(NOT_FOUND)?;
195+
.context("failed to run `rustup update`, is rust installed?")?;
114196
if !status.success() {
115-
bail!("cargo clippy failed");
197+
bail!("failed to run `rustup update`, see above errors");
116198
}
117199
Ok(())
118200
}
119201

202+
/// Get all root cargo workspaces that need to be checked based on changed files
120203
fn get_run_dirs(changed_files: &[PathBuf]) -> HashSet<PathBuf> {
121204
let root_dirs = find_cargo_root_dirs();
122205
let mut run_dirs: HashSet<PathBuf> = HashSet::new();
@@ -136,6 +219,7 @@ fn get_run_dirs(changed_files: &[PathBuf]) -> HashSet<PathBuf> {
136219
run_dirs
137220
}
138221

222+
/// Find all root-level cargo workspaces from the current repository root
139223
fn find_cargo_root_dirs() -> Vec<PathBuf> {
140224
let mut dirs = Vec::new();
141225
for entry in glob("**/Cargo.toml").unwrap() {
@@ -147,6 +231,7 @@ fn find_cargo_root_dirs() -> Vec<PathBuf> {
147231
dirs
148232
}
149233

234+
/// Check if changed file path should trigger a hook run
150235
fn is_rust_file<P: AsRef<Path>>(path: P) -> bool {
151236
let path = path.as_ref();
152237
if let Some(ext) = path.extension() {
@@ -163,11 +248,38 @@ fn is_rust_file<P: AsRef<Path>>(path: P) -> bool {
163248
false
164249
}
165250

166-
fn cargo() -> Command {
167-
/// The compile-time location of cargo. Used to access the pre-commit managed environment
168-
/// of cargo for subcommands;
169-
const CARGO_HOME: &str = std::env!("CARGO_HOME");
251+
fn main() -> ExitCode {
252+
let opts = Opts::parse();
253+
254+
let run_dirs = get_run_dirs(&opts.files);
255+
if run_dirs.is_empty() {
256+
return ExitCode::SUCCESS;
257+
}
258+
259+
if let Err(e) = check_toolchain(&opts.cargo_opts) {
260+
eprintln!("{e}");
261+
return ExitCode::FAILURE;
262+
}
263+
if let Err(e) = opts.cmd.check_subcommand() {
264+
eprintln!("{e}");
265+
return ExitCode::FAILURE;
266+
}
267+
268+
let err_count = run_dirs
269+
.into_iter()
270+
.map(|dir| opts.cmd.run(dir))
271+
.filter(|res| match res {
272+
Ok(()) => false,
273+
Err(e) => {
274+
eprintln!("{}", e);
275+
true
276+
}
277+
})
278+
.count();
170279

171-
let bin = PathBuf::from(CARGO_HOME).join("bin").join("cargo");
172-
Command::new(bin)
280+
if err_count > 0 {
281+
ExitCode::FAILURE
282+
} else {
283+
ExitCode::SUCCESS
284+
}
173285
}

0 commit comments

Comments
 (0)