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:
M | Cargo.toml | | | 1 | + |
M | README.md | | | 14 | +++++++------- |
M | src/args.rs | | | 235 | +++++++++++++++++++++++++++++++++++++++++++++++++++++-------------------------- |
M | src/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)
+ }
+ })
})
})
}