use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use cosh_sdk::find::{FileType, GlobMatch, GlobOptions, glob as sdk_glob};
use super::types::{Glob, GlobEntry, GlobOutput};
#[must_use]
pub fn has_glob_path_chars(s: &str) -> bool {
s.chars().any(|c| matches!(c, '*' | '?' | '[' | '{'))
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ParsedFindPattern {
pub base_path: PathBuf,
pub glob_pattern: String,
pub has_glob: bool,
pub recursive: bool,
}
#[must_use]
pub fn parse_find_pattern(input: &str) -> ParsedFindPattern {
let normalized = input.replace('\\', "/");
let normalized = normalized.trim().trim_end_matches('/');
if normalized.is_empty() {
return ParsedFindPattern {
base_path: PathBuf::from("."),
glob_pattern: String::new(),
has_glob: false,
recursive: false,
};
}
match normalized
.char_indices()
.find(|(_, c)| matches!(c, '*' | '?' | '[' | '{'))
{
Some((idx, _)) => {
let base = normalized[..idx].trim_end_matches('/');
let glob_part = &normalized[idx..];
let base_path = if base.is_empty() {
PathBuf::from(".")
} else {
PathBuf::from(base)
};
let recursive = base.is_empty() || glob_part.starts_with("**");
let glob_pattern = if recursive && !glob_part.starts_with("**") {
format!("**/{glob_part}")
} else {
glob_part.to_string()
};
ParsedFindPattern {
base_path,
glob_pattern,
has_glob: true,
recursive,
}
}
None => ParsedFindPattern {
base_path: PathBuf::from(normalized),
glob_pattern: String::new(),
has_glob: false,
recursive: false,
},
}
}
#[must_use]
pub fn resolve_to_cwd(part: &str, cwd: &Path) -> PathBuf {
let p = Path::new(part);
if p.is_absolute() {
p.to_path_buf()
} else {
cwd.join(p)
}
}
#[must_use]
pub fn format_path_relative_to_cwd(path: &str, cwd: &Path, trailing_slash: bool) -> String {
let mut out = resolve_to_cwd(path, cwd)
.strip_prefix(cwd)
.map(|rel| rel.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| path.to_string().replace('\\', "/"));
if trailing_slash && !out.ends_with('/') {
out.push('/');
}
out
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum PathFormat {
#[default]
Flat,
Grouped,
Tree,
}
impl PathFormat {
pub fn parse_format(s: &str) -> Result<Self, String> {
match s.trim().to_ascii_lowercase().as_str() {
"flat" => Ok(Self::Flat),
"grouped" => Ok(Self::Grouped),
"tree" => Ok(Self::Tree),
other => Err(format!(
"invalid format `{other}`; expected \"flat\", \"grouped\", or \"tree\""
)),
}
}
}
pub fn format_paths(paths: &[String], format: PathFormat) -> String {
match format {
PathFormat::Flat => paths.join("\n"),
PathFormat::Grouped => format_grouped(paths),
PathFormat::Tree => format_tree(paths),
}
}
fn format_grouped(paths: &[String]) -> String {
let mut groups: BTreeMap<String, Vec<&String>> = BTreeMap::new();
for p in paths {
groups.entry(parent_dir(p).to_string()).or_default().push(p);
}
let mut out = Vec::new();
for (dir, mut entries) in groups {
out.push(format!("{dir}/"));
entries.sort_unstable();
for e in entries {
let rel = e.strip_prefix(&format!("{dir}/")).unwrap_or(e).to_string();
out.push(format!(" {rel}"));
}
}
out.join("\n")
}
fn format_tree(paths: &[String]) -> String {
let mut nodes: BTreeMap<String, u16> = BTreeMap::new();
for p in paths {
let comps: Vec<&str> = p.split('/').filter(|c| !c.is_empty()).collect();
for (depth, _name) in comps.iter().enumerate() {
let key = comps[..=depth].join("/");
nodes.entry(key).or_insert(depth as u16);
}
}
if nodes.is_empty() {
return String::new();
}
let mut out: Vec<String> = Vec::new();
for (node, depth) in &nodes {
let indent = " ".repeat(*depth as usize);
let name = node.rsplit('/').next().unwrap_or(node);
let is_dir = nodes
.keys()
.any(|other| other != node && other.starts_with(&format!("{node}/")));
if is_dir {
out.push(format!("{indent}{name}/"));
} else {
out.push(format!("{indent}{name}"));
}
}
out.join("\n")
}
pub(super) fn parent_dir(path: &str) -> &str {
match path.rfind('/') {
Some(idx) if idx > 0 => &path[..idx],
Some(_) => ".",
None => ".",
}
}
pub const DEFAULT_GLOB_TIMEOUT_MS: u32 = 5000;
pub(crate) const DEFAULT_GLOB_LIMIT: u32 = 200;
pub(crate) const MAX_GLOB_LIMIT: u32 = 200;
pub type GlobMatchCallback = dyn Fn(&GlobMatch) + Send + Sync;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct GlobTargetSpec {
pub base_path: PathBuf,
pub pattern: String,
pub has_glob: bool,
}
pub fn glob(glob: &Glob, pattern: &str, path: &str) -> Result<GlobOutput, String> {
glob_with(glob, pattern, &[path.to_string()], None)
}
pub fn glob_with(
glob: &Glob,
pattern: &str,
targets: &[String],
on_match: Option<Arc<GlobMatchCallback>>,
) -> Result<GlobOutput, String> {
let parsed = parse_find_pattern(pattern);
let specs: Vec<GlobTargetSpec> = targets
.iter()
.map(|t| {
let base = PathBuf::from(t);
let base = if parsed.base_path == Path::new(".") {
base
} else {
base.join(&parsed.base_path)
};
GlobTargetSpec {
base_path: base,
pattern: parsed.glob_pattern.clone(),
has_glob: parsed.has_glob,
}
})
.collect();
glob_targets_with(glob, &specs, on_match, None)
}
pub fn glob_targets_with(
glob: &Glob,
specs: &[GlobTargetSpec],
on_match: Option<Arc<GlobMatchCallback>>,
cwd: Option<&Path>,
) -> Result<GlobOutput, String> {
if specs.is_empty() {
return Err("no search targets provided".to_string());
}
let file_type = glob.file_type.as_deref().map(parse_file_type).transpose()?;
let format = glob
.format
.as_deref()
.map(PathFormat::parse_format)
.transpose()?
.unwrap_or_default();
let timeout_ms = glob.timeout_ms.or(Some(DEFAULT_GLOB_TIMEOUT_MS));
let mut missing_paths: Vec<String> = Vec::new();
let mut valid: Vec<&GlobTargetSpec> = Vec::new();
for target in specs {
if target.base_path.exists() {
valid.push(target);
} else {
missing_paths.push(target.base_path.to_string_lossy().to_string());
}
}
if valid.is_empty() {
return Err(format!("Path not found: {}", missing_paths.join(", ")));
}
let abs_roots: Vec<PathBuf> = valid.iter().map(|t| t.base_path.clone()).collect();
let ancestor = if valid.len() > 1 {
super::common_ancestor(&abs_roots)
} else {
None
};
let scope_buf = ancestor
.clone()
.unwrap_or_else(|| valid[0].base_path.clone());
let scope = cwd
.map(|cwd| format_path_relative_to_cwd(&scope_buf.to_string_lossy(), cwd, true))
.unwrap_or_else(|| scope_buf.to_string_lossy().replace('\\', "/"));
let fetch_max = glob.max_results.map(|m| m.saturating_add(1));
let mut seen: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut merged: Vec<GlobEntry> = Vec::new();
let mut timed_out = false;
let mut limit_reached = false;
let mut walk_targets: Vec<&GlobTargetSpec> = Vec::new();
for target in &valid {
if !target.has_glob && target.base_path.is_file() {
make_literal_file_entry(
&target.base_path,
cwd,
ancestor.as_deref(),
&mut seen,
&mut merged,
);
} else {
walk_targets.push(target);
}
}
struct TargetScan {
entries: Vec<GlobEntry>,
entry_count: u32,
timed_out: bool,
}
let scans: Vec<Result<TargetScan, String>> = std::thread::scope(|scope| {
walk_targets
.iter()
.copied()
.map(|target| {
let base_str = target.base_path.to_string_lossy().to_string();
let wrapped: Option<Arc<GlobMatchCallback>> = on_match.as_ref().map(|cb| {
let cb = cb.clone();
let base_owned = base_str.clone();
let ancestor = ancestor.clone();
let cb_arc: Arc<GlobMatchCallback> = Arc::new(move |m: &GlobMatch| {
let rel = rebase_path(&base_owned, &m.path, ancestor.as_deref());
cb(&GlobMatch {
path: rel,
file_type: m.file_type,
mtime: m.mtime,
size: m.size,
});
});
cb_arc
});
let pattern = if target.pattern.is_empty() {
"**".to_string()
} else {
target.pattern.clone()
};
let path = base_str.clone();
let ancestor_owned = ancestor.clone();
scope.spawn(move || -> Result<TargetScan, String> {
let sdk_result = sdk_glob(GlobOptions {
pattern,
path,
file_type,
recursive: Some(false),
hidden: glob.hidden,
max_results: fetch_max,
gitignore: glob.gitignore,
sort_by_mtime: Some(glob.sort_by_mtime.unwrap_or(true)),
cache: None,
include_node_modules: None,
timeout_ms,
on_match: wrapped,
})?;
let mut entries: Vec<GlobEntry> = Vec::new();
for m in sdk_result.matches {
let abs = Path::new(&base_str).join(&m.path);
let display = if cwd.is_some() || ancestor_owned.is_some() {
display_path(
&abs,
cwd,
ancestor_owned.as_deref(),
m.file_type == FileType::Dir,
)
} else {
with_trailing_slash(
&m.path.replace('\\', "/"),
m.file_type == FileType::Dir,
)
};
entries.push(GlobEntry {
path: display,
file_type: file_type_str(m.file_type).to_owned(),
mtime_ms: m.mtime,
size_bytes: m.size,
});
}
let entry_count = u32::try_from(entries.len()).unwrap_or(u32::MAX);
Ok(TargetScan {
entries,
entry_count,
timed_out: sdk_result.timed_out,
})
})
})
.map(|handle| {
handle
.join()
.map_err(|_| "glob target scan panicked".to_string())
.and_then(|inner| inner)
})
.collect()
});
for scan in scans {
let scan = scan?;
timed_out = timed_out || scan.timed_out;
for entry in scan.entries {
if seen.insert(entry.path.clone()) {
merged.push(entry);
}
}
if let Some(max) = glob.max_results
&& scan.entry_count > max
{
limit_reached = true;
}
}
if glob.sort_by_mtime.unwrap_or(true) {
merged.sort_by(|a, b| {
b.mtime_ms
.unwrap_or(0.0)
.total_cmp(&a.mtime_ms.unwrap_or(0.0))
.then_with(|| a.path.cmp(&b.path))
});
}
if let Some(max) = glob.max_results {
if merged.len() > max as usize {
limit_reached = true;
}
merged.truncate(max as usize);
}
let total = u32::try_from(merged.len().min(u32::MAX as usize)).unwrap_or(u32::MAX);
let (useless, mut note) = if merged.is_empty() && !timed_out {
(
Some(true),
Some("No files found matching pattern".to_string()),
)
} else if timed_out && merged.is_empty() {
(
None,
Some(
"Glob timed out before finding any matches — the scan is incomplete, NOT proof of absence. The walk is bounded by directory size, not pattern width; scope the search to a deeper directory (e.g. `sub/dir/*.ext` instead of `*.ext` at a huge root)."
.to_string(),
),
)
} else if timed_out {
(
None,
Some(
"Glob timed out; results are partial and incomplete — scope to a deeper directory instead of retrying blindly"
.to_string(),
),
)
} else if limit_reached {
(
None,
Some(
"Limit reached — more entries may exist; narrow the pattern or add file_type to reduce results"
.to_string(),
),
)
} else {
(None, None)
};
if !missing_paths.is_empty() {
let missing_note = format!("Skipped missing paths: {}", missing_paths.join(", "));
note = Some(match note {
Some(n) => format!("{n}\n{missing_note}"),
None => missing_note,
});
}
let path_list: Vec<String> = merged.iter().map(|m| m.path.clone()).collect();
let formatted = if path_list.is_empty() {
String::new()
} else {
format_paths(&path_list, format)
};
Ok(GlobOutput {
matches: merged,
total,
limit_reached: if limit_reached { Some(true) } else { None },
timed_out: if timed_out { Some(true) } else { None },
note,
useless,
missing_paths: if missing_paths.is_empty() {
None
} else {
Some(missing_paths)
},
formatted,
scope,
cwd: cwd.map(|p| p.to_string_lossy().to_string()),
})
}
fn make_literal_file_entry(
path: &PathBuf,
cwd: Option<&Path>,
ancestor: Option<&Path>,
seen: &mut std::collections::HashSet<String>,
merged: &mut Vec<GlobEntry>,
) {
let display = display_path(path, cwd, ancestor, false);
if seen.insert(display.clone()) {
let meta = std::fs::metadata(path).ok();
merged.push(GlobEntry {
path: display,
file_type: "file".to_string(),
mtime_ms: meta
.as_ref()
.and_then(|m| m.modified().ok())
.and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
.map(|d| d.as_secs_f64() * 1000.0),
size_bytes: meta.map(|m| m.len() as f64),
});
}
}
fn rebase_path(target: &str, relative: &str, ancestor: Option<&Path>) -> String {
let Some(ancestor) = ancestor else {
return relative.to_string();
};
let abs = Path::new(target).join(relative);
abs.strip_prefix(ancestor)
.map(|p| p.to_string_lossy().replace('\\', "/"))
.unwrap_or_else(|_| relative.to_string())
}
fn display_path(
abs: &Path,
cwd: Option<&Path>,
ancestor: Option<&Path>,
trailing_slash: bool,
) -> String {
if let Some(cwd) = cwd
&& let Ok(rel) = abs.strip_prefix(cwd)
{
return with_trailing_slash(&rel.to_string_lossy().replace('\\', "/"), trailing_slash);
}
if let Some(ancestor) = ancestor
&& let Ok(rel) = abs.strip_prefix(ancestor)
{
return with_trailing_slash(&rel.to_string_lossy().replace('\\', "/"), trailing_slash);
}
with_trailing_slash(&abs.to_string_lossy().replace('\\', "/"), trailing_slash)
}
fn with_trailing_slash(s: &str, trailing_slash: bool) -> String {
let mut s = s.to_owned();
if trailing_slash && !s.ends_with('/') {
s.push('/');
}
s
}
#[must_use]
const fn file_type_str(ft: FileType) -> &'static str {
match ft {
FileType::File => "file",
FileType::Dir => "dir",
FileType::Symlink => "symlink",
}
}
fn parse_file_type(s: &str) -> Result<FileType, String> {
match s {
"file" => Ok(FileType::File),
"dir" => Ok(FileType::Dir),
"symlink" => Ok(FileType::Symlink),
other => Err(format!(
"invalid file_type `{other}`; expected \"file\", \"dir\", or \"symlink\""
)),
}
}