ci

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

main.rs (7909B)


      1 //! # `ci`
      2 //!
      3 //! `ci` is a CLI application that runs [`cargo`](https://doc.rust-lang.org/cargo/index.html) with
      4 //! `clippy -q`, `t -q --doc`, and `t -q --tests` with all possible combinations of features defined in `Cargo.toml`.
      5 //! Additionally `ci` always updates the dependencies in `Cargo.toml` to the newest stable version even if such
      6 //! version is not [SemVer](https://semver.org/) compatible. If any dependencies are upgraded, then they will
      7 //! be written to `stderr`. This occurs before any other command is performed.
      8 //!
      9 //! `ci` avoids superfluous combinations of features. For example if feature `foo` automatically enables
     10 //! feature `bar` and `bar` automatically enables feature `fizz`, then no combination of features that contains
     11 //! `foo` and `bar`, `foo` and `fizz`, or `bar` and `fizz` will be tested.
     12 //!
     13 //! `ci` writes to `stderr` iff a command errors for a reason other than [`compile_error`] or `stderr` is written
     14 //! to on success. When a non-`compile_error` occurs, `ci` is terminated after writing the offending command and
     15 //! features. Upon successful completion, `ci` writes all _unique_ messages that were collected. `stdout` is
     16 //! never written to.
     17 //!
     18 //! ## Why is this useful?
     19 //!
     20 //! The number of possible configurations grows exponentially based on the number of features in `Cargo.toml`.
     21 //! This can easily cause a crate to not be tested with certain combinations of features. Instead of manually
     22 //! invoking `cargo` with each possible combination of features, this handles it automatically.
     23 //!
     24 //! ## Options  
     25 //!   
     26 //! * `clippy`: `cargo clippy -q` is invoked for each combination of features.
     27 //! * `doc_tests`: `cargo t -q --doc` is invoked for each combination of features.
     28 //! * `tests`: `cargo t -q --tests` is invoked for each combination of features.
     29 //! * `ignored`: `cargo t -q --tests -- --ignored` is invoked for each combination of features.
     30 //! * `include-ignored`: `cargo t -q --tests -- --include-ignored` is invoked for each combination of features.
     31 //! * `--color`: `--color always` is passed to the above commands; otherwise without this option, `--color never` is
     32 //!   passed.
     33 //! * `--dir <path to directory Cargo.toml is in>`: `ci` changes the working directory to the passed path (after
     34 //!   canonicalizing it) before executing. Without this, the current directory is used.
     35 //!
     36 //! When `clippy`, `doc_tests`, `tests`, `ignored`, and `include-ignored` are not passed; then `clippy`,
     37 //! `doc_tests`, and `tests` are invoked.
     38 //!
     39 //! ## Limitations
     40 //!
     41 //! Any use of [`compile_error`] _not_ related to incompatible features will be silently ignored.
     42 extern crate alloc;
     43 /// Functionality related to the passed arguments.
     44 mod args;
     45 /// Functionality related to `Cargo.toml` parsing.
     46 mod manifest;
     47 use alloc::string::FromUtf8Error;
     48 use args::{ArgsErr, Opts, Success};
     49 use core::{
     50     error::Error,
     51     fmt::{self, Display, Formatter},
     52 };
     53 use std::{
     54     collections::HashSet,
     55     env, fs,
     56     io::{self, ErrorKind, Write as _},
     57     path::PathBuf,
     58 };
     59 use toml::de::Error as TomlErr;
     60 /// Application error.
     61 enum E {
     62     /// Error related to the passed arguments.
     63     Args(ArgsErr),
     64     /// Error related to running `cargo`.
     65     Cmd(String),
     66     /// I/O-related errors.
     67     Io(io::Error),
     68     /// Error related to the format of `Cargo.toml`.
     69     Toml(TomlErr),
     70     /// Error when `stdout` is not valid UTF-8.
     71     Utf8(FromUtf8Error),
     72     /// Error when `cargo t -q --color always --doc` errors due to
     73     /// a lack of library targets. This is not an actual error as
     74     /// it is used to signal that no more invocations should occur.
     75     NoLibraryTargets,
     76 }
     77 impl Display for E {
     78     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
     79         match *self {
     80             Self::Args(ref err) => err.fmt(f),
     81             Self::Cmd(ref err) => err.fmt(f),
     82             Self::Io(ref err) => err.fmt(f),
     83             Self::Toml(ref err) => err.fmt(f),
     84             Self::Utf8(ref err) => err.fmt(f),
     85             Self::NoLibraryTargets => f.write_str("no library targets"),
     86         }
     87     }
     88 }
     89 impl fmt::Debug for E {
     90     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
     91         <Self as Display>::fmt(self, f)
     92     }
     93 }
     94 impl Error for E {}
     95 #[expect(
     96     clippy::panic_in_result_fn,
     97     reason = "asserts are fine when they indicate a bug"
     98 )]
     99 fn main() -> Result<(), E> {
    100     /// Checks if `Cargo.toml` exists in `cur_dir`; if not, it recursively checks the ancestor
    101     /// directories.
    102     ///
    103     /// We make this recursive in the rare (impossible?) case that traversal becomes circular; in which case,
    104     /// we want a stack overflow to occur.
    105     fn set_env(mut cur_dir: PathBuf) -> Result<bool, io::Error> {
    106         if fs::exists("Cargo.toml")? {
    107             Ok(true)
    108         } else if cur_dir.pop() {
    109             env::set_current_dir(cur_dir.as_path()).and_then(|()| set_env(cur_dir))
    110         } else {
    111             Ok(false)
    112         }
    113     }
    114     Opts::from_args().and_then(|(mut opt, path)| {
    115         path.map_or_else(
    116             || {
    117                 env::current_dir().and_then(|dir| {
    118                     set_env(dir).and_then(|exists| {
    119                         if exists {
    120                             Ok(())
    121                         } else {
    122                             Err(io::Error::new(ErrorKind::NotFound, "Cargo.toml does not exist in the current directory nor any of the ancestor directories"))
    123                         }
    124                     })
    125                 })
    126             },
    127             env::set_current_dir
    128         ).map_err(E::Io).and_then(|()| {
    129             fs::read_to_string("Cargo.toml")
    130                 .map_err(E::Io)
    131                 .and_then(|toml| manifest::from_toml(toml.as_str()).map_err(E::Toml))
    132                 .and_then(|features_powerset| {
    133                     features_powerset
    134                         .into_iter()
    135                         .try_fold(HashSet::new(), |mut msgs, features| {
    136                             opt.run_cmd(features.as_str(), &mut msgs)
    137                                 .and_then(|success| {
    138                                     if matches!(success, Success::NoLibraryTargets) {
    139                                         // We don't want to bother continuing to call `cargo t -q --doc` once we
    140                                         // know there is no library target.
    141                                         assert!(matches!(opt, Opts::DocTests(_)), "Opts::DocTests should be the only variant that can return Success::NoLibraryTargets when Opts::run_cmd is called");
    142                                         Err(E::NoLibraryTargets)
    143                                     } else {
    144                                         Ok(msgs)
    145                                     }
    146                                 })
    147                         })
    148                         .or_else(|e| {
    149                             if matches!(e, E::NoLibraryTargets) {
    150                                 Ok(HashSet::new())
    151                             } else {
    152                                 Err(e)
    153                             }
    154                         })
    155                         .and_then(|msgs| {
    156                             if msgs.is_empty() {
    157                                 Ok(())
    158                             } else {
    159                                 io::stderr()
    160                                     .lock()
    161                                     .write_all(
    162                                         msgs.into_iter()
    163                                             .fold(String::new(), |mut buffer, msg| {
    164                                                 buffer.push_str(msg.as_str());
    165                                                 buffer
    166                                             })
    167                                             .as_bytes(),
    168                                     )
    169                                     .map_err(E::Io)
    170                             }
    171                         })
    172                 })
    173         })
    174     })
    175 }