use super::p4ignore::{P4Environment, P4Matcher};
use crate::cancellation::AgentCancellation;
use ignore::WalkBuilder;
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
time::Instant,
};
#[derive(Debug, Default)]
pub(super) struct WorkspaceWalker {}
#[derive(Debug, Clone, Copy, Default)]
pub(super) struct WorkspaceVisitStatus {
pub(super) completed: bool,
pub(super) entries_visited: usize,
pub(super) entry_limit_reached: bool,
pub(super) timed_out: bool,
pub(super) walk_errors: usize,
pub(super) entries_omitted: usize,
}
fn classify_walk_entry(
entry: Result<Option<fs::FileType>, ()>,
status: &mut WorkspaceVisitStatus,
) -> Option<fs::FileType> {
match entry {
Ok(Some(file_type)) => Some(file_type),
Ok(None) => {
status.completed = false;
status.entries_omitted = status.entries_omitted.saturating_add(1);
None
}
Err(()) => {
status.completed = false;
status.walk_errors = status.walk_errors.saturating_add(1);
status.entries_omitted = status.entries_omitted.saturating_add(1);
None
}
}
}
pub(super) struct WorkspaceWalkOptions<'a> {
pub(super) root: &'a Path,
pub(super) skip_dirs: &'a [&'a str],
pub(super) cancel_interval: usize,
}
#[derive(Debug, Clone, Copy, Default)]
struct WorkspaceWalkBounds {
deadline: Option<Instant>,
max_entries: Option<usize>,
}
impl WorkspaceWalker {
pub(super) fn visit_sorted_files_with_diagnostics<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
mut visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(PathBuf) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment(
options,
cancellation,
&P4Environment::process(),
true,
|path, ty| {
if ty.is_file() {
visitor(path.to_path_buf())
} else {
Ok(true)
}
},
)
}
fn visit_entries_with_environment<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
environment: &P4Environment,
sorted: bool,
visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(&Path, fs::FileType) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment_and_bounds(
options,
cancellation,
environment,
sorted,
WorkspaceWalkBounds::default(),
visitor,
)
}
fn visit_entries_with_environment_and_bounds<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
environment: &P4Environment,
sorted: bool,
bounds: WorkspaceWalkBounds,
mut visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(&Path, fs::FileType) -> anyhow::Result<bool>,
{
if let Some(c) = cancellation {
c.check()?;
}
if bounds
.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
{
return Ok(WorkspaceVisitStatus {
timed_out: true,
..WorkspaceVisitStatus::default()
});
}
let (walker, p4_matcher) = configured_walk(&options, environment, sorted, bounds.deadline)?;
let mut status = WorkspaceVisitStatus {
completed: true,
..WorkspaceVisitStatus::default()
};
let max_entries = bounds.max_entries.unwrap_or(usize::MAX);
let mut visitor_stopped = false;
for (index, entry) in walker.take(max_entries).enumerate() {
status.entries_visited = status.entries_visited.saturating_add(1);
if bounds
.deadline
.is_some_and(|deadline| Instant::now() >= deadline)
{
status.completed = false;
status.timed_out = true;
break;
}
if index % options.cancel_interval.max(1) == 0
&& let Some(c) = cancellation
{
c.check()?;
}
let entry = match entry {
Ok(entry) => entry,
Err(_) => {
let _ = classify_walk_entry(Err(()), &mut status);
continue;
}
};
let Some(ty) = classify_walk_entry(Ok(entry.file_type()), &mut status) else {
continue;
};
let path = entry.path();
if path == options.root {
continue;
}
if p4_matcher.is_ignored(path, ty.is_dir()) {
continue;
}
if !visitor(path, ty)? {
status.completed = false;
visitor_stopped = true;
break;
}
}
if !visitor_stopped
&& bounds
.max_entries
.is_some_and(|max_entries| status.entries_visited >= max_entries)
{
status.completed = false;
status.entry_limit_reached = true;
}
if let Some(c) = cancellation {
c.check()?;
}
if let Some(error) = p4_matcher.take_error() {
return Err(error);
}
Ok(status)
}
pub(super) fn visit_files_with_deadline<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
deadline: Option<Instant>,
mut visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(PathBuf) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment_and_bounds(
options,
cancellation,
&P4Environment::process(),
false,
WorkspaceWalkBounds {
deadline,
max_entries: None,
},
|path, ty| {
if ty.is_file() {
visitor(path.to_path_buf())
} else {
Ok(true)
}
},
)
}
pub(super) fn visit_entries_with_bounds<F>(
&self,
options: WorkspaceWalkOptions<'_>,
cancellation: Option<&AgentCancellation>,
deadline: Instant,
max_entries: usize,
visitor: F,
) -> anyhow::Result<WorkspaceVisitStatus>
where
F: FnMut(&Path, fs::FileType) -> anyhow::Result<bool>,
{
self.visit_entries_with_environment_and_bounds(
options,
cancellation,
&P4Environment::process(),
false,
WorkspaceWalkBounds {
deadline: Some(deadline),
max_entries: Some(max_entries),
},
visitor,
)
}
}
fn configured_walk(
options: &WorkspaceWalkOptions<'_>,
environment: &P4Environment,
sorted: bool,
deadline: Option<Instant>,
) -> anyhow::Result<(ignore::Walk, P4Matcher)> {
let p4_matcher = P4Matcher::from_root(options.root, environment)?;
let mut builder = workspace_walk_builder(
options.root,
options.skip_dirs,
deadline,
Some(&p4_matcher),
false,
);
if sorted {
builder.sort_by_file_path(|a, b| a.cmp(b));
}
Ok((builder.build(), p4_matcher))
}
fn workspace_walk_builder(
root: &Path,
skip_dirs: &[&str],
deadline: Option<Instant>,
p4_matcher: Option<&P4Matcher>,
ordinary_paths_only: bool,
) -> WalkBuilder {
let skip_dirs = skip_dirs
.iter()
.map(|name| (*name).to_string())
.collect::<HashSet<_>>();
let p4_filter = p4_matcher.cloned();
let p4_root = root.to_path_buf();
let mut builder = WalkBuilder::new(root);
builder
.standard_filters(p4_matcher.is_some())
.current_dir(root)
.follow_links(false);
builder.filter_entry(move |entry| {
if deadline.is_some_and(|deadline| Instant::now() >= deadline) {
return true;
}
let is_dir = entry.file_type().is_some_and(|x| x.is_dir());
let name = entry.file_name().to_string_lossy();
if name == ".git" || (is_dir && skip_dirs.contains(name.as_ref())) {
return false;
}
if entry.path() == p4_root {
return true;
}
!(is_dir
&& ((ordinary_paths_only && name.starts_with('.'))
|| p4_filter
.as_ref()
.is_some_and(|matcher| matcher.should_prune(entry.path()))))
});
builder
}
pub(crate) fn autocomplete_file_paths(
root: &Path,
respect_ignore_files: bool,
max_candidates: usize,
max_visited_entries: usize,
) -> anyhow::Result<Vec<PathBuf>> {
autocomplete_file_paths_with_environment(
root,
respect_ignore_files,
max_candidates,
max_visited_entries,
&P4Environment::process(),
)
}
fn autocomplete_file_paths_with_environment(
root: &Path,
respect_ignore_files: bool,
max_candidates: usize,
max_visited_entries: usize,
environment: &P4Environment,
) -> anyhow::Result<Vec<PathBuf>> {
let p4_matcher = respect_ignore_files
.then(|| P4Matcher::from_root(root, environment))
.transpose()?;
let mut paths = Vec::new();
let mut visited = 0;
for ordinary_paths_only in [true, false] {
if paths.len() >= max_candidates || visited >= max_visited_entries {
break;
}
let mut builder =
workspace_walk_builder(root, &[], None, p4_matcher.as_ref(), ordinary_paths_only);
builder
.hidden(false)
.require_git(false)
.sort_by_file_path(|left, right| left.cmp(right));
for entry in builder.build().take(max_visited_entries - visited) {
visited += 1;
let Ok(entry) = entry else { continue };
if !entry.file_type().is_some_and(|ty| ty.is_file()) {
continue;
}
let path = entry.path();
let Ok(relative) = path.strip_prefix(root) else {
continue;
};
let in_dotfolder = relative.parent().is_some_and(|parent| {
parent
.components()
.any(|component| component.as_os_str().to_string_lossy().starts_with('.'))
});
if in_dotfolder == ordinary_paths_only
|| p4_matcher
.as_ref()
.is_some_and(|matcher| matcher.is_ignored(path, false))
{
continue;
}
paths.push(path.to_path_buf());
if paths.len() >= max_candidates {
break;
}
}
if let Some(error) = p4_matcher.as_ref().and_then(|matcher| matcher.take_error()) {
return Err(error);
}
}
Ok(paths)
}