@@ -2,19 +2,41 @@ use std::collections::HashSet;
22use std:: path:: { Path , PathBuf } ;
33use 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 } ;
77use 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 ) ]
1214struct 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
120203fn 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
139223fn 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
150235fn 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