Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 41 additions & 10 deletions src/search/windowsSearchAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -137,9 +137,9 @@ function buildWindowsSearchCommand(search) {
"if ($scopeClauses.Count -eq 0) { exit 0 }",
"$whereClauses = @()",
"$whereClauses += '(' + ($scopeClauses -join ' OR ') + ')'",
"$whereClauses += \"System.ItemTypeText <> 'File folder'\"",
"foreach ($term in $terms) {",
" $whereClauses += \"System.FileName LIKE '%\" + (Escape-LikeLiteral $term) + \"%'\"",
" $pathTerm = $term.Replace('/', [string][char]92)",
" $whereClauses += \"System.ItemPathDisplay LIKE '%\" + (Escape-LikeLiteral $pathTerm) + \"%'\"",
"}",
Comment on lines 140 to 143

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 System.ItemPathDisplay expands result set for simple word searches

Switching from System.FileName LIKE '%term%' to System.ItemPathDisplay LIKE '%term%' for all OleDb-backed filename searches causes full-path matching for every term, including simple non-path-like queries (e.g. photos, budget). Before this change, searching for photos returned files whose name contained photos. Now it returns every file whose full path contains photos — for example, every file under C:\Users\Me\Photos\ — which can produce a large false-positive set.

Path-like queries are now routed to runFilenameSearch (rg), so System.ItemPathDisplay is only reached for simple, non-path-like, profile-root queries. rg's matchesFilenameSearch also does full-path matching, so the two branches are now consistent — but users who expect filename-only matching for simple terms will see significantly more results. If full-path matching for all queries is intentional, a comment explaining the deliberate semantic shift would prevent future regressions back to System.FileName.

Fix in Codex

"if ($modifiedWithinDays) {",
" $cutoff = (Get-Date).AddDays(-[int]$modifiedWithinDays).ToString('yyyy-MM-ddTHH:mm:ss')",
Expand All @@ -157,7 +157,6 @@ function buildWindowsSearchCommand(search) {
" while ($reader.Read() -and $count -lt $maxResults) {",
" $path = [string]$reader.GetValue(0)",
" try { $file = Get-Item -LiteralPath $path -ErrorAction Stop } catch { continue }",
" if ($file.PSIsContainer) { continue }",
" [Console]::Out.Write($file.FullName + [char]0)",
" $count += 1",
" }",
Expand Down Expand Up @@ -294,16 +293,18 @@ function resolveRipgrepPath(options = {}) {
return candidates.find((candidate) => candidate && fileExists(candidate)) || "rg";
}

function normalizeFilenameComparable(value) {
return String(value || "").trim().toLowerCase().replace(/\\/g, "/");
}

function splitSearchTerms(query) {
return String(query || "")
.trim()
.toLowerCase()
return normalizeFilenameComparable(query)
.split(/\s+/)
.filter(Boolean);
}

function matchesFilenameSearch(filePath, terms) {
const candidate = String(filePath || "").toLowerCase();
const candidate = normalizeFilenameComparable(filePath);
return terms.every((term) => candidate.includes(term));
}

Expand Down Expand Up @@ -331,7 +332,7 @@ function directFilePathResult(search, roots) {

try {
const stats = statSync(filePath);
if (!stats.isFile() || !isModifiedWithin(filePath, search.modifiedWithinDays, stats)) {
if ((!stats.isFile() && !stats.isDirectory()) || !isModifiedWithin(filePath, search.modifiedWithinDays, stats)) {
return [];
}
} catch {
Expand All @@ -341,6 +342,11 @@ function directFilePathResult(search, roots) {
return [{ source: "windows", target: driveTargetForPath(filePath), path: filePath }];
}

function isPathLikeFilenameQuery(query) {
const normalized = String(query || "").trim().replace(/\\/g, "/");
return normalized.includes("/") || normalized.startsWith(".");
}

function runCommand(command, timeoutMs) {
return new Promise((resolve, reject) => {
const controller = new AbortController();
Expand Down Expand Up @@ -388,7 +394,28 @@ function runFilenameSearch(search, timeoutMs = SEARCH_TIMEOUT_MS) {
const results = [];
let buffer = "";
let settled = false;
const child = spawn(resolveRipgrepPath(), ["--files", "-0", ...roots], { windowsHide: true });
const child = spawn(
resolveRipgrepPath(),
[
"--files",
"-0",
"--hidden",
"--glob",
"!.git",
"--glob",
"!node_modules",
"--glob",
"!.cache",
"--glob",
"!.venv",
"--glob",
"!__pycache__",
"--glob",
"!dist",
...roots,
],
{ windowsHide: true }
);
Comment on lines +397 to +418

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major | 🏗️ Heavy lift

Filesystem fallback still drops directory matches.

shouldUseFilesystemFilenameSearch() now sends .codex/foo/bar filename queries through runFilenameSearch(), but this helper still shells out to rg --files, which never emits directory entries. That means a relative directory query like .codex/plans under %USERPROFILE% still can't return the directory itself unless the caller provides an exact absolute path and hits directFilePathResult(). The new Windows path-like flow is therefore still incomplete for directory searches.

Also applies to: 481-485

const timeout = setTimeout(() => finish(new Error("Command timed out.")), timeoutMs);

function finish(error) {
Expand Down Expand Up @@ -451,7 +478,11 @@ function shouldUseFilesystemFilenameSearch(search, roots) {
return false;
}
const profileRoot = process.env.USERPROFILE ? normalizeComparableWindowsRoot(process.env.USERPROFILE) : "";
return search.root === ALL_WINDOWS_DRIVES || roots.some((root) => normalizeComparableWindowsRoot(root) !== profileRoot);
return (
search.root === ALL_WINDOWS_DRIVES ||
isPathLikeFilenameQuery(search.query) ||
roots.some((root) => normalizeComparableWindowsRoot(root) !== profileRoot)
);
}

function createWindowsSearchAdapter(options = {}) {
Expand Down
72 changes: 57 additions & 15 deletions src/search/wslSearchAdapter.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ function shellQuote(value) {
return `'${String(value).replace(/'/g, `'\\''`)}'`;
}

function escapeWslCommandArgument(value) {
return String(value).replace(/\$/g, "\\$");
}
Comment on lines +18 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 escapeWslCommandArgument breaks buildDirectPathPrefix variable expansion

escapeWslCommandArgument replaces every $ in the already-shellQuoted script — including the $root_path, $candidate, and $under_root references inside the single-quoted inner-bash script body. Those references end up as \$root_path / \$candidate. Inside single quotes, they are passed literally to the inner bash. In double-quoted contexts like [ "\$root_path" = "/" ], bash interprets \$ as a literal $ character, not a variable. root_path and candidate are assigned but never read. The [ -e "$candidate" ] guard tests for a file literally named $candidate, which never exists, so the fast-path exit 0 never fires and every path-like WSL query falls through to a full rg/find scan.

Smallest correct fix: apply escapeWslCommandArgument only before shellQuote on user-controlled input if needed, not on the entire already-quoted script. The outer script's own $var references must remain as unexpanded dollar signs reaching the inner bash.

Fix in Codex

Comment on lines +18 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
set -euo pipefail

search_command='candidate=/tmp/demo; query='\''price$list.txt'\''; printf "candidate=%s\nquery=%s\n" "$candidate" "$query"'
wrapped_without_escape="timeout 1s bash -lc '$search_command'"
wrapped_with_escape="timeout 1s bash -lc '${search_command//$/\\$}'"

echo "without post-escape:"
bash -lc "$wrapped_without_escape"

echo
echo "with post-escape:"
bash -lc "$wrapped_with_escape"

Repository: future3OOO/wsl-search-tool

Length of output: 251


🏁 Script executed:

# First, locate and examine the file
find . -name "wslSearchAdapter.js" -type f

Repository: future3OOO/wsl-search-tool

Length of output: 103


🏁 Script executed:

# Read the entire file to understand the context
wc -l ./src/search/wslSearchAdapter.js

Repository: future3OOO/wsl-search-tool

Length of output: 107


🏁 Script executed:

# Read the critical sections
echo "=== Lines 15-25 (function definition) ==="
sed -n '15,25p' ./src/search/wslSearchAdapter.js

echo
echo "=== Lines 139-160 (buildDirectPathPrefix and variable references) ==="
sed -n '139,160p' ./src/search/wslSearchAdapter.js

echo
echo "=== Lines 185-200 (call sites) ==="
sed -n '185,200p' ./src/search/wslSearchAdapter.js

Repository: future3OOO/wsl-search-tool

Length of output: 2933


🏁 Script executed:

# Check how buildSearchScript is used (where it's called)
echo "=== Searching for buildSearchScript calls ==="
rg "buildSearchScript" ./src/search/wslSearchAdapter.js -B 2 -A 2

echo
echo "=== Check for execSync or similar execution ==="
rg "execSync|spawn|exec" ./src/search/wslSearchAdapter.js -B 2 -A 2

Repository: future3OOO/wsl-search-tool

Length of output: 1018


🏁 Script executed:

# Check how the result of buildSearchScript is used
echo "=== Full buildSearchCommand function ==="
sed -n '173,180p' ./src/search/wslSearchAdapter.js

echo
echo "=== Look for test files ==="
find . -name "*test*" -o -name "*.test.js" -o -name "*.spec.js" | head -20

Repository: future3OOO/wsl-search-tool

Length of output: 685


🏁 Script executed:

# Get the complete buildSearchCommand function
sed -n '160,205p' ./src/search/wslSearchAdapter.js

Repository: future3OOO/wsl-search-tool

Length of output: 2698


🏁 Script executed:

# Check if there are tests for the functions being modified
grep -r "buildSearchScript\|buildSearchCommand\|escapeWslCommandArgument" ./test/ -l

Repository: future3OOO/wsl-search-tool

Length of output: 92


🏁 Script executed:

# Check the search test file for relevant tests
grep -n "buildSearchScript\|buildSearchCommand\|escapeWslCommandArgument" ./test/search.test.js -B 2 -A 5

Repository: future3OOO/wsl-search-tool

Length of output: 2929


🏁 Script executed:

# Read test cases to understand expected behavior
sed -n '36,51p' ./test/search.test.js
echo "---"
sed -n '104,116p' ./test/search.test.js
echo "---"
sed -n '145,157p' ./test/search.test.js

Repository: future3OOO/wsl-search-tool

Length of output: 1590


🏁 Script executed:

# Let's verify what the current output actually produces
# Look at the full test for the exact-path scenario
sed -n '145,157p' ./test/search.test.js

echo "---"
echo "Let's examine what escapeWslCommandArgument is actually doing to the shell command:"
echo "Looking at lines 18-20 again:"
sed -n '18,20p' ./src/search/wslSearchAdapter.js

Repository: future3OOO/wsl-search-tool

Length of output: 778


🏁 Script executed:

# Check if there are any tests that actually execute the shell commands
grep -n "spawn\|execSync\|exec" ./test/search.test.js

echo "---"
echo "Let's understand the quoting flow:"
echo "The test expects escaped dollars. Let's trace what buildSearchScript actually produces."
echo "---"

# Let's run a Node snippet to see what buildSearchScript produces
node <<'EOF'
const { buildSearchCommand } = require('./src/search/wslSearch');

const command = buildSearchCommand({
  distro: "OpenCockLab",
  root: "/home",
  query: "/home/test.txt",
  mode: "filename",
  maxResults: 10,
});

console.log("Full command array:");
command.forEach((arg, i) => {
  if (arg.length > 100) {
    console.log(`[${i}]: ${arg.substring(0, 100)}...`);
  } else {
    console.log(`[${i}]: ${arg}`);
  }
});

console.log("\n=== Command[6] (the bash script) ===");
console.log(command[6]);
console.log("\n=== Looking for $candidate or \\$candidate ===");
if (command[6].includes('$candidate')) {
  console.log("Found: $candidate (unescaped)");
}
if (command[6].includes('\\$candidate')) {
  console.log("Found: \\$candidate (escaped)");
}
EOF

Repository: future3OOO/wsl-search-tool

Length of output: 1090


🏁 Script executed:

# Check what's exported from wslSearchAdapter
grep -n "^module.exports\|^exports" ./src/search/wslSearchAdapter.js

echo "---"
echo "Check if there's a wslSearch.js file:"
find . -name "wslSearch.js" -type f

echo "---"
echo "Check the test imports:"
head -20 ./test/search.test.js

Repository: future3OOO/wsl-search-tool

Length of output: 682


🏁 Script executed:

# Check the root wslSearch.js file to understand exports
head -30 ./wslSearch.js

echo "---"
echo "Now let's run the actual test to see if it passes or fails"
cd ./test && npm test -- search.test.js 2>&1 | head -100

Repository: future3OOO/wsl-search-tool

Length of output: 284


🏁 Script executed:

# Run the test from the root
npm test 2>&1 | grep -A 50 "search.test.js\|buildSearchCommand"

Repository: future3OOO/wsl-search-tool

Length of output: 4563


🏁 Script executed:

# Let's manually trace through what buildSearchScript produces with the exact-path query
node <<'EOF'
const fs = require('fs');
const path = require('path');

// Read and eval the adapter to get the functions
const adapterCode = fs.readFileSync('./src/search/wslSearchAdapter.js', 'utf8');

// Create a context with necessary functions
const module = { exports: {} };
const require_local = (name) => {
  if (name === 'node:child_process') return {};
  return {};
};

// Execute only the relevant parts
eval(adapterCode.replace('require("node:child_process")', '{}'));

const functions = module.exports;

// Test the exact-path scenario
const result = functions.buildSearchCommand({
  distro: "OpenCockLab",
  root: "/home",
  query: "/home/test.txt",
  mode: "filename",
  maxResults: 10,
});

console.log("=== buildSearchCommand result ===");
console.log("Command[6] (the bash -lc argument):");
const bashArg = result[6];
console.log(bashArg.substring(0, 200) + "...");

console.log("\n=== Checking for variable references ===");
if (bashArg.includes('$candidate')) {
  console.log("✓ Found unescaped: $candidate");
} else if (bashArg.includes('\\$candidate')) {
  console.log("✗ Found escaped: \\$candidate");
} else {
  console.log("? Neither found");
}

if (bashArg.includes('$root_path')) {
  console.log("✓ Found unescaped: $root_path");
} else if (bashArg.includes('\\$root_path')) {
  console.log("✗ Found escaped: \\$root_path");
}
EOF

Repository: future3OOO/wsl-search-tool

Length of output: 446


🏁 Script executed:

# Let's check the complete bash script to understand the variable expansion issue
node <<'EOF'
const fs = require('fs');

const adapterCode = fs.readFileSync('./src/search/wslSearchAdapter.js', 'utf8');

const module = { exports: {} };
eval(adapterCode.replace('require("node:child_process")', '{}'));

const functions = module.exports;

// Test the exact-path scenario
const result = functions.buildSearchCommand({
  distro: "OpenCockLab",
  root: "/home",
  query: "/home/test.txt",
  mode: "filename",
  maxResults: 10,
});

const bashArg = result[6];
console.log("=== Full bash -lc argument ===");
console.log(bashArg);

console.log("\n=== Key observation ===");
console.log("In bash, \\$root_path means literal $root_path (backslash escapes the dollar)");
console.log("So when bash interprets the command, [ \"\\$root_path\" = \"/\" ] compares");
console.log("the literal string '$root_path' instead of expanding the variable.");
EOF

Repository: future3OOO/wsl-search-tool

Length of output: 1430


Remove the post-quoting $ escaping from the WSL script wrapper.

searchCommand is already protected by shellQuote(...). Escaping every $ afterwards changes what the inner bash -lc sees: Line 153's variable references like $root_path and $candidate stop expanding (bash interprets \$ as a literal dollar sign within double quotes), and literal queries or paths containing $ are matched as \$... instead of the original text. This breaks the exact-path fast path and any filename/content search for dollar-containing names.

Suggested fix
-function escapeWslCommandArgument(value) {
-  return String(value).replace(/\$/g, "\\$");
-}
-
 ...
-    return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`);
+    return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`;
 ...
-    return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`);
+    return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`;
 ...
-  return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`);
+  return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`;

Also applies to: Lines 139–154, 188–198

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/search/wslSearchAdapter.js` around lines 18 - 20, The wrapper currently
double-escapes dollar signs in escapeWslCommandArgument which alters what the
inner bash -lc sees and prevents expansion of variables like $root_path and
$candidate; remove the post-quoting replacement of `$` (i.e., stop turning $
into \$) so that searchCommand—already protected by shellQuote(...)—is passed
through intact to the bash -lc wrapper; update the escapeWslCommandArgument
function (and any similar escaping in the WSL script wrapper) to no longer
escape `$`, ensuring variable expansion and literal dollar-containing
filenames/queries work correctly.


function parsePositiveInteger(value, fallback) {
const parsed = Number.parseInt(value, 10);
if (!Number.isFinite(parsed) || parsed < 1) {
Expand All @@ -27,13 +31,34 @@ function splitSearchTerms(query) {
return query.split(/\s+/).filter(Boolean);
}

function parseWslUncPath(value) {
const normalized = String(value || "").trim().replace(/\\/g, "/");
const match = normalized.match(/^\/\/wsl(?:\.localhost|\$)\/([^/]+)(\/.*)?$/i);
if (!match || !match[1]) {
return null;
}
return { distro: match[1], path: match[2] || "/" };
}

function normalizeWslRoot(root) {
if (root === "/") {
return root;
}
return root.replace(/\/+$/, "") || "/";
}

function buildPruneExpression() {
return PRUNED_DIRECTORY_NAMES.map((name) => `-name ${shellQuote(name)}`).join(" -o ");
}

function buildFindFilesCommand(root, filePredicates) {
function buildFindPathsCommand(root) {
const pruneExpression = buildPruneExpression();
return `find ${root} \\( -type d \\( ${pruneExpression} \\) -prune \\) -o -type f${filePredicates} -print0 2>/dev/null`;
return `find ${root} \\( -type d \\( ${pruneExpression} \\) -prune \\) -o \\( -type f -o -type d \\) -print0 2>/dev/null`;
}

function buildFindDirectoriesCommand(root) {
const pruneExpression = buildPruneExpression();
return `find ${root} \\( -type d \\( ${pruneExpression} \\) -prune \\) -o -type d -print0 2>/dev/null`;
}

function buildRipgrepFilesCommand(root) {
Expand Down Expand Up @@ -84,9 +109,11 @@ function buildPythonMtimeFilter(modifiedWithinDays, maxResults) {

function validateSearchInput(input) {
const root = String(input.root || DEFAULT_ROOT).trim();
const query = String(input.query || "").trim();
const rawQuery = String(input.query || "").trim();
const wslUncPath = parseWslUncPath(rawQuery);
const query = wslUncPath ? wslUncPath.path : rawQuery;
const mode = String(input.mode || "content").trim();
const distro = String(input.distro || ALL_DISTROS).trim();
const distro = wslUncPath ? wslUncPath.distro : String(input.distro || ALL_DISTROS).trim();
const modifiedWithinDays = String(input.modifiedWithinDays || "").trim();
const maxResults = parsePositiveInteger(input.maxResults, DEFAULT_MAX_RESULTS);

Expand All @@ -109,6 +136,24 @@ function validateSearchInput(input) {
return { distro, root, query, mode, modifiedWithinDays, maxResults };
}

function buildDirectPathPrefix(search) {
const root = shellQuote(normalizeWslRoot(search.root));
const query = shellQuote(search.query);
const output = search.modifiedWithinDays
? `find "$candidate" -maxdepth 0 -mtime -${search.modifiedWithinDays} -print0 2>/dev/null`
: `printf '%s\\0' "$candidate"`;

return [
`root_path=${root}`,
`candidate=${query}`,
"under_root=0",
"[ \"$root_path\" = \"/\" ] && under_root=1",
"[ \"$candidate\" = \"$root_path\" ] && under_root=1",
"case \"$candidate\" in \"$root_path\"/*) under_root=1 ;; esac",
`if [ -e "$candidate" ] && [ "$under_root" = "1" ]; then ${output}; exit 0; fi`,
].join("; ");
}

function validateOpenInput(input) {
const distro = String(input.distro || "").trim();
const path = String(input.path || "").trim();
Expand All @@ -134,26 +179,23 @@ function buildSearchScript(search) {
let searchCommand;

if (search.mode === "filename") {
const namePredicates = splitSearchTerms(search.query)
.map((term) => `-iname ${shellQuote(`*${term}*`)}`)
.join(" ");
const fastFileList = `${buildRipgrepFilesCommand(root)} | ${buildNullSeparatedTermFilters(search.query)}`;
const fallbackFileList = buildFindFilesCommand(root, ` ${namePredicates}${modifiedPredicate}`);
const fastPathList = `{ ${buildRipgrepFilesCommand(root)}; ${buildFindDirectoriesCommand(root)}; } | ${buildNullSeparatedTermFilters(search.query)}`;
const fallbackPathList = `${buildFindPathsCommand(root)} | ${buildNullSeparatedTermFilters(search.query)}`;
const filteredFileList = search.modifiedWithinDays
? `${fastFileList} | ${buildPythonMtimeFilter(search.modifiedWithinDays, maxResults)}`
: fastFileList;
searchCommand = `if command -v rg >/dev/null 2>&1; then ${filteredFileList}; else ${fallbackFileList}; fi | head -z -n ${maxResults}`;
return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`;
? `${fastPathList} | ${buildPythonMtimeFilter(search.modifiedWithinDays, maxResults)}`
: fastPathList;
searchCommand = `${buildDirectPathPrefix(search)}; if command -v rg >/dev/null 2>&1; then ${filteredFileList}; else ${fallbackPathList}${modifiedPredicate ? ` | ${buildPythonMtimeFilter(search.modifiedWithinDays, maxResults)}` : ""}; fi | head -z -n ${maxResults}`;
return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`);
}

if (search.modifiedWithinDays) {
const mtimeFilter = buildPythonMtimeFilter(search.modifiedWithinDays, maxResults);
searchCommand = `if command -v rg >/dev/null 2>&1; then rg -i -l -0 -F --hidden --glob '!.git' --glob '!node_modules' --glob '!.cache' --glob '!.venv' --glob '!__pycache__' -- ${query} ${root}; else grep -RIlZ --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.cache --exclude-dir=.venv --exclude-dir=__pycache__ -- ${query} ${root} 2>/dev/null; fi | ${mtimeFilter} | head -z -n ${maxResults}`;
return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`;
return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`);
}

searchCommand = `if command -v rg >/dev/null 2>&1; then rg -i -l -0 -F --hidden --glob '!.git' --glob '!node_modules' --glob '!.cache' -- ${query} ${root}; else grep -RIlZ --exclude-dir=.git --exclude-dir=node_modules --exclude-dir=.cache -- ${query} ${root} 2>/dev/null; fi | head -z -n ${maxResults}`;
return `timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`;
return escapeWslCommandArgument(`timeout ${WSL_SEARCH_TIMEOUT_SECONDS}s bash -lc ${shellQuote(searchCommand)}`);
}

function buildSearchCommand(input) {
Expand Down
58 changes: 51 additions & 7 deletions test/search.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,8 @@ test("buildSearchCommand creates a filename search", () => {
});

assert.match(command[6], /find/);
assert.match(command[6], /-iname/);
assert.match(command[6], /-type f -o -type d/);
assert.match(command[6], /grep -z -i -F/);
assert.match(command[6], /timeout 12s/);
assert.match(command[6], /head -z -n 10/);
});
Expand All @@ -78,8 +79,9 @@ test("buildSearchCommand adds last edited filter to filename search", () => {
assert.match(command[6], /rg --files -0/);
assert.match(command[6], /grep -z -i -F/);
assert.match(command[6], /-mtime -7/);
assert.match(command[6], /xargs -0 -r -I\{\} find "\{\}" -maxdepth 0 -mtime -7 -print0/);
assert.match(command[6], /\*invoice\*/);
assert.match(command[6], /python3 -c/);
assert.match(command[6], /os\.path\.getmtime/);
assert.match(command[6], /grep -z -i -F -- '\\''invoice'\\''/);
});

test("buildSearchCommand adds last edited filter to content search", () => {
Expand Down Expand Up @@ -108,8 +110,9 @@ test("buildSearchCommand quotes filename search patterns for the shell", () => {
maxResults: 10,
});

assert.match(command[6], /\*\$\(touch\*/);
assert.match(command[6], /\*\/tmp\/wsl-search-bad\)\*/);
assert.match(command[6], /grep -z -i -F -- '\\''\\\$\(touch'\\''/);
assert.match(command[6], /grep -z -i -F -- '\\''\/tmp\/wsl-search-bad\)'\\''/);
assert.doesNotMatch(command[6], /\{;/);
});

test("buildSearchCommand turns spaced filename searches into required terms", () => {
Expand All @@ -121,8 +124,49 @@ test("buildSearchCommand turns spaced filename searches into required terms", ()
maxResults: 10,
});

assert.match(command[6], /\*project\*/);
assert.match(command[6], /\*notes\*/);
assert.match(command[6], /grep -z -i -F -- '\\''project'\\''/);
assert.match(command[6], /grep -z -i -F -- '\\''notes'\\''/);
});

test("buildSearchCommand includes directories in WSL filename searches", () => {
const command = buildSearchCommand({
distro: "OpenCockLab",
root: "/home",
query: "tdd-seam-and-deep-modules-prd-v2.md",
mode: "filename",
maxResults: 10,
});

assert.match(command[6], /-type f -o -type d/);
assert.match(command[6], /grep -z -i -F/);
assert.match(command[6], /tdd-seam-and-deep-modules-prd-v2\.md/);
});

test("buildSearchCommand resolves exact WSL path-like filename searches before broad search", () => {
const command = buildSearchCommand({
distro: "OpenCockLab",
root: "/home",
query: "/home/prop_/.codex/plans/tdd-seam-and-deep-modules-prd-v2.md",
mode: "filename",
maxResults: 10,
});

assert.match(command[6], /if \[ -e/);
assert.match(command[6], /\\\$candidate/);
assert.match(command[6], /\/home\/prop_\/\.codex\/plans\/tdd-seam-and-deep-modules-prd-v2\.md/);
});

test("buildSearchCommand normalizes WSL UNC filename path queries", () => {
const command = buildSearchCommand({
root: "/home",
query: "\\\\wsl.localhost\\OpenCockLab\\home\\prop_\\.codex\\plans\\tdd-seam-and-deep-modules-prd-v2.md",
mode: "filename",
maxResults: 10,
});

assert.deepEqual(command.slice(0, 3), ["wsl.exe", "-d", "OpenCockLab"]);
assert.match(command[6], /\/home\/prop_\/\.codex\/plans\/tdd-seam-and-deep-modules-prd-v2\.md/);
assert.doesNotMatch(command[6], /wsl\.localhost/);
});

test("parseSearchOutput preserves paths with spaces", () => {
Expand Down
Loading