#[cfg(feature = "cli")]
use crate::cli::global::GlobalFlags;
#[cfg(any(feature = "cli", feature = "files"))]
use globset::{Glob, GlobSet, GlobSetBuilder};
#[cfg(any(feature = "cli", feature = "files"))]
use ignore::WalkBuilder;
#[cfg(feature = "cli")]
use ignore::WalkState;
use std::path::Path;
#[cfg(any(feature = "cli", feature = "files"))]
use std::path::PathBuf;
#[cfg(feature = "cli")]
use std::sync::Mutex;
#[cfg(any(feature = "cli", feature = "files"))]
pub fn relative_display<'a>(path: &'a Path, base: &Path) -> &'a Path {
path.strip_prefix(base).unwrap_or(path)
}
#[cfg(feature = "cli")]
pub(crate) fn has_regex_metacharacters(s: &str) -> bool {
s.contains('\\')
|| s.contains('[')
|| s.contains('(')
|| s.contains('{')
|| s.contains('*')
|| s.contains('+')
|| s.contains('?')
|| s.contains('|')
|| s.contains('^')
|| s.contains('$')
}
pub fn is_binary(data: &[u8]) -> bool {
let check_len = data.len().min(8192);
memchr::memchr(0, &data[..check_len]).is_some()
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum TextBytesKind {
Text(String),
Binary,
InvalidUtf8,
}
pub fn classify_text_bytes(bytes: &[u8]) -> TextBytesKind {
if is_binary(bytes) {
return TextBytesKind::Binary;
}
match String::from_utf8(bytes.to_vec()) {
Ok(s) => TextBytesKind::Text(s),
Err(_) => TextBytesKind::InvalidUtf8,
}
}
pub fn load_text_strict(path: &Path, display: &str) -> anyhow::Result<String> {
if path.exists() && !path.is_file() {
return Err(crate::exit::InvalidInputError {
msg: format!("target is not a file: {display}"),
}
.into());
}
let bytes = match std::fs::read(path) {
Ok(b) => b,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let msg = format!("failed to read {display}: {e}");
return Err(anyhow::Error::new(e).context(msg));
}
Err(e) => {
return Err(crate::exit::InvalidInputError {
msg: format!("failed to read {display}: {e}"),
}
.into());
}
};
match classify_text_bytes(&bytes) {
TextBytesKind::Text(s) => Ok(s),
TextBytesKind::Binary => Err(crate::exit::BinaryError {
msg: format!("target is a binary file: {display}"),
}
.into()),
TextBytesKind::InvalidUtf8 => Err(crate::exit::InvalidEncodingError {
msg: format!("target is not valid UTF-8 text: {display}"),
}
.into()),
}
}
pub fn is_binary_file(path: &Path) -> bool {
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return false,
};
let mut buf = [0u8; 8192];
let n = match std::io::Read::read(&mut file, &mut buf) {
Ok(n) => n,
Err(_) => return false,
};
is_binary(&buf[..n])
}
#[cfg(feature = "cli")]
pub(crate) fn all_explicit_paths_missing(paths: &[String], root: Option<&Path>) -> bool {
if paths.is_empty() {
return false;
}
paths.iter().all(|p| {
let resolved = match root {
Some(r) if !std::path::Path::new(p).is_absolute() => r.join(p),
_ => std::path::PathBuf::from(p),
};
!resolved.exists()
})
}
#[cfg(feature = "cli")]
pub(crate) fn all_scan_targets_missing(
global: &GlobalFlags,
paths: &[String],
root: Option<&Path>,
) -> anyhow::Result<bool> {
if global.files_from.as_deref() == Some("-") {
return Ok(false);
}
if global.files_from.is_some() {
let Some(files) = global.read_files_from()? else {
return Ok(false);
};
return Ok(all_explicit_paths_missing(&files, root));
}
Ok(all_explicit_paths_missing(paths, root))
}
#[cfg(feature = "cli")]
pub(crate) fn files_from_missing_entries(
global: &crate::cli::global::GlobalFlags,
cwd: &Path,
) -> anyhow::Result<Option<Vec<String>>> {
if global.files_from.as_deref() == Some("-") {
return Ok(None);
}
let Some(files) = global.read_files_from()? else {
return Ok(None);
};
Ok(missing_paths_under(cwd, &files))
}
#[cfg(feature = "cli")]
#[must_use]
pub(crate) fn explicit_paths_missing_entries(cwd: &Path, paths: &[String]) -> Option<Vec<String>> {
if paths.is_empty() {
return None;
}
missing_paths_under(cwd, paths)
}
#[cfg(feature = "cli")]
pub(crate) fn scan_missing_entries(
global: &crate::cli::global::GlobalFlags,
cwd: &Path,
paths: &[String],
) -> anyhow::Result<Option<Vec<String>>> {
if global.files_from.is_some() {
files_from_missing_entries(global, cwd)
} else {
Ok(explicit_paths_missing_entries(cwd, paths))
}
}
#[cfg(feature = "cli")]
fn missing_paths_under(cwd: &Path, paths: &[String]) -> Option<Vec<String>> {
let mut missing = Vec::new();
for f in paths {
if !cwd.join(f).exists() {
missing.push(f.clone());
}
}
if missing.is_empty() {
None
} else {
Some(missing)
}
}
#[cfg(feature = "cli")]
pub(crate) fn ensure_files_from_nonempty(
global: &GlobalFlags,
file_paths: &[PathBuf],
) -> anyhow::Result<()> {
if global.files_from.is_some() && file_paths.is_empty() {
return Err(crate::exit::InvalidInputError {
msg: "empty --files-from path list (no files to scan)".into(),
}
.into());
}
Ok(())
}
#[cfg(feature = "cli")]
pub(crate) fn collect_file_paths_opts(
paths: &[String],
global: &GlobalFlags,
include_hidden: bool,
root: Option<&Path>,
) -> anyhow::Result<Vec<PathBuf>> {
collect_file_paths_opts_with_list(paths, global, include_hidden, root, None, None)
}
#[cfg(feature = "cli")]
pub(crate) fn collect_file_paths_opts_depth(
paths: &[String],
global: &GlobalFlags,
include_hidden: bool,
root: Option<&Path>,
max_depth: Option<usize>,
) -> anyhow::Result<Vec<PathBuf>> {
collect_file_paths_opts_with_list(paths, global, include_hidden, root, None, max_depth)
}
#[cfg(feature = "cli")]
pub(crate) fn collect_file_paths_opts_with_list(
paths: &[String],
global: &GlobalFlags,
include_hidden: bool,
root: Option<&Path>,
files_from_preload: Option<&[String]>,
max_depth: Option<usize>,
) -> anyhow::Result<Vec<PathBuf>> {
let files_owned;
let files_from: Option<&[String]> = if let Some(pre) = files_from_preload {
Some(pre)
} else if global.files_from.is_some() {
files_owned = global.read_files_from()?;
files_owned.as_deref()
} else {
None
};
if let Some(files) = files_from {
if let Some(r) = root {
global.check_paths_contained(r, files)?;
}
return Ok(files
.iter()
.map(|f| match root {
Some(r) => r.join(f),
None => PathBuf::from(f),
})
.collect());
}
let defaults;
let effective: &[String] = if paths.is_empty() {
defaults = [".".to_string()];
&defaults
} else {
paths
};
let resolve = |p: &str| -> PathBuf {
match root {
Some(r) => r.join(p),
None => PathBuf::from(p),
}
};
if let Some(r) = root {
global.check_paths_contained(r, effective)?;
}
for p in effective {
let resolved = resolve(p);
if !resolved.exists() && !global.json && !global.jsonl {
eprintln!(
"patchloom: {}: No such file or directory",
resolved.display()
);
}
}
let first = resolve(&effective[0]);
let mut builder = WalkBuilder::new(&first);
for p in &effective[1..] {
builder.add(resolve(p));
}
if include_hidden {
builder.hidden(false);
}
if let Some(depth) = max_depth {
builder.max_depth(Some(depth));
}
builder.filter_entry(|e| !should_skip_walk_dirname(e.file_name()));
for name in &global.ignore_file {
builder.add_custom_ignore_filename(name);
}
let collected: Mutex<Vec<PathBuf>> = Mutex::new(Vec::new());
struct FlushOnDrop<'a> {
batch: Vec<PathBuf>,
target: &'a Mutex<Vec<PathBuf>>,
}
impl Drop for FlushOnDrop<'_> {
fn drop(&mut self) {
if !self.batch.is_empty() {
self.target
.lock()
.expect("file list mutex")
.append(&mut self.batch);
}
}
}
builder.build_parallel().run(|| {
let mut state = FlushOnDrop {
batch: Vec::with_capacity(256),
target: &collected,
};
Box::new(move |result| {
let Ok(entry) = result else {
return WalkState::Continue;
};
if should_skip_walk_dirname(entry.file_name()) {
return WalkState::Skip;
}
if entry.file_type().is_some_and(|ft| ft.is_file()) {
state.batch.push(entry.into_path());
if state.batch.len() >= 256 {
state
.target
.lock()
.expect("file list mutex")
.append(&mut state.batch);
}
}
WalkState::Continue
})
});
let mut paths = collected.into_inner().expect("all walkers done");
let explicit_files: Vec<PathBuf> = effective
.iter()
.map(|p| resolve(p))
.filter(|p| p.is_file())
.collect();
apply_exclude_globs(&mut paths, &global.exclude, root)?;
for f in explicit_files {
if !paths.iter().any(|p| p == &f) {
paths.push(f);
}
}
Ok(paths)
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn build_glob_matcher(globs: &[String]) -> anyhow::Result<Option<GlobSet>> {
if globs.is_empty() {
return Ok(None);
}
let mut builder = GlobSetBuilder::new();
for pattern in globs {
builder.add(Glob::new(pattern)?);
}
Ok(Some(builder.build()?))
}
#[cfg(feature = "cli")]
pub(crate) fn build_glob_matcher_from_global(
global: &GlobalFlags,
) -> anyhow::Result<Option<GlobSet>> {
build_glob_matcher(&global.glob)
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn collect_glob_roots(paths: &[PathBuf], root: Option<&Path>) -> Vec<PathBuf> {
let mut roots = Vec::new();
for path in paths {
let resolved = match root {
Some(r) => r.join(path),
None => path.clone(),
};
let glob_root = if resolved.is_file() {
resolved
.parent()
.map(Path::to_path_buf)
.unwrap_or_else(|| resolved.clone())
} else {
resolved.clone()
};
let glob_root = normalize_glob_root(glob_root);
if !roots.contains(&glob_root) {
roots.push(glob_root);
}
}
roots
}
#[cfg(feature = "cli")]
pub(crate) fn collect_glob_roots_from_global(
paths: &[String],
global: &GlobalFlags,
root: Option<&Path>,
) -> anyhow::Result<Vec<PathBuf>> {
if global.files_from.is_some() {
return Ok(root.map(|r| vec![r.to_path_buf()]).unwrap_or_default());
}
let defaults;
let effective: &[String] = if paths.is_empty() {
defaults = [".".to_string()];
&defaults
} else {
paths
};
let paths_buf: Vec<PathBuf> = effective
.iter()
.map(|p| match root {
Some(r) => r.join(p),
None => PathBuf::from(p),
})
.collect();
Ok(collect_glob_roots(&paths_buf, root))
}
#[cfg(any(feature = "cli", feature = "files"))]
fn normalize_glob_root(path: PathBuf) -> PathBuf {
let mut normalized = PathBuf::new();
for component in path.components() {
match component {
std::path::Component::CurDir => {}
_ => normalized.push(component.as_os_str()),
}
}
if normalized.as_os_str().is_empty() {
PathBuf::from(".")
} else {
normalized
}
}
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_matches_path(path: &Path, matcher: &GlobSet) -> bool {
matcher.is_match(path) || path.file_name().is_some_and(|name| matcher.is_match(name))
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn matches_glob_with_roots(path: &Path, matcher: Option<&GlobSet>, roots: &[PathBuf]) -> bool {
match matcher {
None => true,
Some(m) => {
matches_glob(path, Some(m))
|| roots.iter().any(|root| {
path.strip_prefix(root).ok().is_some_and(|relative| {
!relative.as_os_str().is_empty() && matches_glob(relative, Some(m))
})
})
}
}
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn matches_glob(path: &Path, matcher: Option<&GlobSet>) -> bool {
match matcher {
None => true,
Some(m) => glob_matches_path(path, m),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SoftTextSkip {
Binary,
InvalidUtf8,
Unreadable,
}
impl SoftTextSkip {
pub fn as_reason(self) -> &'static str {
match self {
SoftTextSkip::Binary => "binary",
SoftTextSkip::InvalidUtf8 => "invalid_utf8",
SoftTextSkip::Unreadable => "unreadable",
}
}
pub fn is_content_skip(self) -> bool {
matches!(self, SoftTextSkip::Binary | SoftTextSkip::InvalidUtf8)
}
}
pub fn try_read_text_file(path: &Path) -> Result<String, SoftTextSkip> {
use std::io::Read;
let mut file = match std::fs::File::open(path) {
Ok(f) => f,
Err(_) => return Err(SoftTextSkip::Unreadable),
};
let file_len = match file.metadata() {
Ok(m) => m.len() as usize,
Err(_) => return Err(SoftTextSkip::Unreadable),
};
if file_len == 0 {
return Ok(String::new());
}
const BINARY_CHECK_LEN: usize = 8192;
if file_len > BINARY_CHECK_LEN {
let mut header = [0u8; BINARY_CHECK_LEN];
let n = match file.read(&mut header) {
Ok(n) => n,
Err(_) => return Err(SoftTextSkip::Unreadable),
};
if is_binary(&header[..n]) {
return Err(SoftTextSkip::Binary);
}
let mut bytes = Vec::with_capacity(file_len);
bytes.extend_from_slice(&header[..n]);
if file.read_to_end(&mut bytes).is_err() {
return Err(SoftTextSkip::Unreadable);
}
return match String::from_utf8(bytes) {
Ok(s) => Ok(s),
Err(_) => Err(SoftTextSkip::InvalidUtf8),
};
}
let mut bytes = Vec::with_capacity(file_len);
if file.read_to_end(&mut bytes).is_err() {
return Err(SoftTextSkip::Unreadable);
}
match classify_text_bytes(&bytes) {
TextBytesKind::Text(s) => Ok(s),
TextBytesKind::Binary => Err(SoftTextSkip::Binary),
TextBytesKind::InvalidUtf8 => Err(SoftTextSkip::InvalidUtf8),
}
}
pub fn read_text_file(path: &Path) -> Option<String> {
try_read_text_file(path).ok()
}
#[cfg(feature = "cli")]
pub(crate) fn read_text_file_logged(path: &Path, cmd: &str, quiet: bool) -> Option<String> {
match try_read_text_file(path) {
Ok(s) => Some(s),
Err(SoftTextSkip::Binary) => None,
Err(SoftTextSkip::InvalidUtf8) => {
if !quiet {
eprintln!("{cmd}: skipping {} (invalid UTF-8)", path.display());
}
None
}
Err(SoftTextSkip::Unreadable) => {
if !quiet {
let detail = std::fs::File::open(path)
.err()
.map(|e| e.to_string())
.or_else(|| std::fs::metadata(path).err().map(|e| e.to_string()))
.unwrap_or_else(|| "unreadable".into());
eprintln!("{cmd}: skipping {}: {detail}", path.display());
}
None
}
}
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn collect_file_paths(root: &Path, include_hidden: bool) -> anyhow::Result<Vec<PathBuf>> {
collect_file_paths_with_ignores(root, &[], &[], include_hidden)
}
#[cfg(any(feature = "cli", feature = "files"))]
fn apply_exclude_globs(
paths: &mut Vec<PathBuf>,
patterns: &[String],
root: Option<&Path>,
) -> anyhow::Result<()> {
if patterns.is_empty() {
return Ok(());
}
let mut exb = globset::GlobSetBuilder::new();
for pat in patterns {
exb.add(globset::Glob::new(pat)?);
}
let ex = exb.build()?;
paths.retain(|p| {
if glob_matches_path(p, &ex) {
return false;
}
if let Some(r) = root
&& let Ok(rel) = p.strip_prefix(r)
&& !rel.as_os_str().is_empty()
&& ex.is_match(rel)
{
return false;
}
true
});
Ok(())
}
#[cfg(any(feature = "cli", feature = "files"))]
pub(crate) fn should_skip_walk_dirname(name: &std::ffi::OsStr) -> bool {
name == ".git" || name == ".patchloom"
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn collect_file_paths_with_ignores(
root: &Path,
custom_ignore_filenames: &[String],
exclude_patterns: &[String],
include_hidden: bool,
) -> anyhow::Result<Vec<PathBuf>> {
let mut builder = WalkBuilder::new(root);
if include_hidden {
builder.hidden(false);
}
builder.filter_entry(|e| !should_skip_walk_dirname(e.file_name()));
for name in custom_ignore_filenames {
builder.add_custom_ignore_filename(name);
}
let mut paths: Vec<PathBuf> = Vec::new();
for entry in builder.build().filter_map(Result::ok) {
if entry.file_type().is_some_and(|ft| ft.is_file()) {
paths.push(entry.into_path());
}
}
apply_exclude_globs(&mut paths, exclude_patterns, Some(root))?;
Ok(paths)
}
#[cfg(any(feature = "cli", feature = "files"))]
pub fn par_process_files<T, F>(
paths: &[PathBuf],
glob_matcher: Option<&GlobSet>,
glob_roots: &[PathBuf],
f: F,
) -> Vec<T>
where
T: Send,
F: Fn(&Path) -> Option<T> + Sync,
{
fn process_slice<T, F>(
paths: &[PathBuf],
glob_matcher: Option<&GlobSet>,
glob_roots: &[PathBuf],
f: &F,
) -> Vec<T>
where
T: Send,
F: Fn(&Path) -> Option<T> + Sync,
{
paths
.iter()
.filter(|p| matches_glob_with_roots(p, glob_matcher, glob_roots))
.filter_map(|p| f(p.as_path()))
.collect()
}
let num_splits = std::thread::available_parallelism()
.map(|n| n.get())
.unwrap_or(1)
.min(paths.len());
if num_splits <= 1 {
return process_slice(paths, glob_matcher, glob_roots, &f);
}
let chunk_size = paths.len().div_ceil(num_splits);
let chunks: Vec<&[PathBuf]> = paths.chunks(chunk_size).collect();
std::thread::scope(|s| {
let handles: Vec<_> = chunks[1..]
.iter()
.map(|chunk| s.spawn(|| process_slice(chunk, glob_matcher, glob_roots, &f)))
.collect();
let mut results = process_slice(chunks[0], glob_matcher, glob_roots, &f);
for handle in handles {
results.extend(handle.join().expect("worker thread panicked"));
}
results
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
#[cfg(feature = "cli")]
fn plain_text_has_no_regex_meta() {
assert!(!has_regex_metacharacters("hello world"));
assert!(!has_regex_metacharacters("foo-bar_baz"));
}
#[test]
#[cfg(feature = "cli")]
fn regex_patterns_detected() {
assert!(has_regex_metacharacters("fn\\s+main"));
assert!(has_regex_metacharacters("v1\\.0"));
assert!(has_regex_metacharacters("[a-z]+"));
assert!(has_regex_metacharacters("(group)"));
assert!(has_regex_metacharacters("a|b"));
assert!(has_regex_metacharacters("^start"));
assert!(has_regex_metacharacters("end$"));
}
#[test]
#[cfg(feature = "cli")]
fn ensure_files_from_nonempty_rejects_empty_list() {
let global = GlobalFlags {
files_from: Some("list.txt".into()),
..GlobalFlags::default()
};
let err = ensure_files_from_nonempty(&global, &[]).unwrap_err();
let msg = err.to_string();
assert!(
msg.contains("empty --files-from"),
"expected empty-list message: {msg}"
);
assert_eq!(
crate::exit::classify_typed_error(&err).map(|(kind, _)| kind),
Some("invalid_input")
);
}
#[test]
#[cfg(feature = "cli")]
fn ensure_files_from_nonempty_ok_when_paths_present() {
let global = GlobalFlags {
files_from: Some("list.txt".into()),
..GlobalFlags::default()
};
ensure_files_from_nonempty(&global, &[PathBuf::from("a.txt")]).unwrap();
}
#[test]
#[cfg(feature = "cli")]
fn ensure_files_from_nonempty_skips_when_unset() {
let global = GlobalFlags::default();
ensure_files_from_nonempty(&global, &[]).unwrap();
}
#[test]
fn text_is_not_binary() {
assert!(!is_binary(b"hello world\n"));
}
#[test]
fn empty_is_not_binary() {
assert!(!is_binary(b""));
}
#[test]
fn nul_byte_makes_binary() {
assert!(is_binary(b"hello\x00world"));
}
#[test]
fn nul_at_8k_boundary_is_binary() {
let mut data = vec![b'a'; 8191];
data.push(0);
assert!(is_binary(&data));
}
#[test]
fn nul_past_8k_is_not_binary() {
let mut data = vec![b'a'; 8192];
data.push(0);
assert!(!is_binary(&data));
}
#[test]
fn is_binary_file_detects_nul_in_real_file() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("bin.dat");
std::fs::write(&p, b"hello\x00world").unwrap();
assert!(is_binary_file(&p));
}
#[test]
fn is_binary_file_returns_false_for_text_and_nonexistent() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("text.txt");
std::fs::write(&p, b"hello world\n").unwrap();
assert!(!is_binary_file(&p));
assert!(!is_binary_file(&dir.path().join("nope.bin"))); }
#[test]
fn classify_text_bytes_empty_is_text() {
assert_eq!(classify_text_bytes(b""), TextBytesKind::Text(String::new()));
}
#[test]
fn classify_text_bytes_utf8_text() {
assert_eq!(
classify_text_bytes(b"hello\n"),
TextBytesKind::Text("hello\n".into())
);
}
#[test]
fn classify_text_bytes_binary_nul() {
assert_eq!(
classify_text_bytes(b"hello\x00world"),
TextBytesKind::Binary
);
}
#[test]
fn classify_text_bytes_invalid_utf8() {
assert_eq!(
classify_text_bytes(b"hello \xff world"),
TextBytesKind::InvalidUtf8
);
}
#[test]
fn load_text_strict_ok_for_text() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("t.txt");
std::fs::write(&p, "line\n").unwrap();
assert_eq!(load_text_strict(&p, "t.txt").unwrap(), "line\n");
}
#[test]
fn load_text_strict_rejects_binary() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("b.bin");
std::fs::write(&p, b"hello\x00world").unwrap();
let err = load_text_strict(&p, "b.bin").unwrap_err();
assert!(crate::exit::is_binary(&err), "{err:#}");
assert!(!crate::exit::is_invalid_input(&err), "{err:#}");
assert!(err.to_string().contains("binary file"), "msg: {err}");
assert_eq!(
crate::fallback::edit_error_kind(&err),
Some(crate::fallback::EditErrorKind::Binary)
);
assert_eq!(crate::fallback::error_kind_str(&err), Some("binary"));
assert_eq!(std::fs::read(&p).unwrap(), b"hello\x00world");
}
#[test]
fn load_text_strict_rejects_invalid_utf8() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("bad.txt");
std::fs::write(&p, b"hello \xff world").unwrap();
let err = load_text_strict(&p, "bad.txt").unwrap_err();
assert!(crate::exit::is_invalid_encoding(&err), "{err:#}");
assert!(!crate::exit::is_invalid_input(&err), "{err:#}");
assert!(err.to_string().contains("UTF-8"), "msg: {err}");
assert_eq!(
crate::fallback::edit_error_kind(&err),
Some(crate::fallback::EditErrorKind::InvalidEncoding)
);
assert_eq!(
crate::fallback::error_kind_str(&err),
Some("invalid_encoding")
);
}
#[test]
fn load_text_strict_rejects_directory() {
let dir = tempfile::TempDir::new().unwrap();
let err = load_text_strict(dir.path(), "dir").unwrap_err();
assert!(crate::exit::is_invalid_input(&err), "{err:#}");
assert!(err.to_string().contains("not a file"), "msg: {err}");
}
#[test]
#[cfg(unix)]
fn load_text_strict_unreadable_is_invalid_input_with_os_detail() {
use std::os::unix::fs::PermissionsExt;
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("locked.txt");
std::fs::write(&p, "secret\n").unwrap();
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o000)).unwrap();
if std::fs::read_to_string(&p).is_ok() {
std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644)).unwrap();
return;
}
let err = load_text_strict(&p, "locked.txt").unwrap_err();
let _ = std::fs::set_permissions(&p, std::fs::Permissions::from_mode(0o644));
assert!(crate::exit::is_invalid_input(&err), "{err:#}");
let msg = err.to_string();
assert!(msg.contains("failed to read locked.txt"), "msg: {msg}");
assert!(
msg.contains("Permission denied")
|| msg.contains("PermissionDenied")
|| msg.contains("os error"),
"OS detail missing from Display: {msg}"
);
assert_eq!(
msg.matches("failed to read").count(),
1,
"must not double-wrap: {msg}"
);
}
#[test]
fn load_text_strict_missing_is_io_not_found() {
let dir = tempfile::TempDir::new().unwrap();
let p = dir.path().join("nope.txt");
let err = load_text_strict(&p, "nope.txt").unwrap_err();
assert!(
crate::exit::is_io_not_found(&err),
"expected is_io_not_found, got: {err:#}"
);
assert!(
!crate::exit::is_invalid_input(&err),
"NotFound must not be invalid_input: {err:#}"
);
let msg = err.to_string();
assert!(msg.contains("failed to read nope.txt"), "msg: {msg}");
assert!(
msg.contains("No such file") || msg.contains("os error 2") || msg.contains("not found"),
"OS detail missing from Display: {msg}"
);
assert_eq!(
msg.matches("failed to read").count(),
1,
"must not double-wrap: {msg}"
);
let agent = crate::exit::agent_error_message(&err);
let os_hits = agent
.matches("No such file")
.count()
.max(agent.matches("os error 2").count())
.max(agent.matches("not found").count());
assert_eq!(
os_hits, 1,
"agent_error_message must keep OS detail once: {agent}"
);
}
#[test]
#[cfg(feature = "cli")]
fn all_explicit_paths_missing_detects_typos() {
let dir = tempfile::TempDir::new().unwrap();
let missing = vec!["nope.txt".to_string()];
assert!(all_explicit_paths_missing(&missing, Some(dir.path())));
assert!(!all_explicit_paths_missing(&[], Some(dir.path())));
std::fs::write(dir.path().join("exists.txt"), b"x\n").unwrap();
let mixed = vec!["exists.txt".to_string(), "nope.txt".to_string()];
assert!(!all_explicit_paths_missing(&mixed, Some(dir.path())));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn no_matcher_matches_everything() {
assert!(matches_glob(Path::new("any/file.rs"), None));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_matches_extension() {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("*.rs").unwrap());
let matcher = builder.build().unwrap();
assert!(matches_glob(Path::new("src/main.rs"), Some(&matcher)));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_rejects_non_matching() {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("*.rs").unwrap());
let matcher = builder.build().unwrap();
assert!(!matches_glob(Path::new("src/main.py"), Some(&matcher)));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn glob_matches_nested_relative_pattern_with_root() {
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("sub/*.txt").unwrap());
let matcher = builder.build().unwrap();
let roots = vec![PathBuf::from("/tmp/project")];
assert!(matches_glob_with_roots(
Path::new("/tmp/project/sub/file.txt"),
Some(&matcher),
&roots,
));
assert!(!matches_glob_with_roots(
Path::new("/tmp/project/other.txt"),
Some(&matcher),
&roots,
));
}
#[test]
#[cfg(feature = "cli")]
fn collect_glob_roots_normalizes_current_directory_segments() {
let global = GlobalFlags::test_default();
let roots =
collect_glob_roots_from_global(&[], &global, Some(Path::new("/tmp/project"))).unwrap();
assert_eq!(roots, vec![PathBuf::from("/tmp/project")]);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_single_file() {
let paths = vec![PathBuf::from("a.txt")];
let results = par_process_files(&paths, None, &[], |p| {
Some(p.to_string_lossy().into_owned())
});
assert_eq!(results, vec!["a.txt"]);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_filters_with_glob() {
let paths = vec![
PathBuf::from("a.rs"),
PathBuf::from("b.py"),
PathBuf::from("c.rs"),
];
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("*.rs").unwrap());
let matcher = builder.build().unwrap();
let results = par_process_files(&paths, Some(&matcher), &[], |p| {
Some(p.to_string_lossy().into_owned())
});
assert_eq!(results.len(), 2);
assert!(results.contains(&"a.rs".to_string()));
assert!(results.contains(&"c.rs".to_string()));
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_filters_with_relative_glob_root() {
let paths = vec![
PathBuf::from("/tmp/project/sub/a.txt"),
PathBuf::from("/tmp/project/other.txt"),
];
let mut builder = GlobSetBuilder::new();
builder.add(Glob::new("sub/*.txt").unwrap());
let matcher = builder.build().unwrap();
let roots = vec![PathBuf::from("/tmp/project")];
let results = par_process_files(&paths, Some(&matcher), &roots, |p| {
Some(p.to_string_lossy().into_owned())
});
assert_eq!(results, vec!["/tmp/project/sub/a.txt".to_string()]);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_empty_paths() {
let paths: Vec<PathBuf> = vec![];
let results: Vec<String> = par_process_files(&paths, None, &[], |p| {
Some(p.to_string_lossy().into_owned())
});
assert!(results.is_empty());
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn par_process_closure_can_filter() {
let paths = vec![PathBuf::from("a.txt"), PathBuf::from("b.txt")];
let results = par_process_files(&paths, None, &[], |p| {
if p.to_string_lossy().contains('a') {
Some(1)
} else {
None
}
});
assert_eq!(results, vec![1]);
}
#[test]
fn read_text_file_returns_content_for_utf8_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("hello.txt");
std::fs::write(&file, "hello world\n").unwrap();
let result = read_text_file(&file);
assert_eq!(result.unwrap(), "hello world\n");
}
#[test]
fn read_text_file_returns_none_for_binary() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("binary.bin");
std::fs::write(&file, b"hello\x00world").unwrap();
assert!(read_text_file(&file).is_none());
assert_eq!(try_read_text_file(&file).unwrap_err(), SoftTextSkip::Binary);
}
#[test]
fn try_read_text_file_reasons_utf8_and_missing() {
let dir = tempfile::tempdir().unwrap();
let bad = dir.path().join("bad.txt");
std::fs::write(&bad, b"hello \xff world").unwrap();
assert_eq!(
try_read_text_file(&bad).unwrap_err(),
SoftTextSkip::InvalidUtf8
);
assert_eq!(
try_read_text_file(dir.path().join("missing.txt").as_path()).unwrap_err(),
SoftTextSkip::Unreadable
);
assert!(SoftTextSkip::Binary.is_content_skip());
assert!(!SoftTextSkip::Unreadable.is_content_skip());
assert_eq!(SoftTextSkip::InvalidUtf8.as_reason(), "invalid_utf8");
}
#[test]
fn read_text_file_returns_empty_string_for_empty_file() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("empty.txt");
std::fs::write(&file, b"").unwrap();
let result = read_text_file(&file);
assert_eq!(result, Some(String::new()));
}
#[test]
fn read_text_file_returns_none_for_invalid_utf8() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("bad.txt");
std::fs::write(&file, b"hello \xff world\n").unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_returns_none_for_missing_file() {
assert!(read_text_file(Path::new("/tmp/patchloom_nonexistent_xyz.txt")).is_none());
}
#[test]
fn read_text_file_large_file_two_phase_read() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("large.txt");
let content = "a".repeat(10_000) + "\n";
std::fs::write(&file, &content).unwrap();
let result = read_text_file(&file);
assert_eq!(result.unwrap(), content);
}
#[test]
fn read_text_file_large_binary_rejected_via_header_probe() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("large.bin");
let mut data = vec![b'a'; 10_000];
data[4096] = 0; std::fs::write(&file, &data).unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_large_file_invalid_utf8_past_header() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("bad_tail.txt");
let mut data = vec![b'a'; 10_000];
data[9000] = 0xff;
std::fs::write(&file, &data).unwrap();
assert!(read_text_file(&file).is_none());
}
#[test]
fn read_text_file_binary_past_8k_still_read_as_text() {
let dir = tempfile::tempdir().unwrap();
let file = dir.path().join("mostly_text.txt");
let mut data = vec![b'a'; 8192];
data.push(0);
data.push(b'\n');
std::fs::write(&file, &data).unwrap();
let result = read_text_file(&file).expect("NUL past 8KiB should still read as text");
assert_eq!(result.len(), 8194);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_respects_ignore_file_and_exclude() {
use crate::cli::global::GlobalFlags;
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("src")).unwrap();
fs::create_dir_all(root.join("target")).unwrap();
fs::write(root.join("src/lib.rs"), "pub fn foo() {}\n").unwrap();
fs::write(root.join("src/main.rs"), "fn main() {}\n").unwrap();
fs::write(root.join("target/debug"), "binary").unwrap(); fs::write(root.join("README.md"), "# hi\n").unwrap();
fs::write(root.join("Cargo.toml"), "[package]\n").unwrap(); fs::write(root.join(".agentignore"), "target/\n*.md\n").unwrap();
let mut global = GlobalFlags::test_default();
global.cwd = Some(root.to_string_lossy().into_owned());
global.ignore_file = vec![".agentignore".to_string()];
global.exclude = vec!["*.rs".to_string()];
let paths =
collect_file_paths_opts(&[".".to_string()], &global, false, Some(root)).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.contains(&"Cargo.toml".to_string()),
"surviving file missing: {:?}",
rels
);
assert!(
!rels
.iter()
.any(|r| r.starts_with("target") || r.ends_with(".md") || r.ends_with(".rs")),
"advanced ignores not applied: {:?}",
rels
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn collect_file_paths_skips_patchloom_directory() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("real.txt"), "hello\n").unwrap();
fs::create_dir_all(root.join(".patchloom/backups/12345")).unwrap();
fs::write(root.join(".patchloom/backups/12345/manifest.json"), "{}\n").unwrap();
let paths = collect_file_paths(root, false).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.contains(&"real.txt".to_string()),
"real.txt should be collected: {rels:?}"
);
assert!(
!rels.iter().any(|r| r.contains(".patchloom")),
".patchloom files should be excluded: {rels:?}"
);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_depth_prunes_nested() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("top.txt"), "t\n").unwrap();
fs::create_dir_all(root.join("a/b/c")).unwrap();
fs::write(root.join("a/mid.txt"), "m\n").unwrap();
fs::write(root.join("a/b/c/deep.txt"), "d\n").unwrap();
let global = GlobalFlags::test_with_cwd(root);
let shallow =
collect_file_paths_opts_depth(&[".".into()], &global, false, Some(root), Some(1))
.unwrap();
let shallow_rels: Vec<_> = shallow
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(
shallow_rels.iter().any(|r| r == "top.txt"),
"depth 1 includes top: {shallow_rels:?}"
);
assert!(
!shallow_rels
.iter()
.any(|r| r.contains("mid") || r.contains("deep")),
"depth 1 must not enter a/: {shallow_rels:?}"
);
let mid = collect_file_paths_opts_depth(&[".".into()], &global, false, Some(root), Some(2))
.unwrap();
let mid_rels: Vec<_> = mid
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(
mid_rels.iter().any(|r| r.ends_with("mid.txt")),
"depth 2 includes a/mid: {mid_rels:?}"
);
assert!(
!mid_rels.iter().any(|r| r.contains("deep")),
"depth 2 must not reach a/b/c: {mid_rels:?}"
);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_depth_multi_root() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(root.join("left/deep")).unwrap();
fs::create_dir_all(root.join("right/deep")).unwrap();
fs::write(root.join("left/l.txt"), "l\n").unwrap();
fs::write(root.join("left/deep/x.txt"), "x\n").unwrap();
fs::write(root.join("right/r.txt"), "r\n").unwrap();
fs::write(root.join("right/deep/y.txt"), "y\n").unwrap();
let global = GlobalFlags::test_with_cwd(root);
let paths = collect_file_paths_opts_depth(
&["left".into(), "right".into()],
&global,
false,
Some(root),
Some(1),
)
.unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| {
p.strip_prefix(root)
.unwrap()
.to_string_lossy()
.replace('\\', "/")
})
.collect();
assert!(
rels.iter().any(|r| r.ends_with("l.txt")) && rels.iter().any(|r| r.ends_with("r.txt")),
"top of each root: {rels:?}"
);
assert!(
!rels.iter().any(|r| r.contains("deep")),
"deep under each root pruned: {rels:?}"
);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_skips_patchloom_directory() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("real.txt"), "hello\n").unwrap();
fs::create_dir_all(root.join(".patchloom/backups/12345")).unwrap();
fs::write(root.join(".patchloom/backups/12345/manifest.json"), "{}\n").unwrap();
let global = GlobalFlags::test_with_cwd(root);
let paths = collect_file_paths_opts(&[".".to_string()], &global, true, Some(root)).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.contains(&"real.txt".to_string()),
"real.txt should be collected: {rels:?}"
);
assert!(
!rels.iter().any(|r| r.contains(".patchloom")),
".patchloom files should be excluded even with include_hidden=true: {rels:?}"
);
}
#[test]
#[cfg(feature = "cli")]
fn collect_file_paths_opts_skips_git_directory_when_hidden() {
use std::fs;
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::write(root.join("real.txt"), "hello\n").unwrap();
fs::create_dir_all(root.join(".git/objects/ab")).unwrap();
fs::write(root.join(".git/objects/ab/cdef"), [0xffu8, 0xfe, 0x00]).unwrap();
fs::write(root.join(".env"), "SECRET=1\n").unwrap();
let global = GlobalFlags::test_with_cwd(root);
let paths = collect_file_paths_opts(&[".".to_string()], &global, true, Some(root)).unwrap();
let rels: Vec<_> = paths
.iter()
.map(|p| p.strip_prefix(root).unwrap().to_string_lossy().to_string())
.collect();
assert!(
rels.iter()
.any(|r| r == "real.txt" || r.ends_with("real.txt")),
"real.txt should be collected: {rels:?}"
);
assert!(
rels.iter().any(|r| r == ".env" || r.ends_with(".env")),
".env should be collected with include_hidden: {rels:?}"
);
assert!(
!rels.iter().any(|r| r.contains(".git")),
".git must not be walked with include_hidden=true: {rels:?}"
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn exclude_glob_matches_files_in_subdirs() {
let mut paths = vec![
PathBuf::from("src/main.rs"),
PathBuf::from("src/lib.rs"),
PathBuf::from("README.md"),
];
apply_exclude_globs(&mut paths, &["*.rs".into()], None).unwrap();
assert_eq!(
paths,
vec![PathBuf::from("README.md")],
"*.rs should exclude files in subdirs"
);
}
#[test]
#[cfg(any(feature = "cli", feature = "files"))]
fn exclude_glob_matches_directory_pattern_with_root() {
let root = PathBuf::from("/project");
let mut paths = vec![
PathBuf::from("/project/src/main.rs"),
PathBuf::from("/project/vendor/lib.rs"),
PathBuf::from("/project/vendor/sub/dep.rs"),
];
apply_exclude_globs(&mut paths, &["vendor/**".into()], Some(&root)).unwrap();
assert_eq!(
paths,
vec![PathBuf::from("/project/src/main.rs")],
"vendor/** with root should exclude vendor files"
);
}
}
#[cfg(all(test, feature = "cli"))]
mod explicit_exclude_tests {
use super::*;
use crate::cli::global::GlobalFlags;
use std::fs;
use tempfile::TempDir;
#[test]
fn explicit_file_arg_not_dropped_by_exclude_glob() {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::create_dir(root.join("vendor")).unwrap();
let file = root.join("vendor/x.js");
fs::write(&file, "foo\n").unwrap();
let global = GlobalFlags {
exclude: vec!["vendor/**".into()],
..GlobalFlags::test_default()
};
let paths = collect_file_paths_opts_with_list(
&["vendor/x.js".into()],
&global,
false,
Some(root),
None,
None,
)
.unwrap();
assert!(
paths.iter().any(|p| p.ends_with("x.js")),
"explicit file must survive exclude: {paths:?}"
);
}
#[test]
fn directory_root_still_honors_exclude() {
let dir = TempDir::new().unwrap();
let root = dir.path();
fs::create_dir(root.join("vendor")).unwrap();
fs::write(root.join("vendor/x.js"), "foo\n").unwrap();
fs::write(root.join("app.js"), "foo\n").unwrap();
let global = GlobalFlags {
exclude: vec!["vendor/**".into()],
..GlobalFlags::test_default()
};
let paths = collect_file_paths_opts_with_list(
&[".".into()],
&global,
false,
Some(root),
None,
None,
)
.unwrap();
assert!(
paths.iter().any(|p| p.ends_with("app.js")),
"app.js should remain: {paths:?}"
);
assert!(
!paths.iter().any(|p| p.to_string_lossy().contains("vendor")),
"vendor walk contents still excluded: {paths:?}"
);
}
}