From 6ee9dd13759da0ea02713ea12091b35ec6e67a45 Mon Sep 17 00:00:00 2001 From: Lucas Van Laer Date: Sun, 9 Feb 2025 11:30:13 +0100 Subject: [PATCH] feat: add `include_str` and `include_bytes` file input behaviour Adds the ability to set the file input format using the `#[file_mode = "..."]` attribute. The attribute accepts `path`, `include_str`, and `include_bytes`. `path` is the default behaviour, `include_str` and `include_bytes` pass the file contents instead of the path of the file. Closes #295. --- rstest/src/lib.rs | 44 ++++++++++- rstest/tests/resources/rstest/files.rs | 38 ++++++++++ rstest/tests/rstest/mod.rs | 18 +++++ rstest_macros/src/parse/rstest/files.rs | 97 +++++++++++++++++++++++-- 4 files changed, 190 insertions(+), 7 deletions(-) diff --git a/rstest/src/lib.rs b/rstest/src/lib.rs index 1134bbe..a4e7eab 100644 --- a/rstest/src/lib.rs +++ b/rstest/src/lib.rs @@ -617,7 +617,7 @@ pub use rstest_macros::fixture; /// - Arguments Attributes: /// - [`#[case]`](#test-parametrized-cases) define an argument parametrized by test cases /// - [`#[values(...)]`](#values-lists) define an argument that can be a list of values -/// - [`#[files(...)]`-`#[exclude(...)]`-`#[base_dir = ... ]`](#files-path-as-input-arguments) +/// - [`#[files(...)]`-`#[exclude(...)]`-`#[base_dir = ... ]`-`#[file_mode = "..."]`](#files-path-as-input-arguments) /// define an argument that can be a list of path based on a glob pattern /// - [`#[from(...)]`-`#[with(...)]`](#injecting-fixtures) handling injected fixture /// - [`#[future]`](#async) implement future boilerplate argument @@ -1032,6 +1032,48 @@ pub use rstest_macros::fixture; /// } /// ``` /// +/// ### `#[file_mode = "..."]` +/// +/// Using the `#[file_mode = "..."]` attribute on an argument tagged with +/// `#[files(...)]` and friends will set the way the file is passed as an argument. +/// It accepts the following arguments with the following behaviour: +/// +/// - `path`: the default behaviour, passes each file path as a [PathBuf][std::path::PathBuf]. +/// - `include_str`: passes the contents of each file as a [&str][str] using [include_str]. +/// - `include_bytes`: passes the contents of each files as a `&[u8]` using [include_bytes]. +/// +/// Trying to pass directories as arguments will cause a compile error when using `include_str` and +/// `include_bytes`. +/// +/// Note on `println!("cargo::rerun-if-changed=tests/resources");`: +/// All files that get embedded by [include_str] or [include_bytes] will be checked for changes +/// when recompiling. So the `println` is not strictly needed when all necessary files are included +/// with `#[file_mode = "include_str"]` or `#[file_mode = "include_bytes"]`. +/// +/// ``` +/// # use rstest::rstest; +/// #[rstest] +/// fn for_each_path( +/// #[files("src/**/*.rs")] #[exclude("test")] #[file_mode = "path"] path: PathBuf +/// ) { +/// assert!(contents.len() >= 0) +/// } +/// +/// #[rstest] +/// fn for_each_txt_file( +/// #[files("src/**/*.rs")] #[exclude("test")] #[file_mode = "include_str"] contents: &str +/// ) { +/// assert!(contents.len() >= 0) +/// } +/// +/// #[rstest] +/// fn for_each_bin_file( +/// #[files("src/**/*.rs")] #[exclude("test")] #[file_mode = "include_bytes"] contents: &[u8] +/// ) { +/// assert!(contents.len() >= 0) +/// } +/// ``` +/// /// ## Use Parametrize definition in more tests /// /// If you need to use a test list for more than one test you can use diff --git a/rstest/tests/resources/rstest/files.rs b/rstest/tests/resources/rstest/files.rs index df1f81a..680b447 100644 --- a/rstest/tests/resources/rstest/files.rs +++ b/rstest/tests/resources/rstest/files.rs @@ -18,6 +18,22 @@ fn start_with_name( assert!(contents.starts_with(name.to_str().unwrap())) } +#[rstest] +fn start_with_name_file_mode( + #[files("files/**/*.txt")] + #[exclude("exclude")] + #[files("../files_test_sub_folder/**/*.txt")] + #[file_mode = "path"] + path: PathBuf, +) { + let name = path.file_name().unwrap(); + let mut f = File::open(&path).unwrap(); + let mut contents = String::new(); + f.read_to_string(&mut contents).unwrap(); + + assert!(contents.starts_with(name.to_str().unwrap())) +} + #[rstest] fn start_with_name_with_include( #[files("files/**/*.txt")] @@ -91,6 +107,28 @@ fn env_vars_base_dir( assert!(contents.starts_with(name.to_str().unwrap())) } +#[rstest] +fn include_str( + #[files("files/**/*.txt")] + #[exclude("exclude")] + #[files("../files_test_sub_folder/**/*.txt")] + #[file_mode = "include_str"] + contents: &str, +) { + assert!(contents.len() != 0) +} + +#[rstest] +fn include_bytes( + #[files("files/**/*.txt")] + #[exclude("exclude")] + #[files("../files_test_sub_folder/**/*.txt")] + #[file_mode = "include_bytes"] + contents: &[u8], +) { + assert!(contents.len() != 0) +} + mod module { #[rstest::rstest] fn pathbuf_need_not_be_in_scope( diff --git a/rstest/tests/rstest/mod.rs b/rstest/tests/rstest/mod.rs index 3d4b0cd..4a099ab 100644 --- a/rstest/tests/rstest/mod.rs +++ b/rstest/tests/rstest/mod.rs @@ -84,6 +84,18 @@ fn files() { .ok("ignore_missing_env_vars::path_3_files_element_2_txt") .ok("ignore_missing_env_vars::path_4_files_element_3_txt") .ok("ignore_missing_env_vars::path_5_files_sub_sub_dir_file_txt") + .ok("include_bytes::contents_1__UP_files_test_sub_folder_from_parent_folder_txt") + .ok("include_bytes::contents_2_files_element_0_txt") + .ok("include_bytes::contents_3_files_element_1_txt") + .ok("include_bytes::contents_4_files_element_2_txt") + .ok("include_bytes::contents_5_files_element_3_txt") + .ok("include_bytes::contents_6_files_sub_sub_dir_file_txt") + .ok("include_str::contents_1__UP_files_test_sub_folder_from_parent_folder_txt") + .ok("include_str::contents_2_files_element_0_txt") + .ok("include_str::contents_3_files_element_1_txt") + .ok("include_str::contents_4_files_element_2_txt") + .ok("include_str::contents_5_files_element_3_txt") + .ok("include_str::contents_6_files_sub_sub_dir_file_txt") .ok("module::pathbuf_need_not_be_in_scope::path_1_files__ignore_me_txt") .ok("module::pathbuf_need_not_be_in_scope::path_2_files_element_0_txt") .ok("module::pathbuf_need_not_be_in_scope::path_3_files_element_1_txt") @@ -96,6 +108,12 @@ fn files() { .ok("start_with_name::path_4_files_element_2_txt") .ok("start_with_name::path_5_files_element_3_txt") .ok("start_with_name::path_6_files_sub_sub_dir_file_txt") + .ok("start_with_name_file_mode::path_1__UP_files_test_sub_folder_from_parent_folder_txt") + .ok("start_with_name_file_mode::path_2_files_element_0_txt") + .ok("start_with_name_file_mode::path_3_files_element_1_txt") + .ok("start_with_name_file_mode::path_4_files_element_2_txt") + .ok("start_with_name_file_mode::path_5_files_element_3_txt") + .ok("start_with_name_file_mode::path_6_files_sub_sub_dir_file_txt") .ok("start_with_name_with_include::path_1_files__ignore_me_txt") .ok("start_with_name_with_include::path_2_files_element_0_txt") .ok("start_with_name_with_include::path_3_files_element_1_txt") diff --git a/rstest_macros/src/parse/rstest/files.rs b/rstest_macros/src/parse/rstest/files.rs index dbfc148..f5be38f 100644 --- a/rstest_macros/src/parse/rstest/files.rs +++ b/rstest_macros/src/parse/rstest/files.rs @@ -1,4 +1,4 @@ -use std::{env, path::PathBuf}; +use std::{env, path::PathBuf, str::FromStr}; use crate::{ error::ErrorsVec, @@ -22,6 +22,7 @@ pub(crate) struct FilesGlobReferences { exclude: Vec, ignore_dot_files: bool, ignore_missing_env_vars: bool, + files_mode: FilesMode, } impl FilesGlobReferences { @@ -122,6 +123,34 @@ impl FilesGlobReferences { } } +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) enum FilesMode { + Path, + IncludeStr, + IncludeBytes, +} + +impl Default for FilesMode { + fn default() -> Self { + Self::Path + } +} + +pub(crate) struct NotAFilesMode; + +impl FromStr for FilesMode { + type Err = NotAFilesMode; + + fn from_str(s: &str) -> Result { + match s { + "path" => Ok(Self::Path), + "include_str" => Ok(Self::IncludeStr), + "include_bytes" => Ok(Self::IncludeBytes), + _ => Err(NotAFilesMode), + } + } +} + trait RaiseError: ToTokens { fn error(&self, msg: &str) -> syn::Error { syn::Error::new_spanned(self, msg) @@ -142,6 +171,7 @@ impl FilesGlobReferences { exclude: Vec, ignore_dot_files: bool, ignore_missing_env_vars: bool, + files_mode: FilesMode, ) -> Self { Self { glob, @@ -149,6 +179,7 @@ impl FilesGlobReferences { exclude, ignore_dot_files, ignore_missing_env_vars, + files_mode, } } @@ -237,7 +268,7 @@ impl TryFrom for Exclude { impl From> for FilesGlobReferences { fn from(value: Vec) -> Self { - Self::new(value, Default::default(), true, false) + Self::new(value, Default::default(), true, false, Default::default()) } } @@ -337,6 +368,20 @@ impl ValueFilesExtractor { }, ) } + + fn extract_file_mode(&mut self, node: &mut FnArg) -> Vec { + self.extract_argument_attrs( + node, + |a| attr_is(a, "file_mode"), + |attr| { + LitStrAttr::parse_meta_name_value(&attr).map_err(|_| { + attr.error( + "Use #[file_mode = \"...\"] to define the argument of the file input", + ) + }) + }, + ) + } } impl VisitMut for ValueFilesExtractor { @@ -369,6 +414,17 @@ impl VisitMut for ValueFilesExtractor { .push(attr.error(r#"Cannot use #[base_dir = "..."] more than once"#)) }) } + let files_mode_attr = self.extract_file_mode(node); + let files_mode = if let Some(value) = files_mode_attr.first() { + files_mode_attr.iter().skip(1).for_each(|attr| { + self.errors + .push(attr.error(r#"Cannot use #[file_mode = "..."] more than once"#)) + }); + FilesMode::from_str(&value.value()) + .unwrap_or(Default::default()) + } else { + Default::default() + }; if !files.is_empty() { self.files.push(( name, @@ -377,6 +433,7 @@ impl VisitMut for ValueFilesExtractor { excludes, include_dot_files.is_empty(), !ignore_missing_env_vars.is_empty(), + files_mode, ) .with_base_dir_opt(base_dir.into_iter().next()), )) @@ -523,10 +580,19 @@ impl ValueListFromFiles<'_> { } let path_str = abs_path.to_string_lossy(); - values.push(( - parse_quote! { + let value = match refs.files_mode { + FilesMode::Path => parse_quote! { <::std::path::PathBuf as std::str::FromStr>::from_str(#path_str).unwrap() }, + FilesMode::IncludeStr => parse_quote! { + include_str!(#path_str) + }, + FilesMode::IncludeBytes => parse_quote! { + include_bytes!(#path_str) + }, + }; + values.push(( + value, render_file_description(&relative_path), )); } @@ -688,6 +754,7 @@ mod should { ex.as_ref().iter().map(|&ex| ex.into()).collect(), *ignore, *ignore_envvars, + Default::default(), ) .with_base_dir_opt(base_dir.map(base_dir_attr)), ) @@ -743,6 +810,10 @@ mod should { r#"fn f(#[files("some")] #[base_dir = 123] a: PathBuf) {}"#, "base directory for the glob path" )] + #[case::multiple_file_modes( + r#"fn f(#[files("some")] #[file_mode = "include_str"] #[file_mode = "include_str"] a: PathBuf) {}"#, + r#"Cannot use #[file_mode = \"...\"] more than once"# + )] fn raise_error(#[case] item_fn: &str, #[case] message: &str) { let mut item_fn: ItemFn = item_fn.ast(); @@ -896,7 +967,13 @@ mod should { let values = ValueListFromFiles::new(FakeBaseDir::from(bdir), resolver) .to_value_list(vec![( ident("a"), - FilesGlobReferences::new(paths, exclude, ignore_dot_files, false), + FilesGlobReferences::new( + paths, + exclude, + ignore_dot_files, + false, + Default::default() + ), )]) .unwrap(); @@ -958,6 +1035,7 @@ mod should { Default::default(), true, false, + Default::default(), ), )]) .unwrap(); @@ -974,6 +1052,7 @@ mod should { Default::default(), true, false, + Default::default(), ), )]) .unwrap(); @@ -1014,7 +1093,13 @@ mod should { .collect(), ); let refs = - FilesGlobReferences::new(vec![], Default::default(), true, ignore_missing_env_vars); + FilesGlobReferences::new( + vec![], + Default::default(), + true, + ignore_missing_env_vars, + Default::default() + ); let result = refs.replace_env_vars(&files_attr(glob), resolver); match (&result, expected) {