use std::collections::HashSet;
use std::path::{Path, PathBuf};
use walkdir::{DirEntry, WalkDir};
use crate::project::Project;
pub struct FileRef {
pub id: String,
pub path: PathBuf,
pub line: usize,
pub text: String,
pub matched: String,
pub template: String,
pub word: bool,
}
pub fn render(template: &str, id: &str, pad: usize) -> Option<String> {
let (prefix, numstr) = id.rsplit_once('-')?;
let num: u64 = numstr.parse().ok()?;
Some(
template
.replace("{prefix_lower}", &prefix.to_lowercase())
.replace("{prefix}", prefix)
.replace("{padded}", &format!("{num:0pad$}"))
.replace("{num}", &num.to_string())
.replace("{id}", id),
)
}
fn search_re(rendered: &str, word: bool) -> Option<regex::Regex> {
let escaped = regex::escape(rendered);
let pat = if word {
format!(r"\b{escaped}\b")
} else {
escaped
};
regex::Regex::new(&pat).ok()
}
pub fn scan(prj: &Project, ids: &[&str]) -> Vec<FileRef> {
let pad = prj.pcfg.pad;
let formats = prj.pcfg.file_refs.effective_formats();
struct Needle {
id: String,
rendered: String,
re: regex::Regex,
template: String,
word: bool,
}
let mut needles: Vec<Needle> = Vec::new();
for id in ids {
for f in &formats {
let Some(rendered) = render(&f.template, id, pad) else {
continue;
};
let Some(re) = search_re(&rendered, f.word) else {
continue;
};
needles.push(Needle {
id: id.to_string(),
rendered,
re,
template: f.template.clone(),
word: f.word,
});
}
}
if needles.is_empty() {
return Vec::new();
}
let mut hits: Vec<FileRef> = Vec::new();
for abs in list_files(prj, &prj.pcfg.file_refs.roots) {
let Ok(bytes) = std::fs::read(&abs) else {
continue;
};
let Ok(content) = std::str::from_utf8(&bytes) else {
continue;
};
let rel = abs.strip_prefix(&prj.root).unwrap_or(&abs).to_path_buf();
for (i, line) in content.lines().enumerate() {
for n in &needles {
if !line.contains(&n.rendered) {
continue; }
if n.re.is_match(line) {
hits.push(FileRef {
id: n.id.clone(),
path: rel.clone(),
line: i + 1,
text: line.trim().to_string(),
matched: n.rendered.clone(),
template: n.template.clone(),
word: n.word,
});
}
}
}
}
hits.sort_by(|a, b| {
a.path
.cmp(&b.path)
.then(a.line.cmp(&b.line))
.then(a.id.cmp(&b.id))
});
hits
}
pub fn sed_fix(rel_path: &Path, old: &str, new: &str, word: bool) -> String {
let pat = if word {
format!(r"\b{old}\b")
} else {
old.to_string()
};
format!("sed -i 's/{pat}/{new}/g' {}", rel_path.display())
}
fn list_files(prj: &Project, roots: &[String]) -> Vec<PathBuf> {
let base = prj.base.as_path();
let mut seen: HashSet<PathBuf> = HashSet::new();
let mut out: Vec<PathBuf> = Vec::new();
for root in roots {
let start = prj.root.join(root);
for entry in WalkDir::new(&start)
.into_iter()
.filter_entry(|e| !skip_dir(e, base))
.filter_map(|e| e.ok())
{
if entry.file_type().is_file() {
let p = entry.into_path();
if seen.insert(p.clone()) {
out.push(p);
}
}
}
}
out
}
fn skip_dir(e: &DirEntry, base: &Path) -> bool {
if !e.file_type().is_dir() {
return false;
}
if e.depth() == 0 {
return false;
}
let p = e.path();
if p == base {
return true; }
match p.file_name().and_then(|s| s.to_str()) {
Some(".git") | Some("target") | Some("node_modules") => true,
Some(name) => name.starts_with('.') && name.len() > 1,
None => false,
}
}