lib.rs
  1  #![allow(clippy::collapsible_else_if)]
  2  use std::borrow::Cow;
  3  use std::collections::HashMap;
  4  use std::path::{Path, PathBuf};
  5  use std::str::FromStr;
  6  use std::sync;
  7  use std::{env, ffi, fs, io, mem};
  8  
  9  use snapbox::cmd::{Command, OutputAssert};
 10  use snapbox::{Assert, Substitutions};
 11  use thiserror::Error;
 12  
 13  /// Used to ensure the build task is only run once.
 14  static BUILD: sync::Once = sync::Once::new();
 15  
 16  #[derive(Error, Debug)]
 17  pub enum Error {
 18      #[error("parsing failed")]
 19      Parse,
 20      #[error("invalid file path: {0:?}")]
 21      InvalidFilePath(String),
 22      #[error("unknown home {0:?}")]
 23      UnknownHome(String),
 24      #[error("test file not found: {0:?}")]
 25      TestNotFound(PathBuf),
 26      #[error("i/o: {0}")]
 27      Io(#[from] io::Error),
 28      #[error("snapbox: {0}")]
 29      Snapbox(#[from] snapbox::Error),
 30  }
 31  
 32  #[derive(Debug, PartialEq, Eq)]
 33  enum ExitStatus {
 34      Success,
 35      Failure,
 36  }
 37  
 38  /// A test which may contain multiple assertions.
 39  #[derive(Debug, Default, PartialEq, Eq)]
 40  pub struct Test {
 41      /// Human-readable context around the test. Functions as documentation.
 42      context: Vec<String>,
 43      /// Test assertions to run.
 44      assertions: Vec<Assertion>,
 45      /// Whether to check stderr's output instead of stdout.
 46      stderr: bool,
 47      /// Whether to expect an error status code.
 48      fail: bool,
 49      /// Home directory under which to run this test.
 50      home: Option<String>,
 51      /// Local env vars to use just for this test.
 52      env: HashMap<String, String>,
 53  }
 54  
 55  /// An assertion is a command to run with an expected output.
 56  #[derive(Debug, PartialEq, Eq)]
 57  pub struct Assertion {
 58      /// The test file that contains this assertion.
 59      path: PathBuf,
 60      /// Name of command to run, eg. `git`.
 61      command: String,
 62      /// Command arguments, eg. `["push"]`.
 63      args: Vec<String>,
 64      /// Expected output (stdout or stderr).
 65      expected: String,
 66      /// Expected exit status.
 67      exit: ExitStatus,
 68  }
 69  
 70  #[derive(Debug, Default, PartialEq, Eq, Clone)]
 71  pub struct Home {
 72      name: Option<String>,
 73      path: PathBuf,
 74      envs: HashMap<String, String>,
 75  }
 76  
 77  #[derive(Debug)]
 78  pub struct TestRun {
 79      home: Home,
 80  }
 81  
 82  impl TestRun {
 83      fn cd(&mut self, path: PathBuf) {
 84          self.home.path = path;
 85      }
 86  
 87      fn envs(&self) -> impl Iterator<Item = (&String, &String)> {
 88          self.home.envs.iter()
 89      }
 90  
 91      fn path(&self) -> PathBuf {
 92          self.home.path.clone()
 93      }
 94  }
 95  
 96  #[derive(Debug)]
 97  pub struct TestRunner<'a> {
 98      cwd: Option<PathBuf>,
 99      homes: HashMap<String, Home>,
100      formula: &'a TestFormula,
101  }
102  
103  impl<'a> TestRunner<'a> {
104      fn new(formula: &'a TestFormula) -> Self {
105          Self {
106              cwd: None,
107              homes: formula.homes.clone(),
108              formula,
109          }
110      }
111  
112      fn run(&mut self, test: &'a Test) -> TestRun {
113          let mut envs = self.formula.env.clone();
114  
115          if let Some(ref h) = test.home {
116              if let Some(home) = self.homes.get(h) {
117                  envs.extend(home.envs.clone());
118                  envs.extend(test.env.clone());
119  
120                  let home = Home {
121                      name: home.name.clone(),
122                      path: home.path.clone(),
123                      envs,
124                  };
125                  return TestRun { home };
126              } else {
127                  panic!("TestRunner::test: home `~{h}` does not exist");
128              }
129          }
130          envs.extend(test.env.clone());
131  
132          TestRun {
133              home: Home {
134                  name: None,
135                  path: self.cwd.clone().unwrap_or_else(|| self.formula.cwd.clone()),
136                  envs,
137              },
138          }
139      }
140  
141      fn finish(&mut self, run: TestRun) {
142          if let Some(name) = &run.home.name {
143              self.homes.insert(name.clone(), run.home);
144          } else {
145              self.cwd = Some(run.home.path);
146          }
147      }
148  }
149  
150  #[derive(Debug, Default, PartialEq, Eq)]
151  pub struct TestFormula {
152      /// Current working directory to run the test in.
153      cwd: PathBuf,
154      /// User homes.
155      homes: HashMap<String, Home>,
156      /// Environment to pass to the test.
157      env: HashMap<String, String>,
158      /// Tests to run.
159      tests: Vec<Test>,
160      /// Output substitutions.
161      subs: Substitutions,
162      /// Binaries path.
163      bins: Vec<PathBuf>,
164  }
165  
166  impl TestFormula {
167      pub fn new(cwd: PathBuf) -> Self {
168          Self {
169              cwd,
170              env: HashMap::new(),
171              homes: HashMap::new(),
172              tests: Vec::new(),
173              subs: Substitutions::new(),
174              bins: env::var("PATH")
175                  .map(|p| p.split(':').map(PathBuf::from).collect())
176                  .unwrap_or_default(),
177          }
178      }
179  
180      pub fn build(&mut self, binaries: &[(&str, &str)]) -> &mut Self {
181          let manifest = env::var("CARGO_MANIFEST_DIR").expect(
182              "TestFormula::build: cannot build binaries: variable `CARGO_MANIFEST_DIR` is not set",
183          );
184          let profile = if cfg!(debug_assertions) {
185              "debug"
186          } else {
187              "release"
188          };
189          let target_dir = env::var("CARGO_TARGET_DIR").unwrap_or("target".to_string());
190          let manifest = Path::new(manifest.as_str());
191          let bins = manifest.join(&target_dir).join(profile);
192  
193          // Add the target dir to the beginning of the list we will use as `PATH`.
194          self.bins.insert(0, bins);
195  
196          // We don't need to re-build everytime the `build` function is called. Once is enough.
197          BUILD.call_once(|| {
198              use escargot::format::Message;
199  
200              for (package, binary) in binaries {
201                  log::debug!(target: "test", "Building binaries for package `{package}`..");
202  
203                  let results = escargot::CargoBuild::new()
204                      .package(package)
205                      .bin(binary)
206                      .manifest_path(&manifest.join("Cargo.toml"))
207                      .target_dir(&target_dir)
208                      .exec()
209                      .unwrap();
210  
211                  for result in results {
212                      match result {
213                          Ok(msg) => {
214                              if let Ok(Message::CompilerArtifact(a)) = msg.decode() {
215                                  if let Some(e) = a.executable {
216                                      log::debug!(target: "test", "Built {}", e.display());
217                                  }
218                              }
219                          }
220                          Err(e) => {
221                              log::error!(target: "test", "Error building package `{package}`: {e}");
222                          }
223                      }
224                  }
225              }
226          });
227          self
228      }
229  
230      pub fn env(&mut self, key: impl ToString, val: impl ToString) -> &mut Self {
231          self.env.insert(key.to_string(), val.to_string());
232          self
233      }
234  
235      pub fn home(
236          &mut self,
237          user: impl ToString,
238          path: impl AsRef<Path>,
239          envs: impl IntoIterator<Item = (impl ToString, impl ToString)>,
240      ) -> &mut Self {
241          self.homes.insert(
242              user.to_string(),
243              Home {
244                  name: Some(user.to_string()),
245                  path: path.as_ref().to_path_buf(),
246                  envs: envs
247                      .into_iter()
248                      .map(|(k, v)| (k.to_string(), v.to_string()))
249                      .collect(),
250              },
251          );
252          self
253      }
254  
255      pub fn envs<K: ToString, V: ToString>(
256          &mut self,
257          envs: impl IntoIterator<Item = (K, V)>,
258      ) -> &mut Self {
259          for (k, v) in envs {
260              self.env.insert(k.to_string(), v.to_string());
261          }
262          self
263      }
264  
265      pub fn file(&mut self, path: impl AsRef<Path>) -> Result<&mut Self, Error> {
266          let path = path.as_ref();
267          let contents = match fs::read(path) {
268              Ok(bytes) => bytes,
269              Err(err) if err.kind() == io::ErrorKind::NotFound => {
270                  return Err(Error::TestNotFound(path.to_path_buf()));
271              }
272              Err(err) => return Err(err.into()),
273          };
274          self.read(path, io::Cursor::new(contents))
275      }
276  
277      pub fn read(&mut self, path: &Path, r: impl io::BufRead) -> Result<&mut Self, Error> {
278          let mut test = Test::default();
279          let mut fenced = false; // Whether we're inside a fenced code block.
280          let mut file: Option<(PathBuf, String)> = None; // Path and content of file created by this test block.
281  
282          for line in r.lines() {
283              let line = line?;
284  
285              if line.starts_with("```") {
286                  if fenced {
287                      if let Some((ref path, ref mut content)) = file.take() {
288                          // Write file.
289                          let path = self.cwd.join(path);
290  
291                          if let Some(dir) = path.parent() {
292                              log::debug!(target: "test", "Creating directory {}..", dir.display());
293                              fs::create_dir_all(dir)?;
294                          }
295                          log::debug!(target: "test", "Writing {} bytes to {}..", content.len(), path.display());
296                          fs::write(path, content)?;
297                      } else {
298                          // End existing code block.
299                          self.tests.push(mem::take(&mut test));
300                      }
301                  } else {
302                      for token in line.split_whitespace() {
303                          if let Some(home) = token.strip_prefix('~') {
304                              test.home = Some(home.to_owned());
305                          } else if let Some((key, val)) = token.split_once('=') {
306                              test.env.insert(key.to_owned(), val.to_owned());
307                          } else if token.contains("stderr") {
308                              test.stderr = true;
309                          } else if token.contains("fail") {
310                              test.fail = true;
311                          } else if let Some(path) = token.strip_prefix("./") {
312                              file = Some((
313                                  PathBuf::from_str(path)
314                                      .map_err(|_| Error::InvalidFilePath(token.to_owned()))?,
315                                  String::new(),
316                              ));
317                          }
318                      }
319                  }
320                  fenced = !fenced;
321  
322                  continue;
323              }
324  
325              if fenced {
326                  if let Some((_, ref mut content)) = file {
327                      content.push_str(line.as_str());
328                      content.push('\n');
329                  } else if let Some(line) = line.strip_prefix('$') {
330                      let line = line.trim();
331                      let parts = shlex::split(line).ok_or(Error::Parse)?;
332                      let (cmd, args) = parts.split_first().ok_or(Error::Parse)?;
333  
334                      test.assertions.push(Assertion {
335                          path: path.to_path_buf(),
336                          command: cmd.to_owned(),
337                          args: args.to_owned(),
338                          expected: String::new(),
339                          exit: if test.fail {
340                              ExitStatus::Failure
341                          } else {
342                              ExitStatus::Success
343                          },
344                      });
345                  } else if let Some(a) = test.assertions.last_mut() {
346                      a.expected.push_str(line.as_str());
347                      a.expected.push('\n');
348                  } else {
349                      return Err(Error::Parse);
350                  }
351              } else {
352                  test.context.push(line);
353              }
354          }
355          Ok(self)
356      }
357  
358      #[allow(dead_code)]
359      pub fn substitute(
360          &mut self,
361          value: &'static str,
362          other: impl Into<Cow<'static, str>>,
363      ) -> Result<&mut Self, Error> {
364          self.subs.insert(value, other)?;
365          Ok(self)
366      }
367  
368      /// Convert instances of '[..   ]' to '[..]' where the number of ' 's are arbitrary.
369      ///
370      /// Supporting these bracket types help support using the '[..]' pattern while preserving
371      /// spaces important for text alignment.
372      fn map_spaced_brackets(s: &str) -> String {
373          let mut ret = String::new();
374          let mut pos = 0;
375  
376          for c in s.chars() {
377              match (c, pos) {
378                  ('[', 0) => pos += 1,
379                  (' ', 1) => continue,
380                  ('.', 1) => pos += 1,
381                  ('.', 2) => pos += 1,
382                  ('.', 3) => continue,
383                  (' ', 3) => continue,
384                  (']', 3) => pos = 0,
385                  (_, _) => pos = 0,
386              }
387              ret.push(c);
388          }
389  
390          ret
391      }
392  
393      pub fn run(&mut self) -> Result<bool, io::Error> {
394          let assert = Assert::new().substitutions(self.subs.clone());
395          let mut runner = TestRunner::new(self);
396  
397          fs::create_dir_all(&self.cwd)?;
398          log::debug!(target: "test", "Using PATH {:?}", self.bins);
399  
400          // For each code block.
401          for test in &self.tests {
402              let mut run = runner.run(test);
403  
404              // For each command.
405              for assertion in &test.assertions {
406                  let path = assertion
407                      .path
408                      .file_name()
409                      .map(|f| f.to_string_lossy().to_string())
410                      .unwrap_or(String::from("<none>"));
411                  let cmd = if assertion.command == "rad" {
412                      snapbox::cmd::cargo_bin("rad")
413                  } else if assertion.command == "cd" {
414                      let mut arg = assertion.args.first().unwrap().clone();
415                      for (k, v) in run.envs() {
416                          arg = arg.replace(&format!("${k}"), v);
417                      }
418                      let dir: PathBuf = arg.into();
419                      let dir = run.path().join(dir);
420  
421                      // TODO: Add support for `..` and `/`
422                      // TODO: Error if more than one args are given.
423  
424                      log::debug!(target: "test", "{path}: Running `cd {}`..", dir.display());
425  
426                      if !dir.exists() {
427                          return Err(io::Error::new(
428                              io::ErrorKind::NotFound,
429                              format!("cd: '{}' does not exist", dir.display()),
430                          ));
431                      }
432                      run.cd(dir);
433  
434                      continue;
435                  } else {
436                      PathBuf::from(&assertion.command)
437                  };
438                  log::debug!(target: "test", "{path}: Running `{}` with {:?} in `{}`..", cmd.display(), assertion.args, run.path().display());
439  
440                  if !run.path().exists() {
441                      log::error!(target: "test", "{path}: Directory {} does not exist..", run.path().display());
442                  }
443  
444                  let bins = self
445                      .bins
446                      .iter()
447                      .map(|p| p.as_os_str())
448                      .collect::<Vec<_>>()
449                      .join(ffi::OsStr::new(":"));
450                  let result = Command::new(cmd.clone())
451                      .env_clear()
452                      .env("PATH", &bins)
453                      .env("RUST_BACKTRACE", "1")
454                      .envs(run.envs())
455                      .current_dir(run.path())
456                      .args(&assertion.args)
457                      .with_assert(assert.clone())
458                      .output();
459  
460                  match result {
461                      Ok(output) => {
462                          let assert = OutputAssert::new(output).with_assert(assert.clone());
463                          let expected = Self::map_spaced_brackets(&assertion.expected);
464  
465                          let matches = if test.stderr {
466                              assert.stderr_matches(&expected)
467                          } else {
468                              assert.stdout_matches(&expected)
469                          };
470                          match assertion.exit {
471                              ExitStatus::Success => {
472                                  matches.success();
473                              }
474                              ExitStatus::Failure => {
475                                  matches.failure();
476                              }
477                          }
478                      }
479                      Err(err) => {
480                          if err.kind() == io::ErrorKind::NotFound {
481                              log::error!(target: "test", "{path}: Command `{}` does not exist..", cmd.display());
482                          }
483                          return Err(io::Error::new(
484                              err.kind(),
485                              format!("{path}: {err}: `{}`", cmd.display()),
486                          ));
487                      }
488                  }
489              }
490              runner.finish(run);
491          }
492          Ok(true)
493      }
494  }
495  
496  #[cfg(test)]
497  mod tests {
498      use super::*;
499  
500      use pretty_assertions::assert_eq;
501  
502      #[test]
503      fn test_parse() {
504          let input = r#"
505  Let's try to track @dave and @sean:
506  ```
507  $ rad track @dave
508  Tracking relationship established for @dave.
509  Nothing to do.
510  
511  $ rad track @sean
512  Tracking relationship established for @sean.
513  Nothing to do.
514  ```
515  Super, now let's move on to the next step.
516  ```
517  $ rad sync
518  ```
519  "#
520          .trim()
521          .as_bytes()
522          .to_owned();
523  
524          let mut actual = TestFormula::new(PathBuf::new());
525          let path = Path::new("test.md").to_path_buf();
526          actual
527              .read(path.as_path(), io::BufReader::new(io::Cursor::new(input)))
528              .unwrap();
529  
530          let expected = TestFormula {
531              homes: HashMap::new(),
532              cwd: PathBuf::new(),
533              env: HashMap::new(),
534              subs: Substitutions::new(),
535              bins: env::var("PATH")
536                  .unwrap()
537                  .split(':')
538                  .map(PathBuf::from)
539                  .collect(),
540              tests: vec![
541                  Test {
542                      context: vec![String::from("Let's try to track @dave and @sean:")],
543                      home: None,
544                      assertions: vec![
545                          Assertion {
546                              path: path.clone(),
547                              command: String::from("rad"),
548                              args: vec![String::from("track"), String::from("@dave")],
549                              expected: String::from(
550                                  "Tracking relationship established for @dave.\nNothing to do.\n\n",
551                              ),
552                              exit: ExitStatus::Success,
553                          },
554                          Assertion {
555                              path: path.clone(),
556                              command: String::from("rad"),
557                              args: vec![String::from("track"), String::from("@sean")],
558                              expected: String::from(
559                                  "Tracking relationship established for @sean.\nNothing to do.\n",
560                              ),
561                              exit: ExitStatus::Success,
562                          },
563                      ],
564                      fail: false,
565                      stderr: false,
566                      env: HashMap::default(),
567                  },
568                  Test {
569                      context: vec![String::from("Super, now let's move on to the next step.")],
570                      home: None,
571                      assertions: vec![Assertion {
572                          path: path.clone(),
573                          command: String::from("rad"),
574                          args: vec![String::from("sync")],
575                          expected: String::new(),
576                          exit: ExitStatus::Success,
577                      }],
578                      fail: false,
579                      stderr: false,
580                      env: HashMap::default(),
581                  },
582              ],
583          };
584  
585          assert_eq!(actual, expected);
586      }
587  
588      #[test]
589      fn test_run() {
590          let input = r#"
591  Running a simple command such as `head`:
592  ```
593  $ head -n 2 Cargo.toml
594  [package]
595  name = "radicle-cli-test"
596  ```
597  "#
598          .trim()
599          .as_bytes()
600          .to_owned();
601  
602          let mut formula = TestFormula::new(PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap());
603          formula
604              .read(
605                  Path::new("test.md"),
606                  io::BufReader::new(io::Cursor::new(input)),
607              )
608              .unwrap();
609          formula.run().unwrap();
610      }
611  
612      #[test]
613      fn test_example_spaced_brackets() {
614          let input = r#"
615  Running a simple command such as `head`:
616  ```
617  $ echo "    hello"
618  [..]hello
619  $ echo "    hello"
620  [..  ]hello
621  $ echo "    hello"
622  [  ..]hello
623  $ echo "[bug, good-first-issue]"
624  [bug, good-first-issue]
625  $ echo "[bug, good-first-issue]"
626  [bug, [  ..    ]-issue]
627  $ echo "[bug, good-first-issue]"
628  [bug, [  ...   ]-issue]
629  ```
630  "#
631          .trim()
632          .as_bytes()
633          .to_owned();
634  
635          let mut formula = TestFormula::new(PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap());
636          formula
637              .read(
638                  Path::new("test.md"),
639                  io::BufReader::new(io::Cursor::new(input)),
640              )
641              .unwrap();
642          formula.run().unwrap();
643      }
644  }