use crate::core::batch::{available_parallelism, pool_execute};
use crate::core::config::{ensure_path_allowed, env_value};
use crate::core::response::RawResult;
use regex::Regex;
use serde_json::{Map, Value, json};
use std::collections::HashMap;
use std::fs;
use std::io::{BufRead, BufReader};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex, OnceLock, RwLock};
use std::time::{Duration, Instant};
static INSPECT_WILDCARD_CACHE: OnceLock<RwLock<HashMap<String, Regex>>> = OnceLock::new();
struct InspectState {
max_chars: usize,
used_chars: usize,
scanned_files: usize,
bytes_read: usize,
truncated: bool,
deadline: Instant,
budget_hit: bool,
budget_tick: u32,
}
type InspectOutcome = (Value, InspectMetrics);
type InspectSlots = Arc<Vec<Mutex<Option<InspectOutcome>>>>;
#[derive(Clone, Default)]
struct InspectMetrics {
scanned_files: usize,
bytes_read: usize,
snippet_chars: usize,
truncated: bool,
budget_hit: bool,
}
impl InspectMetrics {
fn merge(&mut self, other: &InspectMetrics) {
self.scanned_files += other.scanned_files;
self.bytes_read += other.bytes_read;
self.snippet_chars += other.snippet_chars;
self.truncated |= other.truncated;
self.budget_hit |= other.budget_hit;
}
}
fn inspect_budget_ms() -> u64 {
env_value("INSPECT_BUDGET_MS")
.and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0)
.unwrap_or(25_000)
}
fn budget_exceeded(state: &mut InspectState) -> bool {
if state.budget_hit {
return true;
}
state.budget_tick = state.budget_tick.wrapping_add(1);
if state.budget_tick & 127 != 0 {
return false;
}
if Instant::now() >= state.deadline {
state.budget_hit = true;
state.truncated = true;
}
state.budget_hit
}
struct Hit {
rel: String,
line: usize,
text: String,
fields: Map<String, Value>,
}
struct ExtractSpec {
name: String,
regex: Regex,
}
struct SearchCtx<'a> {
root: &'a Path,
recursive: bool,
file_pattern: Option<&'a str>,
matcher: &'a Regex,
extracts: &'a [ExtractSpec],
max_matches: usize,
}
pub fn handle_fs_inspect(args: &Value) -> RawResult {
let Some(root_text) = args.get("root").and_then(Value::as_str) else {
return RawResult::error(
"root must be a string (fs-inspect args are {root, requests:[{op, path, ...}]}, not items[])",
);
};
let Some(requests) = args.get("requests").and_then(Value::as_array) else {
return RawResult::error("requests must be an array");
};
let root = match inspect_root(root_text) {
Ok(root) => root,
Err(error) => return RawResult::error(error),
};
let max_chars = args
.get("maxSnippetChars")
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(6000);
let mode = args.get("mode").and_then(Value::as_str).unwrap_or("strict");
let deadline = Instant::now() + Duration::from_millis(inspect_budget_ms());
let total = requests.len();
let workers = total.min(available_parallelism()).max(1);
let outcomes: Vec<(Value, InspectMetrics)> = if total <= 1 || workers <= 1 {
requests
.iter()
.enumerate()
.map(|(index, request)| run_request_owned(&root, request, index + 1, max_chars, deadline))
.collect()
} else {
let shared_requests = Arc::new(requests.clone());
let shared_root = Arc::new(root.clone());
let slots: InspectSlots = Arc::new((0..total).map(|_| Mutex::new(None)).collect());
let job_requests = Arc::clone(&shared_requests);
let job_root = Arc::clone(&shared_root);
let job_slots = Arc::clone(&slots);
pool_execute(total, workers - 1, move |index| {
let outcome =
run_request_owned(&job_root, &job_requests[index], index + 1, max_chars, deadline);
*job_slots[index].lock().unwrap() = Some(outcome);
});
slots
.iter()
.map(|slot| {
slot.lock().unwrap().take().unwrap_or_else(|| {
(
answer_error("request", "unknown", "inspect worker panicked"),
InspectMetrics::default(),
)
})
})
.collect()
};
let mut metrics = InspectMetrics::default();
for (_, request_metrics) in &outcomes {
metrics.merge(request_metrics);
}
let mut answers: Vec<Value> = outcomes.into_iter().map(|(answer, _)| answer).collect();
apply_evidence_budget(&mut answers, max_chars, &mut metrics);
let status = inspect_status(&answers);
let text = format!(
"fs-inspect: {} requests, status={status}, snippetChars={}",
answers.len(),
metrics.snippet_chars
);
RawResult::structured(
text,
json!({
"status": status,
"mode": mode,
"answers": answers,
"metrics": {
"scannedFiles": metrics.scanned_files,
"bytesRead": metrics.bytes_read,
"snippetChars": metrics.snippet_chars,
"truncated": metrics.truncated,
"timeBudgetHit": metrics.budget_hit
}
}),
)
}
fn run_request_owned(
root: &Path,
request: &Value,
index: usize,
max_chars: usize,
deadline: Instant,
) -> (Value, InspectMetrics) {
let mut state = InspectState {
max_chars,
used_chars: 0,
scanned_files: 0,
bytes_read: 0,
truncated: false,
deadline,
budget_hit: false,
budget_tick: 0,
};
let answer = run_request(root, request, index, &mut state);
let metrics = InspectMetrics {
scanned_files: state.scanned_files,
bytes_read: state.bytes_read,
snippet_chars: state.used_chars,
truncated: state.truncated,
budget_hit: state.budget_hit,
};
(answer, metrics)
}
fn apply_evidence_budget(answers: &mut [Value], max_chars: usize, metrics: &mut InspectMetrics) {
let mut remaining = max_chars;
let mut kept = 0usize;
for answer in answers.iter_mut() {
let Some(evidence) = answer.get_mut("evidence").and_then(Value::as_array_mut) else {
continue;
};
for entry in evidence.iter_mut() {
let Some(snippet) = entry.get("snippet").and_then(Value::as_str) else {
continue;
};
let count = if snippet.is_ascii() { snippet.len() } else { snippet.chars().count() };
if count <= remaining {
remaining -= count;
kept += count;
continue;
}
let truncated: String = snippet.chars().take(remaining).collect();
kept += if truncated.is_ascii() { truncated.len() } else { truncated.chars().count() };
entry["snippet"] = Value::String(truncated);
remaining = 0;
metrics.truncated = true;
}
}
metrics.snippet_chars = kept;
}
fn run_request(root: &Path, request: &Value, index: usize, state: &mut InspectState) -> Value {
let id = request
.get("id")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| format!("request-{index}"));
let op = request
.get("op")
.and_then(Value::as_str)
.unwrap_or("unknown");
match op {
"count-files" => count_files(root, request, &id, state),
"search" => search_files(root, request, &id, state),
"json-pick" => json_pick(root, request, &id, state),
"snippet" => snippets(root, request, &id, state),
"git-status" => git_status_answer(root, request, &id),
_ => answer_error(&id, op, format!("Unsupported op: {op}")),
}
}
fn git_status_answer(root: &Path, request: &Value, id: &str) -> Value {
let path = request
.get("path")
.and_then(Value::as_str)
.map(str::to_string)
.unwrap_or_else(|| root.display().to_string());
let result = crate::tools::git_tools::handle_git_status(&json!({ "path": path }));
let text = result
.content
.first()
.and_then(|item| item.get("text"))
.and_then(Value::as_str)
.unwrap_or_default()
.to_string();
if result.is_error {
let message = if text.is_empty() {
"git-status failed".to_string()
}
else {
text
};
return answer_error(id, "git-status", message);
}
let repo_path = result
.structured
.as_ref()
.and_then(|structured| structured.get("path"))
.cloned()
.unwrap_or(Value::Null);
answer_ok(
id,
"git-status",
json!({ "path": repo_path, "status": text }),
"high",
Vec::new(),
Vec::new(),
)
}
fn count_files(root: &Path, request: &Value, id: &str, state: &mut InspectState) -> Value {
let op = "count-files";
let path = match request_path(root, request) {
Ok(path) => path,
Err(error) => return answer_error(id, op, error),
};
if !path.is_dir() {
return answer_error(
id,
op,
format!("Path is not a directory: {}", path.display()),
);
}
let glob = request
.get("glob")
.or_else(|| request.get("pattern"))
.and_then(Value::as_str)
.unwrap_or("*");
let recursive = bool_field(request, "recursive", false);
let mut samples = Vec::new();
let count = match count_dir(root, &path, glob, recursive, &mut samples, state) {
Ok(count) => count,
Err(error) => return answer_error(id, op, error),
};
let mut evidence = Vec::new();
let sample_text = if samples.is_empty() {
"no matched files".to_string()
}
else {
format!("sample: {}", samples.join(", "))
};
add_evidence(
&mut evidence,
rel_path(root, &path),
None,
None,
sample_text,
state,
);
let value = json!({
"path": rel_path(root, &path),
"glob": glob,
"recursive": recursive,
"count": count
});
if state.budget_hit {
return answer_partial(
id,
op,
value,
"medium",
evidence,
vec!["time budget exceeded; count is partial".to_string()],
);
}
answer_ok(id, op, value, "high", evidence, Vec::new())
}
fn search_files(root: &Path, request: &Value, id: &str, state: &mut InspectState) -> Value {
let op = "search";
let path = match request_path(root, request) {
Ok(path) => path,
Err(error) => return answer_error(id, op, error),
};
let Some(pattern) = request.get("pattern").and_then(Value::as_str) else {
return answer_error(id, op, "pattern must be a string");
};
let literal = bool_field(request, "literal", false);
let recursive = bool_field(request, "recursive", true);
let max_matches = usize_field(request, "maxMatches", 20);
let file_pattern = request.get("filePattern").and_then(Value::as_str);
let matcher = match search_regex(pattern, literal) {
Ok(regex) => regex,
Err(error) => return answer_error(id, op, error),
};
let extracts = match extract_specs(request) {
Ok(specs) => specs,
Err(error) => return answer_error(id, op, error),
};
let mut hits = Vec::new();
let mut warnings = Vec::new();
let ctx = SearchCtx {
root,
recursive,
file_pattern,
matcher: &matcher,
extracts: &extracts,
max_matches,
};
if let Err(error) = search_path(&ctx, &path, &mut hits, &mut warnings, state) {
return answer_error(id, op, error);
}
if hits.is_empty() {
warnings.push("no matches".to_string());
return answer_partial(id, op, json!({ "matches": 0 }), "low", Vec::new(), warnings);
}
let mut value = Map::new();
value.insert("matches".to_string(), json!(hits.len()));
value.insert("path".to_string(), json!(hits[0].rel.as_str()));
for (key, value_item) in &hits[0].fields {
value.insert(key.clone(), value_item.clone());
}
let mut evidence = Vec::new();
for hit in &hits {
add_evidence(
&mut evidence,
hit.rel.clone(),
Some(hit.line),
Some(hit.line),
hit.text.clone(),
state,
);
}
if hits.len() > 1 {
warnings.push("multiple matches".to_string());
}
let confidence = if hits.len() == 1 { "high" } else { "medium" };
answer_ok(id, op, Value::Object(value), confidence, evidence, warnings)
}
fn json_pick(root: &Path, request: &Value, id: &str, state: &mut InspectState) -> Value {
let op = "json-pick";
let path = match request_path(root, request) {
Ok(path) => path,
Err(error) => return answer_error(id, op, error),
};
let Some(pointers) = request.get("pointers").and_then(Value::as_array) else {
return answer_error(id, op, "pointers must be an array");
};
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => return answer_error(id, op, format!("Failed to read file: {error}")),
};
state.scanned_files += 1;
state.bytes_read += text.len();
let parsed: Value = match serde_json::from_str(&text) {
Ok(value) => value,
Err(error) => return answer_error(id, op, format!("Invalid JSON: {error}")),
};
let mut values = Map::new();
let mut warnings = Vec::new();
let mut evidence = Vec::new();
for pointer in pointers.iter().filter_map(Value::as_str) {
if let Some(value) = parsed.pointer(pointer) {
values.insert(pointer.to_string(), value.clone());
add_evidence(
&mut evidence,
rel_path(root, &path),
None,
None,
format!("{pointer} = {}", compact_json(value)),
state,
);
}
else {
warnings.push(format!("missing pointer: {pointer}"));
}
}
let status = if warnings.is_empty() { "ok" } else { "partial" };
let confidence = if warnings.is_empty() {
"high"
}
else {
"medium"
};
answer(
id,
op,
status,
json!({ "path": rel_path(root, &path), "values": values }),
confidence,
evidence,
warnings,
)
}
fn snippets(root: &Path, request: &Value, id: &str, state: &mut InspectState) -> Value {
let op = "snippet";
let path = match request_path(root, request) {
Ok(path) => path,
Err(error) => return answer_error(id, op, error),
};
let Some(patterns) = string_array(request, "patterns") else {
return answer_error(id, op, "patterns must be a string array");
};
let context = usize_field(request, "contextLines", 2);
let max_snips = usize_field(request, "maxSnippets", 10);
let text = match fs::read_to_string(&path) {
Ok(text) => text,
Err(error) => return answer_error(id, op, format!("Failed to read file: {error}")),
};
state.scanned_files += 1;
state.bytes_read += text.len();
let lines = text.lines().collect::<Vec<_>>();
let mut ranges = Vec::new();
for (index, line) in lines.iter().enumerate() {
if !patterns.iter().any(|pattern| line.contains(pattern)) {
continue;
}
let start = index.saturating_sub(context);
let end = (index + context + 1).min(lines.len());
push_range(&mut ranges, start, end);
if ranges.len() >= max_snips {
break;
}
}
if ranges.is_empty() {
return answer_partial(
id,
op,
json!({ "path": rel_path(root, &path), "matches": 0 }),
"low",
Vec::new(),
vec!["no snippets matched".to_string()],
);
}
let mut evidence = Vec::new();
for (start, end) in &ranges {
let snippet = (*start..*end)
.map(|index| format!("{}: {}", index + 1, lines[index]))
.collect::<Vec<_>>()
.join("\n");
add_evidence(
&mut evidence,
rel_path(root, &path),
Some(start + 1),
Some(*end),
snippet,
state,
);
}
answer_ok(
id,
op,
json!({
"path": rel_path(root, &path),
"matches": ranges.len(),
"ranges": ranges
.into_iter()
.map(|(start, end)| json!({ "lineStart": start + 1, "lineEnd": end }))
.collect::<Vec<_>>()
}),
"high",
evidence,
Vec::new(),
)
}
fn inspect_root(root: &str) -> Result<PathBuf, String> {
let root = ensure_path_allowed(root)?;
if !root.is_dir() {
return Err(format!("root is not a directory: {}", root.display()));
}
fs::canonicalize(&root).map_err(|error| format!("Failed to canonicalize root: {error}"))
}
fn request_path(root: &Path, request: &Value) -> Result<PathBuf, String> {
let Some(path_text) = request.get("path").and_then(Value::as_str) else {
return Err("path must be a string".to_string());
};
let raw = Path::new(path_text);
let joined = if raw.is_absolute() {
raw.to_path_buf()
}
else {
root.join(raw)
};
let path = ensure_path_allowed(joined)?;
if !path.exists() {
return Err(format!("Path does not exist: {}", path.display()));
}
let path = fs::canonicalize(&path)
.map_err(|error| format!("Failed to canonicalize {}: {error}", path.display()))?;
if !within_root(root, &path) {
return Err(format!("Path is outside root: {}", path.display()));
}
Ok(path)
}
fn count_dir(
root: &Path,
dir: &Path,
glob: &str,
recursive: bool,
samples: &mut Vec<String>,
state: &mut InspectState,
) -> Result<usize, String> {
let mut count = 0;
for entry in read_dir(dir)? {
if budget_exceeded(state) {
break;
}
let path = entry.path();
if entry
.file_type()
.map(|kind| kind.is_symlink())
.unwrap_or(false)
{
continue;
}
if path.is_dir() {
if recursive && path.file_name().and_then(|value| value.to_str()) != Some(".git") {
count += count_dir(root, &path, glob, recursive, samples, state)?;
}
continue;
}
if !path.is_file() {
continue;
}
state.scanned_files += 1;
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
if !wildcard_match(glob, name) {
continue;
}
count += 1;
if samples.len() < 20 {
samples.push(rel_path(root, &path));
}
}
Ok(count)
}
fn search_path(
ctx: &SearchCtx<'_>,
path: &Path,
hits: &mut Vec<Hit>,
warnings: &mut Vec<String>,
state: &mut InspectState,
) -> Result<(), String> {
if hits.len() >= ctx.max_matches {
return Ok(());
}
if path.is_file() {
search_file(ctx, path, hits, warnings, state);
return Ok(());
}
if !path.is_dir() {
return Err(format!(
"Path is not a file or directory: {}",
path.display()
));
}
let mut entries = read_dir(path)?;
entries.sort_by_key(|entry| entry.path());
for entry in entries {
if hits.len() >= ctx.max_matches {
warnings.push("maxMatches reached".to_string());
return Ok(());
}
if budget_exceeded(state) {
if !warnings
.iter()
.any(|warning| warning.starts_with("time budget"))
{
warnings.push("time budget exceeded; matches are partial".to_string());
}
return Ok(());
}
let child = entry.path();
if entry
.file_type()
.map(|kind| kind.is_symlink())
.unwrap_or(false)
{
continue;
}
if child.is_dir() {
if ctx.recursive && child.file_name().and_then(|value| value.to_str()) != Some(".git") {
search_path(ctx, &child, hits, warnings, state)?;
}
}
else {
search_file(ctx, &child, hits, warnings, state);
}
}
Ok(())
}
fn search_file(
ctx: &SearchCtx<'_>,
path: &Path,
hits: &mut Vec<Hit>,
warnings: &mut Vec<String>,
state: &mut InspectState,
) {
if hits.len() >= ctx.max_matches || !path.is_file() {
return;
}
let rel = rel_path(ctx.root, path);
let name = path
.file_name()
.and_then(|value| value.to_str())
.unwrap_or("");
if let Some(pattern) = ctx.file_pattern && !wildcard_match(pattern, name) && !wildcard_match(pattern, &rel)
{
return;
}
let file = match fs::File::open(path) {
Ok(file) => file,
Err(error) => {
warnings.push(format!("skipped {rel}: {error}"));
return;
}
};
state.scanned_files += 1;
let mut reader = BufReader::new(file);
let mut line = String::new();
let mut index = 0usize;
loop {
if hits.len() >= ctx.max_matches || budget_exceeded(state) {
return;
}
line.clear();
let read = match reader.read_line(&mut line) {
Ok(read) => read,
Err(error) => {
warnings.push(format!("skipped {rel}: {error}"));
return;
}
};
if read == 0 {
break;
}
state.bytes_read += read;
if line.ends_with('\n') {
line.pop();
if line.ends_with('\r') {
line.pop();
}
}
if !ctx.matcher.is_match(&line) {
index += 1;
continue;
}
hits.push(Hit {
rel: rel.clone(),
line: index + 1,
text: line.clone(),
fields: capture_fields(&line, ctx.extracts),
});
index += 1;
}
}
fn search_regex(pattern: &str, literal: bool) -> Result<Regex, String> {
let pattern = if literal {
regex::escape(pattern)
}
else {
pattern.to_string()
};
Regex::new(&pattern).map_err(|error| format!("Invalid search pattern: {error}"))
}
fn extract_specs(request: &Value) -> Result<Vec<ExtractSpec>, String> {
let Some(items) = request.get("extract") else {
return Ok(Vec::new());
};
let Some(items) = items.as_array() else {
return Err("extract must be an array".to_string());
};
let mut specs = Vec::new();
for item in items {
let Some(name) = item.get("name").and_then(Value::as_str) else {
return Err("extract.name must be a string".to_string());
};
let Some(pattern) = item.get("regex").and_then(Value::as_str) else {
return Err("extract.regex must be a string".to_string());
};
let regex = Regex::new(pattern)
.map_err(|error| format!("Invalid extract regex for {name}: {error}"))?;
specs.push(ExtractSpec {
name: name.to_string(),
regex,
});
}
Ok(specs)
}
fn capture_fields(line: &str, specs: &[ExtractSpec]) -> Map<String, Value> {
let mut fields = Map::new();
for spec in specs {
if let Some(captures) = spec.regex.captures(line) && let Some(value) = captures.get(1)
{
fields.insert(spec.name.clone(), json!(value.as_str()));
}
}
fields
}
fn add_evidence(
evidence: &mut Vec<Value>,
path: String,
line_start: Option<usize>,
line_end: Option<usize>,
snippet: String,
state: &mut InspectState,
) {
if state.used_chars >= state.max_chars {
state.truncated = true;
return;
}
let remaining = state.max_chars - state.used_chars;
let (snippet, snippet_chars) = if snippet.len() <= remaining {
let count = snippet.chars().count();
(snippet, count)
}
else {
let count = snippet.chars().count();
if count > remaining {
state.truncated = true;
let truncated = truncate_chars(&snippet, remaining);
let truncated_count = truncated.chars().count();
(truncated, truncated_count)
}
else {
(snippet, count)
}
};
state.used_chars += snippet_chars;
evidence.push(json!({
"path": path,
"lineStart": line_start,
"lineEnd": line_end,
"snippet": snippet
}));
}
fn truncate_chars(value: &str, max_chars: usize) -> String {
value.chars().take(max_chars).collect()
}
fn answer_ok(
id: &str,
op: &str,
value: Value,
confidence: &str,
evidence: Vec<Value>,
warnings: Vec<String>,
) -> Value {
answer(id, op, "ok", value, confidence, evidence, warnings)
}
fn answer_partial(
id: &str,
op: &str,
value: Value,
confidence: &str,
evidence: Vec<Value>,
warnings: Vec<String>,
) -> Value {
answer(id, op, "partial", value, confidence, evidence, warnings)
}
fn answer_error(id: &str, op: &str, message: impl Into<String>) -> Value {
answer(
id,
op,
"error",
Value::Null,
"low",
Vec::new(),
vec![message.into()],
)
}
fn answer(
id: &str,
op: &str,
status: &str,
value: Value,
confidence: &str,
evidence: Vec<Value>,
warnings: Vec<String>,
) -> Value {
json!({
"id": id,
"op": op,
"status": status,
"value": value,
"confidence": confidence,
"evidence": evidence,
"warnings": warnings
})
}
fn inspect_status(answers: &[Value]) -> &'static str {
if answers
.iter()
.all(|answer| answer.get("status").and_then(Value::as_str) == Some("error"))
{
return "error";
}
let has_error = answers
.iter()
.any(|answer| answer.get("status").and_then(Value::as_str) == Some("error"));
let has_partial = answers
.iter()
.any(|answer| answer.get("status").and_then(Value::as_str) == Some("partial"));
if has_error || has_partial {
"partial"
}
else {
"ok"
}
}
fn read_dir(path: &Path) -> Result<Vec<fs::DirEntry>, String> {
let mut entries = fs::read_dir(path)
.map_err(|error| format!("Failed to list {}: {error}", path.display()))? .collect::<Result<Vec<_>, _>>()
.map_err(|error| format!("Failed to read directory entry: {error}"))?;
entries.sort_by_key(|entry| entry.path());
Ok(entries)
}
fn bool_field(value: &Value, key: &str, default: bool) -> bool {
value.get(key).and_then(Value::as_bool).unwrap_or(default)
}
fn usize_field(value: &Value, key: &str, default: usize) -> usize {
value
.get(key)
.and_then(Value::as_u64)
.map(|value| value as usize)
.unwrap_or(default)
}
fn string_array(value: &Value, key: &str) -> Option<Vec<String>> {
value.get(key).and_then(Value::as_array).map(|items| {
items
.iter()
.filter_map(Value::as_str)
.map(str::to_string)
.collect()
})
}
fn push_range(ranges: &mut Vec<(usize, usize)>, start: usize, end: usize) {
if let Some(last) = ranges.last_mut() && start <= last.1
{
last.1 = last.1.max(end);
return;
}
ranges.push((start, end));
}
fn compact_json(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}
fn rel_path(root: &Path, path: &Path) -> String {
path.strip_prefix(root)
.unwrap_or(path)
.to_string_lossy()
.replace('\\', "/")
}
fn within_root(root: &Path, path: &Path) -> bool {
let root = cmp_path(root);
let path = cmp_path(path);
path == root || path.starts_with(&format!("{root}/"))
}
fn cmp_path(path: &Path) -> String {
let mut value = path.to_string_lossy().replace('\\', "/");
if let Some(stripped) = value.strip_prefix("//?/") {
value = stripped.to_string();
}
if cfg!(windows) {
value = value.to_ascii_lowercase();
}
value.trim_end_matches('/').to_string()
}
fn wildcard_match(pattern: &str, text: &str) -> bool {
let cache = INSPECT_WILDCARD_CACHE.get_or_init(|| RwLock::new(HashMap::new()));
if let Some(regex) = cache.read().unwrap().get(pattern) {
return regex.is_match(text);
}
let mut source = String::with_capacity(pattern.len() * 2 + 2);
source.push('^');
for ch in pattern.chars() {
match ch {
'*' => source.push_str(".*"),
'?' => source.push('.'),
ch if matches!(
ch,
'.' | '+' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\' | '*' | '?'
) =>
{
source.push('\\');
source.push(ch);
}
ch => source.push(ch),
}
}
source.push('$');
let Ok(regex) = Regex::new(&source) else {
return false;
};
let mut cache_w = cache.write().unwrap();
cache_w
.entry(pattern.to_string())
.or_insert(regex)
.is_match(text)
}