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 }