scooter-core 0.3.3

Core find-and-replace functionality
Documentation
use std::io::Cursor;

use crate::{
    line_reader::BufReadExt,
    replace::replace_all_if_match,
    search::{FileSearcher, ParsedDirConfig, ParsedSearchConfig},
    validation::{
        DirConfig, SearchConfig, SimpleErrorHandler, ValidationResult,
        validate_search_configuration,
    },
};

// Perform a find-and-replace recursively in a given directory
pub fn find_and_replace(
    search_config: SearchConfig<'_>,
    dir_config: DirConfig<'_>,
) -> anyhow::Result<String> {
    let (parsed_search_config, parsed_dir_config) = parse_config(search_config, Some(dir_config))?;
    let searcher = FileSearcher::new(
        parsed_search_config,
        parsed_dir_config.expect("Found None dir_config when search_type is Files"),
    );
    let num_files_replaced = searcher.walk_files_and_replace(None);

    Ok(format!(
        "Success: {num_files_replaced} file{prefix} updated\n",
        prefix = if num_files_replaced != 1 { "s" } else { "" },
    ))
}

/// Perform a find-and-replace in a string slice
pub fn find_and_replace_text(
    content: &str,
    search_config: SearchConfig<'_>,
) -> anyhow::Result<String> {
    let (parsed_search_config, _) = parse_config(search_config, None)?;

    // When multiline mode is enabled, perform replacement on the entire content
    if parsed_search_config.multiline {
        let result = replace_all_if_match(
            content,
            &parsed_search_config.search,
            &parsed_search_config.replace,
        )
        .unwrap_or_else(|| content.to_string());
        return Ok(result);
    }

    // Default line-by-line processing
    let mut result = String::with_capacity(content.len());
    let cursor = Cursor::new(content);

    for line_result in cursor.lines_with_endings() {
        let (line_bytes, line_ending) = line_result?;

        let line = String::from_utf8(line_bytes)?;

        if let Some(replaced_line) = replace_all_if_match(
            &line,
            &parsed_search_config.search,
            &parsed_search_config.replace,
        ) {
            result.push_str(&replaced_line);
        } else {
            result.push_str(&line);
        }

        result.push_str(line_ending.as_str());
    }

    Ok(result)
}

fn parse_config(
    search_config: SearchConfig<'_>,
    dir_config: Option<DirConfig<'_>>,
) -> anyhow::Result<(ParsedSearchConfig, Option<ParsedDirConfig>)> {
    let mut error_handler = SimpleErrorHandler::new();

    match validate_search_configuration(search_config, dir_config, &mut error_handler)? {
        ValidationResult::Success(parsed) => Ok(parsed),
        ValidationResult::ValidationErrors => Err(anyhow::anyhow!(
            "{}",
            error_handler
                .errors_str()
                .unwrap_or_else(|| "Unknown validation error".to_string())
        )),
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::validation::SearchConfig;

    fn build_config<'a>(
        search_text: &'a str,
        replacement_text: &'a str,
        multiline: bool,
    ) -> SearchConfig<'a> {
        SearchConfig {
            search_text,
            replacement_text,
            fixed_strings: true,
            advanced_regex: false,
            match_whole_word: false,
            match_case: true,
            multiline,
            interpret_escape_sequences: false,
        }
    }

    #[test]
    fn find_and_replace_text_line_mode_does_not_replace_newlines() {
        let content = "foo\nbar\n";
        let config = build_config("\n", "X", false);

        let result = find_and_replace_text(content, config).unwrap();

        assert_eq!(result, content);
    }

    #[test]
    fn find_and_replace_text_line_mode_preserves_crlf() {
        let content = "foo\r\nbar\r\n";
        let config = build_config("bar", "baz", false);

        let result = find_and_replace_text(content, config).unwrap();

        assert_eq!(result, "foo\r\nbaz\r\n");
    }

    #[test]
    fn find_and_replace_text_multiline_replaces_across_lines() {
        let content = "foo\nbar\nbaz";
        let config = build_config("foo\nbar", "qux", true);

        let result = find_and_replace_text(content, config).unwrap();

        assert_eq!(result, "qux\nbaz");
    }
}