use std::env; use std::fs; use std::io::{self, Write}; #[cfg(unix)] use std::os::unix; #[cfg(windows)] use std::os::windows; use std::path::{Path, PathBuf}; use std::process; use tempfile::TempDir; /// Environment for the integration tests. pub struct TestEnv { /// Temporary working directory. temp_dir: TempDir, /// Path to the *fd* executable. fd_exe: PathBuf, /// Normalize each line by sorting the whitespace-separated words normalize_line: bool, /// Temporary directory for storing test config (global ignore file) config_dir: Option, } /// Create the working directory and the test files. fn create_working_directory( directories: &[&'static str], files: &[&'static str], ) -> Result { let temp_dir = tempfile::Builder::new().prefix("fd-tests").tempdir()?; { let root = temp_dir.path(); // Pretend that this is a Git repository in order for `.gitignore` files to be respected fs::create_dir_all(root.join(".git"))?; for directory in directories { fs::create_dir_all(root.join(directory))?; } for file in files { fs::File::create(root.join(file))?; } #[cfg(unix)] unix::fs::symlink(root.join("one/two"), root.join("symlink"))?; // Note: creating symlinks on Windows requires the `SeCreateSymbolicLinkPrivilege` which // is by default only granted for administrators. #[cfg(windows)] windows::fs::symlink_dir(root.join("one/two"), root.join("symlink"))?; fs::File::create(root.join(".fdignore"))?.write_all(b"fdignored.foo")?; fs::File::create(root.join(".gitignore"))?.write_all(b"gitignored.foo")?; } Ok(temp_dir) } fn create_config_directory_with_global_ignore(ignore_file_content: &str) -> io::Result { let config_dir = tempfile::Builder::new().prefix("fd-config").tempdir()?; let fd_dir = config_dir.path().join("fd"); fs::create_dir(&fd_dir)?; let mut ignore_file = fs::File::create(fd_dir.join("ignore"))?; ignore_file.write_all(ignore_file_content.as_bytes())?; Ok(config_dir) } /// Find the *fd* executable. fn find_fd_exe() -> PathBuf { // Tests exe is in target/debug/deps, the *fd* exe is in target/debug let root = env::current_exe() .expect("tests executable") .parent() .expect("tests executable directory") .parent() .expect("fd executable directory") .to_path_buf(); let exe_name = if cfg!(windows) { "fd.exe" } else { "fd" }; root.join(exe_name) } /// Format an error message for when *fd* did not exit successfully. fn format_exit_error(args: &[&str], output: &process::Output) -> String { format!( "`fd {}` did not exit successfully.\nstdout:\n---\n{}---\nstderr:\n---\n{}---", args.join(" "), String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stderr) ) } /// Format an error message for when the output of *fd* did not match the expected output. fn format_output_error(args: &[&str], expected: &str, actual: &str) -> String { // Generate diff text. let diff_text = diff::lines(expected, actual) .into_iter() .map(|diff| match diff { diff::Result::Left(l) => format!("-{}", l), diff::Result::Both(l, _) => format!(" {}", l), diff::Result::Right(r) => format!("+{}", r), }) .collect::>() .join("\n"); format!( concat!( "`fd {}` did not produce the expected output.\n", "Showing diff between expected and actual:\n{}\n" ), args.join(" "), diff_text ) } /// Normalize the output for comparison. fn normalize_output(s: &str, trim_start: bool, normalize_line: bool) -> String { // Split into lines and normalize separators. let mut lines = s .replace('\0', "NULL\n") .lines() .map(|line| { let line = if trim_start { line.trim_start() } else { line }; let line = line.replace('/', std::path::MAIN_SEPARATOR_STR); if normalize_line { let mut words: Vec<_> = line.split_whitespace().collect(); words.sort_unstable(); return words.join(" "); } line }) .collect::>(); lines.sort(); lines.join("\n") } /// Trim whitespace from the beginning of each line. fn trim_lines(s: &str) -> String { s.lines() .map(|line| line.trim_start()) .fold(String::new(), |mut str, line| { str.push_str(line); str.push('\n'); str }) } impl TestEnv { pub fn new(directories: &[&'static str], files: &[&'static str]) -> TestEnv { let temp_dir = create_working_directory(directories, files).expect("working directory"); let fd_exe = find_fd_exe(); TestEnv { temp_dir, fd_exe, normalize_line: false, config_dir: None, } } pub fn normalize_line(self, normalize: bool) -> TestEnv { TestEnv { temp_dir: self.temp_dir, fd_exe: self.fd_exe, normalize_line: normalize, config_dir: self.config_dir, } } pub fn global_ignore_file(self, content: &str) -> TestEnv { let config_dir = create_config_directory_with_global_ignore(content).expect("config directory"); TestEnv { config_dir: Some(config_dir), ..self } } /// Create a broken symlink at the given path in the temp_dir. pub fn create_broken_symlink>( &mut self, link_path: P, ) -> Result { let root = self.test_root(); let broken_symlink_link = root.join(link_path); { let temp_target_dir = tempfile::Builder::new() .prefix("fd-tests-broken-symlink") .tempdir()?; let broken_symlink_target = temp_target_dir.path().join("broken_symlink_target"); fs::File::create(&broken_symlink_target)?; #[cfg(unix)] unix::fs::symlink(&broken_symlink_target, &broken_symlink_link)?; #[cfg(windows)] windows::fs::symlink_file(&broken_symlink_target, &broken_symlink_link)?; } Ok(broken_symlink_link) } /// Get the root directory for the tests. pub fn test_root(&self) -> PathBuf { self.temp_dir.path().to_path_buf() } /// Get the path of the fd executable. #[cfg_attr(windows, allow(unused))] pub fn test_exe(&self) -> &PathBuf { &self.fd_exe } /// Get the root directory of the file system. pub fn system_root(&self) -> PathBuf { let mut components = self.temp_dir.path().components(); PathBuf::from(components.next().expect("root directory").as_os_str()) } /// Assert that calling *fd* in the specified path under the root working directory, /// and with the specified arguments produces the expected output. pub fn assert_success_and_get_output>( &self, path: P, args: &[&str], ) -> process::Output { // Run *fd*. let output = self.run_command(path.as_ref(), args); // Check for exit status. if !output.status.success() { panic!("{}", format_exit_error(args, &output)); } output } pub fn assert_success_and_get_normalized_output>( &self, path: P, args: &[&str], ) -> String { let output = self.assert_success_and_get_output(path, args); normalize_output( &String::from_utf8_lossy(&output.stdout), false, self.normalize_line, ) } /// Assert that calling *fd* with the specified arguments produces the expected output. pub fn assert_output(&self, args: &[&str], expected: &str) { self.assert_output_subdirectory(".", args, expected) } /// Similar to assert_output, but able to handle non-utf8 output #[cfg(all(unix, not(target_os = "macos")))] pub fn assert_output_raw(&self, args: &[&str], expected: &[u8]) { let output = self.assert_success_and_get_output(".", args); assert_eq!(expected, &output.stdout[..]); } /// Assert that calling *fd* in the specified path under the root working directory, /// and with the specified arguments produces the expected output. pub fn assert_output_subdirectory>( &self, path: P, args: &[&str], expected: &str, ) { // Normalize both expected and actual output. let expected = normalize_output(expected, true, self.normalize_line); let actual = self.assert_success_and_get_normalized_output(path, args); // Compare actual output to expected output. if expected != actual { panic!("{}", format_output_error(args, &expected, &actual)); } } /// Assert that calling *fd* with the specified arguments produces the expected error, /// and does not succeed. pub fn assert_failure_with_error(&self, args: &[&str], expected: &str) { let status = self.assert_error_subdirectory(".", args, Some(expected)); if status.success() { panic!("error '{}' did not occur.", expected); } } /// Assert that calling *fd* with the specified arguments does not succeed. pub fn assert_failure(&self, args: &[&str]) { let status = self.assert_error_subdirectory(".", args, None); if status.success() { panic!("Failure did not occur as expected."); } } /// Assert that calling *fd* with the specified arguments produces the expected error. pub fn assert_error(&self, args: &[&str], expected: &str) -> process::ExitStatus { self.assert_error_subdirectory(".", args, Some(expected)) } fn run_command(&self, path: &Path, args: &[&str]) -> process::Output { // Setup *fd* command. let mut cmd = process::Command::new(&self.fd_exe); cmd.current_dir(self.temp_dir.path().join(path)); if let Some(config_dir) = &self.config_dir { cmd.env("XDG_CONFIG_HOME", config_dir.path()); } else { cmd.arg("--no-global-ignore-file"); } cmd.args(args); // Run *fd*. cmd.output().expect("fd output") } /// Assert that calling *fd* in the specified path under the root working directory, /// and with the specified arguments produces an error with the expected message. fn assert_error_subdirectory>( &self, path: P, args: &[&str], expected: Option<&str>, ) -> process::ExitStatus { let output = self.run_command(path.as_ref(), args); if let Some(expected) = expected { // Normalize both expected and actual output. let expected_error = trim_lines(expected); let actual_err = trim_lines(&String::from_utf8_lossy(&output.stderr)); // Compare actual output to expected output. if !actual_err.trim_start().starts_with(&expected_error) { panic!( "{}", format_output_error(args, &expected_error, &actual_err) ); } } output.status } }