Skip to content

Commit

Permalink
Merge pull request #2 from kykosic/debug
Browse files Browse the repository at this point in the history
options for msrv, auto-update, better errors
  • Loading branch information
kykosic authored Apr 8, 2023
2 parents 6226812 + 37a2049 commit d9f1835
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 63 deletions.
40 changes: 40 additions & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,5 @@ path = "src/main.rs"
anyhow = "1.0.70"
clap = { version = "4.1.13", features = ["derive"] }
glob = "0.3.1"
regex = "1.7.3"
semver = "1.0.17"
238 changes: 175 additions & 63 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2,19 +2,41 @@ use std::collections::HashSet;
use std::path::{Path, PathBuf};
use std::process::{Command, ExitCode};

use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use anyhow::{anyhow, bail, Context, Error, Result};
use clap::{Args, Parser, Subcommand};
use glob::glob;
use regex::Regex;
use semver::Version;

/// Pre-commit hook for running cargo fmt/check/clippy against a repo.
/// The target repo may contain multiple independent cargo projects or workspaces.
#[derive(Debug, Parser)]
struct Opts {
#[command(subcommand)]
cmd: Cmd,
/// List of chaned files to target

/// List of chaned files to target.
#[clap(global = true)]
files: Vec<PathBuf>,

#[command(flatten)]
cargo_opts: CargoOpts,
}

/// Configuration for cargo toolchain versioning
#[derive(Debug, Args)]
struct CargoOpts {
/// Minimum rustc version, checked before running.
// Alternatively, you can set pre-commit `default_language_version.rust`, and a managed rust
// environment will be created and used at the exact version specified.
#[clap(long, global = true)]
rust_version: Option<Version>,
/// If `rust_version` is specified and an update is needed, automatically run `rustup update`.
#[clap(long, global = true)]
auto_update: bool,
/// Override the error message printed if `cargo` or the command executable is not found.
#[clap(long, global = true)]
not_found_message: Option<String>,
}

#[derive(Debug, Subcommand)]
Expand All @@ -38,85 +60,146 @@ enum Cmd {
Clippy,
}

fn main() -> ExitCode {
let opts = Opts::parse();
impl Cmd {
pub fn run(&self, dir: PathBuf) -> Result<()> {
match self {
Cmd::Fmt { config } => {
let mut cmd = Command::new("cargo");
cmd.arg("fmt");

let run_dirs = get_run_dirs(&opts.files);
if let Some(config) = config {
cmd.args(["--", "--config", config]);
}

let err_count = run_dirs
.into_iter()
.map(|dir| match &opts.cmd {
Cmd::Fmt { config } => run_fmt(dir, config),
cmd.current_dir(dir);
let status = cmd.status().context("failed to exec `cargo fmt`")?;
if !status.success() {
bail!("`cargo fmt` found errors");
}
Ok(())
}
Cmd::Check {
features,
all_features,
} => run_check(dir, features, *all_features),
Cmd::Clippy => run_clippy(dir),
})
.filter(|res| match res {
Ok(()) => false,
Err(e) => {
eprintln!("{}", e);
true
}
})
.count();
} => {
let mut cmd = Command::new("cargo");
cmd.arg("check");

if err_count > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
if *all_features {
cmd.arg("--all-features");
} else if let Some(features) = features {
cmd.args(["--features", features]);
}

cmd.current_dir(dir);
let status = cmd.status().context("failed to exec `cargo check`")?;
if !status.success() {
bail!("`cargo check` found errors");
}
Ok(())
}
Cmd::Clippy => {
let status = Command::new("cargo")
.args(["clippy", "--", "-D", "warnings"])
.current_dir(dir)
.status()
.context("failed to exec `cargo clippy`")?;
if !status.success() {
bail!("`cargo clippy` found errors");
}
Ok(())
}
}
}
}

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

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

if let Some(config) = config {
cmd.args(["--config", config]);
if !out.status.success() {
Err(self.missing())
} else {
Ok(())
}
}

cmd.current_dir(dir);
let status = cmd.status()?;
if !status.success() {
bail!("cargo fmt modified files");
fn missing(&self) -> Error {
match self {
Cmd::Fmt { .. } => {
anyhow!("Missing `cargo fmt`, try installing with `rustup component add rustfmt`")
}
Cmd::Check { .. } => {
anyhow!("Missing `cargo check`, you may need to update or reinstall rust.")
}
Cmd::Clippy { .. } => {
anyhow!("Missing `cargo clippy`, try installing with `rustup component add clippy`")
}
}
}
Ok(())
}

fn run_check(dir: PathBuf, features: &Option<String>, all_features: bool) -> Result<()> {
let mut cmd = cargo();
cmd.arg("check");

if all_features {
cmd.arg("--all-features");
} else if let Some(features) = features {
cmd.args(["--features", features]);
}

cmd.current_dir(dir);
let status = cmd.status().context(NOT_FOUND)?;
if !status.success() {
bail!("cargo check failed");
/// Verify the cargo/rust toolchain exists and meets the configured requirements
fn check_toolchain(opts: &CargoOpts) -> Result<()> {
match toolchain_version()? {
Some(ver) => {
if let Some(msrv) = &opts.rust_version {
if &ver < msrv {
if opts.auto_update {
eprintln!("Rust toolchain {ver} does not meet minimum required version {msrv}, updating...");
update_rust()?;
} else {
bail!("Rust toolchain {} does not meet minimum required version {}. You may need to run `rustup update`.", ver, msrv);
}
}
}
}
None => {
match &opts.not_found_message {
Some(msg) => bail!("{}", msg),
None => bail!("Could not locate `cargo` binary. See https://www.rust-lang.org/tools/install to install rust"),
}
}
}
Ok(())
}

fn run_clippy(dir: PathBuf) -> Result<()> {
let status = cargo()
.args(["clippy", "--", "-D", "warnings"])
.current_dir(dir)
/// Returns `Ok(None)` if cargo binary is not found / fails to run.
/// Errors when `cargo --version` runs, but the output cannot be parsed.
fn toolchain_version() -> Result<Option<Version>> {
let Ok(out) = Command::new("cargo").arg("--version").output() else { return Ok(None) };
let stdout = String::from_utf8_lossy(&out.stdout);
let version_re = Regex::new(r"cargo (\d+\.\d+\.\S+)").unwrap();
let caps = version_re
.captures(&stdout)
.ok_or_else(|| anyhow!("Unexpected `cargo --version` output: {stdout}"))?;
let version = caps[1]
.parse()
.context(format!("could not parse cargo version: {}", &caps[1]))?;
Ok(Some(version))
}

fn update_rust() -> Result<()> {
let status = Command::new("rustup")
.arg("update")
.status()
.context(NOT_FOUND)?;
.context("failed to run `rustup update`, is rust installed?")?;
if !status.success() {
bail!("cargo clippy failed");
bail!("failed to run `rustup update`, see above errors");
}
Ok(())
}

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

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

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

fn cargo() -> Command {
/// The compile-time location of cargo. Used to access the pre-commit managed environment
/// of cargo for subcommands;
const CARGO_HOME: &str = std::env!("CARGO_HOME");
fn main() -> ExitCode {
let opts = Opts::parse();

let run_dirs = get_run_dirs(&opts.files);
if run_dirs.is_empty() {
return ExitCode::SUCCESS;
}

if let Err(e) = check_toolchain(&opts.cargo_opts) {
eprintln!("{e}");
return ExitCode::FAILURE;
}
if let Err(e) = opts.cmd.check_subcommand() {
eprintln!("{e}");
return ExitCode::FAILURE;
}

let err_count = run_dirs
.into_iter()
.map(|dir| opts.cmd.run(dir))
.filter(|res| match res {
Ok(()) => false,
Err(e) => {
eprintln!("{}", e);
true
}
})
.count();

let bin = PathBuf::from(CARGO_HOME).join("bin").join("cargo");
Command::new(bin)
if err_count > 0 {
ExitCode::FAILURE
} else {
ExitCode::SUCCESS
}
}

0 comments on commit d9f1835

Please sign in to comment.