ci

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

args.rs (14528B)


      1 use super::E;
      2 use core::{
      3     error::Error,
      4     fmt::{self, Display, Formatter},
      5 };
      6 use std::{
      7     collections::HashSet,
      8     env, fs,
      9     path::PathBuf,
     10     process::{Command, Stdio},
     11 };
     12 /// Error returned when parsing arguments passed to the application.
     13 #[expect(clippy::module_name_repetitions, reason = "prefer this name")]
     14 #[derive(Clone, Debug)]
     15 pub enum ArgsErr {
     16     /// Error when no arguments exist.
     17     NoArgs,
     18     /// Error when an invalid option is passed. The contained [`String`] is the value of the invalid option.
     19     InvalidOption(String),
     20     /// Error when an option is passed more than once. The contained [`String`] is the duplicate option.
     21     DuplicateOption(String),
     22     /// Error when `--dir` is passed with no file path to the directory `ci` should run in.
     23     MissingPath,
     24 }
     25 impl Display for ArgsErr {
     26     fn fmt(&self, f: &mut Formatter<'_>) -> fmt::Result {
     27         match *self {
     28             Self::NoArgs => write!(f, "no arguments exist including the name of the process itself"),
     29             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"),
     30             Self::DuplicateOption(ref arg) => write!(f, "{arg} was passed more than once"),
     31             Self::MissingPath => f.write_str("--dir was passed without a path to the directory ci should run in"),
     32         }
     33     }
     34 }
     35 impl Error for ArgsErr {}
     36 /// The options passed to the application.
     37 #[derive(Clone, Copy, Debug)]
     38 pub enum Opts {
     39     /// Variant when no arguments were passed.
     40     ///
     41     /// The first contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used.
     42     /// The second contained `bool` is `true` iff `Self::DocTests` should be ignored.
     43     None(bool, bool),
     44     /// Variant when `clippy` is passed.
     45     ///
     46     /// The contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used.
     47     Clippy(bool),
     48     /// Variant when `doc_tests` is passed.
     49     ///
     50     /// The contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used.
     51     DocTests(bool),
     52     /// Variant when `tests` is passed.
     53     ///
     54     /// The contained `bool` is `true` iff `--color always` should be used; otherwise `--color never` is used.
     55     Tests(bool),
     56 }
     57 /// Kind of successful completion of a command.
     58 pub enum Success {
     59     /// Ran normally without errors.
     60     Normal,
     61     /// Erred due to [`compile_error`].
     62     CompileError,
     63     /// `cargo t -q --doc` erred since there was no library target.
     64     NoLibraryTargets,
     65 }
     66 impl Opts {
     67     /// Returns `true` iff color should be outputted.
     68     const fn contains_color(self) -> bool {
     69         match self {
     70             Self::None(color, _)
     71             | Self::Clippy(color)
     72             | Self::DocTests(color)
     73             | Self::Tests(color) => color,
     74         }
     75     }
     76     /// Changes `self` such that it should contain color.
     77     fn set_color(&mut self) {
     78         match *self {
     79             Self::None(ref mut color, _)
     80             | Self::Clippy(ref mut color)
     81             | Self::DocTests(ref mut color)
     82             | Self::Tests(ref mut color) => *color = true,
     83         }
     84     }
     85     /// Returns the arguments to pass to the command based on `self`.
     86     fn args(self) -> Vec<&'static str> {
     87         match self {
     88             Self::None(_, _) => Vec::new(),
     89             Self::Clippy(color) => vec![
     90                 "clippy",
     91                 "-q",
     92                 "--color",
     93                 if color { "always" } else { "never" },
     94                 "--no-default-features",
     95             ],
     96             Self::DocTests(color) => vec![
     97                 "t",
     98                 "-q",
     99                 "--color",
    100                 if color { "always" } else { "never" },
    101                 "--doc",
    102                 "--no-default-features",
    103             ],
    104             Self::Tests(color) => vec![
    105                 "t",
    106                 "-q",
    107                 "--color",
    108                 if color { "always" } else { "never" },
    109                 "--tests",
    110                 "--no-default-features",
    111             ],
    112         }
    113     }
    114     /// Returns the appropriate [`Stdio`] to be used to capture `stdout` based on `self`.
    115     fn stdout(self) -> Stdio {
    116         match self {
    117             Self::None(_, _) | Self::Clippy(_) => Stdio::null(),
    118             Self::DocTests(_) | Self::Tests(_) => Stdio::piped(),
    119         }
    120     }
    121     /// Returns the entire command based on `self`.
    122     ///
    123     /// Note this is the same as [`Opts::args`] except a `String` containing the space-separated values is returned
    124     /// with `"\ncargo "` beginning the `String`.
    125     fn cmd_str(self) -> String {
    126         let mut cmd = self
    127             .args()
    128             .into_iter()
    129             .fold(String::from("\ncargo "), |mut cmd, val| {
    130                 cmd.push_str(val);
    131                 cmd.push(' ');
    132                 cmd
    133             });
    134         cmd.pop();
    135         cmd
    136     }
    137     /// Runs `cargo` with argument based on `self` and features of `features` returning `Ok(Success::Normal)` iff
    138     /// the command ran successfully, `Ok(Success::CompileError)` iff a [`compile_error`] occurred, and
    139     /// `Ok(Success::NoLibraryTargets)` iff `Self::DocTests` erred due to there not being any library targets.
    140     /// `err_msgs` is used to collect unique output written to `stderr` when the command successfully completes.
    141     ///
    142     /// `self` is mutated iff `Self::None(_, false)` and `cargo t -q --doc` errors
    143     /// due to a lack of library target; in which case, the second `bool` becomes `true`.
    144     #[expect(clippy::too_many_lines, reason = "not too many")]
    145     pub fn run_cmd(
    146         &mut self,
    147         features: &str,
    148         err_msgs: &mut HashSet<String>,
    149     ) -> Result<Success, E> {
    150         match *self {
    151             Self::None(color, ref mut skip_doc) => {
    152                 Self::Clippy(color)
    153                     .run_cmd(features, err_msgs)
    154                     .and_then(|success| {
    155                         // We don't want to run the other commands since they will also have a `compile_error`.
    156                         if matches!(success, Success::CompileError) {
    157                             Ok(success)
    158                         } else {
    159                             if *skip_doc {
    160                                 Ok(Success::NoLibraryTargets)
    161                             } else {
    162                                 Self::DocTests(color).run_cmd(features, err_msgs)
    163                             }
    164                             .and_then(|success_2| {
    165                                 *skip_doc = matches!(success_2, Success::NoLibraryTargets);
    166                                 Self::Tests(color).run_cmd(features, err_msgs)
    167                             })
    168                         }
    169                     })
    170             }
    171             Self::Clippy(color) | Self::DocTests(color) | Self::Tests(color) => {
    172                 let mut args = self.args();
    173                 if !features.is_empty() {
    174                     args.push("--features");
    175                     args.push(features);
    176                 }
    177                 if matches!(*self, Self::DocTests(_) | Self::Tests(_)) {
    178                     args.push("--");
    179                     args.push("--color");
    180                     args.push(if color { "always" } else { "never" });
    181                 }
    182                 let output = Command::new("cargo")
    183                     .stderr(Stdio::piped())
    184                     .stdin(Stdio::null())
    185                     .stdout(self.stdout())
    186                     .args(args)
    187                     .output()
    188                     .map_err(E::Io)?;
    189                 if let Some(code) = output.status.code() {
    190                     match code {
    191                         0i32 => {
    192                             if output.stderr.is_empty() {
    193                                 Ok(Success::Normal)
    194                             } else {
    195                                 return String::from_utf8(output.stderr).map_err(E::Utf8).map(
    196                                     |msg| {
    197                                         err_msgs.insert(msg);
    198                                         Success::Normal
    199                                     },
    200                                 );
    201                             }
    202                         }
    203                         101i32 => {
    204                             /// `"compile_error!"` as a byte string.
    205                             const COMPILE_ERROR: &[u8; 14] = b"compile_error!";
    206                             /// `"no library targets found in package"` as a byte string.
    207                             const NO_LIB_TARG: &[u8; 35] = b"no library targets found in package";
    208                             output
    209                                 .stderr
    210                                 .windows(COMPILE_ERROR.len())
    211                                 .position(|window| window == COMPILE_ERROR)
    212                                 .map_or_else(
    213                                     || {
    214                                         if matches!(*self, Self::DocTests(_))
    215                                             && output
    216                                                 .stderr
    217                                                 .windows(NO_LIB_TARG.len())
    218                                                 .any(|window| window == NO_LIB_TARG)
    219                                         {
    220                                             Ok(Success::NoLibraryTargets)
    221                                         } else {
    222                                             Err(())
    223                                         }
    224                                     },
    225                                     |_| Ok(Success::CompileError),
    226                                 )
    227                         }
    228                         _ => Err(()),
    229                     }
    230                 } else {
    231                     Err(())
    232                 }
    233                 .or_else(|()| {
    234                     String::from_utf8(output.stderr)
    235                         .map_err(E::Utf8)
    236                         .and_then(|err| {
    237                             String::from_utf8(output.stdout).map_err(E::Utf8).and_then(
    238                                 |mut stdout| {
    239                                     let mut msg = if stdout.is_empty() {
    240                                         err
    241                                     } else {
    242                                         stdout.push_str(err.as_str());
    243                                         stdout
    244                                     };
    245                                     msg.push_str(self.cmd_str().as_str());
    246                                     if !features.is_empty() {
    247                                         msg.push_str(" --features ");
    248                                         msg.push_str(features);
    249                                     }
    250                                     if matches!(*self, Self::DocTests(_) | Self::Tests(_)) {
    251                                         msg.push_str(if color {
    252                                             " -- --color always"
    253                                         } else {
    254                                             " -- --color never"
    255                                         });
    256                                     }
    257                                     Err(E::Cmd(msg))
    258                                 },
    259                             )
    260                         })
    261                 })
    262             }
    263         }
    264     }
    265     /// Returns `Opts` and the directory `ci` should run in based on arguments passed to the application.
    266     #[expect(clippy::redundant_else, reason = "when else-if is used, prefer else")]
    267     pub fn from_args() -> Result<(Self, Option<PathBuf>), E> {
    268         let mut args = env::args();
    269         if args.next().is_none() {
    270             return Err(E::Args(ArgsErr::NoArgs));
    271         }
    272         let mut opt = Self::None(false, false);
    273         let mut path = None;
    274         while let Some(arg) = args.next() {
    275             match arg.as_str() {
    276                 "clippy" => {
    277                     if !matches!(opt, Self::None(_, _)) {
    278                         return Err(E::Args(ArgsErr::InvalidOption(String::from("clippy"))));
    279                     } else if opt.contains_color() {
    280                         return Err(E::Args(ArgsErr::InvalidOption(String::from("--color"))));
    281                     } else if path.is_some() {
    282                         return Err(E::Args(ArgsErr::InvalidOption(String::from("--dir"))));
    283                     } else {
    284                         opt = Self::Clippy(false);
    285                     }
    286                 }
    287                 "doc_tests" => {
    288                     if !matches!(opt, Self::None(_, _)) {
    289                         return Err(E::Args(ArgsErr::InvalidOption(String::from("doc_tests"))));
    290                     } else if opt.contains_color() {
    291                         return Err(E::Args(ArgsErr::InvalidOption(String::from("--color"))));
    292                     } else if path.is_some() {
    293                         return Err(E::Args(ArgsErr::InvalidOption(String::from("--dir"))));
    294                     } else {
    295                         opt = Self::DocTests(false);
    296                     }
    297                 }
    298                 "tests" => {
    299                     if !matches!(opt, Self::None(_, _)) {
    300                         return Err(E::Args(ArgsErr::InvalidOption(String::from("tests"))));
    301                     } else if opt.contains_color() {
    302                         return Err(E::Args(ArgsErr::InvalidOption(String::from("--color"))));
    303                     } else if path.is_some() {
    304                         return Err(E::Args(ArgsErr::InvalidOption(String::from("--dir"))));
    305                     } else {
    306                         opt = Self::Tests(false);
    307                     }
    308                 }
    309                 "--color" => {
    310                     if opt.contains_color() {
    311                         return Err(E::Args(ArgsErr::DuplicateOption(arg)));
    312                     }
    313                     opt.set_color();
    314                 }
    315                 "--dir" => {
    316                     if path.is_some() {
    317                         return Err(E::Args(ArgsErr::DuplicateOption(arg)));
    318                     } else if let Some(p) = args.next() {
    319                         path = Some(fs::canonicalize(p).map_err(E::Io)?);
    320                     } else {
    321                         return Err(E::Args(ArgsErr::MissingPath));
    322                     }
    323                 }
    324                 _ => return Err(E::Args(ArgsErr::InvalidOption(arg))),
    325             }
    326         }
    327         Ok((opt, path))
    328     }
    329 }