use std::path::{Component, Path, PathBuf};
use regex::Regex;
use crate::error::{Error, Result};
const IGNORE_DIRS: &[&str] = &[".git", "target", "node_modules"];
#[derive(Debug, Clone)]
pub struct Workspace {
root: PathBuf,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Match {
pub path: String,
pub line: u32,
pub text: String,
}
impl Workspace {
pub fn new(root: impl Into<PathBuf>) -> Self {
Self { root: root.into() }
}
pub fn root(&self) -> &Path {
&self.root
}
pub fn resolve(&self, rel: &str) -> Result<PathBuf> {
let p = Path::new(rel);
if p.is_absolute() {
return Err(escape(rel));
}
let mut out = self.root.clone();
for comp in p.components() {
match comp {
Component::Normal(c) => out.push(c),
Component::CurDir => {}
Component::ParentDir => {
if !out.pop() || !out.starts_with(&self.root) {
return Err(escape(rel));
}
}
Component::RootDir | Component::Prefix(_) => return Err(escape(rel)),
}
}
Ok(out)
}
pub fn grep(&self, pattern: &str, path_glob: Option<&str>) -> Result<Vec<Match>> {
let re = Regex::new(pattern).map_err(|e| Error::Config(format!("bad grep regex: {e}")))?;
let glob = path_glob.map(glob_to_regex).transpose()?;
let mut out = Vec::new();
for file in self.walk() {
if let Some(g) = &glob {
if !g.is_match(&file) {
continue;
}
}
let Ok(content) = std::fs::read_to_string(self.root.join(&file)) else {
continue;
};
for (i, line) in content.lines().enumerate() {
if re.is_match(line) {
out.push(Match {
path: file.clone(),
line: (i + 1) as u32,
text: line.to_string(),
});
}
}
}
Ok(out)
}
pub fn find(&self, name_glob: &str) -> Result<Vec<String>> {
let re = glob_to_regex(name_glob)?;
Ok(self
.walk()
.into_iter()
.filter(|file| {
let base = Path::new(file)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(file);
re.is_match(base) || re.is_match(file)
})
.collect())
}
pub fn read_file(&self, rel: &str) -> Result<String> {
let abs = self.resolve(rel)?;
Ok(std::fs::read_to_string(abs).unwrap_or_default())
}
pub fn write_file(&self, rel: &str, content: &str) -> Result<()> {
let abs = self.resolve(rel)?;
if let Some(parent) = abs.parent() {
std::fs::create_dir_all(parent)?;
}
std::fs::write(abs, content)?;
Ok(())
}
fn walk(&self) -> Vec<String> {
let mut out = Vec::new();
let mut stack = vec![self.root.clone()];
while let Some(dir) = stack.pop() {
let Ok(entries) = std::fs::read_dir(&dir) else {
continue;
};
for entry in entries.flatten() {
let Ok(ft) = entry.file_type() else { continue };
let name = entry.file_name();
if ft.is_dir() {
if !IGNORE_DIRS.contains(&name.to_string_lossy().as_ref()) {
stack.push(entry.path());
}
} else if ft.is_file() {
if let Ok(rel) = entry.path().strip_prefix(&self.root) {
out.push(rel.to_string_lossy().replace('\\', "/"));
}
}
}
}
out.sort();
out
}
}
fn escape(rel: &str) -> Error {
Error::Config(format!("path escapes workspace: {rel}"))
}
fn glob_to_regex(glob: &str) -> Result<Regex> {
let mut re = String::from("(?s)^");
for ch in glob.chars() {
match ch {
'*' => re.push_str(".*"),
'?' => re.push('.'),
c => re.push_str(®ex::escape(&c.to_string())),
}
}
re.push('$');
Regex::new(&re).map_err(|e| Error::Config(format!("bad glob: {e}")))
}
#[cfg(test)]
mod tests {
use super::*;
fn fixture() -> tempfile::TempDir {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
std::fs::create_dir_all(root.join("src")).unwrap();
std::fs::create_dir_all(root.join("target")).unwrap();
std::fs::write(root.join("src/a.rs"), "pub fn alpha() -> u32 { 1 }\n").unwrap();
std::fs::write(root.join("src/b.rs"), "pub fn beta() -> u32 { 2 }\n// alpha ref\n").unwrap();
std::fs::write(root.join("README.md"), "# alpha and beta\n").unwrap();
std::fs::write(root.join("target/junk.rs"), "fn alpha() {}\n").unwrap();
dir
}
#[test]
fn grep_finds_matches_by_regex_across_files_skipping_ignored() {
let dir = fixture();
let ws = Workspace::new(dir.path());
let hits = ws.grep(r"alpha", None).unwrap();
let paths: Vec<_> = hits.iter().map(|m| m.path.as_str()).collect();
assert!(paths.contains(&"src/a.rs"));
assert!(paths.contains(&"src/b.rs"));
assert!(paths.contains(&"README.md"));
assert!(!paths.iter().any(|p| p.starts_with("target/")));
let b = hits.iter().find(|m| m.path == "src/b.rs").unwrap();
assert_eq!(b.line, 2);
}
#[test]
fn grep_path_glob_restricts_to_matching_files() {
let dir = fixture();
let ws = Workspace::new(dir.path());
let hits = ws.grep("alpha", Some("src/*.rs")).unwrap();
assert!(hits.iter().all(|m| m.path.starts_with("src/")));
assert!(!hits.iter().any(|m| m.path == "README.md"));
}
#[test]
fn find_matches_by_basename_and_path_glob() {
let dir = fixture();
let ws = Workspace::new(dir.path());
let rs = ws.find("*.rs").unwrap();
assert!(rs.contains(&"src/a.rs".to_string()));
assert!(rs.contains(&"src/b.rs".to_string()));
assert!(!rs.iter().any(|p| p.starts_with("target/"))); let only_a = ws.find("a.rs").unwrap();
assert_eq!(only_a, vec!["src/a.rs".to_string()]);
}
#[test]
fn resolve_refuses_escapes_but_allows_inner_paths() {
let dir = fixture();
let ws = Workspace::new(dir.path());
assert!(ws.resolve("src/a.rs").is_ok());
assert!(ws.resolve("src/../README.md").is_ok()); assert!(ws.resolve("../secret").is_err()); assert!(ws.resolve("src/../../etc/passwd").is_err()); #[cfg(unix)]
assert!(ws.resolve("/etc/passwd").is_err()); }
#[test]
fn read_missing_is_empty_then_write_roundtrips_within_root() {
let dir = fixture();
let ws = Workspace::new(dir.path());
assert_eq!(ws.read_file("src/new.rs").unwrap(), "");
ws.write_file("src/new.rs", "fn n() {}").unwrap();
assert_eq!(ws.read_file("src/new.rs").unwrap(), "fn n() {}");
assert!(ws.write_file("../evil.rs", "x").is_err());
}
}