This commit is contained in:
2025-04-05 09:36:54 +02:00
parent 4c50d4b62c
commit 78db13d738
15 changed files with 1119 additions and 248 deletions

View File

@@ -8,6 +8,7 @@ This module provides functions for text manipulation tasks such as:
- Removing indentation from multiline strings
- Adding prefixes to multiline strings
- Normalizing filenames and paths
- Text replacement (regex and literal) with file operations
## Functions
@@ -68,12 +69,86 @@ assert_eq!(path_fix("./relative/path/to/DOCUMENT-123.pdf"), "./relative/path/to/
- Only normalizes the filename portion, leaving the path structure intact
- Handles both absolute and relative paths
### Text Replacement
#### `TextReplacer`
A flexible text replacement utility that supports both regex and literal replacements with a builder pattern.
```rust
// Regex replacement
let replacer = TextReplacer::builder()
.pattern(r"\bfoo\b")
.replacement("bar")
.regex(true)
.add_replacement()
.unwrap()
.build()
.unwrap();
let result = replacer.replace("foo bar foo baz"); // "bar bar bar baz"
```
**Features:**
- Supports both regex and literal string replacements
- Builder pattern for fluent configuration
- Multiple replacements in a single pass
- Case-insensitive matching (for regex replacements)
- File reading and writing operations
#### Multiple Replacements
```rust
let replacer = TextReplacer::builder()
.pattern("foo")
.replacement("qux")
.add_replacement()
.unwrap()
.pattern("bar")
.replacement("baz")
.add_replacement()
.unwrap()
.build()
.unwrap();
let result = replacer.replace("foo bar foo"); // "qux baz qux"
```
#### File Operations
```rust
// Replace in a file and get the result as a string
let result = replacer.replace_file("input.txt")?;
// Replace in a file and write back to the same file
replacer.replace_file_in_place("input.txt")?;
// Replace in a file and write to a new file
replacer.replace_file_to("input.txt", "output.txt")?;
```
#### Case-Insensitive Matching
```rust
let replacer = TextReplacer::builder()
.pattern("foo")
.replacement("bar")
.regex(true)
.case_insensitive(true)
.add_replacement()
.unwrap()
.build()
.unwrap();
let result = replacer.replace("FOO foo Foo"); // "bar bar bar"
```
## Usage
Import the functions from the module:
```rust
use your_crate::text::{dedent, prefix, name_fix, path_fix};
use your_crate::text::{dedent, prefix, name_fix, path_fix, TextReplacer};
```
## Examples

View File

@@ -8,9 +8,10 @@ pub fn name_fix(text: &str) -> String {
// 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 == '>' {
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('_');

View File

@@ -1,5 +1,7 @@
mod dedent;
mod fix;
mod replace;
pub use dedent::*;
pub use fix::*;
pub use fix::*;
pub use replace::*;

293
src/text/replace.rs Normal file
View File

@@ -0,0 +1,293 @@
use regex::Regex;
use std::fs;
use std::io::{self, Read, Seek, SeekFrom};
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.
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)]
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(&regex_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::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(())
}
}

298
src/text/template.rs Normal file
View File

@@ -0,0 +1,298 @@
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.
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
///
/// ```
/// use sal::text::TemplateBuilder;
///
/// let builder = TemplateBuilder::open("templates/example.html")?
/// .add_var("title", "Hello World")
/// .add_var("username", "John Doe");
/// ```
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
///
/// ```
/// use sal::text::TemplateBuilder;
/// use std::collections::HashMap;
///
/// 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);
/// ```
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
///
/// ```
/// use sal::text::TemplateBuilder;
///
/// let result = TemplateBuilder::open("templates/example.html")?
/// .add_var("title", "Hello World")
/// .add_var("username", "John Doe")
/// .render()?;
///
/// println!("Rendered template: {}", result);
/// ```
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
///
/// ```
/// use sal::text::TemplateBuilder;
///
/// TemplateBuilder::open("templates/example.html")?
/// .add_var("title", "Hello World")
/// .add_var("username", "John Doe")
/// .render_to_file("output.html")?;
/// ```
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 mut temp_file = NamedTempFile::new()?;
writeln!(temp_file, "Hello, {{ name }}! Welcome to {{ place }}.")?;
temp_file.flush()?;
// 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 mut temp_file = NamedTempFile::new()?;
writeln!(temp_file, "{% if show_greeting %}Hello, {{ name }}!{% endif %}")?;
writeln!(temp_file, "{% for item in items %}{{ item }}{% if not loop.last %}, {% endif %}{% endfor %}")?;
temp_file.flush()?;
// 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 mut temp_file = NamedTempFile::new()?;
writeln!(temp_file, "{{ message }}")?;
temp_file.flush()?;
// 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(())
}
}