ci

CI for all possible combinations of features in Cargo.toml.
git clone https://git.philomathiclife.com/repos/ci
Log | Files | Refs | README

commit 0a7fb27cbcc4c2114f63423d777737d7119d6196
parent f33a34767075f76b41a9ea78bcf431e3b785ca28
Author: Zack Newman <zack@philomathiclife.com>
Date:   Mon, 19 Aug 2024 16:55:04 -0600

make color and working dir configurable

Diffstat:
MCargo.toml | 4++--
MREADME.md | 12+++++++-----
Msrc/args.rs | 180++++++++++++++++++++++++++++++++++++++++++++++++++++++++++---------------------
Msrc/main.rs | 106++++++++++++++++++++++++++++++++++++++++++-------------------------------------
4 files changed, 198 insertions(+), 104 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -9,14 +9,14 @@ license = "MIT OR Apache-2.0" name = "ci" readme = "README.md" repository = "https://git.philomathiclife.com/repos/ci/" -rust-version = "1.74.0" +rust-version = "1.77.0" version = "0.1.0" [badges] maintenance = { status = "actively-developed" } [dependencies] -serde = { version = "1.0.207", default-features = false } +serde = { version = "1.0.208", default-features = false } toml = { version = "0.8.19", default-features = false, features = ["parse"] } [profile.release] diff --git a/README.md b/README.md @@ -1,7 +1,7 @@ # `ci` -`ci` is a CLI application that runs [`cargo`](https://doc.rust-lang.org/cargo/index.html) with `clippy -q --color always`, -`t -q --color always --doc`, and `t -q --color always --tests` with all possible combinations of features defined in `Cargo.toml`. +`ci` is a CLI application that runs [`cargo`](https://doc.rust-lang.org/cargo/index.html) with +`clippy -q`, `t -q --doc`, and `t -q --tests` with all possible combinations of features defined in `Cargo.toml`. `ci` avoids superfluous combinations of features. For example if feature `foo` automatically enables feature `bar` and `bar` automatically enables feature `fizz`, then no combination of features that contains @@ -23,11 +23,13 @@ invoking `cargo` with each possible combination of features, this handles it aut * `clippy`: `cargo clippy -q` is invoked for each combination of features. * `doc_tests`: `cargo t -q --doc` is invoked for each combination of features. * `tests`: `cargo t -q --tests` is invoked for each combination of features. +* `--color`: `--color always` is passed to the above commands; otherwise without this option, `--color never` is + passed. +* `--dir <path to directory Cargo.toml is in>`: `ci` changes the working directory to the passed path (after + canonicalizing it) before executing. Without this, the current directory is used. -When no options are passed, then all three options above are invoked. +When `clippy`, `doc_tests`, and `tests` are not passed; then all three are invoked. ## Limitations Any use of `compile_error` _not_ related to incompatible features will be silently ignored. - -`ci` must be run in the same directory as the `Cargo.toml` file that contains the features. diff --git a/src/args.rs b/src/args.rs @@ -4,6 +4,8 @@ use std::{ collections::HashSet, env, error::Error, + fs, + path::PathBuf, process::{Command, Stdio}, }; /// Error returned when parsing arguments passed to the application. @@ -12,16 +14,21 @@ use std::{ pub enum ArgsErr { /// Error when no arguments exist. NoArgs, - /// Error when an invalid option is passed. The contained [`String`] - /// is the value of the invalid option. + /// Error when an invalid option is passed. The contained [`String`] is the value of the invalid option. InvalidOption(String), + /// Error when an option is passed more than once. The contained [`String`] is the duplicate option. + DuplicateOption(String), + /// Error when `--dir` is passed with no file path to the directory `ci` should run in. + MissingPath, } impl Display for ArgsErr { #[allow(clippy::ref_patterns)] fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result { match *self { Self::NoArgs => write!(f, "no arguments exist including the name of the process itself"), - Self::InvalidOption(ref arg) => write!(f, "{arg} is an invalid option. No arguments or exactly one of 'clippy', 'doc_tests', or 'tests' is allowed"), + Self::InvalidOption(ref arg) => write!(f, "{arg} is an invalid option. No arguments or exactly one of 'clippy', 'doc_tests', or 'tests' is allowed followed by nothing, '--color', or '--dir' and the path to the directory ci should run in"), + Self::DuplicateOption(ref arg) => write!(f, "{arg} was passed more than once"), + Self::MissingPath => f.write_str("--dir was passed without a path to the directory ci should run in"), } } } @@ -31,15 +38,21 @@ impl Error for ArgsErr {} pub enum Opts { /// Variant when no arguments were passed. /// - /// The contained `bool` is `true` iff - /// `Self::DocTests` should be ignored. - None(bool), + /// The first contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used. + /// The second contained `bool` is `true` iff `Self::DocTests` should be ignored. + None(bool, bool), /// Variant when `clippy` is passed. - Clippy, + /// + /// The contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used. + Clippy(bool), /// Variant when `doc_tests` is passed. - DocTests, + /// + /// The contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used. + DocTests(bool), /// Variant when `tests` is passed. - Tests, + /// + /// The contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used. + Tests(bool), } /// Kind of successful completion of a command. pub enum Success { @@ -47,29 +60,52 @@ pub enum Success { Normal, /// Erred due to [`compile_error`]. CompileError, - /// `cargo t -q --color always --doc --no-default-features` erred since - /// there was no library target. + /// `cargo t -q --doc` erred since there was no library target. NoLibraryTargets, } impl Opts { + /// Returns `true` iff color should be outputted. + const fn contains_color(self) -> bool { + match self { + Self::None(color, _) + | Self::Clippy(color) + | Self::DocTests(color) + | Self::Tests(color) => color, + } + } + /// Changes `self` such that it should contain color. + fn set_color(&mut self) { + match *self { + Self::None(ref mut color, _) + | Self::Clippy(ref mut color) + | Self::DocTests(ref mut color) + | Self::Tests(ref mut color) => *color = true, + } + } /// Returns the arguments to pass to the command based on `self`. fn args(self) -> Vec<&'static str> { match self { - Self::None(_) => Vec::new(), - Self::Clippy => vec!["clippy", "-q", "--color", "always", "--no-default-features"], - Self::DocTests => vec![ + Self::None(_, _) => Vec::new(), + Self::Clippy(color) => vec![ + "clippy", + "-q", + "--color", + if color { "always" } else { "never" }, + "--no-default-features", + ], + Self::DocTests(color) => vec![ "t", "-q", "--color", - "always", + if color { "always" } else { "never" }, "--doc", "--no-default-features", ], - Self::Tests => vec![ + Self::Tests(color) => vec![ "t", "-q", "--color", - "always", + if color { "always" } else { "never" }, "--tests", "--no-default-features", ], @@ -78,8 +114,8 @@ impl Opts { /// Returns the appropriate [`Stdio`] to be used to capture `stdout` based on `self`. fn stdout(self) -> Stdio { match self { - Self::None(_) | Self::Clippy => Stdio::null(), - Self::DocTests | Self::Tests => Stdio::piped(), + Self::None(_, _) | Self::Clippy(_) => Stdio::null(), + Self::DocTests(_) | Self::Tests(_) => Stdio::piped(), } } /// Returns the entire command based on `self`. @@ -103,8 +139,8 @@ impl Opts { /// `Ok(Success::NoLibraryTargets)` iff `Self::DocTests` erred due to there not being any library targets. /// `err_msgs` is used to collect unique output written to `stderr` when the command successfully completes. /// - /// `self` is mutated iff `Self::None(false)` and `cargo t -q --color always --doc` errors - /// due to a lack of library target; in which case, the contained `bool` becomes `true`. + /// `self` is mutated iff `Self::None(_, false)` and `cargo t -q --doc` errors + /// due to a lack of library target; in which case, the second `bool` becomes `true`. #[allow(clippy::too_many_lines)] pub fn run_cmd( &mut self, @@ -112,8 +148,8 @@ impl Opts { err_msgs: &mut HashSet<String>, ) -> Result<Success, E> { match *self { - Self::None(ref mut skip_doc) => { - Self::Clippy + Self::None(color, ref mut skip_doc) => { + Self::Clippy(color) .run_cmd(features, err_msgs) .and_then(|success| { // We don't want to run the other commands since they will also have a `compile_error`. @@ -121,27 +157,27 @@ impl Opts { Ok(success) } else { if *skip_doc { - Ok(Success::Normal) + Ok(Success::NoLibraryTargets) } else { - Self::DocTests.run_cmd(features, err_msgs) + Self::DocTests(color).run_cmd(features, err_msgs) } .and_then(|success_2| { *skip_doc = matches!(success_2, Success::NoLibraryTargets); - Self::Tests.run_cmd(features, err_msgs) + Self::Tests(color).run_cmd(features, err_msgs) }) } }) } - Self::Clippy | Self::DocTests | Self::Tests => { + Self::Clippy(color) | Self::DocTests(color) | Self::Tests(color) => { let mut args = self.args(); if !features.is_empty() { args.push("--features"); args.push(features); } - if matches!(*self, Self::DocTests | Self::Tests) { + if matches!(*self, Self::DocTests(_) | Self::Tests(_)) { args.push("--"); args.push("--color"); - args.push("always"); + args.push(if color { "always" } else { "never" }); } let output = Command::new("cargo") .stderr(Stdio::piped()) @@ -175,7 +211,7 @@ impl Opts { .position(|window| window == COMPILE_ERROR) .map_or_else( || { - if matches!(*self, Self::DocTests) + if matches!(*self, Self::DocTests(_)) && output .stderr .windows(NO_LIB_TARG.len()) @@ -211,8 +247,12 @@ impl Opts { msg.push_str(" --features "); msg.push_str(features); } - if matches!(*self, Self::DocTests | Self::Tests) { - msg.push_str(" -- --color always"); + if matches!(*self, Self::DocTests(_) | Self::Tests(_)) { + msg.push_str(if color { + " -- --color always" + } else { + " -- --color never" + }); } Err(E::Cmd(msg)) }, @@ -222,22 +262,68 @@ impl Opts { } } } - /// Returns `Opts` based on arguments passed to the application. - pub fn from_args() -> Result<Self, ArgsErr> { + /// Returns `Opts` and the directory `ci` should run in based on arguments passed to the application. + #[allow(clippy::redundant_else)] + pub fn from_args() -> Result<(Self, Option<PathBuf>), E> { let mut args = env::args(); - args.next().ok_or(ArgsErr::NoArgs).and_then(|_| { - args.next().map_or(Ok(Self::None(false)), |arg| { - match arg.as_str() { - "clippy" => Ok(Self::Clippy), - "doc_tests" => Ok(Self::DocTests), - "tests" => Ok(Self::Tests), - _ => Err(ArgsErr::InvalidOption(arg)), + if args.next().is_none() { + return Err(E::Args(ArgsErr::NoArgs)); + } + let mut opt = Self::None(false, false); + let mut path = None; + while let Some(arg) = args.next() { + match arg.as_str() { + "clippy" => { + if !matches!(opt, Self::None(_, _)) { + return Err(E::Args(ArgsErr::InvalidOption(String::from("clippy")))); + } else if opt.contains_color() { + return Err(E::Args(ArgsErr::InvalidOption(String::from("--color")))); + } else if path.is_some() { + return Err(E::Args(ArgsErr::InvalidOption(String::from("--dir")))); + } else { + opt = Self::Clippy(false); + } } - .and_then(|opts| { - args.next() - .map_or(Ok(opts), |opt| Err(ArgsErr::InvalidOption(opt))) - }) - }) - }) + "doc_tests" => { + if !matches!(opt, Self::None(_, _)) { + return Err(E::Args(ArgsErr::InvalidOption(String::from("doc_tests")))); + } else if opt.contains_color() { + return Err(E::Args(ArgsErr::InvalidOption(String::from("--color")))); + } else if path.is_some() { + return Err(E::Args(ArgsErr::InvalidOption(String::from("--dir")))); + } else { + opt = Self::DocTests(false); + } + } + "tests" => { + if !matches!(opt, Self::None(_, _)) { + return Err(E::Args(ArgsErr::InvalidOption(String::from("tests")))); + } else if opt.contains_color() { + return Err(E::Args(ArgsErr::InvalidOption(String::from("--color")))); + } else if path.is_some() { + return Err(E::Args(ArgsErr::InvalidOption(String::from("--dir")))); + } else { + opt = Self::Tests(false); + } + } + "--color" => { + if opt.contains_color() { + return Err(E::Args(ArgsErr::DuplicateOption(arg))); + } + opt.set_color(); + } + "--dir" => { + if path.is_some() { + return Err(E::Args(ArgsErr::DuplicateOption(arg))); + } else if let Some(p) = args.next() { + path = Some(fs::canonicalize(p).map_err(E::Io)?); + } else { + return Err(E::Args(ArgsErr::MissingPath)); + } + } + _ => return Err(E::Args(ArgsErr::InvalidOption(arg))), + } + } + Ok((opt, path)) } } diff --git a/src/main.rs b/src/main.rs @@ -1,7 +1,7 @@ //! # `ci` //! -//! `ci` is a CLI application that runs [`cargo`](https://doc.rust-lang.org/cargo/index.html) with `clippy -q --color always`, -//! `t -q --color always --doc`, and `t -q --color always --tests` with all possible combinations of features defined in `Cargo.toml`. +//! `ci` is a CLI application that runs [`cargo`](https://doc.rust-lang.org/cargo/index.html) with +//! `clippy -q`, `t -q --doc`, and `t -q --tests` with all possible combinations of features defined in `Cargo.toml`. //! //! `ci` avoids superfluous combinations of features. For example if feature `foo` automatically enables //! feature `bar` and `bar` automatically enables feature `fizz`, then no combination of features that contains @@ -23,15 +23,18 @@ //! * `clippy`: `cargo clippy -q` is invoked for each combination of features. //! * `doc_tests`: `cargo t -q --doc` is invoked for each combination of features. //! * `tests`: `cargo t -q --tests` is invoked for each combination of features. +//! * `--color`: `--color always` is passed to the above commands; otherwise without this option, `--color never` is +//! passed. +//! * `--dir <path to directory Cargo.toml is in>`: `ci` changes the working directory to the passed path (after +//! canonicalizing it) before executing. Without this, the current directory is used. //! -//! When no options are passed, then all three options above are invoked. +//! When `clippy`, `doc_tests`, and `tests` are not passed; then all three are invoked. //! //! ## Limitations //! //! Any use of [`compile_error`] _not_ related to incompatible features will be silently ignored. -//! -//! `ci` must be run in the same directory as the `Cargo.toml` file that contains the features. #![deny( + unknown_lints, future_incompatible, let_underscore, missing_docs, @@ -39,6 +42,7 @@ rust_2018_compatibility, rust_2018_idioms, rust_2021_compatibility, + rust_2024_compatibility, unsafe_code, unused, warnings, @@ -73,6 +77,7 @@ use args::{ArgsErr, Opts, Success}; use core::fmt::{self, Display, Formatter}; use std::{ collections::HashSet, + env, error::Error, fs, io::{self, Write}, @@ -115,51 +120,52 @@ impl fmt::Debug for E { } impl Error for E {} fn main() -> Result<(), E> { - Opts::from_args().map_err(E::Args).and_then(|mut opt| { - fs::read_to_string("Cargo.toml") - .map_err(E::Io) - .and_then(|toml| manifest::from_toml(toml.as_str()).map_err(E::Toml)) - .and_then(|features_powerset| { - features_powerset - .into_iter() - .try_fold(HashSet::new(), |mut msgs, features| { - opt.run_cmd(features.as_str(), &mut msgs) - .and_then(|success| { - if matches!(success, Success::NoLibraryTargets) { - // We don't want to bother continuing to call - // `cargo t -q --color always --doc` once we know - // there is no library target. - assert!(matches!(opt, Opts::DocTests), "Opts::DocTests should be the only variant that can return Success::NoLibraryTargets when Opts::run_cmd is called"); - Err(E::NoLibraryTargets) - } else { - Ok(msgs) - } - }) - }) - .or_else(|e| { - if matches!(e, E::NoLibraryTargets) { - Ok(HashSet::new()) - } else { - Err(e) - } - }) - .and_then(|msgs| { - if msgs.is_empty() { - Ok(()) - } else { - io::stderr() - .lock() - .write_all( - msgs.into_iter() - .fold(String::new(), |mut buffer, msg| { - buffer.push_str(msg.as_str()); - buffer - }) - .as_bytes(), - ) - .map_err(E::Io) - } - }) + Opts::from_args().and_then(|(mut opt, path)| { + path.map_or(Ok(()), |p| env::set_current_dir(p).map_err(E::Io)).and_then(|()| { + fs::read_to_string("Cargo.toml") + .map_err(E::Io) + .and_then(|toml| manifest::from_toml(toml.as_str()).map_err(E::Toml)) + .and_then(|features_powerset| { + features_powerset + .into_iter() + .try_fold(HashSet::new(), |mut msgs, features| { + opt.run_cmd(features.as_str(), &mut msgs) + .and_then(|success| { + if matches!(success, Success::NoLibraryTargets) { + // We don't want to bother continuing to call `cargo t -q --doc` once we + // know there is no library target. + assert!(matches!(opt, Opts::DocTests(_)), "Opts::DocTests should be the only variant that can return Success::NoLibraryTargets when Opts::run_cmd is called"); + Err(E::NoLibraryTargets) + } else { + Ok(msgs) + } + }) + }) + .or_else(|e| { + if matches!(e, E::NoLibraryTargets) { + Ok(HashSet::new()) + } else { + Err(e) + } + }) + .and_then(|msgs| { + if msgs.is_empty() { + Ok(()) + } else { + io::stderr() + .lock() + .write_all( + msgs.into_iter() + .fold(String::new(), |mut buffer, msg| { + buffer.push_str(msg.as_str()); + buffer + }) + .as_bytes(), + ) + .map_err(E::Io) + } + }) + }) }) }) }