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


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