sal-text 0.1.0

SAL Text - Text processing and manipulation utilities with regex, templating, and normalization
Documentation
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(&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::{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(())
    }
}