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