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 (8667B)


      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 //! * `--color`: `--color always` is passed to the above commands; otherwise without this option, `--color never` is
     30 //!   passed.
     31 //! * `--dir <path to directory Cargo.toml is in>`: `ci` changes the working directory to the passed path (after
     32 //!   canonicalizing it) before executing. Without this, the current directory is used.
     33 //!
     34 //! When `clippy`, `doc_tests`, and `tests` are not passed; then all three are invoked.
     35 //!
     36 //! ## Limitations
     37 //!
     38 //! Any use of [`compile_error`] _not_ related to incompatible features will be silently ignored. Any `ignored`
     39 //! tests are not run.
     40 #![deny(
     41     unknown_lints,
     42     future_incompatible,
     43     let_underscore,
     44     missing_docs,
     45     nonstandard_style,
     46     rust_2018_compatibility,
     47     rust_2018_idioms,
     48     rust_2021_compatibility,
     49     rust_2024_compatibility,
     50     unsafe_code,
     51     unused,
     52     unused_crate_dependencies,
     53     warnings,
     54     clippy::all,
     55     clippy::cargo,
     56     clippy::complexity,
     57     clippy::correctness,
     58     clippy::nursery,
     59     clippy::pedantic,
     60     clippy::perf,
     61     clippy::restriction,
     62     clippy::style,
     63     clippy::suspicious
     64 )]
     65 #![expect(
     66     clippy::blanket_clippy_restriction_lints,
     67     clippy::arbitrary_source_item_ordering,
     68     clippy::implicit_return,
     69     clippy::min_ident_chars,
     70     clippy::missing_trait_methods,
     71     clippy::question_mark_used,
     72     clippy::ref_patterns,
     73     clippy::return_and_then,
     74     clippy::single_call_fn,
     75     clippy::single_char_lifetime_names,
     76     clippy::unseparated_literal_suffix,
     77     reason = "noisy, opinionated, and likely doesn't prevent bugs or improve APIs"
     78 )]
     79 extern crate alloc;
     80 /// Functionality related to the passed arguments.
     81 mod args;
     82 /// Functionality related to `Cargo.toml` parsing.
     83 mod manifest;
     84 use alloc::string::FromUtf8Error;
     85 use args::{ArgsErr, Opts, Success};
     86 use core::{
     87     error::Error,
     88     fmt::{self, Display, Formatter},
     89 };
     90 use std::{
     91     collections::HashSet,
     92     env, fs,
     93     io::{self, ErrorKind, Write as _},
     94     path::PathBuf,
     95 };
     96 use toml::de::Error as TomlErr;
     97 /// Application error.
     98 enum E {
     99     /// Error related to the passed arguments.
    100     Args(ArgsErr),
    101     /// Error related to running `cargo`.
    102     Cmd(String),
    103     /// I/O-related errors.
    104     Io(io::Error),
    105     /// Error related to the format of `Cargo.toml`.
    106     Toml(TomlErr),
    107     /// Error when `stdout` is not valid UTF-8.
    108     Utf8(FromUtf8Error),
    109     /// Error when `cargo t -q --color always --doc` errors due to
    110     /// a lack of library targets. This is not an actual error as
    111     /// it is used to signal that no more invocations should occur.
    112     NoLibraryTargets,
    113 }
    114 impl Display for E {
    115     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    116         match *self {
    117             Self::Args(ref err) => err.fmt(f),
    118             Self::Cmd(ref err) => err.fmt(f),
    119             Self::Io(ref err) => err.fmt(f),
    120             Self::Toml(ref err) => err.fmt(f),
    121             Self::Utf8(ref err) => err.fmt(f),
    122             Self::NoLibraryTargets => f.write_str("no library targets"),
    123         }
    124     }
    125 }
    126 impl fmt::Debug for E {
    127     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
    128         <Self as Display>::fmt(self, f)
    129     }
    130 }
    131 impl Error for E {}
    132 #[expect(
    133     clippy::panic_in_result_fn,
    134     reason = "asserts are fine when they indicate a bug"
    135 )]
    136 fn main() -> Result<(), E> {
    137     /// Checks if `Cargo.toml` exists in `cur_dir`; if not, it recursively checks the ancestor
    138     /// directories.
    139     ///
    140     /// We make this recursive in the rare (impossible?) case that traversal becomes circular; in which case,
    141     /// we want a stack overflow to occur.
    142     fn set_env(mut cur_dir: PathBuf) -> Result<bool, io::Error> {
    143         if fs::exists("Cargo.toml")? {
    144             Ok(true)
    145         } else if cur_dir.pop() {
    146             env::set_current_dir(cur_dir.as_path()).and_then(|()| set_env(cur_dir))
    147         } else {
    148             Ok(false)
    149         }
    150     }
    151     Opts::from_args().and_then(|(mut opt, path)| {
    152         path.map_or_else(
    153             || {
    154                 env::current_dir().and_then(|dir| {
    155                     set_env(dir).and_then(|exists| {
    156                         if exists {
    157                             Ok(())
    158                         } else {
    159                             Err(io::Error::new(ErrorKind::NotFound, "Cargo.toml does not exist in the current directory nor any of the ancestor directories"))
    160                         }
    161                     })
    162                 })
    163             },
    164             env::set_current_dir
    165         ).map_err(E::Io).and_then(|()| {
    166             fs::read_to_string("Cargo.toml")
    167                 .map_err(E::Io)
    168                 .and_then(|toml| manifest::from_toml(toml.as_str()).map_err(E::Toml))
    169                 .and_then(|features_powerset| {
    170                     features_powerset
    171                         .into_iter()
    172                         .try_fold(HashSet::new(), |mut msgs, features| {
    173                             opt.run_cmd(features.as_str(), &mut msgs)
    174                                 .and_then(|success| {
    175                                     if matches!(success, Success::NoLibraryTargets) {
    176                                         // We don't want to bother continuing to call `cargo t -q --doc` once we
    177                                         // know there is no library target.
    178                                         assert!(matches!(opt, Opts::DocTests(_)), "Opts::DocTests should be the only variant that can return Success::NoLibraryTargets when Opts::run_cmd is called");
    179                                         Err(E::NoLibraryTargets)
    180                                     } else {
    181                                         Ok(msgs)
    182                                     }
    183                                 })
    184                         })
    185                         .or_else(|e| {
    186                             if matches!(e, E::NoLibraryTargets) {
    187                                 Ok(HashSet::new())
    188                             } else {
    189                                 Err(e)
    190                             }
    191                         })
    192                         .and_then(|msgs| {
    193                             if msgs.is_empty() {
    194                                 Ok(())
    195                             } else {
    196                                 io::stderr()
    197                                     .lock()
    198                                     .write_all(
    199                                         msgs.into_iter()
    200                                             .fold(String::new(), |mut buffer, msg| {
    201                                                 buffer.push_str(msg.as_str());
    202                                                 buffer
    203                                             })
    204                                             .as_bytes(),
    205                                     )
    206                                     .map_err(E::Io)
    207                             }
    208                         })
    209                 })
    210         })
    211     })
    212 }