use std::path::{Path, PathBuf};
use crate::scanner;
#[derive(Debug, Clone)]
pub struct ReplaceMatch {
pub abs_path: PathBuf,
pub rel_path: String,
pub line: usize,
pub col_byte: usize,
pub line_text: String,
}
#[derive(Debug, Default)]
pub struct ApplyResult {
pub applied: usize,
pub drifted: usize,
pub errors: Vec<(PathBuf, String)>,
pub files_changed: Vec<PathBuf>,
}
pub fn scan(scope: &Path, root: &Path, query: &str) -> Vec<ReplaceMatch> {
if query.is_empty() {
return Vec::new();
}
let files = match scanner::scan_directory(scope, &globset::GlobSet::empty()) {
Ok(f) => f,
Err(_) => return Vec::new(),
};
let mut out = Vec::new();
for f in files {
let Ok(content) = std::fs::read_to_string(&f.abs_path) else {
continue;
};
let rel_path = f
.abs_path
.strip_prefix(root)
.unwrap_or(&f.abs_path)
.to_string_lossy()
.to_string();
for (line_idx, line) in content.lines().enumerate() {
let mut cursor = 0;
while let Some(found) = line[cursor..].find(query) {
let col = cursor + found;
out.push(ReplaceMatch {
abs_path: f.abs_path.clone(),
rel_path: rel_path.clone(),
line: line_idx + 1,
col_byte: col,
line_text: line.to_string(),
});
cursor = col + query.len();
}
}
}
out
}
pub fn apply(matches: &[ReplaceMatch], query: &str, target: &str) -> ApplyResult {
let mut result = ApplyResult::default();
if matches.is_empty() || query.is_empty() {
return result;
}
let mut by_file: std::collections::BTreeMap<PathBuf, Vec<&ReplaceMatch>> =
std::collections::BTreeMap::new();
for m in matches {
by_file.entry(m.abs_path.clone()).or_default().push(m);
}
for (path, mut hits) in by_file {
hits.sort_by_key(|h| std::cmp::Reverse((h.line, h.col_byte)));
let original = match std::fs::read_to_string(&path) {
Ok(s) => s,
Err(e) => {
result.errors.push((path, e.to_string()));
continue;
}
};
let mut lines: Vec<String> = original.split('\n').map(|s| s.to_string()).collect();
let mut changed = false;
for m in hits {
let idx = m.line.saturating_sub(1);
let Some(line) = lines.get_mut(idx) else {
result.drifted += 1;
continue;
};
let end = m.col_byte + query.len();
if end > line.len() || &line[m.col_byte..end] != query {
result.drifted += 1;
continue;
}
line.replace_range(m.col_byte..end, target);
result.applied += 1;
changed = true;
}
if changed {
let new_content = lines.join("\n");
match std::fs::write(&path, new_content) {
Ok(()) => result.files_changed.push(path),
Err(e) => result.errors.push((path, e.to_string())),
}
}
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
fn tmp() -> PathBuf {
use std::sync::atomic::{AtomicU64, Ordering};
static COUNTER: AtomicU64 = AtomicU64::new(0);
let mut p = std::env::temp_dir();
p.push(format!(
"penknife-replace-test-{}-{}",
std::process::id(),
COUNTER.fetch_add(1, Ordering::Relaxed)
));
fs::create_dir_all(&p).unwrap();
p
}
#[test]
fn scan_finds_multiple_matches_in_a_line() {
let dir = tmp();
fs::write(dir.join("a.md"), "Urslog met another Urslog.\n").unwrap();
let m = scan(&dir, &dir, "Urslog");
assert_eq!(m.len(), 2);
assert_eq!(m[0].line, 1);
assert_eq!(m[0].col_byte, 0);
assert_eq!(m[1].col_byte, 19);
}
#[test]
fn scan_walks_subdirectories() {
let dir = tmp();
fs::create_dir_all(dir.join("rp-posts")).unwrap();
fs::create_dir_all(dir.join("session-notes")).unwrap();
fs::write(dir.join("rp-posts/p1.md"), "Urslog grunts.\n").unwrap();
fs::write(dir.join("session-notes/s1.md"), "The Urslog flees.\n").unwrap();
let m = scan(&dir, &dir, "Urslog");
assert_eq!(m.len(), 2);
let paths: Vec<&str> = m.iter().map(|h| h.rel_path.as_str()).collect();
assert!(paths.iter().any(|p| p.contains("rp-posts")));
assert!(paths.iter().any(|p| p.contains("session-notes")));
}
#[test]
fn apply_replaces_only_checked_matches() {
let dir = tmp();
fs::write(
dir.join("a.md"),
"Urslog appears.\nUrslog roars.\nUrslog flees.\n",
)
.unwrap();
let mut hits = scan(&dir, &dir, "Urslog");
assert_eq!(hits.len(), 3);
hits.retain(|h| h.line == 2);
let r = apply(&hits, "Urslog", "Viewslog");
assert_eq!(r.applied, 1);
assert_eq!(r.drifted, 0);
let content = fs::read_to_string(dir.join("a.md")).unwrap();
assert_eq!(content, "Urslog appears.\nViewslog roars.\nUrslog flees.\n");
}
#[test]
fn apply_multiple_matches_in_one_line_preserves_offsets() {
let dir = tmp();
fs::write(dir.join("a.md"), "foo foo foo\n").unwrap();
let hits = scan(&dir, &dir, "foo");
assert_eq!(hits.len(), 3);
let r = apply(&hits, "foo", "BARBAZ"); assert_eq!(r.applied, 3);
let content = fs::read_to_string(dir.join("a.md")).unwrap();
assert_eq!(content, "BARBAZ BARBAZ BARBAZ\n");
}
#[test]
fn apply_detects_drift_when_file_mutated_between_scan_and_apply() {
let dir = tmp();
fs::write(dir.join("a.md"), "Urslog roars.\n").unwrap();
let hits = scan(&dir, &dir, "Urslog");
fs::write(dir.join("a.md"), "Goblin roars.\n").unwrap();
let r = apply(&hits, "Urslog", "Viewslog");
assert_eq!(r.applied, 0);
assert_eq!(r.drifted, 1);
let content = fs::read_to_string(dir.join("a.md")).unwrap();
assert_eq!(content, "Goblin roars.\n");
}
#[test]
fn empty_query_returns_no_matches() {
let dir = tmp();
fs::write(dir.join("a.md"), "Anything.\n").unwrap();
assert_eq!(scan(&dir, &dir, "").len(), 0);
}
}