Skip to main content

lean_ctx_server/tools/ctx_search/
implementation.rs

1// SPDX-License-Identifier: Apache-2.0
2use std::collections::HashSet;
3use std::path::Path;
4use std::path::PathBuf;
5use std::time::{Duration, Instant};
6
7use glob::Pattern;
8use ignore::WalkBuilder;
9use regex::RegexBuilder;
10
11use crate::tools::CrpMode;
12use lean_ctx::core::protocol;
13use lean_ctx::core::symbol_map::{self, SymbolMap};
14use lean_ctx::core::tokens::count_tokens;
15
16pub(crate) use lean_ctx::core::search_walk::{
17    MAX_FILE_SIZE, MAX_WALK_DEPTH, is_binary_ext, is_generated_file,
18};
19/// Size cap when `path` names one regular file (#1917). [`MAX_FILE_SIZE`]
20/// bounds a directory walk; a file the caller named explicitly is the whole
21/// question, so skipping it would turn "not searched" into "0 matches". This
22/// cap only guards memory against pathological inputs.
23const MAX_EXPLICIT_FILE_SIZE: u64 = 64 * 1024 * 1024;
24/// How many size-skipped files the notes name before collapsing to `+N more`.
25const MAX_NAMED_SKIPS: usize = 3;
26const MAX_MATCH_LINE_WIDTH: usize = 150;
27
28/// Modeled baseline for the *estimated* savings series (GL #479 D1): a native
29/// agent grep tool ships matches with surrounding context lines, per-file
30/// headers and line numbers, which is roughly 2.5x the tokens of the bare
31/// match lines lean-ctx observes. This factor is a documented model
32/// assumption — it feeds `stats.json` ("estimated") only. The signed savings
33/// ledger ("verified") records `observed_tokens` without any factor applied.
34pub const NATIVE_GREP_BASELINE_FACTOR: f64 = 2.5;
35
36/// Result of a search: the rendered output plus both baseline figures.
37pub struct SearchOutcome {
38    /// Rendered, compressed search output.
39    pub text: String,
40    /// Modeled native-tool baseline (`observed_tokens` x [`NATIVE_GREP_BASELINE_FACTOR`]).
41    /// Feeds the estimated stats series.
42    pub modeled_baseline: usize,
43    /// Tokens actually measured in the raw match lines — no model applied.
44    /// Feeds the verified savings ledger.
45    pub observed_tokens: usize,
46    /// The search itself failed (pattern refused, invalid regex, missing or
47    /// refused root) and `text` is its error message. This is the signal
48    /// callers act on — never the wording of `text`: a search whose output
49    /// merely begins with "ERROR" is a result, and "0 matches" is one too.
50    pub failed: bool,
51    /// Files the secret-path boundary kept out of the scan (the result names
52    /// them). The `ctx_search` handler records the count once per call.
53    pub secret_paths_withheld: usize,
54}
55
56impl SearchOutcome {
57    /// A search that did not run: `text` is the error message.
58    fn error(text: String) -> Self {
59        Self {
60            text,
61            modeled_baseline: 0,
62            observed_tokens: 0,
63            failed: true,
64            secret_paths_withheld: 0,
65        }
66    }
67
68    /// A search that ran and returned no match lines: `text` is the report.
69    fn without_matches(text: String) -> Self {
70        Self {
71            text,
72            modeled_baseline: 0,
73            observed_tokens: 0,
74            failed: false,
75            secret_paths_withheld: 0,
76        }
77    }
78
79    fn from_observed(text: String, observed_tokens: usize) -> Self {
80        let modeled = (observed_tokens as f64 * NATIVE_GREP_BASELINE_FACTOR).ceil() as usize;
81        Self {
82            text,
83            modeled_baseline: modeled.max(observed_tokens),
84            observed_tokens,
85            failed: false,
86            secret_paths_withheld: 0,
87        }
88    }
89
90    fn withholding(mut self, secret_paths: usize) -> Self {
91        self.secret_paths_withheld = secret_paths;
92        self
93    }
94}
95
96/// Wall-clock budget for a single `ctx_search` call. The regular-file guard in
97/// the read loop removes the known infinite block — `read_to_string` on a
98/// FIFO/socket/device (#336) — while this deadline is the backstop for any
99/// *other* pathological case (a gigantic corpus, a stuck network mount): the
100/// tool returns partial results with a hint instead of appearing to hang.
101/// Tunable via `LEAN_CTX_SEARCH_DEADLINE_MS` (`0` disables). Default 10s.
102fn search_deadline() -> Option<Duration> {
103    const DEFAULT_MS: u64 = 10_000;
104    let ms = std::env::var("LEAN_CTX_SEARCH_DEADLINE_MS")
105        .ok()
106        .and_then(|v| v.trim().parse::<u64>().ok())
107        .unwrap_or(DEFAULT_MS);
108    (ms > 0).then(|| Duration::from_millis(ms))
109}
110
111/// Back-compat shim: the pre-#870 8-arg entry point. Delegates to
112/// [`handle_filtered`] with no exclude filters. New callers pass the two
113/// `exclude` args directly.
114#[allow(clippy::too_many_arguments)]
115pub fn handle(
116    pattern: &str,
117    dir: &str,
118    include: Option<&str>,
119    max_results: usize,
120    crp_mode: CrpMode,
121    respect_gitignore: bool,
122    allow_secret_paths: bool,
123    anchored: bool,
124) -> SearchOutcome {
125    handle_filtered(
126        pattern,
127        dir,
128        include,
129        max_results,
130        crp_mode,
131        respect_gitignore,
132        allow_secret_paths,
133        anchored,
134        None,
135        None,
136    )
137}
138
139/// Searches files for a regex pattern with compressed output and monorepo scope hints.
140///
141/// `anchored` (opt-in, #1008) appends a `:hh` line-hash to every match
142/// (`path:line:hh content`) so a hit can be edited directly with `ctx_patch`
143/// without a separate `ctx_read(mode="anchored")`. Default output is byte-for-byte
144/// unchanged (#498).
145///
146/// `exclude` (path glob, complement of `include`) and `exclude_pattern` (regex
147/// on result lines, like `grep -v`) are the negative filters added in #870.
148///
149/// A pattern that is not valid regex syntax (`foo(`, `a[0`, `fn x(&self`) is
150/// almost always code the agent wants to find verbatim, so it is searched
151/// literally with a one-line note instead of failing the call. Size-limit
152/// errors still fail: escaping cannot make an oversized pattern smaller.
153#[allow(clippy::too_many_arguments)]
154pub fn handle_filtered(
155    pattern: &str,
156    dir: &str,
157    include: Option<&str>,
158    max_results: usize,
159    crp_mode: CrpMode,
160    respect_gitignore: bool,
161    allow_secret_paths: bool,
162    anchored: bool,
163    exclude: Option<&str>,
164    exclude_pattern: Option<&str>,
165) -> SearchOutcome {
166    let literal = matches!(regex::Regex::new(pattern), Err(regex::Error::Syntax(_)))
167        .then(|| regex::escape(pattern));
168    let mut outcome = search_compiled_pattern(
169        literal.as_deref().unwrap_or(pattern),
170        dir,
171        include,
172        max_results,
173        crp_mode,
174        respect_gitignore,
175        allow_secret_paths,
176        anchored,
177        exclude,
178        exclude_pattern,
179    );
180    if literal.is_some() && !outcome.text.starts_with("ERROR:") {
181        outcome.text = format!(
182            "note: '{pattern}' is not valid regex — searched for it literally.\n{}",
183            outcome.text
184        );
185    }
186    outcome
187}
188
189#[allow(clippy::too_many_arguments)]
190fn search_compiled_pattern(
191    pattern: &str,
192    dir: &str,
193    include: Option<&str>,
194    max_results: usize,
195    _crp_mode: CrpMode,
196    respect_gitignore: bool,
197    allow_secret_paths: bool,
198    anchored: bool,
199    exclude: Option<&str>,
200    exclude_pattern: Option<&str>,
201) -> SearchOutcome {
202    // `include` is a glob matched against each file's path *relative to* `dir`
203    // (e.g. `*.ts`, `*.{rs,ts}`, `src/**/*.tsx`). Bare globs without `/` match
204    // at any directory depth (like `rg --glob`), so `*.ts` finds `a/b.ts` too.
205    // Brace alternation is expanded here because the `glob` crate has no native
206    // support for it. An empty result (no `include`, or only unparsable globs)
207    // means "no filter", so a typo never silently drops every match.
208    let include_patterns = compile_include(include);
209    // `exclude` is the negative complement of `include`: a file matching any
210    // exclude glob is dropped, even when it also matches `include` (#870).
211    let exclude_patterns = compile_include(exclude);
212    const MAX_PATTERN_LEN: usize = 1024;
213    const MAX_REGEX_SIZE: usize = 1 << 20; // 1 MiB DFA limit
214
215    let redact = lean_ctx::core::redaction::redaction_enabled_for_active_role();
216    let protected = lean_ctx::core::policy::runtime::active().is_some();
217    let protected_root = lean_ctx::core::policy::diagnostics::project();
218    // Observed before any resident store is consulted: a changed admission
219    // policy drops the content cache and trigram index first (G5).
220    let admission = lean_ctx::core::context_admission::stores::StoreAdmission::current();
221    let mut protected_budget = lean_ctx::core::limits::max_read_bytes()
222        .min(lean_ctx::core::policy::content::MAX_PROTECTED_CONTENT_BYTES);
223    // Files that contributed at least one returned match, in scan order.
224    let mut matched_sources: Vec<std::path::PathBuf> = Vec::new();
225    if pattern.len() > MAX_PATTERN_LEN {
226        return SearchOutcome::error(format!(
227            "ERROR: pattern too long ({} > {MAX_PATTERN_LEN} chars)",
228            pattern.len()
229        ));
230    }
231    let re = match RegexBuilder::new(pattern)
232        .size_limit(MAX_REGEX_SIZE)
233        .dfa_size_limit(MAX_REGEX_SIZE)
234        .build()
235    {
236        Ok(r) => r,
237        Err(e) => return SearchOutcome::error(format!("ERROR: invalid regex: {e}")),
238    };
239    // #870: `exclude_pattern` drops any *result line* matching this regex — the
240    // in-pipeline equivalent of `grep -v`. An unparsable pattern is ignored
241    // (no exclusion), so a typo never silently hides every match.
242    let exclude_re = exclude_pattern.and_then(|p| {
243        RegexBuilder::new(p)
244            .size_limit(MAX_REGEX_SIZE)
245            .dfa_size_limit(MAX_REGEX_SIZE)
246            .build()
247            .ok()
248    });
249
250    let root = Path::new(dir);
251    if !root.exists() {
252        let hint = lean_ctx::core::path_suggest::hint(root);
253        return SearchOutcome::error(format!("ERROR: {dir} does not exist{hint}"));
254    }
255    // Broad-root guard (#356 class): with cwd == $HOME a defaulted `path`
256    // would walk the whole home dir and trip macOS TCC privacy prompts.
257    if let Some(err) = crate::tools::walk_guard::deny_unsafe_walk_root(dir) {
258        return SearchOutcome::error(err);
259    }
260    let size_limit = if std::fs::metadata(root).is_ok_and(|m| m.is_file()) {
261        MAX_EXPLICIT_FILE_SIZE
262    } else {
263        MAX_FILE_SIZE
264    };
265
266    let mut files: Vec<PathBuf> = Vec::new();
267    let mut matches = Vec::new();
268    let mut raw_tokens_accum: usize = 0;
269    let mut files_searched = 0u32;
270    let mut files_skipped_size = 0u32;
271    // Which files the size cap skipped, so the note names them instead of only
272    // counting them (#1917).
273    let mut skipped_size_files: Vec<String> = Vec::new();
274    let mut files_skipped_encoding = 0u32;
275    let mut files_withheld = 0u32;
276    let mut skipped_boundary_files: Vec<(String, &'static str)> = Vec::new();
277    let mut files_skipped_special = 0u32;
278    let mut deadline_hit = false;
279    // #1625: whether the result cap stopped the scan. Without this, a search
280    // that quit after one file reported "10 matches in 1 files (scanned 1)" —
281    // indistinguishable from an exhaustive scan that genuinely found ten.
282    let mut cap_hit = false;
283    // Under a policy every scanned original is charged to one byte budget. When
284    // it cannot cover the next file the scan stops and says so; the files left
285    // unscanned were not withheld by the policy and must not be counted as such.
286    let mut budget_hit = false;
287
288    // Fast path: a warm resident trigram index narrows the candidate files in
289    // memory, eliminating the per-call directory walk + full-corpus read. The
290    // index covers the exact same file universe as the walk below, and matches
291    // are still verified line-by-line with the same regex — so results are
292    // identical. Missing/stale index → returns None and triggers a background
293    // (re)build; this call uses the walk fallback.
294    // Files the filters admit when the index narrowed candidates; the walk
295    // path scans its whole scope, so `None` there. Protected searches bypass
296    // legacy unbound indexes and admit original source before matching.
297    let mut narrowed_scope: Option<usize> = None;
298    let used_index = if !protected
299        && let Some(idx) =
300            lean_ctx::core::search_index::get_fresh(dir, respect_gitignore, allow_secret_paths)
301    {
302        let candidates = idx.candidate_paths(pattern, &include_patterns, root);
303        if matches!(
304            candidates,
305            lean_ctx::core::search_index::CandidateSet::Narrowed(_)
306        ) {
307            narrowed_scope = Some(idx.scope_count(&include_patterns, &exclude_patterns, root));
308        }
309        files = candidates.into_paths();
310        if !allow_secret_paths {
311            skipped_boundary_files = lean_ctx::core::search_index::boundary_skipped_files(
312                dir,
313                respect_gitignore,
314                &include_patterns,
315            );
316        }
317        true
318    } else {
319        false
320    };
321
322    if !used_index {
323        // Vendor dirs (node_modules, …) follow the gitignore toggle: explicitly
324        // disabling gitignore is the escape hatch to look inside them (#400).
325        // GH #1431: hidden(false) for consistency with ctx_glob — dot-dirs
326        // must be searchable; gitignore + is_secret_like guard the rest.
327        let walker = WalkBuilder::new(root)
328            .hidden(false)
329            .max_depth(Some(MAX_WALK_DEPTH))
330            .git_ignore(respect_gitignore)
331            .git_global(respect_gitignore)
332            .git_exclude(respect_gitignore)
333            .require_git(false)
334            .filter_entry(move |e| {
335                if respect_gitignore {
336                    lean_ctx::core::walk_filter::keep_entry(e)
337                } else {
338                    lean_ctx::core::cloud_files::keep_entry(e)
339                }
340            })
341            .build();
342
343        for entry in walker.filter_map(std::result::Result::ok) {
344            if entry.file_type().is_none_or(|ft| ft.is_dir()) {
345                continue;
346            }
347
348            if entry.file_type().is_some_and(|ft| ft.is_symlink()) {
349                continue;
350            }
351
352            let path = entry.path();
353
354            if is_binary_ext(path) || is_generated_file(path) {
355                continue;
356            }
357
358            if !include_patterns.is_empty() {
359                let rel_str = filter_rel(path, root);
360                if !include_patterns.iter().any(|p| p.matches(&rel_str)) {
361                    continue;
362                }
363            }
364
365            if !allow_secret_paths {
366                if let Some(reason) = lean_ctx::core::io_boundary::is_secret_like(path) {
367                    skipped_boundary_files.push((filter_rel(path, root).into_owned(), reason));
368                    continue;
369                }
370            }
371
372            // Size / regular-file filtering happens once in the shared read loop
373            // below, so the walk path and the trigram-index fast path apply the
374            // exact same eligibility rules.
375            files.push(path.to_path_buf());
376        }
377        skipped_boundary_files.sort_unstable();
378    }
379
380    // #870: drop excluded paths after both the index and walk populate `files`,
381    // so the negative glob filter applies uniformly to either collection path.
382    if !exclude_patterns.is_empty() {
383        files.retain(|path| {
384            let rel_str = filter_rel(path, root);
385            !exclude_patterns.iter().any(|p| p.matches(&rel_str))
386        });
387    }
388
389    // ent2-3: the Hive policy's readable roots and never-send patterns apply
390    // to every file the scan would read, from either collection path; a policy
391    // that cannot be trusted refuses the search (fail closed).
392    match lean_ctx::core::io_boundary::hive_io_rules(None) {
393        Ok(None) => {}
394        Ok(Some(rules)) => {
395            files.retain(|path| match rules.refusal(path) {
396                None => true,
397                Some(refusal) => {
398                    let reason = match refusal {
399                        lean_ctx::core::policy::hive_io::Refusal::OutsideRoots => {
400                            "outside the Hive readable roots"
401                        }
402                        lean_ctx::core::policy::hive_io::Refusal::NeverSend(_) => "Hive never_send",
403                    };
404                    skipped_boundary_files.push((filter_rel(path, root).into_owned(), reason));
405                    false
406                }
407            });
408            skipped_boundary_files.sort_unstable();
409        }
410        Err(e) => {
411            return SearchOutcome::error(format!(
412                "ERROR: [I/O BOUNDARY] search refused: {e}; reads stay refused until a valid \
413                 Hive policy is installed."
414            ));
415        }
416    }
417
418    // Deterministic search: stable file ordering makes max_results truncation reproducible.
419    files.sort_unstable_by(|a, b| a.as_os_str().cmp(b.as_os_str()));
420
421    // #1994: the scope is every file the filters admit. Files the trigram
422    // index ruled out provably lack the literal, so they count as covered:
423    // "scanned" then means the same thing with or without a warm index, and
424    // `scanned 0` can only mean the filters left nothing to search.
425    let files_in_scope = narrowed_scope.unwrap_or(files.len());
426    let index_pruned =
427        u32::try_from(files_in_scope.saturating_sub(files.len())).unwrap_or(u32::MAX);
428
429    let root_str = root.to_string_lossy();
430    let deadline = search_deadline().map(|budget| Instant::now() + budget);
431    for path in &files {
432        if matches.len() >= max_results {
433            cap_hit = true;
434            break;
435        }
436
437        // Stop gracefully instead of appearing to hang on a pathological corpus
438        // or a stuck read (#336): once the wall-clock budget is spent, return
439        // the partial results gathered so far with a hint to narrow the search.
440        if deadline.is_some_and(|dl| Instant::now() >= dl) {
441            deadline_hit = true;
442            break;
443        }
444
445        // Only ever read regular files within the size budget. A FIFO, socket or
446        // device node would block `read_to_string` forever — the root cause of
447        // #336 — and oversized or unstatable files are skipped. `metadata`
448        // (stat) never opens the file, so it cannot block on a special file.
449        let file_len: u64;
450        let state = match std::fs::metadata(path) {
451            Ok(meta) if !meta.file_type().is_file() => {
452                files_skipped_special += 1;
453                continue;
454            }
455            Ok(meta) if meta.len() > size_limit => {
456                files_skipped_size += 1;
457                if skipped_size_files.len() < MAX_NAMED_SKIPS {
458                    skipped_size_files.push(protocol::shorten_path_relative(
459                        &path.to_string_lossy(),
460                        &root_str,
461                    ));
462                }
463                continue;
464            }
465            Ok(meta) => {
466                file_len = meta.len();
467                lean_ctx::core::content_cache::FileState::from_metadata(&meta)
468            }
469            Err(_) => {
470                files_skipped_encoding += 1;
471                continue;
472            }
473        };
474
475        // Reuse the copy the trigram-index build already read (issue #148): the
476        // corpus is read from disk once and the regex-verify pass here is an
477        // in-memory hit. On a miss (cold cache / evicted) read once and publish
478        // it for the next caller. `(mtime, size)` validation guarantees we never
479        // verify against stale bytes.
480        let content: std::sync::Arc<str> = if protected {
481            // The budget decision rests on the file size alone, never on its
482            // content, so stopping here discloses nothing about any source.
483            if (protected_budget as u64) < file_len {
484                budget_hit = true;
485                break;
486            }
487            // Scanning a file does not make it a source of the result: only a
488            // file that contributes a match is published (below, after the
489            // scan). Publishing every scanned file would bind the result to
490            // the whole tree.
491            let admitted = protected_root.as_ref().and_then(|root| {
492                lean_ctx::core::archive::authority::without_capture(|| {
493                    crate::tools::ctx_read::read_file_for_tool_rooted_budgeted(
494                        path.to_str()?,
495                        root.to_str()?,
496                        "ctx_search",
497                        &mut protected_budget,
498                    )
499                    .ok()
500                })
501            });
502            if let Some(text) = admitted {
503                std::sync::Arc::from(text)
504            } else {
505                // No source path or diagnostic payload is disclosed for
506                // an unreadable or policy-withheld original.
507                files_withheld += 1;
508                continue;
509            }
510        } else if let Some(cached) = state.and_then(|s| lean_ctx::core::content_cache::get(path, s))
511        {
512            lean_ctx::core::cache::record_search_content_read(true);
513            cached
514        } else {
515            if state.is_some() {
516                lean_ctx::core::cache::record_search_content_read(false);
517            }
518            let Ok(decoded) = lean_ctx::core::text_decode::read_text_checked(path) else {
519                files_skipped_encoding += 1;
520                continue;
521            };
522            // Replaced or dropped bytes mean the text is not the whole source.
523            // Governed and sovereign mode demand full inspection: the file is
524            // withheld from the search. Developer mode still searches it, but
525            // it is never published to the shared content cache.
526            let complete = decoded.complete();
527            if !complete
528                && admission.admission_policy().gateway.mode
529                    != lean_ctx_protocol::context_gateway::GatewayModeV1::Developer
530            {
531                files_withheld += 1;
532                continue;
533            }
534            let text = decoded.text;
535            // Search matches only ever come from admitted text: a masked value
536            // cannot be found, and a withheld or restricted source is skipped
537            // without disclosing it (G5).
538            let Some(text) = admission.admit(&text, path, None) else {
539                files_withheld += 1;
540                continue;
541            };
542            let arc: std::sync::Arc<str> = std::sync::Arc::from(text);
543            // An explicitly named file above the walk cap is read once and
544            // not published: it would evict the whole shared cache.
545            if complete
546                && let Some(s) = state
547                && arc.len() as u64 <= MAX_FILE_SIZE
548            {
549                lean_ctx::core::content_cache::insert(
550                    path,
551                    s,
552                    std::sync::Arc::clone(&arc),
553                    &admission,
554                );
555            }
556            arc
557        };
558
559        files_searched += 1;
560        // Enclosing-symbol spans for this file, computed lazily on the first hit
561        // (never for non-matching files) and reused for every later hit here.
562        let mut file_enclosing: Option<EnclosingIndex> = None;
563
564        for (i, line) in content.lines().enumerate() {
565            // #870: `exclude_pattern` filters out matching lines (grep -v).
566            if re.is_match(line) && !exclude_re.as_ref().is_some_and(|ex| ex.is_match(line)) {
567                let short_path =
568                    protocol::shorten_path_relative(&path.to_string_lossy(), &root_str);
569                let source: &Path = path.as_ref();
570                if matched_sources
571                    .last()
572                    .is_none_or(|last| last.as_path() != source)
573                {
574                    matched_sources.push(source.to_path_buf());
575                }
576                // Count raw tokens incrementally (avoids separate Vec + join)
577                raw_tokens_accum += count_tokens(line.trim()) + 2;
578                let mut shown = if redact {
579                    lean_ctx::core::redaction::redact_text(line.trim())
580                } else {
581                    line.trim().to_string()
582                };
583                if shown.len() > MAX_MATCH_LINE_WIDTH {
584                    shown.truncate(shown.floor_char_boundary(MAX_MATCH_LINE_WIDTH));
585                    shown.push_str("...");
586                }
587                // grep-ast enrichment (#608): name the enclosing symbol + its
588                // handle anchor so the hit is actionable without a follow-up
589                // read. Appended after `shown`, so the `path:line content` prefix
590                // every caller/test relies on is untouched.
591                let tag = file_enclosing
592                    .get_or_insert_with(|| EnclosingIndex::for_file(path, content.as_ref()))
593                    .tag_for(i + 1)
594                    .unwrap_or_default();
595                // The anchor hash is over the RAW line (matching ctx_read/ctx_patch
596                // which both hash `content.lines()`), never the trimmed/truncated
597                // display text — otherwise ctx_patch would always see a mismatch.
598                if anchored {
599                    matches.push(format!(
600                        "{short_path}:{}:{} {}{}",
601                        i + 1,
602                        lean_ctx::core::anchor::line_hash(line),
603                        shown,
604                        tag
605                    ));
606                } else {
607                    matches.push(format!("{short_path}:{} {}{}", i + 1, shown, tag));
608                }
609                if matches.len() >= max_results {
610                    cap_hit = true;
611                    break;
612                }
613            }
614        }
615    }
616
617    // Source lineage of the result (G04): every file that contributed a match,
618    // and nothing else. Each is read once through the admitted rooted reader,
619    // which publishes it bound to what is on disk now; a file that cannot be
620    // bound makes the set unknown. A no-op without a capture or protection.
621    // A result WITHOUT matches names no source and stays of unknown lineage:
622    // "nothing matched" is a statement about every scanned file.
623    let lineage_root = lean_ctx::core::policy::diagnostics::project().map_or_else(
624        || dir.to_owned(),
625        |root| root.to_string_lossy().into_owned(),
626    );
627    for source in &matched_sources {
628        crate::tools::ctx_read::publish_requested_source(
629            &source.to_string_lossy(),
630            &lineage_root,
631            "ctx_search",
632        );
633    }
634
635    // #883: sort matches by file match-density — files with more hits surface
636    // first, which is a proxy for relevance when the pattern contains common tokens.
637    if matches.len() > 1 {
638        use std::collections::HashMap;
639        let mut file_counts: HashMap<String, usize> = HashMap::new();
640        for m in &matches {
641            let file = extract_file_from_match(m).to_string();
642            *file_counts.entry(file).or_default() += 1;
643        }
644        matches.sort_by(|a, b| {
645            let fa = extract_file_from_match(a);
646            let fb = extract_file_from_match(b);
647            let ca = file_counts.get(fa).copied().unwrap_or(0);
648            let cb = file_counts.get(fb).copied().unwrap_or(0);
649            cb.cmp(&ca).then_with(|| fa.cmp(fb))
650        });
651    }
652
653    let files_covered = files_searched.saturating_add(index_pruned);
654
655    if matches.is_empty() {
656        // Match the hit response's scoped scan count, including candidates the
657        // index ruled out, while retaining policy-withheld visibility.
658        let mut msg = format!("0 matches for '{pattern}' (scanned {files_covered} files)");
659        if files_withheld > 0 {
660            msg.push_str(&format!(
661                " ({files_withheld} sources withheld under policy)"
662            ));
663        }
664        if files_in_scope == 0 {
665            msg.push_str(&empty_scope_note(dir, include, exclude));
666        }
667        if files_skipped_size > 0 {
668            msg.push_str(&format!(
669                " ({files_skipped_size} large files skipped: {})",
670                name_skipped(&skipped_size_files, files_skipped_size)
671            ));
672        }
673        if files_skipped_encoding > 0 {
674            msg.push_str(&format!(
675                " ({files_skipped_encoding} files skipped: binary/encoding)"
676            ));
677        }
678        if let Some(note) = format_boundary_skips(&skipped_boundary_files, false) {
679            msg.push_str(&note);
680        }
681        if files_skipped_special > 0 {
682            msg.push_str(&format!(
683                " ({files_skipped_special} special files skipped: not regular files)"
684            ));
685        }
686        if deadline_hit {
687            msg.push_str(
688                " (search stopped at the time budget — refine the pattern or scope with path=)",
689            );
690        }
691        if budget_hit {
692            msg.push_str(&budget_note(files_covered));
693        }
694        return SearchOutcome::without_matches(msg).withholding(skipped_boundary_files.len());
695    }
696
697    // Prefix-cache-friendly: structural file list before per-query match content
698    let matched_files: Vec<&str> = {
699        let mut seen = HashSet::new();
700        matches
701            .iter()
702            .filter_map(|m| {
703                let file = extract_file_from_match(m);
704                if seen.insert(file) { Some(file) } else { None }
705            })
706            .collect()
707    };
708
709    // #1591: "N matches in M files" read as "M files matched" — but `M` was the
710    // number of files *searched*. Report what matched; keep the scanned count as
711    // a clearly-labelled secondary number so scope is still visible.
712    let mut result = format!(
713        "{} matches in {} files (scanned {files_covered})",
714        matches.len(),
715        matched_files.len()
716    );
717    if matched_files.len() > 1 {
718        if matched_files.len() <= 10 {
719            result.push_str(" [");
720            result.push_str(&matched_files.join(", "));
721            result.push(']');
722        } else {
723            let shown: Vec<&str> = matched_files.iter().take(8).copied().collect();
724            result.push_str(&format!(
725                " [{}, +{} more]",
726                shown.join(", "),
727                matched_files.len() - 8
728            ));
729        }
730    }
731    result.push_str(":\n");
732    // Self-describing output (GL #580): the anchor notation ships its own legend.
733    if anchored {
734        result.push_str("[anchored: path:line:hh → edit via ctx_patch]\n");
735    }
736    // The `∈name@Lstart` tags carry no legend line: in the Dispatch benchmark
737    // transcripts agents used the tag names (`ctx_read symbol=`) and start
738    // lines, and never the `handle=` form the legend advertised (0 of 244
739    // tagged results, 2026-11-01); it cost ~30 tokens per run.
740    result.push_str(&matches.join("\n"));
741    if files_withheld > 0 {
742        result.push_str(&format!(
743            "\n({files_withheld} sources withheld under policy)"
744        ));
745    }
746
747    if files_skipped_size > 0 {
748        result.push_str(&format!(
749            "\n({files_skipped_size} files >512KB skipped: {})",
750            name_skipped(&skipped_size_files, files_skipped_size)
751        ));
752    }
753    if files_skipped_encoding > 0 {
754        result.push_str(&format!(
755            "\n({files_skipped_encoding} files skipped: binary/encoding)"
756        ));
757    }
758    if let Some(note) = format_boundary_skips(&skipped_boundary_files, true) {
759        result.push_str(&note);
760    }
761    if files_skipped_special > 0 {
762        result.push_str(&format!(
763            "\n({files_skipped_special} special files skipped: not regular files)"
764        ));
765    }
766    if deadline_hit {
767        result.push_str(&format!(
768            "\n(search stopped after the {}s budget — {files_covered} files scanned; \
769             refine the pattern or scope with path= for full coverage)",
770            search_deadline().map_or(0, |d| d.as_secs())
771        ));
772    }
773    if budget_hit {
774        result.push('\n');
775        result.push_str(budget_note(files_covered).trim_start());
776    }
777    // #1625: the cap stopping the scan is the same class of event as the
778    // deadline above, and it was silent. A caller auditing a codebase read
779    // "10 matches in 1 files (scanned 1)" as an exhaustive answer when the
780    // scan had in fact stopped after the first file of 32.
781    if cap_hit {
782        result.push_str(&format!(
783            "\n(stopped at max_results={max_results}: {files_covered} of {files_in_scope} \
784             files scanned; counts are a floor, not a total)"
785        ));
786    }
787
788    if let Some(delta) = lean_ctx::core::search_delta::compute_delta(pattern, &matches) {
789        return SearchOutcome::from_observed(delta, raw_tokens_accum)
790            .withholding(skipped_boundary_files.len());
791    }
792
793    if symbol_map::substitution_enabled() {
794        let exts = extract_extensions(include);
795        let ext_refs: Vec<&str> = exts.iter().map(String::as_str).collect();
796        let mut sym = SymbolMap::new();
797        let idents = symbol_map::extract_identifiers(&result, &ext_refs);
798        for ident in &idents {
799            sym.register(ident);
800        }
801        if sym.len() >= 3 {
802            let sym_table = sym.format_table();
803            let compressed = sym.apply(&result);
804            let original_tok = count_tokens(&result);
805            let compressed_tok = count_tokens(&compressed) + count_tokens(&sym_table);
806            let net_saving = original_tok.saturating_sub(compressed_tok);
807            if original_tok > 0 && net_saving * 100 / original_tok >= 5 {
808                result = format!("{compressed}{sym_table}");
809            }
810        }
811    }
812
813    SearchOutcome::from_observed(result, raw_tokens_accum).withholding(skipped_boundary_files.len())
814}
815
816/// `a.log, b.log, +3 more` — the size-skipped files by name (#1917), so a
817/// skipped target is never mistaken for a searched one.
818fn name_skipped(named: &[String], total: u32) -> String {
819    let mut out = named.join(", ");
820    let rest = (total as usize).saturating_sub(named.len());
821    if rest > 0 {
822        out.push_str(&format!(", +{rest} more"));
823    }
824    out
825}
826
827/// Render boundary-skip audit note (#1481): paths + reason codes, capped at 10.
828fn format_boundary_skips(skipped: &[(String, &'static str)], newline: bool) -> Option<String> {
829    if skipped.is_empty() {
830        return None;
831    }
832    const MAX_SHOW: usize = 10;
833    let n = skipped.len();
834    let details: Vec<String> = skipped
835        .iter()
836        .take(MAX_SHOW)
837        .map(|(p, r)| format!("{p} [{r}]"))
838        .collect();
839    let prefix = if newline { "\n(" } else { " (" };
840    let mut msg = format!(
841        "{prefix}{n} secret-like files skipped by boundary policy: {}",
842        details.join(", ")
843    );
844    if n > MAX_SHOW {
845        msg.push_str(&format!(", and {} more...", n - MAX_SHOW));
846    }
847    msg.push(')');
848    Some(msg)
849}
850
851/// Per-file map from a line number to its narrowest enclosing symbol, built
852/// once per matched file from the signature spans. Powers the `∈name@Lstart`
853/// tag on each `ctx_search` hit (grep-ast pattern): the agent sees which
854/// function/class a match lives in — and the handle to fetch it — without a
855/// follow-up read. Tree-sitter-gated by construction: the regex-fallback
856/// extractor yields single-line spans (`end == start`), which are filtered out
857/// here, so a non-tree-sitter build emits byte-identical output (#498).
858struct EnclosingIndex {
859    /// `(start_line, end_line, name)` for multi-line symbols, sorted by start.
860    spans: Vec<(usize, usize, String)>,
861}
862
863impl EnclosingIndex {
864    fn for_file(path: &Path, content: &str) -> Self {
865        let ext = path.extension().and_then(|e| e.to_str()).unwrap_or("");
866        let mut spans: Vec<(usize, usize, String)> =
867            lean_ctx::core::signatures::extract_signatures(content, ext)
868                .into_iter()
869                .filter_map(|s| match (s.start_line, s.end_line) {
870                    (Some(a), Some(b)) if b > a => Some((a, b, s.name)),
871                    _ => None,
872                })
873                .collect();
874        spans.sort_by(|x, y| x.0.cmp(&y.0).then(x.1.cmp(&y.1)));
875        Self { spans }
876    }
877
878    /// The narrowest span containing `line`, rendered as the compact
879    /// ` ∈name@Lstart` tag, or `None` when no multi-line symbol encloses it.
880    fn tag_for(&self, line: usize) -> Option<String> {
881        let mut best: Option<&(usize, usize, String)> = None;
882        for sp in &self.spans {
883            if line >= sp.0 && line <= sp.1 {
884                match best {
885                    None => best = Some(sp),
886                    Some(b) if (sp.1 - sp.0) < (b.1 - b.0) => best = Some(sp),
887                    _ => {}
888                }
889            }
890        }
891        best.map(|(start, _, name)| format!(" ∈{name}@L{start}"))
892    }
893}
894
895/// Upper bound on the number of globs a single `include` may expand to, so a
896/// pathological brace pattern (`{a,b}{c,d}{e,f}…`) can never blow up.
897const MAX_INCLUDE_GLOBS: usize = 64;
898
899/// Compile an `include` filter into one or more matchers.
900///
901/// Brace alternation (`*.{rs,ts}`) is expanded to multiple globs (`*.rs`,
902/// `*.ts`) because the `glob` crate matches `{` / `}` literally. A file is
903/// included when it matches *any* of the returned patterns. An empty vec means
904/// "no filter": `include` was `None`, or every expansion failed to parse.
905///
906/// Bare globs without a `/` (e.g. `pathjail.rs`, `*.rs`) are auto-prefixed
907/// with `**/` to match at any directory depth — matching `rg --glob` and
908/// `git grep` behaviour. Globs that already contain `/` are used as-is, so
909/// `src/**/*.rs` only matches under `src/`.
910///
911/// A comma outside braces separates globs (`*.go,*.yaml`), the list form
912/// callers reach for first (#1994). It used to compile to one glob no file
913/// could match, so the search reported a clean miss over an empty scope.
914fn compile_include(include: Option<&str>) -> Vec<Pattern> {
915    let Some(raw) = include else {
916        return Vec::new();
917    };
918    split_top_level_commas(raw)
919        .into_iter()
920        .flat_map(expand_braces)
921        .take(MAX_INCLUDE_GLOBS)
922        .filter(|g| !g.is_empty())
923        .map(|g| {
924            if g.contains('/') {
925                g
926            } else {
927                format!("**/{g}")
928            }
929        })
930        .filter_map(|g| Pattern::new(&g).ok())
931        .collect()
932}
933
934/// Split a glob list at commas outside `{…}` groups, trimming each piece.
935fn split_top_level_commas(raw: &str) -> Vec<&str> {
936    let mut pieces = Vec::new();
937    let mut depth = 0usize;
938    let mut start = 0;
939    for (i, c) in raw.char_indices() {
940        match c {
941            '{' => depth += 1,
942            '}' => depth = depth.saturating_sub(1),
943            ',' if depth == 0 => {
944                pieces.push(raw[start..i].trim());
945                start = i + 1;
946            }
947            _ => {}
948        }
949    }
950    pieces.push(raw[start..].trim());
951    pieces
952}
953
954/// Why a policy-protected search stopped early: every scanned original is
955/// charged to one byte budget per search (`LEAN_CTX_MAX_READ_BYTES`, at most the
956/// protected-content cap). The unscanned rest was not withheld by the policy.
957fn budget_note(files_covered: u32) -> String {
958    format!(
959        " (search stopped after {files_covered} files: the protected-content read budget of this \
960         search is spent; the remaining files were not scanned, not withheld — narrow with \
961         path= or include= for full coverage)"
962    )
963}
964
965/// The path include/exclude globs are matched against: relative to the search
966/// root, or the file name when the root is the file itself (#2059) — there
967/// `strip_prefix` yields the empty string, which no glob admits.
968fn filter_rel<'a>(path: &'a Path, root: &Path) -> std::borrow::Cow<'a, str> {
969    match path.strip_prefix(root) {
970        Ok(rel) if !rel.as_os_str().is_empty() => rel.to_string_lossy(),
971        _ => path
972            .file_name()
973            .map_or_else(|| path.to_string_lossy(), |name| name.to_string_lossy()),
974    }
975}
976
977/// Why a search had nothing to scan (#1994), appended to a zero-hit result so
978/// it cannot be read as "the pattern is not in the codebase".
979fn empty_scope_note(dir: &str, include: Option<&str>, exclude: Option<&str>) -> String {
980    match (include, exclude) {
981        (Some(include), _) => format!(
982            " — include=`{include}` admits no file under {dir}, so nothing was searched. \
983             Check the glob; a list is `*.{{go,yaml}}` or `*.go,*.yaml`."
984        ),
985        (None, Some(exclude)) => format!(
986            " — exclude=`{exclude}` removes every file under {dir}, so nothing was searched."
987        ),
988        (None, None) => format!(" — {dir} holds no searchable text file."),
989    }
990}
991
992/// Expand one or more `{a,b,c}` brace groups into the cartesian set of concrete
993/// globs. Patterns without braces (or with an unbalanced brace) are returned
994/// unchanged, so this is safe to call on any input.
995fn expand_braces(pattern: &str) -> Vec<String> {
996    let Some(open) = pattern.find('{') else {
997        return vec![pattern.to_string()];
998    };
999    let Some(close_rel) = pattern[open..].find('}') else {
1000        return vec![pattern.to_string()];
1001    };
1002    let close = open + close_rel;
1003    let prefix = &pattern[..open];
1004    let inner = &pattern[open + 1..close];
1005    let suffix = &pattern[close + 1..];
1006
1007    let mut out = Vec::new();
1008    for alt in inner.split(',') {
1009        let alt = alt.trim();
1010        for expanded_suffix in expand_braces(suffix) {
1011            out.push(format!("{prefix}{alt}{expanded_suffix}"));
1012            if out.len() >= MAX_INCLUDE_GLOBS {
1013                return out;
1014            }
1015        }
1016    }
1017    out
1018}
1019
1020/// Extract the file extensions referenced by an `include` glob, used by the
1021/// symbol-substitution pass (which keyword-filters per language).
1022///
1023/// Only the final path component is inspected, so dots inside directory
1024/// segments never leak in. Handles a single trailing extension (`*.rs` → `rs`)
1025/// and brace expansion (`*.{rs,ts}` → `rs`, `ts`); a glob without an extension
1026/// (`src/**/*`) yields an empty list. Unknown extensions are returned verbatim —
1027/// `symbol_map::is_keyword` simply treats them as "no keywords", so no allowlist
1028/// has to be kept in sync here.
1029fn extract_extensions(include: Option<&str>) -> Vec<String> {
1030    let Some(list) = include else {
1031        return Vec::new();
1032    };
1033    split_top_level_commas(list)
1034        .into_iter()
1035        .flat_map(extensions_of_glob)
1036        .collect()
1037}
1038
1039fn extensions_of_glob(pattern: &str) -> Vec<String> {
1040    let filename = pattern.rsplit('/').next().unwrap_or(pattern);
1041    let Some(dot) = filename.rfind('.') else {
1042        return Vec::new();
1043    };
1044    let ext_part = &filename[dot + 1..];
1045
1046    if let Some(inner) = ext_part.strip_prefix('{').and_then(|s| s.strip_suffix('}')) {
1047        return inner
1048            .split(',')
1049            .map(|e| e.trim().to_string())
1050            .filter(|e| !e.is_empty())
1051            .collect();
1052    }
1053
1054    if ext_part.is_empty() {
1055        return Vec::new();
1056    }
1057    vec![ext_part.to_string()]
1058}
1059
1060/// Extract file path from a grep match line, handling Windows drive letters (e.g. "C:").
1061fn extract_file_from_match(line: &str) -> &str {
1062    let start = if line.len() >= 2
1063        && line.as_bytes().first().is_some_and(u8::is_ascii_alphabetic)
1064        && line.as_bytes().get(1) == Some(&b':')
1065    {
1066        2
1067    } else {
1068        0
1069    };
1070    match line[start..].find(':') {
1071        Some(pos) => &line[..start + pos],
1072        None => line,
1073    }
1074}
1075
1076#[cfg(test)]
1077#[path = "tests.rs"]
1078mod tests;