1use std::borrow::Cow;
2use std::collections::HashMap;
3use std::path::{Path, PathBuf};
4use std::str::FromStr;
5use std::sync;
6use std::{env, fs, io, mem};
7
8use snapbox::cmd::{Command, OutputAssert};
9use snapbox::{Assert, Redactions};
10use thiserror::Error;
11
12const CARGO_TARGET_DIR_DIRNAME: &str = "target";
13
14const CARGO_PROFILE: &str = "debug";
15
16static BUILD: sync::Once = sync::Once::new();
18
19#[derive(Error, Debug)]
20pub enum Error {
21 #[error("parsing failed")]
22 Parse,
23 #[error("invalid file path: {0:?}")]
24 InvalidFilePath(String),
25 #[error("unknown home {0:?}")]
26 UnknownHome(String),
27 #[error("test file not found: {0:?}")]
28 TestNotFound(PathBuf),
29 #[error("i/o: {0}")]
30 Io(#[from] io::Error),
31 #[error("snapbox: {0}")]
32 Snapbox(#[from] snapbox::assert::Error),
33}
34
35#[derive(Debug, PartialEq, Eq)]
36enum ExitStatus {
37 Success,
38 Failure,
39}
40
41#[derive(Debug, Default, PartialEq, Eq)]
43pub struct Test {
44 context: Vec<String>,
46 assertions: Vec<Assertion>,
48 stderr: bool,
50 fail: bool,
52 home: Option<String>,
54 env: HashMap<String, String>,
56}
57
58#[derive(Debug, PartialEq, Eq)]
60pub struct Assertion {
61 path: PathBuf,
63 command: String,
65 args: Vec<String>,
67 expected: String,
69 exit: ExitStatus,
71 line: usize,
73}
74
75#[derive(Debug, Default, PartialEq, Eq, Clone)]
76pub struct Home {
77 name: Option<String>,
78 path: PathBuf,
79 envs: HashMap<String, String>,
80}
81
82#[derive(Debug)]
83pub struct TestRun {
84 home: Home,
85 env: HashMap<String, String>,
86}
87
88impl TestRun {
89 fn cd(&mut self, path: PathBuf) {
90 self.home.path = path;
91 }
92
93 fn envs(&self) -> impl Iterator<Item = (String, String)> + '_ {
94 self.home
95 .envs
96 .iter()
97 .chain(self.env.iter())
98 .map(|(k, v)| (k.to_owned(), v.to_owned()))
99 .chain(Some((
100 "PWD".to_owned(),
101 self.home.path.to_string_lossy().to_string(),
102 )))
103 }
104
105 fn path(&self) -> PathBuf {
106 self.home.path.clone()
107 }
108}
109
110#[derive(Debug)]
111pub struct TestRunner<'a> {
112 cwd: Option<PathBuf>,
113 homes: HashMap<String, Home>,
114 formula: &'a TestFormula,
115}
116
117impl<'a> TestRunner<'a> {
118 fn new(formula: &'a TestFormula) -> Self {
119 Self {
120 cwd: None,
121 homes: formula.homes.clone(),
122 formula,
123 }
124 }
125
126 fn run(&mut self, test: &'a Test) -> TestRun {
127 let mut env = self.formula.env.clone();
128 env.extend(test.env.clone());
129
130 if let Some(ref h) = test.home {
131 if let Some(home) = self.homes.get(h) {
132 env.insert("USER".to_owned(), h.to_owned());
133 return TestRun {
134 home: home.clone(),
135 env,
136 };
137 } else {
138 panic!("TestRunner::test: home `~{h}` does not exist");
139 }
140 }
141 TestRun {
142 home: Home {
143 name: None,
144 path: self.cwd.clone().unwrap_or_else(|| self.formula.cwd.clone()),
145 envs: HashMap::new(),
146 },
147 env,
148 }
149 }
150
151 fn finish(&mut self, run: TestRun) {
152 if let Some(name) = &run.home.name {
153 self.homes.insert(name.clone(), run.home);
154 } else {
155 self.cwd = Some(run.home.path);
156 }
157 }
158}
159
160#[derive(Debug, Default, PartialEq, Eq)]
161pub struct TestFormula {
162 cwd: PathBuf,
164 homes: HashMap<String, Home>,
166 env: HashMap<String, String>,
168 tests: Vec<Test>,
170 subs: Redactions,
172}
173
174impl TestFormula {
175 pub fn new(cwd: PathBuf) -> Self {
176 Self {
177 cwd: cwd.clone(),
178 env: HashMap::new(),
179 homes: HashMap::new(),
180 tests: Vec::new(),
181 subs: Redactions::new(),
182 }
183 }
184
185 pub fn build(&mut self, binaries: &[(&str, &str)]) -> &mut Self {
186 BUILD.call_once(|| {
188 use escargot::format::Message;
189 use radicle_log::env_level;
190 use radicle_log::test::Logger;
191 use radicle_term::Paint;
192
193 Paint::force(true);
194
195 let level = env_level().unwrap_or(log::Level::Debug);
196 let logger = Box::new(Logger::new(level));
197
198 log::set_boxed_logger(logger).expect("no other logger should have been set already");
199 log::set_max_level(level.to_level_filter());
200
201 for (package, binary) in binaries {
202 log::debug!(target: "test", "Building binaries for package `{package}`..");
203
204 let results = escargot::CargoBuild::new()
205 .package(package)
206 .bin(binary)
207 .manifest_path(cargo_manifest_dir().join("Cargo.toml"))
208 .target_dir(cargo_target_dir())
209 .exec()
210 .unwrap();
211
212 for result in results {
213 match result {
214 Ok(msg) => {
215 if let Ok(Message::CompilerArtifact(a)) = msg.decode()
216 && let Some(e) = a.executable
217 {
218 log::debug!(target: "test", "Built {}", e.display());
219 }
220 }
221 Err(e) => {
222 log::error!(target: "test", "Error building package `{package}`: {e}");
223 }
224 }
225 }
226 }
227 });
228 self
229 }
230
231 pub fn env(&mut self, key: impl ToString, val: impl ToString) -> &mut Self {
232 self.env.insert(key.to_string(), val.to_string());
233 self
234 }
235
236 pub fn home(
237 &mut self,
238 user: impl ToString,
239 path: impl AsRef<Path>,
240 envs: impl IntoIterator<Item = (impl ToString, impl ToString)>,
241 ) -> &mut Self {
242 self.homes.insert(
243 user.to_string(),
244 Home {
245 name: Some(user.to_string()),
246 path: path.as_ref().to_path_buf(),
247 envs: envs
248 .into_iter()
249 .map(|(k, v)| (k.to_string(), v.to_string()))
250 .collect(),
251 },
252 );
253 self
254 }
255
256 pub fn envs<K: ToString, V: ToString>(
257 &mut self,
258 envs: impl IntoIterator<Item = (K, V)>,
259 ) -> &mut Self {
260 for (k, v) in envs {
261 self.env.insert(k.to_string(), v.to_string());
262 }
263 self
264 }
265
266 pub fn file(&mut self, path: impl AsRef<Path>) -> Result<&mut Self, Error> {
267 let path = path.as_ref();
268 let contents = match fs::read(path) {
269 Ok(bytes) => bytes,
270 Err(err) if err.kind() == io::ErrorKind::NotFound => {
271 return Err(Error::TestNotFound(path.to_path_buf()));
272 }
273 Err(err) => return Err(err.into()),
274 };
275 self.read(path, io::Cursor::new(contents))
276 }
277
278 pub fn read(&mut self, path: &Path, r: impl io::BufRead) -> Result<&mut Self, Error> {
279 let mut test = Test::default();
280 let mut fenced = false; let mut file: Option<(PathBuf, String)> = None; for (row, line) in r.lines().enumerate() {
284 let line = line?;
285
286 if line.starts_with("```") {
287 if fenced {
288 if let Some((ref path, ref mut content)) = file.take() {
289 let path = self.cwd.join(path);
291
292 if let Some(dir) = path.parent() {
293 log::debug!(target: "test", "Creating directory {}..", dir.display());
294 fs::create_dir_all(dir)?;
295 }
296 log::debug!(target: "test", "Writing {} bytes to {}..", content.len(), path.display());
297 fs::write(path, content)?;
298 } else {
299 self.tests.push(mem::take(&mut test));
301 }
302 } else {
303 for token in line.split_whitespace() {
304 if let Some(home) = token.strip_prefix('~') {
305 test.home = Some(home.to_owned());
306 } else if let Some((key, val)) = token.split_once('=') {
307 test.env.insert(key.to_owned(), val.to_owned());
308 } else if token.contains("stderr") {
309 test.stderr = true;
310 } else if token.contains("fail") {
311 test.fail = true;
312 } else if let Some(path) = token.strip_prefix("./") {
313 file = Some((
314 PathBuf::from_str(path)
315 .map_err(|_| Error::InvalidFilePath(token.to_owned()))?,
316 String::new(),
317 ));
318 }
319 }
320 }
321 fenced = !fenced;
322
323 continue;
324 }
325
326 if fenced {
327 if let Some((_, ref mut content)) = file {
328 content.push_str(line.as_str());
329 content.push('\n');
330 } else if let Some(line) = line.strip_prefix('$') {
331 let line = line.trim();
332
333 #[cfg(unix)]
334 let parts = shlex::split(line).ok_or(Error::Parse)?;
335
336 #[cfg(windows)]
337 let parts = winsplit::split(line);
338
339 let (cmd, args) = parts.split_first().ok_or(Error::Parse)?;
340
341 test.assertions.push(Assertion {
342 path: path.to_path_buf(),
343 command: cmd.to_owned(),
344 args: args.to_owned(),
345 expected: String::new(),
346 exit: if test.fail {
347 ExitStatus::Failure
348 } else {
349 ExitStatus::Success
350 },
351 line: row + 1,
352 });
353 } else if let Some(a) = test.assertions.last_mut() {
354 a.expected.push_str(line.as_str());
355 a.expected.push('\n');
356 } else {
357 return Err(Error::Parse);
358 }
359 } else {
360 test.context.push(line);
361 }
362 }
363 Ok(self)
364 }
365
366 #[allow(dead_code)]
367 pub fn substitute(
368 &mut self,
369 value: &'static str,
370 other: impl Into<Cow<'static, str>>,
371 ) -> Result<&mut Self, Error> {
372 self.subs.insert(value, other.into())?;
373 Ok(self)
374 }
375
376 fn map_spaced_brackets(s: &str) -> String {
381 let mut ret = String::new();
382 let mut pos = 0;
383
384 for c in s.chars() {
385 match (c, pos) {
386 ('[', 0) => pos += 1,
387 (' ', 1) => continue,
388 ('.', 1) => pos += 1,
389 ('.', 2) => pos += 1,
390 ('.', 3) => continue,
391 (' ', 3) => continue,
392 (']', 3) => pos = 0,
393 (_, _) => pos = 0,
394 }
395 ret.push(c);
396 }
397
398 ret
399 }
400
401 pub fn run(&mut self) -> Result<bool, io::Error> {
402 let assert = Assert::new()
403 .normalize_paths(false)
404 .redact_with(self.subs.clone());
405 let mut runner = TestRunner::new(self);
406
407 fs::create_dir_all(&self.cwd)?;
408
409 for test in &self.tests {
411 let mut run = runner.run(test);
412
413 for (i, assertion) in test.assertions.iter().enumerate() {
415 let location = assertion
416 .path
417 .file_name()
418 .map(|f| f.to_string_lossy().to_string())
419 .map(|f| f.strip_suffix(".md").unwrap_or(&f).to_owned())
420 .map(|f| f + ":" + assertion.line.to_string().as_str())
421 .unwrap_or(String::from("<none>"));
422
423 if assertion.command == "cd" {
424 let arg = assertion.args.first().unwrap();
425 let dir: PathBuf = arg.into();
426 let dir = run.path().join(dir);
427
428 log::debug!(target: "test", "{location}: `cd {}`..", dir.display());
432
433 if !dir.exists() {
434 return Err(io::Error::new(
435 io::ErrorKind::NotFound,
436 format!("cd: '{}' does not exist", dir.display()),
437 ));
438 }
439 run.cd(dir);
440
441 continue;
442 }
443
444 let mut args = assertion.args.clone();
446 for arg in &mut args {
447 for (k, v) in run.envs() {
448 *arg = arg.replace(format!("${k}").as_str(), &v);
449 }
450 }
451
452 if !run.path().exists() {
453 log::warn!(target: "test", "{location}: Directory {} does not exist. Creating..", run.path().display());
454 fs::create_dir_all(run.path())?;
455 }
456
457 let jj_envs = if assertion.command == "jj" {
458 vec![
459 ("JJ_RANDOMNESS_SEED", i.to_string()),
460 ("JJ_TIMESTAMP", "2001-02-03T04:05:06+07:00".to_string()),
461 ("JJ_OP_TIMESTAMP", "2001-02-03T04:05:06+07:00".to_string()),
462 ]
463 } else {
464 vec![]
465 };
466
467 let bins = std::env::join_paths(bins(self.cwd.clone())).unwrap();
468
469 let command = Command::new(assertion.command.clone())
470 .env_clear()
471 .env("PATH", &bins)
472 .env("RUST_BACKTRACE", "1")
473 .envs(jj_envs)
474 .envs(run.envs())
475 .current_dir(run.path())
476 .args(args.clone())
477 .with_assert(assert.clone());
478
479 log::debug!(target: "test", "{location}: `{} {}` @ {}", assertion.command, args.join(" "), run.path().display());
480 log::trace!(target: "test", "{location}: {}", run.envs().map(|(k, v)| format!("{}={}", k, v)).collect::<Vec<_>>().join(", "));
481 log::logger().flush();
482
483 match command.output() {
487 Ok(output) => {
488 let assert = OutputAssert::new(output).with_assert(assert.clone());
489 let expected = Self::map_spaced_brackets(&assertion.expected);
490
491 let expected = {
492 #[cfg(windows)]
493 const EXE: &str = ".exe";
494
495 #[cfg(unix)]
496 const EXE: &str = "";
497
498 expected.replace("[EXE]", EXE)
499 };
500
501 let matches = if test.stderr {
502 assert.stderr_eq(&expected)
503 } else {
504 assert.stdout_eq(&expected)
505 };
506 match assertion.exit {
507 ExitStatus::Success => {
508 matches.success();
509 }
510 ExitStatus::Failure => {
511 matches.failure();
512 }
513 }
514 }
515 Err(err) => {
516 if err.kind() == io::ErrorKind::NotFound {
517 log::error!(target: "test", "{location}: Command `{}` does not exist..", assertion.command);
518 }
519 return Err(io::Error::new(
520 err.kind(),
521 format!("{location}: {err}: `{}`", assertion.command),
522 ));
523 }
524 }
525 }
526 runner.finish(run);
527 }
528 Ok(true)
529 }
530}
531
532fn cargo_manifest_dir() -> PathBuf {
533 env::var("CARGO_MANIFEST_DIR").map(PathBuf::from).unwrap()
534}
535
536fn cargo_target_dir() -> PathBuf {
537 env::var("CARGO_TARGET_DIR")
538 .map(PathBuf::from)
539 .unwrap_or(cargo_manifest_dir().join(CARGO_TARGET_DIR_DIRNAME))
540}
541
542fn bins(cwd: PathBuf) -> Vec<PathBuf> {
545 let mut bins: Vec<PathBuf> = Vec::new();
546
547 bins.push(cwd);
550
551 bins.push(cargo_target_dir().join(CARGO_PROFILE));
552
553 if let Ok(path) = env::var("PATH") {
555 bins.extend(env::split_paths(&path));
556 }
557
558 #[cfg(windows)]
559 {
560 bins.push(PathBuf::from(r#"C:\Program Files\Git\usr\bin"#));
567 }
568
569 bins
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575
576 use pretty_assertions::assert_eq;
577
578 #[test]
579 fn test_parse() {
580 let input = r#"
581Let's try to track @dave and @sean:
582``` RAD_HINT=true
583$ rad track @dave
584Tracking relationship established for @dave.
585Nothing to do.
586
587$ rad track @sean
588Tracking relationship established for @sean.
589Nothing to do.
590```
591Super, now let's move on to the next step.
592``` ~alice (stderr)
593$ rad sync
594```
595"#
596 .trim()
597 .as_bytes()
598 .to_owned();
599
600 let cwd = PathBuf::from("radicle-cli-test");
601
602 let mut actual = TestFormula::new(cwd.clone());
603 let path = Path::new("test.md").to_path_buf();
604 actual
605 .read(path.as_path(), io::BufReader::new(io::Cursor::new(input)))
606 .unwrap();
607
608 let expected = TestFormula {
609 homes: HashMap::new(),
610 cwd: cwd.clone(),
611 env: HashMap::new(),
612 subs: Redactions::new(),
613 tests: vec![
614 Test {
615 context: vec![String::from("Let's try to track @dave and @sean:")],
616 home: None,
617 assertions: vec![
618 Assertion {
619 line: 3,
620 path: path.clone(),
621 command: String::from("rad"),
622 args: vec![String::from("track"), String::from("@dave")],
623 expected: String::from(
624 "Tracking relationship established for @dave.\nNothing to do.\n\n",
625 ),
626 exit: ExitStatus::Success,
627 },
628 Assertion {
629 line: 7,
630 path: path.clone(),
631 command: String::from("rad"),
632 args: vec![String::from("track"), String::from("@sean")],
633 expected: String::from(
634 "Tracking relationship established for @sean.\nNothing to do.\n",
635 ),
636 exit: ExitStatus::Success,
637 },
638 ],
639 fail: false,
640 stderr: false,
641 env: vec![("RAD_HINT".to_owned(), "true".to_owned())]
642 .into_iter()
643 .collect(),
644 },
645 Test {
646 context: vec![String::from("Super, now let's move on to the next step.")],
647 home: Some("alice".to_owned()),
648 assertions: vec![Assertion {
649 line: 13,
650 path: path.clone(),
651 command: String::from("rad"),
652 args: vec![String::from("sync")],
653 expected: String::new(),
654 exit: ExitStatus::Success,
655 }],
656 fail: false,
657 stderr: true,
658 env: HashMap::default(),
659 },
660 ],
661 };
662
663 assert_eq!(actual, expected);
664 }
665
666 #[test]
667 fn test_run() {
668 let input = r#"
669Running a simple command such as `head`:
670```
671$ head -n 2 Cargo.toml
672[package]
673name = "radicle-cli-test"
674```
675"#
676 .trim()
677 .as_bytes()
678 .to_owned();
679
680 let mut formula = TestFormula::new(PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap());
681 formula
682 .read(
683 Path::new("test.md"),
684 io::BufReader::new(io::Cursor::new(input)),
685 )
686 .unwrap();
687 formula.run().unwrap();
688 }
689
690 #[test]
691 fn test_example_spaced_brackets() {
692 let input = r#"
693Running a simple command such as `head`:
694```
695$ echo " hello"
696[..]hello
697$ echo " hello"
698[.. ]hello
699$ echo " hello"
700[ ..]hello
701$ echo "[bug, good-first-issue]"
702[bug, good-first-issue]
703$ echo "[bug, good-first-issue]"
704[bug, [ .. ]-issue]
705$ echo "[bug, good-first-issue]"
706[bug, [ ... ]-issue]
707```
708"#
709 .trim()
710 .as_bytes()
711 .to_owned();
712
713 let mut formula = TestFormula::new(PathBuf::from_str(env!("CARGO_MANIFEST_DIR")).unwrap());
714 formula
715 .read(
716 Path::new("test.md"),
717 io::BufReader::new(io::Cursor::new(input)),
718 )
719 .unwrap();
720 formula.run().unwrap();
721 }
722}