use anyhow::{Context, Result};
use std::path::Path;
#[derive(Debug, Clone)]
pub struct PatternPair {
pub pattern: String,
pub substitute: String,
}
pub fn parse_patterns_file(path: &Path) -> Result<Vec<PatternPair>> {
let content = std::fs::read_to_string(path)
.with_context(|| format!("Failed to read patterns file: {}", path.display()))?;
let mut pairs = Vec::new();
for (line_num, line) in content.lines().enumerate() {
let trimmed = line.trim();
if trimmed.is_empty() || trimmed.starts_with('#') {
continue;
}
let tokens = tokenize_shell_line(trimmed).with_context(|| {
format!(
"Failed to parse line {} of patterns file: {}",
line_num + 1,
path.display()
)
})?;
if tokens.len() != 2 {
anyhow::bail!(
"Line {} of patterns file must have exactly 2 tokens (pattern and substitute), found {}: {:?}",
line_num + 1,
tokens.len(),
trimmed
);
}
if tokens[0].is_empty() {
anyhow::bail!("Line {}: pattern cannot be empty", line_num + 1);
}
if tokens[1].is_empty() {
anyhow::bail!("Line {}: substitute cannot be empty", line_num + 1);
}
pairs.push(PatternPair {
pattern: tokens[0].clone(),
substitute: tokens[1].clone(),
});
}
if pairs.is_empty() {
anyhow::bail!(
"Patterns file is empty or contains only comments: {}",
path.display()
);
}
Ok(pairs)
}
fn tokenize_shell_line(line: &str) -> Result<Vec<String>> {
let mut tokens = Vec::new();
let mut current = String::new();
let mut chars = line.chars().peekable();
let mut in_token = false;
while let Some(&ch) = chars.peek() {
match ch {
'\'' => {
chars.next();
in_token = true;
loop {
match chars.next() {
Some('\'') => break,
Some(c) => current.push(c),
None => anyhow::bail!("Unterminated single quote"),
}
}
}
'"' => {
chars.next();
in_token = true;
loop {
match chars.next() {
Some('"') => break,
Some('\\') => match chars.next() {
Some('\\') => current.push('\\'),
Some('"') => current.push('"'),
Some('n') => current.push('\n'),
Some('t') => current.push('\t'),
Some(c) => {
current.push('\\');
current.push(c);
}
None => anyhow::bail!("Unterminated escape in double quote"),
},
Some(c) => current.push(c),
None => anyhow::bail!("Unterminated double quote"),
}
}
}
'\\' => {
chars.next();
in_token = true;
match chars.next() {
Some(c) => current.push(c),
None => anyhow::bail!("Trailing backslash"),
}
}
c if c.is_whitespace() => {
chars.next();
if in_token {
tokens.push(std::mem::take(&mut current));
in_token = false;
}
}
c => {
chars.next();
current.push(c);
in_token = true;
}
}
}
if in_token {
tokens.push(current);
}
Ok(tokens)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
use tempfile::TempDir;
#[test]
fn test_tokenize_simple() {
let tokens = tokenize_shell_line("foo bar").unwrap();
assert_eq!(tokens, vec!["foo", "bar"]);
}
#[test]
fn test_tokenize_multiple_spaces() {
let tokens = tokenize_shell_line("foo bar").unwrap();
assert_eq!(tokens, vec!["foo", "bar"]);
}
#[test]
fn test_tokenize_tabs_and_spaces() {
let tokens = tokenize_shell_line("foo\t bar").unwrap();
assert_eq!(tokens, vec!["foo", "bar"]);
}
#[test]
fn test_tokenize_single_quotes() {
let tokens = tokenize_shell_line("'foo bar' baz").unwrap();
assert_eq!(tokens, vec!["foo bar", "baz"]);
}
#[test]
fn test_tokenize_single_quotes_preserve_backslash() {
let tokens = tokenize_shell_line(r"'foo\nbar' baz").unwrap();
assert_eq!(tokens, vec!["foo\\nbar", "baz"]);
}
#[test]
fn test_tokenize_double_quotes() {
let tokens = tokenize_shell_line(r#""foo bar" baz"#).unwrap();
assert_eq!(tokens, vec!["foo bar", "baz"]);
}
#[test]
fn test_tokenize_double_quotes_escape_backslash() {
let tokens = tokenize_shell_line(r#""foo\\bar" baz"#).unwrap();
assert_eq!(tokens, vec!["foo\\bar", "baz"]);
}
#[test]
fn test_tokenize_double_quotes_escape_quote() {
let tokens = tokenize_shell_line(r#""foo\"bar" baz"#).unwrap();
assert_eq!(tokens, vec!["foo\"bar", "baz"]);
}
#[test]
fn test_tokenize_double_quotes_escape_newline() {
let tokens = tokenize_shell_line(r#""foo\nbar" baz"#).unwrap();
assert_eq!(tokens, vec!["foo\nbar", "baz"]);
}
#[test]
fn test_tokenize_double_quotes_escape_tab() {
let tokens = tokenize_shell_line(r#""foo\tbar" baz"#).unwrap();
assert_eq!(tokens, vec!["foo\tbar", "baz"]);
}
#[test]
fn test_tokenize_double_quotes_unknown_escape_preserved() {
let tokens = tokenize_shell_line(r#""foo\xbar" baz"#).unwrap();
assert_eq!(tokens, vec!["foo\\xbar", "baz"]);
}
#[test]
fn test_tokenize_backslash_escape_space() {
let tokens = tokenize_shell_line(r"foo\ bar baz").unwrap();
assert_eq!(tokens, vec!["foo bar", "baz"]);
}
#[test]
fn test_tokenize_backslash_escape_backslash() {
let tokens = tokenize_shell_line(r"foo\\ bar").unwrap();
assert_eq!(tokens, vec!["foo\\", "bar"]);
}
#[test]
fn test_tokenize_mixed_quoting() {
let tokens = tokenize_shell_line(r#"'hello world' "goodbye\"cruel" world"#).unwrap();
assert_eq!(tokens, vec!["hello world", "goodbye\"cruel", "world"]);
}
#[test]
fn test_tokenize_adjacent_quoted_and_unquoted() {
let tokens = tokenize_shell_line("foo'bar' baz").unwrap();
assert_eq!(tokens, vec!["foobar", "baz"]);
}
#[test]
fn test_tokenize_empty_string() {
let tokens = tokenize_shell_line("").unwrap();
assert!(tokens.is_empty());
}
#[test]
fn test_tokenize_only_whitespace() {
let tokens = tokenize_shell_line(" \t ").unwrap();
assert!(tokens.is_empty());
}
#[test]
fn test_tokenize_single_token() {
let tokens = tokenize_shell_line("hello").unwrap();
assert_eq!(tokens, vec!["hello"]);
}
#[test]
fn test_tokenize_three_tokens() {
let tokens = tokenize_shell_line("a b c").unwrap();
assert_eq!(tokens, vec!["a", "b", "c"]);
}
#[test]
fn test_unterminated_single_quote() {
assert!(tokenize_shell_line("'unterminated").is_err());
}
#[test]
fn test_unterminated_double_quote() {
assert!(tokenize_shell_line(r#""unterminated"#).is_err());
}
#[test]
fn test_trailing_backslash() {
assert!(tokenize_shell_line(r"foo\").is_err());
}
#[test]
fn test_unterminated_escape_in_double_quote() {
assert!(tokenize_shell_line(r#""foo\"#).is_err());
}
#[test]
fn test_parse_patterns_file_basic() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "foo bar\nbaz qux\n").unwrap();
let pairs = parse_patterns_file(&file).unwrap();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].pattern, "foo");
assert_eq!(pairs[0].substitute, "bar");
assert_eq!(pairs[1].pattern, "baz");
assert_eq!(pairs[1].substitute, "qux");
}
#[test]
fn test_parse_patterns_file_comments_and_blanks() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(
&file,
"# comment\n\nfoo bar\n # indented comment\nbaz qux\n\n",
)
.unwrap();
let pairs = parse_patterns_file(&file).unwrap();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].pattern, "foo");
assert_eq!(pairs[1].pattern, "baz");
}
#[test]
fn test_parse_patterns_file_quoted_values() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "'foo bar' 'baz qux'\n\"hello world\" replacement\n").unwrap();
let pairs = parse_patterns_file(&file).unwrap();
assert_eq!(pairs.len(), 2);
assert_eq!(pairs[0].pattern, "foo bar");
assert_eq!(pairs[0].substitute, "baz qux");
assert_eq!(pairs[1].pattern, "hello world");
assert_eq!(pairs[1].substitute, "replacement");
}
#[test]
fn test_parse_patterns_file_backslash_escaped() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "foo\\ bar baz\n").unwrap();
let pairs = parse_patterns_file(&file).unwrap();
assert_eq!(pairs.len(), 1);
assert_eq!(pairs[0].pattern, "foo bar");
assert_eq!(pairs[0].substitute, "baz");
}
#[test]
fn test_parse_patterns_file_wrong_token_count_three() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "foo bar baz\n").unwrap();
let err = parse_patterns_file(&file).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("exactly 2 tokens"), "Error: {}", msg);
}
#[test]
fn test_parse_patterns_file_wrong_token_count_one() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "foo\n").unwrap();
let err = parse_patterns_file(&file).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("exactly 2 tokens"), "Error: {}", msg);
}
#[test]
fn test_parse_patterns_file_empty() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "# only comments\n\n").unwrap();
assert!(parse_patterns_file(&file).is_err());
}
#[test]
fn test_parse_patterns_file_nonexistent() {
assert!(parse_patterns_file(Path::new("/nonexistent/patterns.txt")).is_err());
}
#[test]
fn test_parse_patterns_file_empty_pattern_via_quotes() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "'' bar\n").unwrap();
let err = parse_patterns_file(&file).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("pattern cannot be empty"), "Error: {}", msg);
}
#[test]
fn test_parse_patterns_file_empty_substitute_via_quotes() {
let tmp = TempDir::new().unwrap();
let file = tmp.path().join("patterns.txt");
fs::write(&file, "foo ''\n").unwrap();
let err = parse_patterns_file(&file).unwrap_err();
let msg = format!("{}", err);
assert!(msg.contains("substitute cannot be empty"), "Error: {}", msg);
}
}