ci

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

commit 3d63f3912263b87f7802273466c18bddc3b3401e
parent a1fddf250797ecdc4ab78e6c0311190da14fdd77
Author: Zack Newman <zack@philomathiclife.com>
Date:   Tue, 13 Aug 2024 22:18:51 -0600

pass unique stderr messages on success. retain color output. dont error when no library targets exist

Diffstat:
MCargo.toml | 1+
MREADME.md | 14+++++++-------
Msrc/args.rs | 235+++++++++++++++++++++++++++++++++++++++++++++++++++++--------------------------
Msrc/main.rs | 74++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--------------
4 files changed, 226 insertions(+), 98 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml @@ -9,6 +9,7 @@ license = "MIT OR Apache-2.0" name = "ci" readme = "README.md" repository = "https://git.philomathiclife.com/repos/ci/" +rust-version = "1.74.0" version = "0.1.0" [badges] diff --git a/README.md b/README.md @@ -1,14 +1,16 @@ # `ci` -`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` 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` 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 `foo` and `bar`, `foo` and `fizz`, or `bar` and `fizz` will be tested. -`ci` writes to `stderr` iff an error arises. The error is written as well as the command and features -that caused the error. `stdout` is never written to. +`ci` writes to `stderr` iff a command errors for a reason other than `compile_error` or `stderr` is written +to on success. When a non-`compile_error` occurs, `ci` is terminated after writing the offending command and +features. Upon successful completion, `ci` writes all _unique_ messages that were collected. `stdout` is +never written to. ## Why is this useful? @@ -26,8 +28,6 @@ When no options are passed, then all three options above are invoked. ## Limitations -All possible combinations of features are tested; thus if `compile_error` is used to prevent certain -combinations, `ci` will error. Since `ci` is designed for testing and development, it should be easy to -temporarily comment out instances of `compile_error`. +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 @@ -1,6 +1,7 @@ use super::E; use core::fmt::{self, Display, Formatter}; use std::{ + collections::HashSet, env, error::Error, process::{Command, Stdio}, @@ -29,7 +30,10 @@ impl Error for ArgsErr {} #[derive(Clone, Copy, Debug)] pub enum Opts { /// Variant when no arguments were passed. - None, + /// + /// The contained `bool` is `true` iff + /// `Self::DocTests` should be ignored. + None(bool), /// Variant when `clippy` is passed. Clippy, /// Variant when `doc_tests` is passed. @@ -37,98 +41,175 @@ pub enum Opts { /// Variant when `tests` is passed. Tests, } +/// Kind of successful completion of a command. +pub enum Success { + /// Ran normally without errors. + Normal, + /// Erred due to [`compile_error`]. + CompileError, + /// `cargo t -q --color always --doc --no-default-features` erred since + /// there was no library target. + NoLibraryTargets, +} impl Opts { - /// Runs `cargo` with argument based on `self` and features of `features`. - pub fn run_cmd(self, features: &str) -> Result<(), E> { + /// Returns the arguments to pass to the command based on `self`. + fn args(self) -> Vec<&'static str> { match self { - Self::None => Self::Clippy.run_cmd(features).and_then(|()| { - Self::DocTests - .run_cmd(features) - .and_then(|()| Self::Tests.run_cmd(features)) - }), - Self::Clippy => { - let mut args = vec!["clippy", "-q", "--no-default-features"]; - if !features.is_empty() { - args.push("--features"); - args.push(features); - } - let output = Command::new("cargo") - .stderr(Stdio::inherit()) - .stdin(Stdio::null()) - .stdout(Stdio::null()) - .args(args) - .output() - .map_err(E::Io)?; - if output.status.success() { - Ok(()) - } else { - String::from_utf8(output.stderr) - .map_err(E::Utf8) - .and_then(|mut err| { - err.push_str("\ncargo clippy -q --no-default-features"); - if !features.is_empty() { - err.push_str(" --features "); - err.push_str(features); + Self::None(_) => Vec::new(), + Self::Clippy => vec!["clippy", "-q", "--color", "always", "--no-default-features"], + Self::DocTests => vec![ + "t", + "-q", + "--color", + "always", + "--doc", + "--no-default-features", + ], + Self::Tests => vec![ + "t", + "-q", + "--color", + "always", + "--tests", + "--no-default-features", + ], + } + } + /// 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(), + } + } + /// Returns the entire command based on `self`. + /// + /// Note this is the same as [`Opts::args`] except a `String` containing the space-separated values is returned + /// with `"\ncargo "` beginning the `String`. + fn cmd_str(self) -> String { + let mut cmd = self + .args() + .into_iter() + .fold(String::from("\ncargo "), |mut cmd, val| { + cmd.push_str(val); + cmd.push(' '); + cmd + }); + cmd.pop(); + cmd + } + /// Runs `cargo` with argument based on `self` and features of `features` returning `Ok(Success::Normal)` iff + /// the command ran successfully, `Ok(Success::CompileError)` iff a [`compile_error`] occurred, and + /// `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`. + pub fn run_cmd( + &mut self, + features: &str, + err_msgs: &mut HashSet<String>, + ) -> Result<Success, E> { + match *self { + Self::None(ref mut skip_doc) => { + Self::Clippy + .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`. + if matches!(success, Success::CompileError) { + Ok(success) + } else { + if *skip_doc { + Ok(Success::Normal) + } else { + Self::DocTests.run_cmd(features, err_msgs) } - Err(E::Cmd(err)) - }) - } + .and_then(|success_2| { + *skip_doc = matches!(success_2, Success::NoLibraryTargets); + Self::Tests.run_cmd(features, err_msgs) + }) + } + }) } - Self::DocTests => { - let mut args = vec!["t", "-q", "--doc", "--no-default-features"]; + Self::Clippy | Self::DocTests | Self::Tests => { + let mut args = self.args(); if !features.is_empty() { args.push("--features"); args.push(features); } let output = Command::new("cargo") - .stderr(Stdio::inherit()) + .stderr(Stdio::piped()) .stdin(Stdio::null()) - .stdout(Stdio::piped()) + .stdout(self.stdout()) .args(args) .output() .map_err(E::Io)?; - if output.status.success() { - Ok(()) - } else { - String::from_utf8(output.stdout) - .map_err(E::Utf8) - .and_then(|mut err| { - err.push_str("\ncargo t -q --doc --no-default-features"); - if !features.is_empty() { - err.push_str(" --features "); - err.push_str(features); + if let Some(code) = output.status.code() { + match code { + 0i32 => { + if output.stderr.is_empty() { + Ok(Success::Normal) + } else { + return String::from_utf8(output.stderr).map_err(E::Utf8).map( + |msg| { + err_msgs.insert(msg); + Success::Normal + }, + ); } - Err(E::Cmd(err)) - }) - } - } - Self::Tests => { - let mut args = vec!["t", "-q", "--tests", "--no-default-features"]; - if !features.is_empty() { - args.push("--features"); - args.push(features); - } - let output = Command::new("cargo") - .stderr(Stdio::inherit()) - .stdin(Stdio::null()) - .stdout(Stdio::piped()) - .args(args) - .output() - .map_err(E::Io)?; - if output.status.success() { - Ok(()) + } + 101i32 => { + /// `"compile_error!"` as a byte string. + const COMPILE_ERROR: &[u8; 14] = b"compile_error!"; + /// `"no library targets found in package"` as a byte string. + const NO_LIB_TARG: &[u8; 35] = b"no library targets found in package"; + output + .stderr + .windows(COMPILE_ERROR.len()) + .position(|window| window == COMPILE_ERROR) + .map_or_else( + || { + if matches!(*self, Self::DocTests) + && output + .stderr + .windows(NO_LIB_TARG.len()) + .any(|window| window == NO_LIB_TARG) + { + Ok(Success::NoLibraryTargets) + } else { + Err(()) + } + }, + |_| Ok(Success::CompileError), + ) + } + _ => Err(()), + } } else { - String::from_utf8(output.stdout) + Err(()) + } + .or_else(|()| { + String::from_utf8(output.stderr) .map_err(E::Utf8) - .and_then(|mut err| { - err.push_str("\ncargo t -q --tests --no-default-features"); - if !features.is_empty() { - err.push_str(" --features "); - err.push_str(features); - } - Err(E::Cmd(err)) + .and_then(|err| { + String::from_utf8(output.stdout).map_err(E::Utf8).and_then( + |mut stdout| { + let mut msg = if stdout.is_empty() { + err + } else { + stdout.push_str(err.as_str()); + stdout + }; + msg.push_str(self.cmd_str().as_str()); + if !features.is_empty() { + msg.push_str(" --features "); + msg.push_str(features); + } + Err(E::Cmd(msg)) + }, + ) }) - } + }) } } } @@ -136,7 +217,7 @@ impl Opts { pub fn from_args() -> Result<Self, ArgsErr> { let mut args = env::args(); args.next().ok_or(ArgsErr::NoArgs).and_then(|_| { - args.next().map_or(Ok(Self::None), |arg| { + args.next().map_or(Ok(Self::None(false)), |arg| { match arg.as_str() { "clippy" => Ok(Self::Clippy), "doc_tests" => Ok(Self::DocTests), diff --git a/src/main.rs b/src/main.rs @@ -1,14 +1,16 @@ //! # `ci` //! -//! `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` 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` 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 //! `foo` and `bar`, `foo` and `fizz`, or `bar` and `fizz` will be tested. //! -//! `ci` writes to `stderr` iff an error arises. The error is written as well as the command and features -//! that caused the error. `stdout` is never written to. +//! `ci` writes to `stderr` iff a command errors for a reason other than [`compile_error`] or `stderr` is written +//! to on success. When a non-`compile_error` occurs, `ci` is terminated after writing the offending command and +//! features. Upon successful completion, `ci` writes all _unique_ messages that were collected. `stdout` is +//! never written to. //! //! ## Why is this useful? //! @@ -26,9 +28,7 @@ //! //! ## Limitations //! -//! All possible combinations of features are tested; thus if [`compile_error`] is used to prevent certain -//! combinations, `ci` will error. Since `ci` is designed for testing and development, it should be easy to -//! temporarily comment out instances of `compile_error`. +//! 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( @@ -39,7 +39,6 @@ rust_2018_compatibility, rust_2018_idioms, rust_2021_compatibility, - rust_2024_compatibility, unsafe_code, unused, warnings, @@ -70,9 +69,14 @@ mod args; /// Functionality related to `Cargo.toml` parsing. mod manifest; use alloc::string::FromUtf8Error; -use args::{ArgsErr, Opts}; +use args::{ArgsErr, Opts, Success}; use core::fmt::{self, Display, Formatter}; -use std::{error::Error, fs, io}; +use std::{ + collections::HashSet, + error::Error, + fs, + io::{self, Write}, +}; use toml::de::Error as TomlErr; /// Application error. enum E { @@ -86,6 +90,10 @@ enum E { Toml(TomlErr), /// Error when `stdout` is not valid UTF-8. Utf8(FromUtf8Error), + /// Error when `cargo t -q --color always --doc` errors due to + /// a lack of library targets. This is not an actual error as + /// it is used to signal that no more invocations should occur. + NoLibraryTargets, } impl Display for E { #[allow(clippy::ref_patterns)] @@ -96,6 +104,7 @@ impl Display for E { Self::Io(ref err) => err.fmt(f), Self::Toml(ref err) => err.fmt(f), Self::Utf8(ref err) => err.fmt(f), + Self::NoLibraryTargets => f.write_str("no library targets"), } } } @@ -106,14 +115,51 @@ impl fmt::Debug for E { } impl Error for E {} fn main() -> Result<(), E> { - Opts::from_args().map_err(E::Args).and_then(|opt| { + 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| { - features + .and_then(|features_powerset| { + features_powerset .into_iter() - .try_fold((), |(), feature| opt.run_cmd(feature.as_str())) + .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) + } + }) }) }) }