- Add a new crate `sal-text` for text manipulation utilities. - Integrate `sal-text` into the main `sal` crate. - Remove the previous `text` module from `sal`. This improves organization and allows for independent development of the `sal-text` library.
This commit is contained in:
		
							
								
								
									
										137
									
								
								text/src/dedent.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										137
									
								
								text/src/dedent.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,137 @@ | ||||
| /** | ||||
|  * Dedent a multiline string by removing common leading whitespace. | ||||
|  * | ||||
|  * This function analyzes all non-empty lines in the input text to determine | ||||
|  * the minimum indentation level, then removes that amount of whitespace | ||||
|  * from the beginning of each line. This is useful for working with | ||||
|  * multi-line strings in code that have been indented to match the | ||||
|  * surrounding code structure. | ||||
|  * | ||||
|  * # Arguments | ||||
|  * | ||||
|  * * `text` - The multiline string to dedent | ||||
|  * | ||||
|  * # Returns | ||||
|  * | ||||
|  * * `String` - The dedented string | ||||
|  * | ||||
|  * # Examples | ||||
|  * | ||||
|  * ``` | ||||
|  * use sal::text::dedent; | ||||
|  * | ||||
|  * let indented = "    line 1\n    line 2\n        line 3"; | ||||
|  * let dedented = dedent(indented); | ||||
|  * assert_eq!(dedented, "line 1\nline 2\n    line 3"); | ||||
|  * ``` | ||||
|  * | ||||
|  * # Notes | ||||
|  * | ||||
|  * - Empty lines are preserved but have all leading whitespace removed | ||||
|  * - Tabs are counted as 4 spaces for indentation purposes | ||||
|  */ | ||||
| pub fn dedent(text: &str) -> String { | ||||
|     let lines: Vec<&str> = text.lines().collect(); | ||||
|  | ||||
|     // Find the minimum indentation level (ignore empty lines) | ||||
|     let min_indent = lines | ||||
|         .iter() | ||||
|         .filter(|line| !line.trim().is_empty()) | ||||
|         .map(|line| { | ||||
|             let mut spaces = 0; | ||||
|             for c in line.chars() { | ||||
|                 if c == ' ' { | ||||
|                     spaces += 1; | ||||
|                 } else if c == '\t' { | ||||
|                     spaces += 4; // Count tabs as 4 spaces | ||||
|                 } else { | ||||
|                     break; | ||||
|                 } | ||||
|             } | ||||
|             spaces | ||||
|         }) | ||||
|         .min() | ||||
|         .unwrap_or(0); | ||||
|  | ||||
|     // Remove that many spaces from the beginning of each line | ||||
|     lines | ||||
|         .iter() | ||||
|         .map(|line| { | ||||
|             if line.trim().is_empty() { | ||||
|                 return String::new(); | ||||
|             } | ||||
|  | ||||
|             let mut count = 0; | ||||
|             let mut chars = line.chars().peekable(); | ||||
|  | ||||
|             // Skip initial spaces up to min_indent | ||||
|             while count < min_indent && chars.peek().is_some() { | ||||
|                 match chars.peek() { | ||||
|                     Some(' ') => { | ||||
|                         chars.next(); | ||||
|                         count += 1; | ||||
|                     } | ||||
|                     Some('\t') => { | ||||
|                         chars.next(); | ||||
|                         count += 4; | ||||
|                     } | ||||
|                     _ => break, | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Return the remaining characters | ||||
|             chars.collect::<String>() | ||||
|         }) | ||||
|         .collect::<Vec<String>>() | ||||
|         .join("\n") | ||||
| } | ||||
|  | ||||
| /** | ||||
|  * Prefix a multiline string with a specified prefix. | ||||
|  * | ||||
|  * This function adds the specified prefix to the beginning of each line in the input text. | ||||
|  * | ||||
|  * # Arguments | ||||
|  * | ||||
|  * * `text` - The multiline string to prefix | ||||
|  * * `prefix` - The prefix to add to each line | ||||
|  * | ||||
|  * # Returns | ||||
|  * | ||||
|  * * `String` - The prefixed string | ||||
|  * | ||||
|  * # Examples | ||||
|  * | ||||
|  * ``` | ||||
|  * use sal::text::prefix; | ||||
|  * | ||||
|  * let text = "line 1\nline 2\nline 3"; | ||||
|  * let prefixed = prefix(text, "    "); | ||||
|  * assert_eq!(prefixed, "    line 1\n    line 2\n    line 3"); | ||||
|  * ``` | ||||
|  */ | ||||
| pub fn prefix(text: &str, prefix: &str) -> String { | ||||
|     text.lines() | ||||
|         .map(|line| format!("{}{}", prefix, line)) | ||||
|         .collect::<Vec<String>>() | ||||
|         .join("\n") | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_dedent() { | ||||
|         let indented = "    line 1\n    line 2\n        line 3"; | ||||
|         let dedented = dedent(indented); | ||||
|         assert_eq!(dedented, "line 1\nline 2\n    line 3"); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_prefix() { | ||||
|         let text = "line 1\nline 2\nline 3"; | ||||
|         let prefixed = prefix(text, "    "); | ||||
|         assert_eq!(prefixed, "    line 1\n    line 2\n    line 3"); | ||||
|     } | ||||
| } | ||||
							
								
								
									
										99
									
								
								text/src/fix.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										99
									
								
								text/src/fix.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,99 @@ | ||||
|  | ||||
|  | ||||
| pub fn name_fix(text: &str) -> String { | ||||
|     let mut result = String::with_capacity(text.len()); | ||||
|      | ||||
|     let mut last_was_underscore = false; | ||||
|     for c in text.chars() { | ||||
|         // Keep only ASCII characters | ||||
|         if c.is_ascii() { | ||||
|             // Replace specific characters with underscore | ||||
|             if c.is_whitespace() || c == ',' || c == '-' || c == '"' || c == '\'' || | ||||
|                c == '#' || c == '!' || c == '(' || c == ')' || c == '[' || c == ']' || | ||||
|                c == '=' || c == '+' || c == '<' || c == '>' || c == '@' || c == '$' || | ||||
|                c == '%' || c == '^' || c == '&' || c == '*' { | ||||
|                 // Only add underscore if the last character wasn't an underscore | ||||
|                 if !last_was_underscore { | ||||
|                     result.push('_'); | ||||
|                     last_was_underscore = true; | ||||
|                 } | ||||
|             } else { | ||||
|                 // Add the character as is (will be converted to lowercase later) | ||||
|                 result.push(c); | ||||
|                 last_was_underscore = false; | ||||
|             } | ||||
|         } | ||||
|         // Non-ASCII characters are simply skipped | ||||
|     } | ||||
|      | ||||
|     // Convert to lowercase | ||||
|     return result.to_lowercase(); | ||||
| } | ||||
|  | ||||
| pub fn path_fix(text: &str) -> String { | ||||
|     // If path ends with '/', return as is | ||||
|     if text.ends_with('/') { | ||||
|         return text.to_string(); | ||||
|     } | ||||
|      | ||||
|     // Find the last '/' to extract the filename part | ||||
|     match text.rfind('/') { | ||||
|         Some(pos) => { | ||||
|             // Extract the path and filename parts | ||||
|             let path = &text[..=pos]; | ||||
|             let filename = &text[pos+1..]; | ||||
|              | ||||
|             // Apply name_fix to the filename part only | ||||
|             return format!("{}{}", path, name_fix(filename)); | ||||
|         }, | ||||
|         None => { | ||||
|             // No '/' found, so the entire text is a filename | ||||
|             return name_fix(text); | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_name_fix() { | ||||
|         // Test ASCII conversion and special character replacement | ||||
|         assert_eq!(name_fix("Hello World"), "hello_world"); | ||||
|         assert_eq!(name_fix("File-Name.txt"), "file_name.txt"); | ||||
|         assert_eq!(name_fix("Test!@#$%^&*()"), "test_"); | ||||
|         assert_eq!(name_fix("Space, Tab\t, Comma,"), "space_tab_comma_"); | ||||
|         assert_eq!(name_fix("Quotes\"'"), "quotes_"); | ||||
|         assert_eq!(name_fix("Brackets[]<>"), "brackets_"); | ||||
|         assert_eq!(name_fix("Operators=+-"), "operators_"); | ||||
|          | ||||
|         // Test non-ASCII characters removal | ||||
|         assert_eq!(name_fix("Café"), "caf"); | ||||
|         assert_eq!(name_fix("Résumé"), "rsum"); | ||||
|         assert_eq!(name_fix("Über"), "ber"); | ||||
|          | ||||
|         // Test lowercase conversion | ||||
|         assert_eq!(name_fix("UPPERCASE"), "uppercase"); | ||||
|         assert_eq!(name_fix("MixedCase"), "mixedcase"); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_path_fix() { | ||||
|         // Test path ending with / | ||||
|         assert_eq!(path_fix("/path/to/directory/"), "/path/to/directory/"); | ||||
|          | ||||
|         // Test single filename | ||||
|         assert_eq!(path_fix("filename.txt"), "filename.txt"); | ||||
|         assert_eq!(path_fix("UPPER-file.md"), "upper_file.md"); | ||||
|          | ||||
|         // Test path with filename | ||||
|         assert_eq!(path_fix("/path/to/File Name.txt"), "/path/to/file_name.txt"); | ||||
|         assert_eq!(path_fix("./relative/path/to/DOCUMENT-123.pdf"), "./relative/path/to/document_123.pdf"); | ||||
|         assert_eq!(path_fix("/absolute/path/to/Résumé.doc"), "/absolute/path/to/rsum.doc"); | ||||
|          | ||||
|         // Test path with special characters in filename | ||||
|         assert_eq!(path_fix("/path/with/[special]<chars>.txt"), "/path/with/_special_chars_.txt"); | ||||
|     } | ||||
| } | ||||
|  | ||||
							
								
								
									
										59
									
								
								text/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										59
									
								
								text/src/lib.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,59 @@ | ||||
| //! SAL Text - Text processing and manipulation utilities | ||||
| //! | ||||
| //! This crate provides a comprehensive collection of text processing utilities including: | ||||
| //! - **Text indentation**: Remove common leading whitespace (`dedent`) and add prefixes (`prefix`) | ||||
| //! - **String normalization**: Sanitize strings for filenames (`name_fix`) and paths (`path_fix`) | ||||
| //! - **Text replacement**: Powerful `TextReplacer` for regex and literal replacements | ||||
| //! - **Template rendering**: `TemplateBuilder` using Tera engine for dynamic text generation | ||||
| //! | ||||
| //! All functionality is available in both Rust and Rhai scripting environments. | ||||
| //! | ||||
| //! # Examples | ||||
| //! | ||||
| //! ## Text Indentation | ||||
| //! | ||||
| //! ```rust | ||||
| //! use sal_text::dedent; | ||||
| //! | ||||
| //! let indented = "    line 1\n    line 2\n        line 3"; | ||||
| //! let dedented = dedent(indented); | ||||
| //! assert_eq!(dedented, "line 1\nline 2\n    line 3"); | ||||
| //! ``` | ||||
| //! | ||||
| //! ## String Normalization | ||||
| //! | ||||
| //! ```rust | ||||
| //! use sal_text::name_fix; | ||||
| //! | ||||
| //! let unsafe_name = "User's File [Draft].txt"; | ||||
| //! let safe_name = name_fix(unsafe_name); | ||||
| //! assert_eq!(safe_name, "users_file_draft_.txt"); | ||||
| //! ``` | ||||
| //! | ||||
| //! ## Text Replacement | ||||
| //! | ||||
| //! ```rust | ||||
| //! use sal_text::TextReplacer; | ||||
| //! | ||||
| //! let replacer = TextReplacer::builder() | ||||
| //!     .pattern(r"\d+") | ||||
| //!     .replacement("NUMBER") | ||||
| //!     .regex(true) | ||||
| //!     .build() | ||||
| //!     .expect("Failed to build replacer"); | ||||
| //! | ||||
| //! let result = replacer.replace("There are 123 items"); | ||||
| //! assert_eq!(result, "There are NUMBER items"); | ||||
| //! ``` | ||||
|  | ||||
| mod dedent; | ||||
| mod fix; | ||||
| mod replace; | ||||
| mod template; | ||||
|  | ||||
| pub mod rhai; | ||||
|  | ||||
| pub use dedent::*; | ||||
| pub use fix::*; | ||||
| pub use replace::*; | ||||
| pub use template::*; | ||||
							
								
								
									
										292
									
								
								text/src/replace.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										292
									
								
								text/src/replace.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,292 @@ | ||||
| use regex::Regex; | ||||
| use std::fs; | ||||
| use std::io::{self, Read}; | ||||
| use std::path::Path; | ||||
|  | ||||
| /// Represents the type of replacement to perform. | ||||
| #[derive(Clone)] | ||||
| pub enum ReplaceMode { | ||||
|     /// Regex-based replacement using the `regex` crate | ||||
|     Regex(Regex), | ||||
|     /// Literal substring replacement (non-regex) | ||||
|     Literal(String), | ||||
| } | ||||
|  | ||||
| /// A single replacement operation with a pattern and replacement text | ||||
| #[derive(Clone)] | ||||
| pub struct ReplacementOperation { | ||||
|     mode: ReplaceMode, | ||||
|     replacement: String, | ||||
| } | ||||
|  | ||||
| impl ReplacementOperation { | ||||
|     /// Applies this replacement operation to the input text | ||||
|     fn apply(&self, input: &str) -> String { | ||||
|         match &self.mode { | ||||
|             ReplaceMode::Regex(re) => re.replace_all(input, self.replacement.as_str()).to_string(), | ||||
|             ReplaceMode::Literal(search) => input.replace(search, &self.replacement), | ||||
|         } | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Text replacer that can perform multiple replacement operations | ||||
| /// in a single pass over the input text. | ||||
| #[derive(Clone)] | ||||
| pub struct TextReplacer { | ||||
|     operations: Vec<ReplacementOperation>, | ||||
| } | ||||
|  | ||||
| impl TextReplacer { | ||||
|     /// Creates a new builder for configuring a TextReplacer | ||||
|     pub fn builder() -> TextReplacerBuilder { | ||||
|         TextReplacerBuilder::default() | ||||
|     } | ||||
|  | ||||
|     /// Applies all configured replacement operations to the input text | ||||
|     pub fn replace(&self, input: &str) -> String { | ||||
|         let mut result = input.to_string(); | ||||
|  | ||||
|         // Apply each replacement operation in sequence | ||||
|         for op in &self.operations { | ||||
|             result = op.apply(&result); | ||||
|         } | ||||
|  | ||||
|         result | ||||
|     } | ||||
|  | ||||
|     /// Reads a file, applies all replacements, and returns the result as a string | ||||
|     pub fn replace_file<P: AsRef<Path>>(&self, path: P) -> io::Result<String> { | ||||
|         let mut file = fs::File::open(path)?; | ||||
|         let mut content = String::new(); | ||||
|         file.read_to_string(&mut content)?; | ||||
|  | ||||
|         Ok(self.replace(&content)) | ||||
|     } | ||||
|  | ||||
|     /// Reads a file, applies all replacements, and writes the result back to the file | ||||
|     pub fn replace_file_in_place<P: AsRef<Path>>(&self, path: P) -> io::Result<()> { | ||||
|         let content = self.replace_file(&path)?; | ||||
|         fs::write(path, content)?; | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Reads a file, applies all replacements, and writes the result to a new file | ||||
|     pub fn replace_file_to<P1: AsRef<Path>, P2: AsRef<Path>>( | ||||
|         &self, | ||||
|         input_path: P1, | ||||
|         output_path: P2, | ||||
|     ) -> io::Result<()> { | ||||
|         let content = self.replace_file(&input_path)?; | ||||
|         fs::write(output_path, content)?; | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
|  | ||||
| /// Builder for the TextReplacer. | ||||
| #[derive(Default, Clone)] | ||||
| pub struct TextReplacerBuilder { | ||||
|     operations: Vec<ReplacementOperation>, | ||||
|     pattern: Option<String>, | ||||
|     replacement: Option<String>, | ||||
|     use_regex: bool, | ||||
|     case_insensitive: bool, | ||||
| } | ||||
|  | ||||
| impl TextReplacerBuilder { | ||||
|     /// Sets the pattern to search for | ||||
|     pub fn pattern(mut self, pat: &str) -> Self { | ||||
|         self.pattern = Some(pat.to_string()); | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Sets the replacement text | ||||
|     pub fn replacement(mut self, rep: &str) -> Self { | ||||
|         self.replacement = Some(rep.to_string()); | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Sets whether to use regex | ||||
|     pub fn regex(mut self, yes: bool) -> Self { | ||||
|         self.use_regex = yes; | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Sets whether the replacement should be case-insensitive | ||||
|     pub fn case_insensitive(mut self, yes: bool) -> Self { | ||||
|         self.case_insensitive = yes; | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Adds another replacement operation to the chain and resets the builder for a new operation | ||||
|     pub fn and(mut self) -> Self { | ||||
|         self.add_current_operation(); | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     // Helper method to add the current operation to the list | ||||
|     fn add_current_operation(&mut self) -> bool { | ||||
|         if let Some(pattern) = self.pattern.take() { | ||||
|             let replacement = self.replacement.take().unwrap_or_default(); | ||||
|             let use_regex = self.use_regex; | ||||
|             let case_insensitive = self.case_insensitive; | ||||
|  | ||||
|             // Reset current settings | ||||
|             self.use_regex = false; | ||||
|             self.case_insensitive = false; | ||||
|  | ||||
|             // Create the replacement mode | ||||
|             let mode = if use_regex { | ||||
|                 let mut regex_pattern = pattern; | ||||
|  | ||||
|                 // If case insensitive, add the flag to the regex pattern | ||||
|                 if case_insensitive && !regex_pattern.starts_with("(?i)") { | ||||
|                     regex_pattern = format!("(?i){}", regex_pattern); | ||||
|                 } | ||||
|  | ||||
|                 match Regex::new(®ex_pattern) { | ||||
|                     Ok(re) => ReplaceMode::Regex(re), | ||||
|                     Err(_) => return false, // Failed to compile regex | ||||
|                 } | ||||
|             } else { | ||||
|                 // For literal replacement, we'll handle case insensitivity differently | ||||
|                 // since String::replace doesn't have a case-insensitive option | ||||
|                 if case_insensitive { | ||||
|                     return false; // Case insensitive not supported for literal | ||||
|                 } | ||||
|                 ReplaceMode::Literal(pattern) | ||||
|             }; | ||||
|  | ||||
|             self.operations | ||||
|                 .push(ReplacementOperation { mode, replacement }); | ||||
|  | ||||
|             true | ||||
|         } else { | ||||
|             false | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     /// Builds the TextReplacer with all configured replacement operations | ||||
|     pub fn build(mut self) -> Result<TextReplacer, String> { | ||||
|         // If there's a pending replacement operation, add it | ||||
|         self.add_current_operation(); | ||||
|  | ||||
|         // Ensure we have at least one replacement operation | ||||
|         if self.operations.is_empty() { | ||||
|             return Err("No replacement operations configured".to_string()); | ||||
|         } | ||||
|  | ||||
|         Ok(TextReplacer { | ||||
|             operations: self.operations, | ||||
|         }) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|     use std::io::{Seek, SeekFrom, Write}; | ||||
|     use tempfile::NamedTempFile; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_regex_replace() { | ||||
|         let replacer = TextReplacer::builder() | ||||
|             .pattern(r"\bfoo\b") | ||||
|             .replacement("bar") | ||||
|             .regex(true) | ||||
|             .build() | ||||
|             .unwrap(); | ||||
|  | ||||
|         let input = "foo bar foo baz"; | ||||
|         let output = replacer.replace(input); | ||||
|  | ||||
|         assert_eq!(output, "bar bar bar baz"); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_literal_replace() { | ||||
|         let replacer = TextReplacer::builder() | ||||
|             .pattern("foo") | ||||
|             .replacement("qux") | ||||
|             .regex(false) | ||||
|             .build() | ||||
|             .unwrap(); | ||||
|  | ||||
|         let input = "foo bar foo baz"; | ||||
|         let output = replacer.replace(input); | ||||
|  | ||||
|         assert_eq!(output, "qux bar qux baz"); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_multiple_replacements() { | ||||
|         let replacer = TextReplacer::builder() | ||||
|             .pattern("foo") | ||||
|             .replacement("qux") | ||||
|             .and() | ||||
|             .pattern("bar") | ||||
|             .replacement("baz") | ||||
|             .build() | ||||
|             .unwrap(); | ||||
|  | ||||
|         let input = "foo bar foo"; | ||||
|         let output = replacer.replace(input); | ||||
|  | ||||
|         assert_eq!(output, "qux baz qux"); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_case_insensitive_regex() { | ||||
|         let replacer = TextReplacer::builder() | ||||
|             .pattern("foo") | ||||
|             .replacement("bar") | ||||
|             .regex(true) | ||||
|             .case_insensitive(true) | ||||
|             .build() | ||||
|             .unwrap(); | ||||
|  | ||||
|         let input = "FOO foo Foo"; | ||||
|         let output = replacer.replace(input); | ||||
|  | ||||
|         assert_eq!(output, "bar bar bar"); | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_file_operations() -> io::Result<()> { | ||||
|         // Create a temporary file | ||||
|         let mut temp_file = NamedTempFile::new()?; | ||||
|         writeln!(temp_file, "foo bar foo baz")?; | ||||
|  | ||||
|         // Flush the file to ensure content is written | ||||
|         temp_file.as_file_mut().flush()?; | ||||
|  | ||||
|         let replacer = TextReplacer::builder() | ||||
|             .pattern("foo") | ||||
|             .replacement("qux") | ||||
|             .build() | ||||
|             .unwrap(); | ||||
|  | ||||
|         // Test replace_file | ||||
|         let result = replacer.replace_file(temp_file.path())?; | ||||
|         assert_eq!(result, "qux bar qux baz\n"); | ||||
|  | ||||
|         // Test replace_file_in_place | ||||
|         replacer.replace_file_in_place(temp_file.path())?; | ||||
|  | ||||
|         // Verify the file was updated - need to seek to beginning of file first | ||||
|         let mut content = String::new(); | ||||
|         temp_file.as_file_mut().seek(SeekFrom::Start(0))?; | ||||
|         temp_file.as_file_mut().read_to_string(&mut content)?; | ||||
|         assert_eq!(content, "qux bar qux baz\n"); | ||||
|  | ||||
|         // Test replace_file_to with a new temporary file | ||||
|         let output_file = NamedTempFile::new()?; | ||||
|         replacer.replace_file_to(temp_file.path(), output_file.path())?; | ||||
|  | ||||
|         // Verify the output file has the replaced content | ||||
|         let mut output_content = String::new(); | ||||
|         fs::File::open(output_file.path())?.read_to_string(&mut output_content)?; | ||||
|         assert_eq!(output_content, "qux bar qux baz\n"); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
							
								
								
									
										228
									
								
								text/src/rhai.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								text/src/rhai.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,228 @@ | ||||
| //! Rhai wrappers for Text module functions | ||||
| //! | ||||
| //! This module provides Rhai wrappers for the functions in the Text module. | ||||
|  | ||||
| use crate::{TemplateBuilder, TextReplacer, TextReplacerBuilder}; | ||||
| use rhai::{Array, Engine, EvalAltResult, Map, Position}; | ||||
| use std::collections::HashMap; | ||||
|  | ||||
| /// Register Text module functions with the Rhai engine | ||||
| /// | ||||
| /// # Arguments | ||||
| /// | ||||
| /// * `engine` - The Rhai engine to register the functions with | ||||
| /// | ||||
| /// # Returns | ||||
| /// | ||||
| /// * `Result<(), Box<EvalAltResult>>` - Ok if registration was successful, Err otherwise | ||||
| pub fn register_text_module(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> { | ||||
|     // Register types | ||||
|     register_text_types(engine)?; | ||||
|  | ||||
|     // Register TextReplacer constructor | ||||
|     engine.register_fn("text_replacer_new", text_replacer_new); | ||||
|  | ||||
|     // Register TextReplacerBuilder instance methods | ||||
|     engine.register_fn("pattern", pattern); | ||||
|     engine.register_fn("replacement", replacement); | ||||
|     engine.register_fn("regex", regex); | ||||
|     engine.register_fn("case_insensitive", case_insensitive); | ||||
|     engine.register_fn("and", and); | ||||
|     engine.register_fn("build", build); | ||||
|  | ||||
|     // Register TextReplacer instance methods | ||||
|     engine.register_fn("replace", replace); | ||||
|     engine.register_fn("replace_file", replace_file); | ||||
|     engine.register_fn("replace_file_in_place", replace_file_in_place); | ||||
|     engine.register_fn("replace_file_to", replace_file_to); | ||||
|  | ||||
|     // Register TemplateBuilder constructor | ||||
|     engine.register_fn("template_builder_open", template_builder_open); | ||||
|  | ||||
|     // Register TemplateBuilder instance methods | ||||
|     engine.register_fn("add_var", add_var_string); | ||||
|     engine.register_fn("add_var", add_var_int); | ||||
|     engine.register_fn("add_var", add_var_float); | ||||
|     engine.register_fn("add_var", add_var_bool); | ||||
|     engine.register_fn("add_var", add_var_array); | ||||
|     engine.register_fn("add_vars", add_vars); | ||||
|     engine.register_fn("render", render); | ||||
|     engine.register_fn("render_to_file", render_to_file); | ||||
|  | ||||
|     // Register Fix functions directly from text module | ||||
|     engine.register_fn("name_fix", crate::name_fix); | ||||
|     engine.register_fn("path_fix", crate::path_fix); | ||||
|  | ||||
|     // Register Dedent functions directly from text module | ||||
|     engine.register_fn("dedent", crate::dedent); | ||||
|     engine.register_fn("prefix", crate::prefix); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| /// Register Text module types with the Rhai engine | ||||
| fn register_text_types(engine: &mut Engine) -> Result<(), Box<EvalAltResult>> { | ||||
|     // Register TextReplacerBuilder type | ||||
|     engine.register_type_with_name::<TextReplacerBuilder>("TextReplacerBuilder"); | ||||
|  | ||||
|     // Register TextReplacer type | ||||
|     engine.register_type_with_name::<TextReplacer>("TextReplacer"); | ||||
|  | ||||
|     // Register TemplateBuilder type | ||||
|     engine.register_type_with_name::<TemplateBuilder>("TemplateBuilder"); | ||||
|  | ||||
|     Ok(()) | ||||
| } | ||||
|  | ||||
| // Helper functions for error conversion | ||||
| fn io_error_to_rhai_error<T>(result: std::io::Result<T>) -> Result<T, Box<EvalAltResult>> { | ||||
|     result.map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("IO error: {}", e).into(), | ||||
|             Position::NONE, | ||||
|         )) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| fn tera_error_to_rhai_error<T>(result: Result<T, tera::Error>) -> Result<T, Box<EvalAltResult>> { | ||||
|     result.map_err(|e| { | ||||
|         Box::new(EvalAltResult::ErrorRuntime( | ||||
|             format!("Template error: {}", e).into(), | ||||
|             Position::NONE, | ||||
|         )) | ||||
|     }) | ||||
| } | ||||
|  | ||||
| fn string_error_to_rhai_error<T>(result: Result<T, String>) -> Result<T, Box<EvalAltResult>> { | ||||
|     result.map_err(|e| Box::new(EvalAltResult::ErrorRuntime(e.into(), Position::NONE))) | ||||
| } | ||||
|  | ||||
| // TextReplacer implementation | ||||
|  | ||||
| /// Creates a new TextReplacerBuilder | ||||
| pub fn text_replacer_new() -> TextReplacerBuilder { | ||||
|     TextReplacerBuilder::default() | ||||
| } | ||||
|  | ||||
| /// Sets the pattern to search for | ||||
| pub fn pattern(builder: TextReplacerBuilder, pat: &str) -> TextReplacerBuilder { | ||||
|     builder.pattern(pat) | ||||
| } | ||||
|  | ||||
| /// Sets the replacement text | ||||
| pub fn replacement(builder: TextReplacerBuilder, rep: &str) -> TextReplacerBuilder { | ||||
|     builder.replacement(rep) | ||||
| } | ||||
|  | ||||
| /// Sets whether to use regex | ||||
| pub fn regex(builder: TextReplacerBuilder, yes: bool) -> TextReplacerBuilder { | ||||
|     builder.regex(yes) | ||||
| } | ||||
|  | ||||
| /// Sets whether the replacement should be case-insensitive | ||||
| pub fn case_insensitive(builder: TextReplacerBuilder, yes: bool) -> TextReplacerBuilder { | ||||
|     builder.case_insensitive(yes) | ||||
| } | ||||
|  | ||||
| /// Adds another replacement operation to the chain and resets the builder for a new operation | ||||
| pub fn and(builder: TextReplacerBuilder) -> TextReplacerBuilder { | ||||
|     builder.and() | ||||
| } | ||||
|  | ||||
| /// Builds the TextReplacer with all configured replacement operations | ||||
| pub fn build(builder: TextReplacerBuilder) -> Result<TextReplacer, Box<EvalAltResult>> { | ||||
|     string_error_to_rhai_error(builder.build()) | ||||
| } | ||||
|  | ||||
| /// Applies all configured replacement operations to the input text | ||||
| pub fn replace(replacer: &mut TextReplacer, input: &str) -> String { | ||||
|     replacer.replace(input) | ||||
| } | ||||
|  | ||||
| /// Reads a file, applies all replacements, and returns the result as a string | ||||
| pub fn replace_file(replacer: &mut TextReplacer, path: &str) -> Result<String, Box<EvalAltResult>> { | ||||
|     io_error_to_rhai_error(replacer.replace_file(path)) | ||||
| } | ||||
|  | ||||
| /// Reads a file, applies all replacements, and writes the result back to the file | ||||
| pub fn replace_file_in_place( | ||||
|     replacer: &mut TextReplacer, | ||||
|     path: &str, | ||||
| ) -> Result<(), Box<EvalAltResult>> { | ||||
|     io_error_to_rhai_error(replacer.replace_file_in_place(path)) | ||||
| } | ||||
|  | ||||
| /// Reads a file, applies all replacements, and writes the result to a new file | ||||
| pub fn replace_file_to( | ||||
|     replacer: &mut TextReplacer, | ||||
|     input_path: &str, | ||||
|     output_path: &str, | ||||
| ) -> Result<(), Box<EvalAltResult>> { | ||||
|     io_error_to_rhai_error(replacer.replace_file_to(input_path, output_path)) | ||||
| } | ||||
|  | ||||
| // TemplateBuilder implementation | ||||
|  | ||||
| /// Creates a new TemplateBuilder with the specified template path | ||||
| pub fn template_builder_open(template_path: &str) -> Result<TemplateBuilder, Box<EvalAltResult>> { | ||||
|     io_error_to_rhai_error(TemplateBuilder::open(template_path)) | ||||
| } | ||||
|  | ||||
| /// Adds a string variable to the template context | ||||
| pub fn add_var_string(builder: TemplateBuilder, name: &str, value: &str) -> TemplateBuilder { | ||||
|     builder.add_var(name, value) | ||||
| } | ||||
|  | ||||
| /// Adds an integer variable to the template context | ||||
| pub fn add_var_int(builder: TemplateBuilder, name: &str, value: i64) -> TemplateBuilder { | ||||
|     builder.add_var(name, value) | ||||
| } | ||||
|  | ||||
| /// Adds a float variable to the template context | ||||
| pub fn add_var_float(builder: TemplateBuilder, name: &str, value: f64) -> TemplateBuilder { | ||||
|     builder.add_var(name, value) | ||||
| } | ||||
|  | ||||
| /// Adds a boolean variable to the template context | ||||
| pub fn add_var_bool(builder: TemplateBuilder, name: &str, value: bool) -> TemplateBuilder { | ||||
|     builder.add_var(name, value) | ||||
| } | ||||
|  | ||||
| /// Adds an array variable to the template context | ||||
| pub fn add_var_array(builder: TemplateBuilder, name: &str, array: Array) -> TemplateBuilder { | ||||
|     // Convert Rhai Array to Vec<String> | ||||
|     let vec: Vec<String> = array | ||||
|         .iter() | ||||
|         .filter_map(|v| v.clone().into_string().ok()) | ||||
|         .collect(); | ||||
|  | ||||
|     builder.add_var(name, vec) | ||||
| } | ||||
|  | ||||
| /// Adds multiple variables to the template context from a Map | ||||
| pub fn add_vars(builder: TemplateBuilder, vars: Map) -> TemplateBuilder { | ||||
|     // Convert Rhai Map to Rust HashMap | ||||
|     let mut hash_map = HashMap::new(); | ||||
|  | ||||
|     for (key, value) in vars.iter() { | ||||
|         if let Ok(val_str) = value.clone().into_string() { | ||||
|             hash_map.insert(key.to_string(), val_str); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Add the variables | ||||
|     builder.add_vars(hash_map) | ||||
| } | ||||
|  | ||||
| /// Renders the template with the current context | ||||
| pub fn render(builder: &mut TemplateBuilder) -> Result<String, Box<EvalAltResult>> { | ||||
|     tera_error_to_rhai_error(builder.render()) | ||||
| } | ||||
|  | ||||
| /// Renders the template and writes the result to a file | ||||
| pub fn render_to_file( | ||||
|     builder: &mut TemplateBuilder, | ||||
|     output_path: &str, | ||||
| ) -> Result<(), Box<EvalAltResult>> { | ||||
|     io_error_to_rhai_error(builder.render_to_file(output_path)) | ||||
| } | ||||
							
								
								
									
										310
									
								
								text/src/template.rs
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										310
									
								
								text/src/template.rs
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,310 @@ | ||||
| use std::collections::HashMap; | ||||
| use std::fs; | ||||
| use std::io; | ||||
| use std::path::Path; | ||||
| use tera::{Context, Tera}; | ||||
|  | ||||
| /// A builder for creating and rendering templates using the Tera template engine. | ||||
| #[derive(Clone)] | ||||
| pub struct TemplateBuilder { | ||||
|     template_path: String, | ||||
|     context: Context, | ||||
|     tera: Option<Tera>, | ||||
| } | ||||
|  | ||||
| impl TemplateBuilder { | ||||
|     /// Creates a new TemplateBuilder with the specified template path. | ||||
|     /// | ||||
|     /// # Arguments | ||||
|     /// | ||||
|     /// * `template_path` - The path to the template file | ||||
|     /// | ||||
|     /// # Returns | ||||
|     /// | ||||
|     /// A new TemplateBuilder instance | ||||
|     /// | ||||
|     /// # Example | ||||
|     /// | ||||
|     /// ``` | ||||
|     /// use sal::text::TemplateBuilder; | ||||
|     /// | ||||
|     /// let builder = TemplateBuilder::open("templates/example.html"); | ||||
|     /// ``` | ||||
|     pub fn open<P: AsRef<Path>>(template_path: P) -> io::Result<Self> { | ||||
|         let path_str = template_path.as_ref().to_string_lossy().to_string(); | ||||
|  | ||||
|         // Verify the template file exists | ||||
|         if !Path::new(&path_str).exists() { | ||||
|             return Err(io::Error::new( | ||||
|                 io::ErrorKind::NotFound, | ||||
|                 format!("Template file not found: {}", path_str), | ||||
|             )); | ||||
|         } | ||||
|  | ||||
|         Ok(Self { | ||||
|             template_path: path_str, | ||||
|             context: Context::new(), | ||||
|             tera: None, | ||||
|         }) | ||||
|     } | ||||
|  | ||||
|     /// Adds a variable to the template context. | ||||
|     /// | ||||
|     /// # Arguments | ||||
|     /// | ||||
|     /// * `name` - The name of the variable to add | ||||
|     /// * `value` - The value to associate with the variable | ||||
|     /// | ||||
|     /// # Returns | ||||
|     /// | ||||
|     /// The builder instance for method chaining | ||||
|     /// | ||||
|     /// # Example | ||||
|     /// | ||||
|     /// ```no_run | ||||
|     /// use sal::text::TemplateBuilder; | ||||
|     /// | ||||
|     /// fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     ///     let builder = TemplateBuilder::open("templates/example.html")? | ||||
|     ///         .add_var("title", "Hello World") | ||||
|     ///         .add_var("username", "John Doe"); | ||||
|     ///     Ok(()) | ||||
|     /// } | ||||
|     /// ``` | ||||
|     pub fn add_var<S, V>(mut self, name: S, value: V) -> Self | ||||
|     where | ||||
|         S: AsRef<str>, | ||||
|         V: serde::Serialize, | ||||
|     { | ||||
|         self.context.insert(name.as_ref(), &value); | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Adds multiple variables to the template context from a HashMap. | ||||
|     /// | ||||
|     /// # Arguments | ||||
|     /// | ||||
|     /// * `vars` - A HashMap containing variable names and values | ||||
|     /// | ||||
|     /// # Returns | ||||
|     /// | ||||
|     /// The builder instance for method chaining | ||||
|     /// | ||||
|     /// # Example | ||||
|     /// | ||||
|     /// ```no_run | ||||
|     /// use sal::text::TemplateBuilder; | ||||
|     /// use std::collections::HashMap; | ||||
|     /// | ||||
|     /// fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     ///     let mut vars = HashMap::new(); | ||||
|     ///     vars.insert("title", "Hello World"); | ||||
|     ///     vars.insert("username", "John Doe"); | ||||
|     /// | ||||
|     ///     let builder = TemplateBuilder::open("templates/example.html")? | ||||
|     ///         .add_vars(vars); | ||||
|     ///     Ok(()) | ||||
|     /// } | ||||
|     /// ``` | ||||
|     pub fn add_vars<S, V>(mut self, vars: HashMap<S, V>) -> Self | ||||
|     where | ||||
|         S: AsRef<str>, | ||||
|         V: serde::Serialize, | ||||
|     { | ||||
|         for (name, value) in vars { | ||||
|             self.context.insert(name.as_ref(), &value); | ||||
|         } | ||||
|         self | ||||
|     } | ||||
|  | ||||
|     /// Initializes the Tera template engine with the template file. | ||||
|     /// | ||||
|     /// This method is called automatically by render() if not called explicitly. | ||||
|     /// | ||||
|     /// # Returns | ||||
|     /// | ||||
|     /// The builder instance for method chaining | ||||
|     fn initialize_tera(&mut self) -> Result<(), tera::Error> { | ||||
|         if self.tera.is_none() { | ||||
|             // Create a new Tera instance with just this template | ||||
|             let mut tera = Tera::default(); | ||||
|  | ||||
|             // Read the template content | ||||
|             let template_content = fs::read_to_string(&self.template_path) | ||||
|                 .map_err(|e| tera::Error::msg(format!("Failed to read template file: {}", e)))?; | ||||
|  | ||||
|             // Add the template to Tera | ||||
|             let template_name = Path::new(&self.template_path) | ||||
|                 .file_name() | ||||
|                 .and_then(|n| n.to_str()) | ||||
|                 .unwrap_or("template"); | ||||
|  | ||||
|             tera.add_raw_template(template_name, &template_content)?; | ||||
|             self.tera = Some(tera); | ||||
|         } | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     /// Renders the template with the current context. | ||||
|     /// | ||||
|     /// # Returns | ||||
|     /// | ||||
|     /// The rendered template as a string | ||||
|     /// | ||||
|     /// # Example | ||||
|     /// | ||||
|     /// ```no_run | ||||
|     /// use sal::text::TemplateBuilder; | ||||
|     /// | ||||
|     /// fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     ///     let result = TemplateBuilder::open("templates/example.html")? | ||||
|     ///         .add_var("title", "Hello World") | ||||
|     ///         .add_var("username", "John Doe") | ||||
|     ///         .render()?; | ||||
|     /// | ||||
|     ///     println!("Rendered template: {}", result); | ||||
|     ///     Ok(()) | ||||
|     /// } | ||||
|     /// ``` | ||||
|     pub fn render(&mut self) -> Result<String, tera::Error> { | ||||
|         // Initialize Tera if not already done | ||||
|         self.initialize_tera()?; | ||||
|  | ||||
|         // Get the template name | ||||
|         let template_name = Path::new(&self.template_path) | ||||
|             .file_name() | ||||
|             .and_then(|n| n.to_str()) | ||||
|             .unwrap_or("template"); | ||||
|  | ||||
|         // Render the template | ||||
|         let tera = self.tera.as_ref().unwrap(); | ||||
|         tera.render(template_name, &self.context) | ||||
|     } | ||||
|  | ||||
|     /// Renders the template and writes the result to a file. | ||||
|     /// | ||||
|     /// # Arguments | ||||
|     /// | ||||
|     /// * `output_path` - The path where the rendered template should be written | ||||
|     /// | ||||
|     /// # Returns | ||||
|     /// | ||||
|     /// Result indicating success or failure | ||||
|     /// | ||||
|     /// # Example | ||||
|     /// | ||||
|     /// ```no_run | ||||
|     /// use sal::text::TemplateBuilder; | ||||
|     /// | ||||
|     /// fn main() -> Result<(), Box<dyn std::error::Error>> { | ||||
|     ///     TemplateBuilder::open("templates/example.html")? | ||||
|     ///         .add_var("title", "Hello World") | ||||
|     ///         .add_var("username", "John Doe") | ||||
|     ///         .render_to_file("output.html")?; | ||||
|     ///     Ok(()) | ||||
|     /// } | ||||
|     /// ``` | ||||
|     pub fn render_to_file<P: AsRef<Path>>(&mut self, output_path: P) -> io::Result<()> { | ||||
|         let rendered = self.render().map_err(|e| { | ||||
|             io::Error::new( | ||||
|                 io::ErrorKind::Other, | ||||
|                 format!("Template rendering error: {}", e), | ||||
|             ) | ||||
|         })?; | ||||
|  | ||||
|         fs::write(output_path, rendered) | ||||
|     } | ||||
| } | ||||
|  | ||||
| #[cfg(test)] | ||||
| mod tests { | ||||
|     use super::*; | ||||
|     use std::io::Write; | ||||
|     use tempfile::NamedTempFile; | ||||
|  | ||||
|     #[test] | ||||
|     fn test_template_rendering() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         // Create a temporary template file | ||||
|         let temp_file = NamedTempFile::new()?; | ||||
|         let template_content = "Hello, {{ name }}! Welcome to {{ place }}.\n"; | ||||
|         fs::write(temp_file.path(), template_content)?; | ||||
|  | ||||
|         // Create a template builder and add variables | ||||
|         let mut builder = TemplateBuilder::open(temp_file.path())?; | ||||
|         builder = builder.add_var("name", "John").add_var("place", "Rust"); | ||||
|  | ||||
|         // Render the template | ||||
|         let result = builder.render()?; | ||||
|         assert_eq!(result, "Hello, John! Welcome to Rust.\n"); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_template_with_multiple_vars() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         // Create a temporary template file | ||||
|         let temp_file = NamedTempFile::new()?; | ||||
|         let template_content = "{% if show_greeting %}Hello, {{ name }}!{% endif %}\n{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}\n"; | ||||
|         fs::write(temp_file.path(), template_content)?; | ||||
|  | ||||
|         // Create a template builder and add variables | ||||
|         let mut builder = TemplateBuilder::open(temp_file.path())?; | ||||
|  | ||||
|         // Add variables including a boolean and a vector | ||||
|         builder = builder | ||||
|             .add_var("name", "Alice") | ||||
|             .add_var("show_greeting", true) | ||||
|             .add_var("items", vec!["apple", "banana", "cherry"]); | ||||
|  | ||||
|         // Render the template | ||||
|         let result = builder.render()?; | ||||
|         assert_eq!(result, "Hello, Alice!\napple, banana, cherry\n"); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|  | ||||
|     #[test] | ||||
|     fn test_template_with_hashmap_vars() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         // Create a temporary template file | ||||
|         let mut temp_file = NamedTempFile::new()?; | ||||
|         writeln!(temp_file, "{{{{ greeting }}}}, {{{{ name }}}}!")?; | ||||
|         temp_file.flush()?; | ||||
|  | ||||
|         // Create a HashMap of variables | ||||
|         let mut vars = HashMap::new(); | ||||
|         vars.insert("greeting", "Hi"); | ||||
|         vars.insert("name", "Bob"); | ||||
|  | ||||
|         // Create a template builder and add variables from HashMap | ||||
|         let mut builder = TemplateBuilder::open(temp_file.path())?; | ||||
|         builder = builder.add_vars(vars); | ||||
|  | ||||
|         // Render the template | ||||
|         let result = builder.render()?; | ||||
|         assert_eq!(result, "Hi, Bob!\n"); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
|     #[test] | ||||
|     fn test_render_to_file() -> Result<(), Box<dyn std::error::Error>> { | ||||
|         // Create a temporary template file | ||||
|         let temp_file = NamedTempFile::new()?; | ||||
|         let template_content = "{{ message }}\n"; | ||||
|         fs::write(temp_file.path(), template_content)?; | ||||
|  | ||||
|         // Create an output file | ||||
|         let output_file = NamedTempFile::new()?; | ||||
|  | ||||
|         // Create a template builder, add a variable, and render to file | ||||
|         let mut builder = TemplateBuilder::open(temp_file.path())?; | ||||
|         builder = builder.add_var("message", "This is a test"); | ||||
|         builder.render_to_file(output_file.path())?; | ||||
|  | ||||
|         // Read the output file and verify its contents | ||||
|         let content = fs::read_to_string(output_file.path())?; | ||||
|         assert_eq!(content, "This is a test\n"); | ||||
|  | ||||
|         Ok(()) | ||||
|     } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user