返回 CodeWhale
working_set.rs
根目录 / crates / tui / src / working_set.rs
1 //! Repo-aware working set tracking and prompt context packing.
2 //!
3 //! The goal of this module is to keep a small, high-signal list of
4 //! "active" paths that the assistant should prioritize. It observes
5 //! user messages and tool calls, extracts likely paths, and produces:
6 //! - a compact working-set summary block for the system prompt
7 //! - pinned message indices that compaction should preserve
8
9 use crate::models::{ContentBlock, Message};
10 use crate::workspace_discovery::{
11 DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery, should_skip_unignored_discovery_entry,
12 };
13 use ignore::WalkBuilder;
14 use regex::Regex;
15 use serde::{Deserialize, Serialize};
16 use serde_json::Value;
17 use std::collections::{HashMap, HashSet};
18 use std::ffi::OsStr;
19 use std::fs;
20 use std::path::{Component, Path, PathBuf};
21 use std::sync::OnceLock;
22
23 /// Repo-aware resolver for `@`-mentions and file pickers.
24 ///
25 /// `cwd` is captured at construction; if the host's current directory changes
26 /// during a session, build a fresh `Workspace`. Composer discovery callers run
27 /// its bounded walkers on a background worker; [`Workspace::resolve_exact`]
28 /// deliberately performs no fuzzy tree traversal.
29 #[derive(Debug)]
30 pub struct Workspace {
31 pub root: PathBuf,
32 cwd: Option<PathBuf>,
33 #[cfg(test)]
34 file_index: OnceLock<HashMap<String, Vec<PathBuf>>>,
35 completion_walk_depth: Option<usize>,
36 /// Follow symbolic links during file discovery walks. When `true`,
37 /// symlinked directories are traversed, enabling multi-project workspaces
38 /// where project directories are symlinked into a hub directory.
39 follow_links: bool,
40 }
41
42 struct SearchContext<'a> {
43 needle: &'a str,
44 limit: usize,
45 prefix_hits: &'a mut Vec<String>,
46 substring_hits: &'a mut Vec<String>,
47 seen: &'a mut HashSet<PathBuf>,
48 cancelled: &'a dyn Fn() -> bool,
49 }
50
51 impl SearchContext<'_> {
52 fn is_full(&self) -> bool {
53 self.prefix_hits.len() + self.substring_hits.len() >= self.limit
54 }
55
56 fn should_stop(&self) -> bool {
57 self.is_full() || (self.cancelled)()
58 }
59
60 fn remember(&mut self, path: PathBuf) -> bool {
61 self.seen.insert(path)
62 }
63
64 fn push_match(&mut self, candidate: String) {
65 let lower = candidate.to_lowercase();
66 if self.needle.is_empty() || lower.starts_with(self.needle) {
67 self.prefix_hits.push(candidate);
68 } else if lower.contains(self.needle) {
69 self.substring_hits.push(candidate);
70 }
71 }
72 }
73
74 impl Workspace {
75 /// Construct a workspace anchored at `root`, capturing the process CWD as
76 /// the secondary resolution pass. Convenience entry point intended for
77 /// callers that don't already have a CWD on hand; the App routes through
78 /// [`Workspace::with_cwd`] with its own captured launch directory.
79 #[allow(dead_code)] // Keeps the surface stable for #97 (Ctrl+P picker).
80 pub fn new(root: PathBuf) -> Self {
81 Self::with_cwd(root, std::env::current_dir().ok())
82 }
83
84 /// Construct with an explicit cwd. Used by tests that need deterministic
85 /// resolution against a known directory without depending on (and
86 /// mutating) the process's real working directory.
87 pub fn with_cwd(root: PathBuf, cwd: Option<PathBuf>) -> Self {
88 Self::with_cwd_and_depth(root, cwd, DEFAULT_COMPLETIONS_WALK_DEPTH)
89 }
90
91 /// Construct with an explicit completion walk depth. A depth of `0`
92 /// disables the depth limit for users with deeply nested workspaces.
93 pub fn with_cwd_and_depth(root: PathBuf, cwd: Option<PathBuf>, walk_depth: usize) -> Self {
94 Self::with_cwd_depth_and_follow_links(root, cwd, walk_depth, false)
95 }
96
97 /// Construct with an explicit completion walk depth and symlink-following
98 /// preference. See [`Workspace::follow_links`].
99 pub fn with_cwd_depth_and_follow_links(
100 root: PathBuf,
101 cwd: Option<PathBuf>,
102 walk_depth: usize,
103 follow_links: bool,
104 ) -> Self {
105 Self {
106 root,
107 cwd,
108 #[cfg(test)]
109 file_index: OnceLock::new(),
110 completion_walk_depth: normalize_completion_walk_depth(walk_depth),
111 follow_links,
112 }
113 }
114
115 /// Two-pass resolution: workspace, then cwd, then fuzzy fallback.
116 #[cfg(test)]
117 pub fn resolve(&self, raw_path: &str) -> Result<PathBuf, PathBuf> {
118 let literal = self.resolve_exact(raw_path);
119 if literal.is_ok() {
120 return literal;
121 }
122 let path = expand_mention_home(raw_path);
123 if let Some(fuzzy) = self.fuzzy_resolve(&path) {
124 return Ok(fuzzy);
125 }
126 literal
127 }
128
129 /// Resolve only the path the user actually typed: workspace, then cwd.
130 ///
131 /// Composer send-time resolution uses this path because fuzzy fallback
132 /// builds a full basename index. Fuzzy tree discovery is instead confined
133 /// to the bounded background completion worker (#4365).
134 pub fn resolve_exact(&self, raw_path: &str) -> Result<PathBuf, PathBuf> {
135 let path = expand_mention_home(raw_path);
136 if path.is_absolute() {
137 if path.exists() {
138 return Ok(path);
139 }
140 return Err(path);
141 }
142
143 let ws_path = self.root.join(&path);
144 if ws_path.exists() {
145 return Ok(ws_path);
146 }
147
148 if let Some(cwd) = self.cwd.as_ref() {
149 let cwd_path = cwd.join(&path);
150 if cwd_path.exists() {
151 return Ok(cwd_path);
152 }
153 }
154 Err(ws_path)
155 }
156
157 #[cfg(test)]
158 fn fuzzy_resolve(&self, path: &Path) -> Option<PathBuf> {
159 let needle = path.file_name()?.to_string_lossy().to_lowercase();
160 if needle.is_empty() {
161 return None;
162 }
163
164 let index = self.file_index.get_or_init(|| self.build_file_index());
165 index.get(&needle).and_then(|paths| paths.first()).cloned()
166 }
167
168 #[cfg(test)]
169 fn build_file_index(&self) -> HashMap<String, Vec<PathBuf>> {
170 let mut index: HashMap<String, Vec<PathBuf>> = HashMap::new();
171 let mut total: usize = 0;
172 let builder =
173 discovery_walk_builder(&self.root, self.completion_walk_depth, self.follow_links);
174
175 for entry in builder.build().flatten() {
176 if total >= FILE_INDEX_MAX_ENTRIES {
177 tracing::warn!(
178 target: "working_set",
179 limit = FILE_INDEX_MAX_ENTRIES,
180 "file-index discovery hit the entry cap; truncating to keep first-turn latency bounded (#697)"
181 );
182 return index;
183 }
184 if entry
185 .file_type()
186 .is_some_and(|ft| ft.is_file() || ft.is_dir())
187 {
188 let name = entry.file_name().to_string_lossy().to_lowercase();
189 index
190 .entry(name)
191 .or_default()
192 .push(entry.path().to_path_buf());
193 total += 1;
194 }
195 }
196
197 // Also index AI-tool dot-directories with gitignore disabled.
198 for dir_name in DISCOVERY_ALWAYS_DIRS {
199 if total >= FILE_INDEX_MAX_ENTRIES {
200 break;
201 }
202 let dot_dir = self.root.join(dir_name);
203 if !dot_dir.is_dir() {
204 continue;
205 }
206 let mut dot_builder = WalkBuilder::new(&dot_dir);
207 dot_builder
208 .hidden(true)
209 .follow_links(self.follow_links)
210 .git_ignore(false)
211 .ignore(false);
212 if let Some(depth) = child_completion_walk_depth(self.completion_walk_depth) {
213 dot_builder.max_depth(Some(depth));
214 }
215 for entry in dot_builder.build().flatten() {
216 if total >= FILE_INDEX_MAX_ENTRIES {
217 break;
218 }
219 // Exclude machine-generated bulk (e.g. .deepseek/snapshots/).
220 if path_is_excluded_from_discovery(&self.root, entry.path()) {
221 continue;
222 }
223 if entry
224 .file_type()
225 .is_some_and(|ft| ft.is_file() || ft.is_dir())
226 {
227 let name = entry.file_name().to_string_lossy().to_lowercase();
228 index
229 .entry(name)
230 .or_default()
231 .push(entry.path().to_path_buf());
232 total += 1;
233 }
234 }
235 }
236
237 // Beyond the curated dot-dir whitelist above, also index any explicit
238 // hidden/ignored path the user might `@`-mention (e.g. a project's
239 // own `.generated/specs/`). `local_reference_paths` walks with
240 // gitignore disabled but still honors `.deepseekignore`.
241 for path in local_reference_paths(
242 &self.root,
243 LOCAL_REFERENCE_SCAN_LIMIT,
244 self.completion_walk_depth,
245 self.follow_links,
246 ) {
247 if total >= FILE_INDEX_MAX_ENTRIES {
248 break;
249 }
250 let Some(name) = path
251 .file_name()
252 .map(|name| name.to_string_lossy().to_lowercase())
253 else {
254 continue;
255 };
256 index.entry(name).or_default().push(path);
257 total += 1;
258 }
259 index
260 }
261
262 /// Walk the workspace (and the recorded `cwd` when it diverges) and
263 /// return relative paths whose representation matches `partial`.
264 ///
265 /// Ranking: a candidate matches when its case-insensitive display string
266 /// starts with `partial` (prefix hit) or contains it as a substring; prefix
267 /// hits sort first so `docs/de` lands `docs/deepseek_v4.pdf` ahead of any
268 /// path that merely shares those bytes.
269 ///
270 /// Display strings are workspace-relative for files under `root`, and
271 /// cwd-relative for files only under the recorded `cwd` — so what the user
272 /// Tab-completes matches what their shell would have shown them.
273 ///
274 /// Honors `.gitignore`, `.git/info/exclude`, `.ignore`, and
275 /// `.deepseekignore`. Capped at `limit` results.
276 #[must_use]
277 #[cfg(test)]
278 pub fn completions(&self, partial: &str, limit: usize) -> Vec<String> {
279 if limit == 0 {
280 return Vec::new();
281 }
282 let needle = partial.to_lowercase();
283 let mut prefix_hits: Vec<String> = Vec::new();
284 let mut substring_hits: Vec<String> = Vec::new();
285 let mut seen: HashSet<PathBuf> = HashSet::new();
286 let never_cancelled = || false;
287
288 // Walk the recorded cwd first when it diverges from the workspace
289 // root, so cwd-relative entries appear ahead of duplicates surfaced by
290 // the workspace walk.
291 {
292 let mut ctx = SearchContext {
293 needle: &needle,
294 limit,
295 prefix_hits: &mut prefix_hits,
296 substring_hits: &mut substring_hits,
297 seen: &mut seen,
298 cancelled: &never_cancelled,
299 };
300
301 let cwd_diverges = self
302 .cwd
303 .as_deref()
304 .map(|c| c != self.root.as_path())
305 .unwrap_or(false);
306 if cwd_diverges && let Some(cwd) = self.cwd.as_deref() {
307 walk_for_completions(
308 cwd,
309 cwd,
310 &mut ctx,
311 self.completion_walk_depth,
312 self.follow_links,
313 );
314 add_local_reference_completions(
315 cwd,
316 cwd,
317 &mut ctx,
318 self.completion_walk_depth,
319 self.follow_links,
320 );
321 }
322 walk_for_completions(
323 &self.root,
324 &self.root,
325 &mut ctx,
326 self.completion_walk_depth,
327 self.follow_links,
328 );
329 add_local_reference_completions(
330 &self.root,
331 &self.root,
332 &mut ctx,
333 self.completion_walk_depth,
334 self.follow_links,
335 );
336 }
337
338 prefix_hits.sort();
339 substring_hits.sort();
340 prefix_hits.extend(substring_hits);
341 prefix_hits.truncate(limit);
342 prefix_hits
343 }
344
345 /// One full completion walk with no needle: every discoverable display
346 /// string from the workspace walk plus the divergent-cwd walk (and the
347 /// always-discoverable AI dot-directories), deduped, in walk order.
348 /// Pair with [`rank_completion_candidates`] so the composer can filter
349 /// per keystroke without re-walking the filesystem (#3757).
350 ///
351 /// Needle-gated local path-reference completions are NOT included;
352 /// callers must fall back to [`Workspace::completions`] for path-like
353 /// needles (starting with `.` or containing a separator).
354 #[must_use]
355 #[cfg(test)]
356 pub fn completion_candidates(&self) -> Vec<String> {
357 let never_cancelled = || false;
358 self.completion_candidates_inner(usize::MAX, false, &never_cancelled)
359 }
360
361 /// Candidate collection used by the composer background worker. The walk
362 /// is hard-capped and checks `cancelled` between filesystem iterator
363 /// steps. A single slow iterator step may still return late, but it runs on
364 /// the discovery worker and can never park terminal input (#4365).
365 pub(crate) fn completion_discovery_candidates(
366 &self,
367 limit: usize,
368 cancelled: &dyn Fn() -> bool,
369 ) -> Vec<String> {
370 self.completion_candidates_inner(limit, true, cancelled)
371 }
372
373 fn completion_candidates_inner(
374 &self,
375 limit: usize,
376 include_local_references: bool,
377 cancelled: &dyn Fn() -> bool,
378 ) -> Vec<String> {
379 if limit == 0 || cancelled() {
380 return Vec::new();
381 }
382 let mut prefix_hits: Vec<String> = Vec::new();
383 let mut substring_hits: Vec<String> = Vec::new();
384 let mut seen: HashSet<PathBuf> = HashSet::new();
385 {
386 let mut ctx = SearchContext {
387 needle: "",
388 limit,
389 prefix_hits: &mut prefix_hits,
390 substring_hits: &mut substring_hits,
391 seen: &mut seen,
392 cancelled,
393 };
394 let cwd_diverges = self
395 .cwd
396 .as_deref()
397 .map(|c| c != self.root.as_path())
398 .unwrap_or(false);
399 if cwd_diverges && let Some(cwd) = self.cwd.as_deref() {
400 walk_for_completions(
401 cwd,
402 cwd,
403 &mut ctx,
404 self.completion_walk_depth,
405 self.follow_links,
406 );
407 if include_local_references {
408 add_all_local_reference_completions(
409 cwd,
410 cwd,
411 &mut ctx,
412 self.completion_walk_depth,
413 self.follow_links,
414 );
415 }
416 }
417 walk_for_completions(
418 &self.root,
419 &self.root,
420 &mut ctx,
421 self.completion_walk_depth,
422 self.follow_links,
423 );
424 if include_local_references {
425 add_all_local_reference_completions(
426 &self.root,
427 &self.root,
428 &mut ctx,
429 self.completion_walk_depth,
430 self.follow_links,
431 );
432 }
433 }
434 // Empty needle routes everything into prefix_hits.
435 prefix_hits
436 }
437
438 /// Deterministic directory-browser completions for `@` mentions.
439 ///
440 /// Unlike [`Workspace::completions`], this mode does not fuzzy-rank across
441 /// the full workspace. It locks onto the directory part of `partial` and
442 /// returns only that directory's immediate children in case-insensitive
443 /// alphabetical order.
444 #[must_use]
445 #[cfg(test)]
446 pub fn browser_completions(&self, partial: &str, limit: usize) -> Vec<String> {
447 if limit == 0 {
448 return Vec::new();
449 }
450 let never_cancelled = || false;
451 let mut entries =
452 self.browser_completion_candidates_inner(partial, usize::MAX, &never_cancelled);
453 entries.truncate(limit);
454 entries
455 }
456
457 /// Background-worker directory-browser collection. It stops at `limit`
458 /// before sorting, so a directory with millions of direct children cannot
459 /// grow the cache without bound.
460 pub(crate) fn browser_completion_discovery_candidates(
461 &self,
462 partial: &str,
463 limit: usize,
464 cancelled: &dyn Fn() -> bool,
465 ) -> Vec<String> {
466 self.browser_completion_candidates_inner(partial, limit, cancelled)
467 }
468
469 fn browser_completion_candidates_inner(
470 &self,
471 partial: &str,
472 limit: usize,
473 cancelled: &dyn Fn() -> bool,
474 ) -> Vec<String> {
475 if limit == 0 || cancelled() {
476 return Vec::new();
477 }
478
479 let normalized = partial.replace('\\', "/");
480 let trimmed = normalized.trim_start_matches('/');
481 let (dir_part, name_part) = match trimmed.rsplit_once('/') {
482 Some((dir, name)) => (dir.trim_end_matches('/'), name),
483 None => ("", trimmed),
484 };
485 let Some(safe_dir_part) = browser_completion_dir_part(dir_part) else {
486 return Vec::new();
487 };
488 let dir = if safe_dir_part.as_os_str().is_empty() {
489 self.root.clone()
490 } else {
491 self.root.join(&safe_dir_part)
492 };
493 if !dir.is_dir() {
494 return Vec::new();
495 }
496 let display_dir_part = safe_dir_part.to_string_lossy().replace('\\', "/");
497
498 let show_hidden = name_part.starts_with('.');
499 let needle = name_part.to_lowercase();
500 let mut entries = Vec::new();
501
502 let mut builder = WalkBuilder::new(&dir);
503 builder
504 .hidden(!show_hidden)
505 .follow_links(self.follow_links)
506 .max_depth(Some(1));
507 let _ = builder.add_custom_ignore_filename(".deepseekignore");
508
509 let mut visited = 0usize;
510 for entry in builder.build().flatten() {
511 if visited >= limit || cancelled() {
512 break;
513 }
514 visited = visited.saturating_add(1);
515 let path = entry.path();
516 if path == dir || path_is_excluded_from_discovery(&self.root, path) {
517 continue;
518 }
519 let Some(file_type) = entry.file_type() else {
520 continue;
521 };
522 if !file_type.is_file() && !file_type.is_dir() {
523 continue;
524 }
525 let name = entry.file_name().to_string_lossy();
526 if !needle.is_empty() && !name.to_lowercase().starts_with(&needle) {
527 continue;
528 }
529 let mut candidate = if display_dir_part.is_empty() {
530 name.to_string()
531 } else {
532 format!("{display_dir_part}/{name}")
533 };
534 if file_type.is_dir() {
535 candidate.push('/');
536 }
537 entries.push(candidate);
538 }
539
540 entries.sort_by_key(|entry| entry.to_lowercase());
541 entries
542 }
543 }
544
545 fn browser_completion_dir_part(dir_part: &str) -> Option<PathBuf> {
546 let mut safe = PathBuf::new();
547 for component in Path::new(dir_part).components() {
548 match component {
549 Component::CurDir => {}
550 Component::Normal(part) => safe.push(part),
551 Component::Prefix(_) | Component::RootDir | Component::ParentDir => return None,
552 }
553 }
554 Some(safe)
555 }
556
557 /// Default directory depth walked when surfacing file-mention completions.
558 /// Set high enough that conventionally nested source trees (Java/.NET/web
559 /// projects routinely reach 7-9 levels) stay reachable, while a `0` override
560 /// removes the limit entirely. Keeps Tab snappy in deep monorepos via the
561 /// `.gitignore`-aware walk and per-keypress candidate caps (#2488).
562 pub const DEFAULT_COMPLETIONS_WALK_DEPTH: usize = 10;
563
564 fn normalize_completion_walk_depth(depth: usize) -> Option<usize> {
565 if depth == 0 { None } else { Some(depth) }
566 }
567
568 #[cfg(test)]
569 fn child_completion_walk_depth(depth: Option<usize>) -> Option<usize> {
570 depth.map(|depth| depth.saturating_sub(1))
571 }
572
573 /// Hard cap on the number of `(file or directory)` entries indexed by
574 /// [`Workspace::build_file_index`]. The fuzzy-resolve index is a
575 /// convenience for [`Workspace::fuzzy_resolve`]; missing entries fall
576 /// back to literal-path resolution. Capping here keeps the first
577 /// `fuzzy_resolve` call bounded on huge workspaces (#697 reported a
578 /// ~10s hang on the first turn). For typical projects 50K is well
579 /// above the actual entry count and the cap is a no-op.
580 #[cfg(test)]
581 const FILE_INDEX_MAX_ENTRIES: usize = 50_000;
582
583 /// Configure a `WalkBuilder` for workspace discovery: hidden files,
584 /// depth-limited, custom `.deepseekignore` honored, and gitignore overrides
585 /// for AI-tool dot-directories so `@`-completion finds them even when
586 /// they're gitignored. Symlink following is controlled by `follow_links`.
587 fn discovery_walk_builder(
588 root: &Path,
589 max_depth: Option<usize>,
590 follow_links: bool,
591 ) -> WalkBuilder {
592 let mut builder = WalkBuilder::new(root);
593 builder.hidden(true).follow_links(follow_links);
594 if let Some(depth) = max_depth {
595 builder.max_depth(Some(depth));
596 }
597 let _ = builder.add_custom_ignore_filename(".deepseekignore");
598 builder
599 }
600
601 /// Walk the AI-tool dot-directories (`.deepseek/`, `.cursor/`, `.claude/`,
602 /// `.agents/`) with gitignore disabled so their contents are discoverable
603 /// even when the project's `.gitignore` / `.ignore` excludes them.
604 fn walk_always_discoverable_dirs(
605 walk_root: &Path,
606 display_root: &Path,
607 ctx: &mut SearchContext<'_>,
608 max_depth: Option<usize>,
609 follow_links: bool,
610 ) {
611 for dir_name in DISCOVERY_ALWAYS_DIRS {
612 if ctx.should_stop() {
613 break;
614 }
615 let dot_dir = walk_root.join(dir_name);
616 if !dot_dir.is_dir() {
617 continue;
618 }
619 let mut builder = WalkBuilder::new(&dot_dir);
620 builder
621 .hidden(true)
622 .follow_links(follow_links)
623 .git_ignore(false)
624 .ignore(false);
625 if let Some(depth) = max_depth {
626 builder.max_depth(Some(depth.saturating_sub(1)));
627 }
628 for entry in builder.build().flatten() {
629 if ctx.should_stop() {
630 break;
631 }
632 let path = entry.path();
633 // Exclude machine-generated bulk (e.g. .deepseek/snapshots/)
634 // even though gitignore is disabled for this walk.
635 if path_is_excluded_from_discovery(walk_root, path) {
636 continue;
637 }
638 let Ok(rel) = path.strip_prefix(display_root) else {
639 continue;
640 };
641 let rel_str = rel.to_string_lossy().replace('\\', "/");
642 if rel_str.is_empty() {
643 continue;
644 }
645 let abs = path.to_path_buf();
646 if !ctx.remember(abs) {
647 continue;
648 }
649 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
650 let candidate = if is_dir {
651 format!("{rel_str}/")
652 } else {
653 rel_str.clone()
654 };
655 ctx.push_match(candidate);
656 }
657 }
658 }
659
660 fn walk_for_completions(
661 walk_root: &Path,
662 display_root: &Path,
663 ctx: &mut SearchContext<'_>,
664 max_depth: Option<usize>,
665 follow_links: bool,
666 ) {
667 let builder = discovery_walk_builder(walk_root, max_depth, follow_links);
668
669 for entry in builder.build().flatten() {
670 if ctx.should_stop() {
671 break;
672 }
673 let path = entry.path();
674 let Ok(rel) = path.strip_prefix(display_root) else {
675 continue;
676 };
677 let rel_str = rel.to_string_lossy().replace('\\', "/");
678 if rel_str.is_empty() {
679 continue;
680 }
681 // Dedup across the (cwd, workspace) double-walk by absolute path; we
682 // want the cwd-relative display when both walks see the same file.
683 let abs = path.to_path_buf();
684 if !ctx.remember(abs) {
685 continue;
686 }
687 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
688 let candidate = if is_dir {
689 format!("{rel_str}/")
690 } else {
691 rel_str.clone()
692 };
693 ctx.push_match(candidate);
694 }
695
696 // Also walk the AI-tool dot-directories with gitignore disabled so
697 // `.deepseek/`, `.cursor/`, etc. are always discoverable.
698 walk_always_discoverable_dirs(walk_root, display_root, ctx, max_depth, follow_links);
699 }
700
701 const LOCAL_REFERENCE_SCAN_LIMIT: usize = 4096;
702
703 #[cfg(test)]
704 fn add_local_reference_completions(
705 root: &Path,
706 display_root: &Path,
707 ctx: &mut SearchContext<'_>,
708 max_depth: Option<usize>,
709 follow_links: bool,
710 ) {
711 if !should_try_local_reference_completion(ctx.needle) {
712 return;
713 }
714
715 for path in local_reference_paths(root, LOCAL_REFERENCE_SCAN_LIMIT, max_depth, follow_links) {
716 if ctx.should_stop() {
717 break;
718 }
719 let Ok(rel) = path.strip_prefix(display_root) else {
720 continue;
721 };
722 let rel_str = rel.to_string_lossy().replace('\\', "/");
723 if rel_str.is_empty() || !ctx.remember(path.clone()) {
724 continue;
725 }
726 ctx.push_match(rel_str);
727 }
728 }
729
730 /// Add hidden/ignored candidates to the background cache even when the
731 /// current needle is empty. The old UI path could defer this walk until a
732 /// path-like query existed; the background cache must be self-contained so no
733 /// later keystroke falls back to synchronous discovery.
734 fn add_all_local_reference_completions(
735 root: &Path,
736 display_root: &Path,
737 ctx: &mut SearchContext<'_>,
738 max_depth: Option<usize>,
739 follow_links: bool,
740 ) {
741 if ctx.should_stop() {
742 return;
743 }
744 let paths = local_reference_paths_with_cancel(
745 root,
746 LOCAL_REFERENCE_SCAN_LIMIT,
747 max_depth,
748 follow_links,
749 ctx.cancelled,
750 );
751 for path in paths {
752 if ctx.should_stop() {
753 break;
754 }
755 let Ok(rel) = path.strip_prefix(display_root) else {
756 continue;
757 };
758 let rel_str = rel.to_string_lossy().replace('\\', "/");
759 if rel_str.is_empty() || !ctx.remember(path.clone()) {
760 continue;
761 }
762 ctx.push_match(rel_str);
763 }
764 }
765
766 /// Rank pre-collected completion candidates for `partial` the same way
767 /// `Workspace::completions` ranks live walk hits: case-insensitive prefix
768 /// matches first, then substring matches, each bucket alphabetical, truncated
769 /// to `limit` (#3757).
770 #[must_use]
771 pub fn rank_completion_candidates(
772 candidates: &[String],
773 partial: &str,
774 limit: usize,
775 ) -> Vec<String> {
776 if limit == 0 {
777 return Vec::new();
778 }
779 let needle = partial.to_lowercase();
780 let mut prefix_hits: Vec<String> = Vec::new();
781 let mut substring_hits: Vec<String> = Vec::new();
782 for candidate in candidates {
783 let lower = candidate.to_lowercase();
784 if needle.is_empty() || lower.starts_with(&needle) {
785 prefix_hits.push(candidate.clone());
786 } else if lower.contains(&needle) {
787 substring_hits.push(candidate.clone());
788 }
789 }
790 prefix_hits.sort();
791 substring_hits.sort();
792 prefix_hits.extend(substring_hits);
793 prefix_hits.truncate(limit);
794 prefix_hits
795 }
796
797 #[cfg(test)]
798 fn should_try_local_reference_completion(needle: &str) -> bool {
799 if needle.is_empty() {
800 return false;
801 }
802 // A bare separator or dot isn't an actionable path yet. Without this
803 // guard, a single `@/` keystroke triggers a `LOCAL_REFERENCE_SCAN_LIMIT`
804 // (4096-path) walk on the UI thread for #1921 — on WSL2 with a
805 // `/mnt/c/...` workspace each entry crosses Windows-host I/O and the
806 // composer appears frozen for seconds to minutes.
807 if matches!(needle, "/" | "\\" | "." | "..") {
808 return false;
809 }
810 needle.starts_with('.') || needle.contains('/') || needle.contains('\\')
811 }
812
813 #[cfg(test)]
814 fn local_reference_paths(
815 root: &Path,
816 limit: usize,
817 max_depth: Option<usize>,
818 follow_links: bool,
819 ) -> Vec<PathBuf> {
820 let never_cancelled = || false;
821 local_reference_paths_with_cancel(root, limit, max_depth, follow_links, &never_cancelled)
822 }
823
824 fn local_reference_paths_with_cancel(
825 root: &Path,
826 limit: usize,
827 max_depth: Option<usize>,
828 follow_links: bool,
829 cancelled: &dyn Fn() -> bool,
830 ) -> Vec<PathBuf> {
831 let mut out = Vec::new();
832 let mut builder = WalkBuilder::new(root);
833 builder
834 .hidden(false)
835 .follow_links(follow_links)
836 .git_ignore(false)
837 .git_global(false)
838 .git_exclude(false);
839 if let Some(depth) = max_depth {
840 builder.max_depth(Some(depth));
841 }
842 let _ = builder.add_custom_ignore_filename(".deepseekignore");
843 let root_for_filter = root.to_path_buf();
844 builder.filter_entry(move |entry| {
845 !should_skip_unignored_discovery_entry(&root_for_filter, entry.path())
846 });
847
848 for entry in builder.build().flatten() {
849 if out.len() >= limit || cancelled() {
850 break;
851 }
852 let path = entry.path();
853 if path == root {
854 continue;
855 }
856 if entry
857 .file_type()
858 .is_some_and(|ft| ft.is_file() || ft.is_dir())
859 {
860 out.push(path.to_path_buf());
861 }
862 }
863 out
864 }
865
866 impl Clone for Workspace {
867 fn clone(&self) -> Self {
868 // Don't carry the cached file_index — clones get a fresh OnceLock so
869 // they don't pin a stale snapshot of the previous owner's tree.
870 Self {
871 root: self.root.clone(),
872 cwd: self.cwd.clone(),
873 #[cfg(test)]
874 file_index: OnceLock::new(),
875 completion_walk_depth: self.completion_walk_depth,
876 follow_links: self.follow_links,
877 }
878 }
879 }
880
881 fn expand_mention_home(path: &str) -> PathBuf {
882 if path == "~"
883 && let Some(home) = std::env::var_os("HOME")
884 {
885 return PathBuf::from(home);
886 }
887 if let Some(rest) = path.strip_prefix("~/")
888 && let Some(home) = std::env::var_os("HOME")
889 {
890 return PathBuf::from(home).join(rest);
891 }
892 PathBuf::from(path)
893 }
894
895 /// Truncate `s` to at most `max_bytes`, snapping down to a UTF-8 char
896 /// boundary so the result is always valid. Returns the slice and whether any
897 /// truncation happened.
898 fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> (&str, bool) {
899 if s.len() <= max_bytes {
900 return (s, false);
901 }
902 let mut end = max_bytes;
903 while end > 0 && !s.is_char_boundary(end) {
904 end -= 1;
905 }
906 (&s[..end], true)
907 }
908
909 /// Configuration for working-set tracking.
910 #[derive(Debug, Clone, Serialize, Deserialize)]
911 pub struct WorkingSetConfig {
912 /// Maximum number of entries to keep.
913 pub max_entries: usize,
914 /// Maximum number of paths to pin during compaction.
915 pub max_pinned_paths: usize,
916 /// Maximum characters to scan per text block when pinning messages.
917 pub max_scan_chars: usize,
918 /// Maximum entries to show in the system prompt block.
919 pub max_prompt_entries: usize,
920 /// Cache-maximal context mode (#528): when enabled, the working-set block
921 /// materializes the full current contents of the top active files into the
922 /// system prompt (deterministic order, size-bounded) instead of only a
923 /// path list. The contents stay byte-stable while the files are unchanged,
924 /// so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses
925 /// from that file's block onward. Off by default — existing behavior is the
926 /// path list only.
927 #[serde(default)]
928 pub cache_maximal: bool,
929 /// Per-file byte cap for materialized contents in cache-maximal mode.
930 #[serde(default = "default_max_resident_file_bytes")]
931 pub max_resident_file_bytes: usize,
932 /// Total byte cap across all materialized files in cache-maximal mode.
933 #[serde(default = "default_max_total_resident_bytes")]
934 pub max_total_resident_bytes: usize,
935 }
936
937 fn default_max_resident_file_bytes() -> usize {
938 24_000
939 }
940
941 fn default_max_total_resident_bytes() -> usize {
942 96_000
943 }
944
945 impl Default for WorkingSetConfig {
946 fn default() -> Self {
947 Self {
948 max_entries: 16,
949 max_pinned_paths: 8,
950 max_scan_chars: 2_000,
951 max_prompt_entries: 8,
952 cache_maximal: false,
953 max_resident_file_bytes: default_max_resident_file_bytes(),
954 max_total_resident_bytes: default_max_total_resident_bytes(),
955 }
956 }
957 }
958
959 /// The source that most recently updated an entry.
960 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
961 pub enum WorkingSetSource {
962 UserMessage,
963 ToolInput,
964 ToolOutput,
965 Rebuild,
966 }
967
968 /// A single working-set entry.
969 #[derive(Debug, Clone, Serialize, Deserialize)]
970 pub struct WorkingSetEntry {
971 /// Workspace-relative path string.
972 pub path: String,
973 /// Whether the path is a directory (best-effort).
974 pub is_dir: bool,
975 /// Whether the path exists on disk (best-effort).
976 pub exists: bool,
977 /// Number of times this path was observed.
978 pub touches: u32,
979 /// The last observed turn index.
980 pub last_turn: u64,
981 /// The last update source.
982 pub last_source: WorkingSetSource,
983 }
984
985 impl WorkingSetEntry {
986 fn new(path: String, exists: bool, is_dir: bool, turn: u64, source: WorkingSetSource) -> Self {
987 Self {
988 path,
989 is_dir,
990 exists,
991 touches: 1,
992 last_turn: turn,
993 last_source: source,
994 }
995 }
996 }
997
998 /// Repo-aware working-set state.
999 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1000 pub struct WorkingSet {
1001 /// Tracking configuration.
1002 pub config: WorkingSetConfig,
1003 /// Monotonic turn counter (increments on user messages).
1004 pub turn: u64,
1005 /// Path entries keyed by workspace-relative path.
1006 pub entries: HashMap<String, WorkingSetEntry>,
1007 }
1008
1009 impl WorkingSet {
1010 /// Advance to the next turn.
1011 pub fn next_turn(&mut self) {
1012 self.turn = self.turn.saturating_add(1);
1013 }
1014
1015 /// Observe a user message and update the working set.
1016 pub fn observe_user_message(&mut self, text: &str, workspace: &Path) {
1017 self.next_turn();
1018 let paths = extract_paths_from_text(text);
1019 self.record_candidates(paths, workspace, WorkingSetSource::UserMessage);
1020 }
1021
1022 /// Observe a tool call (input and optional output).
1023 pub fn observe_tool_call(
1024 &mut self,
1025 tool_name: &str,
1026 input: &Value,
1027 output: Option<&str>,
1028 workspace: &Path,
1029 ) {
1030 let input_candidates = extract_paths_from_value(input, Some(tool_name));
1031 self.record_candidates(input_candidates, workspace, WorkingSetSource::ToolInput);
1032
1033 if let Some(text) = output {
1034 let output_candidates = extract_paths_from_text(text);
1035 self.record_candidates(output_candidates, workspace, WorkingSetSource::ToolOutput);
1036 }
1037 }
1038
1039 /// Rebuild the working set from existing messages (best effort).
1040 ///
1041 /// This is used when syncing a resumed session.
1042 pub fn rebuild_from_messages(&mut self, messages: &[Message], workspace: &Path) {
1043 self.entries.clear();
1044 self.turn = 0;
1045
1046 for message in messages {
1047 if message.role == "user" {
1048 self.next_turn();
1049 }
1050 let candidates = extract_paths_from_message(message);
1051 if candidates.is_empty() {
1052 continue;
1053 }
1054 self.record_candidates(candidates, workspace, WorkingSetSource::Rebuild);
1055 }
1056 }
1057
1058 /// Render a compact working-set block for the system prompt.
1059 ///
1060 /// Byte-stable across `next_turn()` calls when no new paths are observed
1061 /// (#280): the rendered lines drop the turn-relative `touches` and
1062 /// `last seen N turn(s) ago` fields, and the order is taken from
1063 /// `sorted_for_prompt` (turn-agnostic) instead of `sorted_entries`.
1064 /// The block lands in the system prompt before the historical
1065 /// conversation; any byte that drifts here cache-misses everything that
1066 /// follows in DeepSeek's KV prefix cache.
1067 pub fn summary_block(&self, workspace: &Path) -> Option<String> {
1068 // Only stat-verified paths reach the model. Prose observation happily
1069 // records tokens that merely look like paths ("120x40",
1070 // "Hmbown/CodeWhale"), and a fabricated Active-paths line teaches the
1071 // model false workspace facts it then spends turns disproving.
1072 // Re-statting at render time also drops files deleted mid-session.
1073 // Bytes only change when the filesystem genuinely changed — the same
1074 // exception the #280 stability contract already makes for newly
1075 // observed paths.
1076 let prompt_entries: Vec<(&WorkingSetEntry, bool)> = self
1077 .sorted_for_prompt()
1078 .into_iter()
1079 .filter_map(|entry| {
1080 let metadata = fs::metadata(workspace.join(&entry.path)).ok()?;
1081 Some((entry, metadata.is_dir()))
1082 })
1083 .take(self.config.max_prompt_entries)
1084 .collect();
1085
1086 let repo_summary = summarize_repo_root(workspace);
1087
1088 if repo_summary.is_none() && prompt_entries.is_empty() {
1089 return None;
1090 }
1091
1092 let mut lines: Vec<String> = Vec::new();
1093 lines.push("## Repo Working Set".to_string());
1094
1095 if let Some(summary) = repo_summary {
1096 lines.push(summary);
1097 }
1098
1099 if !prompt_entries.is_empty() {
1100 lines.push("Active paths (prioritize these):".to_string());
1101 for (entry, is_dir) in &prompt_entries {
1102 let kind = if *is_dir { "dir" } else { "file" };
1103 lines.push(format!("- {} ({kind})", entry.path));
1104 }
1105 }
1106
1107 lines.push(
1108 "When in doubt, use tools to verify and keep changes focused on the working set."
1109 .to_string(),
1110 );
1111
1112 // Cache-maximal mode (#528): append the full current contents of the
1113 // top active files so the model reads live source each turn instead of
1114 // re-fetching it with tools. Kept after the path list and bounded by
1115 // per-file and total byte caps; order follows `sorted_for_prompt` so
1116 // the block is byte-stable while the files are unchanged.
1117 if self.cache_maximal_enabled() && !prompt_entries.is_empty() {
1118 let content_entries: Vec<&WorkingSetEntry> =
1119 prompt_entries.iter().map(|(entry, _)| *entry).collect();
1120 self.append_resident_file_contents(&mut lines, workspace, &content_entries);
1121 }
1122
1123 Some(lines.join("\n"))
1124 }
1125
1126 /// Whether cache-maximal context mode is active: explicit config, or the
1127 /// `CODEWHALE_CACHE_MAXIMAL` env toggle (`1`/`true`/`on`/`yes`). The env
1128 /// value is constant for the process, so the rendered block stays
1129 /// byte-stable turn-over-turn.
1130 fn cache_maximal_enabled(&self) -> bool {
1131 if self.config.cache_maximal {
1132 return true;
1133 }
1134 match std::env::var("CODEWHALE_CACHE_MAXIMAL") {
1135 Ok(v) => matches!(
1136 v.trim().to_ascii_lowercase().as_str(),
1137 "1" | "true" | "on" | "yes"
1138 ),
1139 Err(_) => false,
1140 }
1141 }
1142
1143 /// Render `### Active file contents` blocks for the resident files, honoring
1144 /// the per-file and total byte caps. Unreadable or non-UTF-8 files are noted
1145 /// rather than skipped silently so the omission is visible to the model.
1146 fn append_resident_file_contents(
1147 &self,
1148 lines: &mut Vec<String>,
1149 workspace: &Path,
1150 prompt_entries: &[&WorkingSetEntry],
1151 ) {
1152 let mut header_pushed = false;
1153 let mut total_bytes: usize = 0;
1154 let mut omitted: usize = 0;
1155
1156 for entry in prompt_entries {
1157 if entry.is_dir || !entry.exists {
1158 continue;
1159 }
1160 if total_bytes >= self.config.max_total_resident_bytes {
1161 omitted += 1;
1162 continue;
1163 }
1164
1165 let abs = workspace.join(&entry.path);
1166 let body = match std::fs::read_to_string(&abs) {
1167 Ok(text) => text,
1168 Err(_) => {
1169 if !header_pushed {
1170 lines.push("### Active file contents (cache-resident)".to_string());
1171 header_pushed = true;
1172 }
1173 lines.push(format!(
1174 "<!-- file: {} (unreadable, skipped) -->",
1175 entry.path
1176 ));
1177 continue;
1178 }
1179 };
1180
1181 if !header_pushed {
1182 lines.push("### Active file contents (cache-resident)".to_string());
1183 header_pushed = true;
1184 }
1185
1186 let remaining_total = self
1187 .config
1188 .max_total_resident_bytes
1189 .saturating_sub(total_bytes);
1190 let cap = self.config.max_resident_file_bytes.min(remaining_total);
1191 let (shown, truncated) = truncate_on_char_boundary(&body, cap);
1192 total_bytes += shown.len();
1193
1194 lines.push(format!("<!-- file: {} -->", entry.path));
1195 lines.push("```".to_string());
1196 lines.push(shown.to_string());
1197 if truncated {
1198 lines.push(format!(
1199 "<!-- ...{} more bytes truncated for prompt budget -->",
1200 body.len().saturating_sub(shown.len())
1201 ));
1202 }
1203 lines.push("```".to_string());
1204 }
1205
1206 if omitted > 0 {
1207 lines.push(format!(
1208 "<!-- {omitted} additional active file(s) omitted from the cache-resident budget -->"
1209 ));
1210 }
1211 }
1212
1213 /// Return the most relevant paths in score order.
1214 pub fn top_paths(&self, limit: usize) -> Vec<String> {
1215 self.sorted_entries()
1216 .into_iter()
1217 .take(limit)
1218 .map(|entry| entry.path.clone())
1219 .collect()
1220 }
1221
1222 /// Identify message indices that should be pinned during compaction.
1223 pub fn pinned_message_indices(&self, messages: &[Message], workspace: &Path) -> Vec<usize> {
1224 if messages.is_empty() || self.entries.is_empty() {
1225 return Vec::new();
1226 }
1227
1228 let pinned_paths: Vec<&WorkingSetEntry> = self
1229 .sorted_entries()
1230 .into_iter()
1231 .take(self.config.max_pinned_paths)
1232 .collect();
1233 if pinned_paths.is_empty() {
1234 return Vec::new();
1235 }
1236
1237 let needles = build_search_needles(&pinned_paths, workspace);
1238 if needles.is_empty() {
1239 return Vec::new();
1240 }
1241
1242 let mut pinned: Vec<usize> = Vec::new();
1243 for (idx, message) in messages.iter().enumerate() {
1244 if message_mentions_any_path(message, &needles, self.config.max_scan_chars) {
1245 pinned.push(idx);
1246 }
1247 }
1248 pinned
1249 }
1250
1251 fn record_candidates(
1252 &mut self,
1253 candidates: Vec<String>,
1254 workspace: &Path,
1255 source: WorkingSetSource,
1256 ) {
1257 if candidates.is_empty() {
1258 return;
1259 }
1260
1261 let workspace_canon = workspace.canonicalize().ok();
1262
1263 for raw in candidates {
1264 let Some(normalized) = normalize_candidate(&raw) else {
1265 continue;
1266 };
1267 let Some((rel, exists, is_dir)) =
1268 relativize_candidate(&normalized, workspace, workspace_canon.as_deref())
1269 else {
1270 continue;
1271 };
1272 self.record_path(rel, exists, is_dir, source);
1273 }
1274
1275 self.prune();
1276 }
1277
1278 fn record_path(&mut self, rel: String, exists: bool, is_dir: bool, source: WorkingSetSource) {
1279 match self.entries.get_mut(&rel) {
1280 Some(entry) => {
1281 entry.exists |= exists;
1282 entry.is_dir |= is_dir;
1283 entry.touches = entry.touches.saturating_add(1);
1284 entry.last_turn = self.turn;
1285 entry.last_source = source;
1286 }
1287 None => {
1288 let entry = WorkingSetEntry::new(rel.clone(), exists, is_dir, self.turn, source);
1289 let _ = self.entries.insert(rel, entry);
1290 }
1291 }
1292 }
1293
1294 fn prune(&mut self) {
1295 let max_entries = self.config.max_entries;
1296 if self.entries.len() <= max_entries {
1297 return;
1298 }
1299
1300 // Rank by score ascending and drop the lowest until within bounds.
1301 let mut ranked: Vec<(String, i64)> = self
1302 .entries
1303 .values()
1304 .map(|entry| (entry.path.clone(), score_entry(entry, self.turn)))
1305 .collect();
1306 ranked.sort_by_key(|a| a.1);
1307
1308 let to_remove = self.entries.len().saturating_sub(max_entries);
1309 for (path, _) in ranked.into_iter().take(to_remove) {
1310 let _ = self.entries.remove(&path);
1311 }
1312 }
1313
1314 fn sorted_entries(&self) -> Vec<&WorkingSetEntry> {
1315 let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect();
1316 entries.sort_by(|a, b| {
1317 let sb = score_entry(b, self.turn);
1318 let sa = score_entry(a, self.turn);
1319 sb.cmp(&sa).then_with(|| a.path.cmp(&b.path))
1320 });
1321 entries
1322 }
1323
1324 /// Turn-agnostic ordering used when rendering the prompt summary block.
1325 /// `sorted_entries` mixes in a recency bonus from `self.turn`, so its
1326 /// output reorders as turns advance even when no new paths are touched —
1327 /// that movement would cross `max_prompt_entries` boundaries and bust the
1328 /// KV prefix cache (#280). Compaction pinning still uses the recency-aware
1329 /// `sorted_entries`; only the prompt-facing surface is stabilised here.
1330 fn sorted_for_prompt(&self) -> Vec<&WorkingSetEntry> {
1331 let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect();
1332 entries.sort_by(|a, b| b.touches.cmp(&a.touches).then_with(|| a.path.cmp(&b.path)));
1333 entries
1334 }
1335 }
1336
1337 fn score_entry(entry: &WorkingSetEntry, current_turn: u64) -> i64 {
1338 let age = current_turn.saturating_sub(entry.last_turn);
1339 let recency_bonus = match age {
1340 0 => 6,
1341 1 => 4,
1342 2 => 3,
1343 3..=5 => 2,
1344 6..=10 => 1,
1345 _ => 0,
1346 };
1347 i64::from(entry.touches) * 4 + recency_bonus
1348 }
1349
1350 fn normalize_candidate(raw: &str) -> Option<String> {
1351 let trimmed = raw.trim().trim_matches(|c: char| {
1352 matches!(
1353 c,
1354 '"' | '\'' | '`' | ',' | ';' | ':' | '(' | ')' | '[' | ']'
1355 )
1356 });
1357 if trimmed.is_empty() {
1358 return None;
1359 }
1360 Some(trimmed.to_string())
1361 }
1362
1363 fn relativize_candidate(
1364 candidate: &str,
1365 workspace: &Path,
1366 workspace_canon: Option<&Path>,
1367 ) -> Option<(String, bool, bool)> {
1368 let candidate_path = Path::new(candidate);
1369
1370 // Reject obvious URLs and non-paths early.
1371 if candidate.contains("://") {
1372 return None;
1373 }
1374
1375 let (rel_path, abs_path) = if candidate_path.is_absolute() {
1376 let within_workspace = workspace_canon
1377 .map(|ws| candidate_path.starts_with(ws))
1378 .unwrap_or_else(|| candidate_path.starts_with(workspace));
1379 if !within_workspace {
1380 return None;
1381 }
1382 let rel = candidate_path.strip_prefix(workspace).ok()?.to_path_buf();
1383 (rel, candidate_path.to_path_buf())
1384 } else {
1385 if starts_with_parent_dir(candidate_path) {
1386 return None;
1387 }
1388 let rel = clean_relative(candidate_path);
1389 let abs = workspace.join(&rel);
1390 (rel, abs)
1391 };
1392
1393 let metadata = fs::metadata(&abs_path).ok();
1394 let exists = metadata.is_some();
1395 let is_dir = metadata
1396 .as_ref()
1397 .map(fs::Metadata::is_dir)
1398 .unwrap_or_else(|| candidate.ends_with('/'));
1399
1400 let rel_string = path_to_string(&rel_path)?;
1401 Some((rel_string, exists, is_dir))
1402 }
1403
1404 fn starts_with_parent_dir(path: &Path) -> bool {
1405 matches!(
1406 path.components().next(),
1407 Some(std::path::Component::ParentDir)
1408 )
1409 }
1410
1411 fn clean_relative(path: &Path) -> PathBuf {
1412 use std::path::Component;
1413
1414 let mut parts: Vec<PathBuf> = Vec::new();
1415 for comp in path.components() {
1416 match comp {
1417 Component::CurDir => {}
1418 Component::ParentDir => {
1419 let _ = parts.pop();
1420 }
1421 Component::Normal(p) => parts.push(PathBuf::from(p)),
1422 Component::RootDir | Component::Prefix(_) => {}
1423 }
1424 }
1425 let mut out = PathBuf::new();
1426 for part in parts {
1427 out.push(part);
1428 }
1429 out
1430 }
1431
1432 fn path_to_string(path: &Path) -> Option<String> {
1433 path.as_os_str().to_str().map(|s| s.replace('\\', "/"))
1434 }
1435
1436 fn extract_paths_from_message(message: &Message) -> Vec<String> {
1437 let mut paths = Vec::new();
1438 for block in &message.content {
1439 match block {
1440 ContentBlock::Text { text, .. } => {
1441 paths.extend(extract_paths_from_text(text));
1442 }
1443 ContentBlock::ToolUse { input, .. } => {
1444 paths.extend(extract_paths_from_value(input, None));
1445 }
1446 ContentBlock::ToolResult { content, .. } => {
1447 paths.extend(extract_paths_from_text(content));
1448 }
1449 ContentBlock::Thinking { .. }
1450 | ContentBlock::ServerToolUse { .. }
1451 | ContentBlock::ToolSearchToolResult { .. }
1452 | ContentBlock::CodeExecutionToolResult { .. }
1453 | ContentBlock::ImageUrl { .. } => {}
1454 }
1455 }
1456 paths
1457 }
1458
1459 fn extract_paths_from_value(value: &Value, tool_hint: Option<&str>) -> Vec<String> {
1460 let mut out = Vec::new();
1461 extract_paths_from_value_inner(value, tool_hint, None, &mut out);
1462 out
1463 }
1464
1465 fn extract_paths_from_value_inner(
1466 value: &Value,
1467 tool_hint: Option<&str>,
1468 key_hint: Option<&str>,
1469 out: &mut Vec<String>,
1470 ) {
1471 match value {
1472 Value::String(s) => {
1473 let key_suggests_path = key_hint.map(key_is_path_like).unwrap_or(false);
1474 if key_suggests_path || looks_like_path(s) {
1475 out.extend(extract_paths_from_text(s));
1476 if key_suggests_path && !s.contains('/') && !s.contains('\\') {
1477 out.push(s.to_string());
1478 }
1479 } else if tool_hint == Some("exec_shell") && s.len() < 400 {
1480 out.extend(extract_paths_from_text(s));
1481 }
1482 }
1483 Value::Array(arr) => {
1484 for item in arr {
1485 extract_paths_from_value_inner(item, tool_hint, key_hint, out);
1486 }
1487 }
1488 Value::Object(map) => {
1489 for (k, v) in map {
1490 extract_paths_from_value_inner(v, tool_hint, Some(k.as_str()), out);
1491 }
1492 }
1493 Value::Null | Value::Bool(_) | Value::Number(_) => {}
1494 }
1495 }
1496
1497 fn key_is_path_like(key: &str) -> bool {
1498 let lower = key.to_ascii_lowercase();
1499 lower.contains("path")
1500 || lower.contains("file")
1501 || lower.contains("dir")
1502 || lower.contains("cwd")
1503 || lower.contains("workspace")
1504 || lower.contains("root")
1505 || lower == "target"
1506 }
1507
1508 fn looks_like_path(text: &str) -> bool {
1509 let trimmed = text.trim();
1510 if trimmed.is_empty() {
1511 return false;
1512 }
1513 if trimmed.contains('/') || trimmed.contains('\\') {
1514 return true;
1515 }
1516 match Path::new(trimmed).extension().and_then(OsStr::to_str) {
1517 Some(ext) => COMMON_EXTENSIONS.contains(&ext),
1518 None => false,
1519 }
1520 }
1521
1522 const COMMON_EXTENSIONS: &[&str] = &[
1523 "rs", "toml", "md", "txt", "json", "yaml", "yml", "ts", "tsx", "js", "jsx", "py", "go", "java",
1524 "c", "cc", "cpp", "h", "hpp", "sh", "bash", "zsh", "sql", "html", "css", "scss",
1525 ];
1526
1527 fn extract_paths_from_text(text: &str) -> Vec<String> {
1528 if text.trim().is_empty() {
1529 return Vec::new();
1530 }
1531
1532 let re = path_regex();
1533 re.find_iter(text)
1534 .map(|m| m.as_str().to_string())
1535 .filter(|s| looks_like_path(s))
1536 .collect()
1537 }
1538
1539 fn path_regex() -> &'static Regex {
1540 static RE: OnceLock<Regex> = OnceLock::new();
1541 RE.get_or_init(|| {
1542 // Path-ish tokens with separators or file extensions.
1543 Regex::new(
1544 r#"(?x)
1545 (?:
1546 (?:[A-Za-z]:\\)? # optional Windows drive
1547 (?:\./|\../|/)? # optional leading
1548 [A-Za-z0-9._-]+
1549 (?:[/\\][A-Za-z0-9._-]+)+
1550 (?:\.[A-Za-z0-9]{1,8})? # optional extension
1551 )
1552 |
1553 (?:
1554 [A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}
1555 )
1556 "#,
1557 )
1558 .expect("path regex should compile")
1559 })
1560 }
1561
1562 fn truncate_chars(text: &str, max_chars: usize) -> &str {
1563 if max_chars == 0 {
1564 return "";
1565 }
1566 match text.char_indices().nth(max_chars) {
1567 Some((idx, _)) => &text[..idx],
1568 None => text,
1569 }
1570 }
1571
1572 fn build_search_needles(entries: &[&WorkingSetEntry], workspace: &Path) -> Vec<String> {
1573 let mut needles: HashSet<String> = HashSet::new();
1574 for entry in entries {
1575 let rel = entry.path.clone();
1576 if rel.is_empty() {
1577 continue;
1578 }
1579 let abs = workspace.join(&rel);
1580 let abs_str = abs.as_os_str().to_str().map(ToOwned::to_owned);
1581
1582 let _ = needles.insert(rel.clone());
1583 if let Some(abs_str) = abs_str {
1584 let _ = needles.insert(abs_str);
1585 }
1586 }
1587 needles.into_iter().collect()
1588 }
1589
1590 fn message_mentions_any_path(message: &Message, needles: &[String], max_scan_chars: usize) -> bool {
1591 if needles.is_empty() {
1592 return false;
1593 }
1594 for block in &message.content {
1595 match block {
1596 ContentBlock::Text { text, .. } => {
1597 let snippet = truncate_chars(text, max_scan_chars);
1598 if contains_any(snippet, needles) {
1599 return true;
1600 }
1601 }
1602 ContentBlock::ToolUse { input, .. } => {
1603 if let Ok(json) = serde_json::to_string(input)
1604 && contains_any(&json, needles)
1605 {
1606 return true;
1607 }
1608 }
1609 ContentBlock::ToolResult { content, .. } => {
1610 let snippet = truncate_chars(content, max_scan_chars);
1611 if contains_any(snippet, needles) {
1612 return true;
1613 }
1614 }
1615 ContentBlock::Thinking { .. }
1616 | ContentBlock::ServerToolUse { .. }
1617 | ContentBlock::ToolSearchToolResult { .. }
1618 | ContentBlock::CodeExecutionToolResult { .. }
1619 | ContentBlock::ImageUrl { .. } => {}
1620 }
1621 }
1622 false
1623 }
1624
1625 fn contains_any(text: &str, needles: &[String]) -> bool {
1626 needles
1627 .iter()
1628 .any(|needle| !needle.is_empty() && text.contains(needle))
1629 }
1630
1631 fn summarize_repo_root(workspace: &Path) -> Option<String> {
1632 let key_files = detect_key_files(workspace);
1633 let top_dirs = list_top_level_dirs(workspace, 8);
1634
1635 if key_files.is_empty() && top_dirs.is_empty() {
1636 return None;
1637 }
1638
1639 let mut parts: Vec<String> = Vec::new();
1640 if !key_files.is_empty() {
1641 parts.push(format!("Key files: {}", key_files.join(", ")));
1642 }
1643 if !top_dirs.is_empty() {
1644 parts.push(format!("Top-level dirs: {}", top_dirs.join(", ")));
1645 }
1646 Some(parts.join("\n"))
1647 }
1648
1649 fn detect_key_files(workspace: &Path) -> Vec<String> {
1650 const CANDIDATES: &[&str] = &[
1651 "Cargo.toml",
1652 "README.md",
1653 "AGENTS.md",
1654 "CLAUDE.md",
1655 "package.json",
1656 "pyproject.toml",
1657 "go.mod",
1658 "Makefile",
1659 ];
1660
1661 CANDIDATES
1662 .iter()
1663 .filter_map(|name| {
1664 let path = workspace.join(name);
1665 if path.exists() {
1666 Some((*name).to_string())
1667 } else {
1668 None
1669 }
1670 })
1671 .collect()
1672 }
1673
1674 fn list_top_level_dirs(workspace: &Path, limit: usize) -> Vec<String> {
1675 let mut dirs = Vec::new();
1676 let entries = match fs::read_dir(workspace) {
1677 Ok(entries) => entries,
1678 Err(_) => return dirs,
1679 };
1680
1681 for entry in entries.flatten() {
1682 let file_name = entry.file_name();
1683 let Some(name) = file_name.to_str() else {
1684 continue;
1685 };
1686
1687 if name.starts_with('.') || IGNORED_ROOT_DIRS.contains(&name) {
1688 continue;
1689 }
1690
1691 if let Ok(meta) = entry.metadata()
1692 && meta.is_dir()
1693 {
1694 dirs.push(name.to_string());
1695 }
1696
1697 if dirs.len() >= limit {
1698 break;
1699 }
1700 }
1701
1702 dirs.sort();
1703 dirs
1704 }
1705
1706 const IGNORED_ROOT_DIRS: &[&str] = &["target", "node_modules", "dist", "build", ".git"];
1707
1708 #[cfg(test)]
1709 mod tests {
1710 use super::*;
1711 use tempfile::TempDir;
1712
1713 fn make_message(role: &str, text: &str) -> Message {
1714 Message {
1715 role: role.to_string(),
1716 content: vec![ContentBlock::Text {
1717 text: text.to_string(),
1718 cache_control: None,
1719 }],
1720 }
1721 }
1722
1723 #[test]
1724 fn observe_user_message_tracks_paths() {
1725 let tmp = TempDir::new().expect("tempdir");
1726 let src = tmp.path().join("src");
1727 let file = src.join("lib.rs");
1728 fs::create_dir_all(&src).expect("mkdir");
1729 fs::write(&file, "pub fn x() {}").expect("write");
1730
1731 let mut ws = WorkingSet::default();
1732 ws.observe_user_message("Please check src/lib.rs", tmp.path());
1733
1734 assert!(ws.entries.contains_key("src/lib.rs"));
1735 let entry = ws.entries.get("src/lib.rs").expect("entry");
1736 assert!(entry.exists);
1737 assert!(!entry.is_dir);
1738 }
1739
1740 #[test]
1741 fn observe_tool_call_extracts_paths_from_input() {
1742 let tmp = TempDir::new().expect("tempdir");
1743 let file = tmp.path().join("Cargo.toml");
1744 fs::write(&file, "[package]\nname = \"x\"").expect("write");
1745
1746 let mut ws = WorkingSet::default();
1747 let input = serde_json::json!({ "path": "Cargo.toml" });
1748 ws.observe_tool_call("read_file", &input, None, tmp.path());
1749
1750 assert!(ws.entries.contains_key("Cargo.toml"));
1751 }
1752
1753 #[test]
1754 fn pinned_message_indices_respects_working_set() {
1755 let tmp = TempDir::new().expect("tempdir");
1756 let src = tmp.path().join("src");
1757 fs::create_dir_all(&src).expect("mkdir");
1758 let file = src.join("main.rs");
1759 fs::write(&file, "fn main() {}").expect("write");
1760
1761 let mut ws = WorkingSet::default();
1762 ws.observe_user_message("Edit src/main.rs", tmp.path());
1763
1764 let messages = vec![
1765 make_message("user", "Unrelated text"),
1766 make_message("assistant", "I will read src/main.rs next."),
1767 make_message("user", "More unrelated text"),
1768 ];
1769
1770 let pinned = ws.pinned_message_indices(&messages, tmp.path());
1771 assert_eq!(pinned, vec![1]);
1772 }
1773
1774 #[test]
1775 fn summary_block_includes_repo_and_working_set() {
1776 let tmp = TempDir::new().expect("tempdir");
1777 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1778 let src = tmp.path().join("src");
1779 fs::create_dir_all(&src).expect("mkdir");
1780 fs::write(src.join("lib.rs"), "pub fn x() {}").expect("write");
1781
1782 let mut ws = WorkingSet::default();
1783 ws.observe_user_message("src/lib.rs", tmp.path());
1784 let block = ws.summary_block(tmp.path()).expect("block");
1785
1786 assert!(block.contains("Repo Working Set"));
1787 assert!(!block.contains("Workspace:"));
1788 assert!(block.contains("Cargo.toml"));
1789 assert!(block.contains("src"));
1790 assert!(block.contains("src/lib.rs"));
1791 }
1792
1793 /// #280 regression: `summary_block` must produce byte-identical output
1794 /// across `next_turn()` advances when no new paths are touched. Prior to
1795 /// the fix, the rendered lines interpolated `entry.touches` and
1796 /// `self.turn - entry.last_turn`, both of which drift turn-over-turn even
1797 /// when the path set is unchanged. The drift busted DeepSeek's KV prefix
1798 /// cache on every user message because the working-set block lands in the
1799 /// system prompt before the historical conversation.
1800 #[test]
1801 fn summary_block_is_byte_stable_across_next_turn_when_no_new_paths_observed() {
1802 use crate::test_support::assert_byte_identical;
1803
1804 let tmp = TempDir::new().expect("tempdir");
1805 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1806 let src = tmp.path().join("src");
1807 fs::create_dir_all(&src).expect("mkdir");
1808 fs::write(src.join("a.rs"), "a").expect("write");
1809 fs::write(src.join("b.rs"), "b").expect("write");
1810
1811 let mut ws = WorkingSet::default();
1812 ws.observe_user_message("Edit src/a.rs and src/b.rs", tmp.path());
1813
1814 let before = ws.summary_block(tmp.path()).expect("block before");
1815 ws.next_turn();
1816 let after = ws.summary_block(tmp.path()).expect("block after");
1817
1818 assert_byte_identical(
1819 "summary_block must be stable across next_turn when no new paths touched",
1820 &before,
1821 &after,
1822 );
1823 }
1824
1825 /// Companion to the byte-stability test: a fresh path *should* invalidate
1826 /// the block (the KV cache is allowed to miss when there's genuinely new
1827 /// signal), so the model still sees newly touched paths after the block
1828 /// stabilises across no-op turns.
1829 #[test]
1830 fn summary_block_changes_when_a_new_path_is_observed() {
1831 let tmp = TempDir::new().expect("tempdir");
1832 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1833 let src = tmp.path().join("src");
1834 fs::create_dir_all(&src).expect("mkdir");
1835 fs::write(src.join("a.rs"), "a").expect("write");
1836 fs::write(src.join("c.rs"), "c").expect("write");
1837
1838 let mut ws = WorkingSet::default();
1839 ws.observe_user_message("src/a.rs", tmp.path());
1840 let before = ws.summary_block(tmp.path()).expect("block before");
1841
1842 ws.observe_user_message("src/c.rs", tmp.path());
1843 let after = ws.summary_block(tmp.path()).expect("block after");
1844
1845 assert_ne!(before, after, "new path must update the rendered summary");
1846 assert!(after.contains("src/c.rs"));
1847 }
1848
1849 #[test]
1850 fn summary_block_renders_only_paths_that_stat_verify() {
1851 // Prose observation records tokens that merely look like paths
1852 // ("120x40", "Hmbown/CodeWhale"); the rendered Active-paths list must
1853 // never teach the model a workspace fact the filesystem contradicts.
1854 let tmp = TempDir::new().expect("tempdir");
1855 let src = tmp.path().join("src");
1856 fs::create_dir_all(&src).expect("mkdir");
1857 fs::write(src.join("real.rs"), "real").expect("write");
1858
1859 let mut ws = WorkingSet::default();
1860 ws.observe_user_message(
1861 "Fix src/real.rs, test at 120x40/80x24, and check Hmbown/CodeWhale",
1862 tmp.path(),
1863 );
1864
1865 let block = ws.summary_block(tmp.path()).expect("block");
1866 assert!(block.contains("- src/real.rs (file)"), "{block}");
1867 assert!(!block.contains("120x40"), "{block}");
1868 assert!(!block.contains("Hmbown/CodeWhale"), "{block}");
1869
1870 // A file deleted mid-session falls out on the next render — the same
1871 // filesystem-changed exception #280 makes for newly observed paths.
1872 fs::remove_file(src.join("real.rs")).expect("remove");
1873 let after_delete = ws.summary_block(tmp.path());
1874 assert!(
1875 after_delete
1876 .as_deref()
1877 .is_none_or(|block| !block.contains("src/real.rs")),
1878 "{after_delete:?}"
1879 );
1880 }
1881
1882 // ── Cache-maximal context mode (#528) ──
1883 // Tests drive the flag through `config.cache_maximal` directly so they
1884 // don't touch the process-wide `CODEWHALE_CACHE_MAXIMAL` env var (which
1885 // would race with parallel tests).
1886
1887 fn cache_maximal_ws() -> WorkingSet {
1888 let mut ws = WorkingSet::default();
1889 ws.config.cache_maximal = true;
1890 ws
1891 }
1892
1893 #[test]
1894 fn cache_maximal_off_keeps_path_list_only() {
1895 let tmp = TempDir::new().expect("tempdir");
1896 let src = tmp.path().join("src");
1897 fs::create_dir_all(&src).expect("mkdir");
1898 fs::write(src.join("lib.rs"), "pub fn hello() {}").expect("write");
1899
1900 let mut ws = WorkingSet::default(); // cache_maximal defaults to false
1901 ws.observe_user_message("src/lib.rs", tmp.path());
1902 let block = ws.summary_block(tmp.path()).expect("block");
1903
1904 assert!(block.contains("src/lib.rs"), "path list still present");
1905 assert!(
1906 !block.contains("Active file contents"),
1907 "no materialized contents when the flag is off"
1908 );
1909 assert!(!block.contains("pub fn hello"));
1910 }
1911
1912 #[test]
1913 fn cache_maximal_on_materializes_file_contents() {
1914 let tmp = TempDir::new().expect("tempdir");
1915 let src = tmp.path().join("src");
1916 fs::create_dir_all(&src).expect("mkdir");
1917 fs::write(src.join("lib.rs"), "pub fn hello() {}").expect("write");
1918
1919 let mut ws = cache_maximal_ws();
1920 ws.observe_user_message("src/lib.rs", tmp.path());
1921 let block = ws.summary_block(tmp.path()).expect("block");
1922
1923 assert!(block.contains("Active file contents (cache-resident)"));
1924 assert!(block.contains("<!-- file: src/lib.rs -->"));
1925 assert!(block.contains("pub fn hello() {}"));
1926 }
1927
1928 #[test]
1929 fn cache_maximal_directories_are_not_materialized() {
1930 let tmp = TempDir::new().expect("tempdir");
1931 let src = tmp.path().join("src");
1932 fs::create_dir_all(&src).expect("mkdir");
1933
1934 let mut ws = cache_maximal_ws();
1935 ws.observe_user_message("look in src/", tmp.path());
1936 let block = ws.summary_block(tmp.path()).expect("block");
1937
1938 // `src` is a dir; it appears in the path list but has no content block.
1939 assert!(!block.contains("<!-- file: src -->"));
1940 }
1941
1942 #[test]
1943 fn cache_maximal_respects_per_file_byte_cap() {
1944 let tmp = TempDir::new().expect("tempdir");
1945 let src = tmp.path().join("src");
1946 fs::create_dir_all(&src).expect("mkdir");
1947 let big = "x".repeat(10_000);
1948 fs::write(src.join("big.rs"), &big).expect("write");
1949
1950 let mut ws = cache_maximal_ws();
1951 ws.config.max_resident_file_bytes = 100;
1952 ws.config.max_total_resident_bytes = 10_000;
1953 ws.observe_user_message("src/big.rs", tmp.path());
1954 let block = ws.summary_block(tmp.path()).expect("block");
1955
1956 assert!(block.contains("truncated for prompt budget"));
1957 // The full 10k body must not be inlined.
1958 assert!(!block.contains(&big));
1959 }
1960
1961 #[test]
1962 fn cache_maximal_total_cap_omits_extra_files() {
1963 let tmp = TempDir::new().expect("tempdir");
1964 let src = tmp.path().join("src");
1965 fs::create_dir_all(&src).expect("mkdir");
1966 fs::write(src.join("a.rs"), "a".repeat(200)).expect("write");
1967 fs::write(src.join("b.rs"), "b".repeat(200)).expect("write");
1968
1969 let mut ws = cache_maximal_ws();
1970 ws.config.max_resident_file_bytes = 200;
1971 ws.config.max_total_resident_bytes = 200; // only one file fits
1972 ws.observe_user_message("Edit src/a.rs and src/b.rs", tmp.path());
1973 let block = ws.summary_block(tmp.path()).expect("block");
1974
1975 assert!(
1976 block.contains("omitted from the cache-resident budget"),
1977 "second file should be reported as omitted:\n{block}"
1978 );
1979 }
1980
1981 #[test]
1982 fn cache_maximal_is_byte_stable_when_files_unchanged() {
1983 use crate::test_support::assert_byte_identical;
1984
1985 let tmp = TempDir::new().expect("tempdir");
1986 let src = tmp.path().join("src");
1987 fs::create_dir_all(&src).expect("mkdir");
1988 fs::write(src.join("a.rs"), "fn a() {}").expect("write");
1989
1990 let mut ws = cache_maximal_ws();
1991 ws.observe_user_message("src/a.rs", tmp.path());
1992 let before = ws.summary_block(tmp.path()).expect("before");
1993 ws.next_turn();
1994 let after = ws.summary_block(tmp.path()).expect("after");
1995
1996 assert_byte_identical(
1997 "cache-maximal block must be stable while files are unchanged (KV cache hit)",
1998 &before,
1999 &after,
2000 );
2001 }
2002
2003 #[test]
2004 fn cache_maximal_changes_when_file_edited() {
2005 let tmp = TempDir::new().expect("tempdir");
2006 let src = tmp.path().join("src");
2007 fs::create_dir_all(&src).expect("mkdir");
2008 let file = src.join("a.rs");
2009 fs::write(&file, "fn a() {}").expect("write");
2010
2011 let mut ws = cache_maximal_ws();
2012 ws.observe_user_message("src/a.rs", tmp.path());
2013 let before = ws.summary_block(tmp.path()).expect("before");
2014
2015 fs::write(&file, "fn a() { todo!() }").expect("rewrite");
2016 let after = ws.summary_block(tmp.path()).expect("after");
2017
2018 assert_ne!(before, after, "editing the file must change the block");
2019 assert!(after.contains("todo!()"));
2020 }
2021
2022 #[test]
2023 fn extract_paths_from_message_picks_up_tool_results() {
2024 let msg = Message {
2025 role: "user".to_string(),
2026 content: vec![ContentBlock::ToolResult {
2027 tool_use_id: "tool_1".to_string(),
2028 content: "Changed src/compaction.rs".to_string(),
2029 is_error: None,
2030 content_blocks: None,
2031 }],
2032 };
2033
2034 let paths = extract_paths_from_message(&msg);
2035 assert!(paths.iter().any(|p| p.contains("src/compaction.rs")));
2036 }
2037
2038 #[test]
2039 fn pinning_prefers_high_signal_paths() {
2040 let tmp = TempDir::new().expect("tempdir");
2041 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
2042 fs::write(tmp.path().join("src/a.rs"), "a").expect("write");
2043 fs::write(tmp.path().join("src/b.rs"), "b").expect("write");
2044
2045 let mut ws = WorkingSet::default();
2046 ws.observe_user_message("src/a.rs", tmp.path());
2047 ws.observe_tool_call(
2048 "read_file",
2049 &serde_json::json!({ "path": "src/a.rs" }),
2050 Some("src/a.rs"),
2051 tmp.path(),
2052 );
2053 ws.observe_user_message("src/b.rs", tmp.path());
2054
2055 let a_score = score_entry(ws.entries.get("src/a.rs").expect("a"), ws.turn);
2056 let b_score = score_entry(ws.entries.get("src/b.rs").expect("b"), ws.turn);
2057 assert!(a_score >= b_score);
2058 }
2059
2060 #[test]
2061 fn estimate_tokens_is_available_for_future_budgeting() {
2062 use crate::compaction::estimate_tokens;
2063 let messages = vec![make_message("user", "src/main.rs")];
2064 assert!(estimate_tokens(&messages) > 0);
2065 }
2066
2067 #[test]
2068 fn workspace_resolve_respects_cwd_and_workspace() {
2069 let tmp = TempDir::new().unwrap();
2070
2071 let sub = tmp.path().join("sub");
2072 std::fs::create_dir_all(&sub).unwrap();
2073 let bar = sub.join("bar.txt");
2074 std::fs::write(&bar, "bar").unwrap();
2075
2076 let nested = tmp.path().join("nested/deep");
2077 std::fs::create_dir_all(&nested).unwrap();
2078 let file_md = nested.join("file.md");
2079 std::fs::write(&file_md, "md").unwrap();
2080
2081 // Construct with an explicit cwd so the test doesn't race with other
2082 // tests that mutate the real process cwd.
2083 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(sub.clone()));
2084
2085 // #101 repro #1: @bar.txt with cwd=sub MUST resolve via the cwd pass,
2086 // never to the bogus workspace path tmp/bar.txt (which doesn't exist).
2087 let res1 = ws.resolve("bar.txt").unwrap();
2088 assert_eq!(
2089 res1.canonicalize().unwrap_or(res1.clone()),
2090 bar.canonicalize().unwrap_or(bar.clone())
2091 );
2092 let wrong = tmp.path().join("bar.txt");
2093 assert_ne!(res1, wrong, "must not have routed to workspace fallback");
2094
2095 // #101 repro #2: @nested/deep/file.md falls through to workspace root.
2096 let res2 = ws.resolve("nested/deep/file.md").unwrap();
2097 assert_eq!(
2098 res2.canonicalize().unwrap_or(res2),
2099 file_md.canonicalize().unwrap_or(file_md)
2100 );
2101 }
2102
2103 /// Negative test (#101): a truly missing path returns `Err` with a path
2104 /// that callers can show to the user as a signal of failure.
2105 #[test]
2106 fn workspace_resolve_returns_err_for_truly_missing_path() {
2107 let tmp = TempDir::new().unwrap();
2108 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf()));
2109
2110 let res = ws.resolve("does/not/exist.txt");
2111 assert!(res.is_err(), "expected Err for missing path, got: {res:?}");
2112 }
2113
2114 /// `Workspace::completions` returns workspace-relative entries for files
2115 /// under the root, and cwd-relative entries when the cwd-only file lives
2116 /// outside the workspace tree. Honors `.gitignore`.
2117 #[test]
2118 fn workspace_completions_walk_surfaces_workspace_and_cwd() {
2119 let tmp = TempDir::new().unwrap();
2120 // Two trees: a workspace under `ws/` and a cwd under `cwd/` that is
2121 // NOT inside the workspace, so the two walks are disjoint and we can
2122 // assert each branch contributed.
2123 let ws_root = tmp.path().join("ws");
2124 let cwd_root = tmp.path().join("cwd");
2125 std::fs::create_dir_all(&ws_root).unwrap();
2126 std::fs::create_dir_all(&cwd_root).unwrap();
2127 std::fs::write(ws_root.join("alpha.txt"), "a").unwrap();
2128 std::fs::write(cwd_root.join("alphabeta.txt"), "b").unwrap();
2129
2130 let ws = Workspace::with_cwd(ws_root.clone(), Some(cwd_root.clone()));
2131 let entries = ws.completions("alpha", 16);
2132 assert!(
2133 entries.iter().any(|e| e == "alpha.txt"),
2134 "expected workspace entry alpha.txt; got: {entries:?}",
2135 );
2136 assert!(
2137 entries.iter().any(|e| e == "alphabeta.txt"),
2138 "expected cwd entry alphabeta.txt; got: {entries:?}",
2139 );
2140 }
2141
2142 #[test]
2143 fn workspace_completions_honor_configured_walk_depth() {
2144 let tmp = TempDir::new().unwrap();
2145 // Sits at component depth 12, past the default walk depth (10) but
2146 // within the explicit deeper walk (16) below.
2147 let deep_dir = tmp.path().join("a/b/c/d/e/f/g/h/i/j/k");
2148 std::fs::create_dir_all(&deep_dir).unwrap();
2149 std::fs::write(deep_dir.join("target.txt"), "target").unwrap();
2150
2151 let default_ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2152 let default_entries = default_ws.completions("target", 16);
2153 assert!(
2154 !default_entries
2155 .iter()
2156 .any(|entry| entry.ends_with("target.txt")),
2157 "default depth should keep very deep entries out of the hot completion path: {default_entries:?}",
2158 );
2159
2160 let deep_ws = Workspace::with_cwd_and_depth(tmp.path().to_path_buf(), None, 16);
2161 let deep_entries = deep_ws.completions("target", 16);
2162 assert!(
2163 deep_entries
2164 .iter()
2165 .any(|entry| entry.ends_with("target.txt")),
2166 "configured deeper walk should surface the nested file: {deep_entries:?}",
2167 );
2168
2169 let unlimited_ws = Workspace::with_cwd_and_depth(tmp.path().to_path_buf(), None, 0);
2170 let unlimited_entries = unlimited_ws.completions("target", 16);
2171 assert!(
2172 unlimited_entries
2173 .iter()
2174 .any(|entry| entry.ends_with("target.txt")),
2175 "depth 0 should disable the completion walk depth limit: {unlimited_entries:?}",
2176 );
2177 }
2178
2179 #[test]
2180 fn browser_completions_show_only_immediate_children() {
2181 let tmp = TempDir::new().unwrap();
2182 std::fs::create_dir_all(tmp.path().join("src/nested")).unwrap();
2183 std::fs::write(tmp.path().join("src/lib.rs"), "lib").unwrap();
2184 std::fs::write(tmp.path().join("src/nested/deep.rs"), "deep").unwrap();
2185 std::fs::write(tmp.path().join("README.md"), "readme").unwrap();
2186
2187 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2188
2189 let root_entries = ws.browser_completions("", 16);
2190 assert_eq!(root_entries, vec!["README.md", "src/"]);
2191
2192 let src_entries = ws.browser_completions("src/", 16);
2193 assert_eq!(src_entries, vec!["src/lib.rs", "src/nested/"]);
2194 assert!(
2195 !src_entries.iter().any(|entry| entry.ends_with("deep.rs")),
2196 "browser mode must not walk past immediate children: {src_entries:?}",
2197 );
2198 }
2199
2200 #[test]
2201 fn browser_completions_hide_dot_entries_until_dot_query() {
2202 let tmp = TempDir::new().unwrap();
2203 std::fs::create_dir_all(tmp.path().join(".agents")).unwrap();
2204 std::fs::write(tmp.path().join(".env"), "secret-ish fixture").unwrap();
2205 std::fs::write(tmp.path().join("app.rs"), "app").unwrap();
2206
2207 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2208
2209 let default_entries = ws.browser_completions("", 16);
2210 assert_eq!(default_entries, vec!["app.rs"]);
2211
2212 let dot_entries = ws.browser_completions(".", 16);
2213 assert_eq!(dot_entries, vec![".agents/", ".env"]);
2214 }
2215
2216 #[test]
2217 fn browser_completions_reject_path_escape_segments() {
2218 let tmp = TempDir::new().unwrap();
2219 let workspace = tmp.path().join("workspace");
2220 let sibling = tmp.path().join("outside");
2221 std::fs::create_dir_all(&workspace).unwrap();
2222 std::fs::create_dir_all(&sibling).unwrap();
2223 std::fs::write(workspace.join("inside.rs"), "inside").unwrap();
2224 std::fs::write(sibling.join("secret.rs"), "outside").unwrap();
2225
2226 let ws = Workspace::with_cwd(workspace, None);
2227
2228 assert_eq!(ws.browser_completions("", 16), vec!["inside.rs"]);
2229 assert!(
2230 ws.browser_completions("../", 16).is_empty(),
2231 "browser mode must not list workspace siblings",
2232 );
2233 assert!(
2234 ws.browser_completions("../outside", 16).is_empty(),
2235 "browser mode must not complete names from outside the workspace",
2236 );
2237 }
2238
2239 #[test]
2240 fn workspace_completions_surface_explicit_hidden_and_ignored_paths() {
2241 let tmp = TempDir::new().unwrap();
2242 std::fs::write(tmp.path().join(".gitignore"), ".deepseek/\n.generated/\n").unwrap();
2243 std::fs::write(
2244 tmp.path().join(".deepseekignore"),
2245 ".generated/specs/secrets.env\n",
2246 )
2247 .unwrap();
2248 let deepseek_commands = tmp.path().join(".deepseek").join("commands");
2249 let generated_specs = tmp.path().join(".generated").join("specs");
2250 std::fs::create_dir_all(&deepseek_commands).unwrap();
2251 std::fs::create_dir_all(&generated_specs).unwrap();
2252 std::fs::write(deepseek_commands.join("start-task.md"), "start").unwrap();
2253 std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap();
2254 std::fs::write(generated_specs.join("secrets.env"), "secret").unwrap();
2255
2256 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf()));
2257
2258 let start_entries = ws.completions(".deepseek/commands", 16);
2259 assert!(
2260 start_entries
2261 .iter()
2262 .any(|e| e == ".deepseek/commands/start-task.md"),
2263 "expected explicitly addressed hidden command file in completions: {start_entries:?}",
2264 );
2265
2266 let generated_entries = ws.completions(".generated/specs", 16);
2267 assert!(
2268 generated_entries
2269 .iter()
2270 .any(|e| e == ".generated/specs/device-layout.md"),
2271 "expected explicitly addressed ignored user folder in completions: {generated_entries:?}",
2272 );
2273 assert!(
2274 !generated_entries
2275 .iter()
2276 .any(|e| e == ".generated/specs/secrets.env"),
2277 ".deepseekignore entries must not be reintroduced by local fallback: {generated_entries:?}",
2278 );
2279 }
2280
2281 #[test]
2282 fn workspace_completions_skip_hidden_worktrees_and_build_bulk() {
2283 let tmp = TempDir::new().unwrap();
2284 let root = tmp.path();
2285 std::fs::write(root.join(".gitignore"), ".worktrees/\n.generated/\n").unwrap();
2286
2287 std::fs::create_dir_all(root.join(".worktrees/release/src")).unwrap();
2288 std::fs::write(
2289 root.join(".worktrees/release/src/worktree-only.rs"),
2290 "fn main() {}",
2291 )
2292 .unwrap();
2293 std::fs::create_dir_all(root.join(".worktrees/release/target/debug")).unwrap();
2294 std::fs::write(
2295 root.join(".worktrees/release/target/debug/generated.o"),
2296 "object",
2297 )
2298 .unwrap();
2299
2300 std::fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
2301 std::fs::write(
2302 root.join(".claude/worktrees/agent/src/agent-only.md"),
2303 "agent note",
2304 )
2305 .unwrap();
2306 std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
2307 std::fs::write(root.join(".claude/commands/keep.md"), "command").unwrap();
2308
2309 std::fs::create_dir_all(root.join(".generated/specs")).unwrap();
2310 std::fs::write(root.join(".generated/specs/device-layout.md"), "layout").unwrap();
2311
2312 let ws = Workspace::with_cwd(root.to_path_buf(), Some(root.to_path_buf()));
2313
2314 let worktree_entries = ws.completions(".worktrees", 32);
2315 assert!(
2316 worktree_entries
2317 .iter()
2318 .all(|entry| !entry.starts_with(".worktrees/")),
2319 "hidden release worktrees must stay out of completions: {worktree_entries:?}",
2320 );
2321
2322 let claude_worktree_entries = ws.completions(".claude/worktrees", 32);
2323 assert!(
2324 claude_worktree_entries
2325 .iter()
2326 .all(|entry| !entry.starts_with(".claude/worktrees/")),
2327 ".claude/worktrees must stay out of completions: {claude_worktree_entries:?}",
2328 );
2329
2330 let generated_entries = ws.completions(".generated/specs", 32);
2331 assert!(
2332 generated_entries
2333 .iter()
2334 .any(|entry| entry == ".generated/specs/device-layout.md"),
2335 "explicit user-generated hidden folders should still complete: {generated_entries:?}",
2336 );
2337
2338 let command_entries = ws.completions(".claude/commands", 32);
2339 assert!(
2340 command_entries
2341 .iter()
2342 .any(|entry| entry == ".claude/commands/keep.md"),
2343 "normal .claude command files should still complete: {command_entries:?}",
2344 );
2345
2346 assert!(
2347 ws.resolve("worktree-only.rs").is_err(),
2348 "fuzzy resolution must not index files from hidden release worktrees"
2349 );
2350 assert!(
2351 ws.resolve("agent-only.md").is_err(),
2352 "fuzzy resolution must not index files from .claude/worktrees"
2353 );
2354 assert!(ws.resolve("keep.md").is_ok());
2355 }
2356
2357 #[test]
2358 fn fuzzy_index_resolves_hidden_and_ignored_files_except_deepseekignored() {
2359 let tmp = TempDir::new().unwrap();
2360 std::fs::write(tmp.path().join(".gitignore"), ".generated/\n").unwrap();
2361 std::fs::write(
2362 tmp.path().join(".deepseekignore"),
2363 ".generated/specs/secrets.env\n",
2364 )
2365 .unwrap();
2366 let generated_specs = tmp.path().join(".generated").join("specs");
2367 std::fs::create_dir_all(&generated_specs).unwrap();
2368 std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap();
2369 std::fs::write(generated_specs.join("secrets.env"), "secret").unwrap();
2370
2371 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2372 let resolved = ws.resolve("device-layout.md").unwrap();
2373
2374 assert!(resolved.ends_with(".generated/specs/device-layout.md"));
2375 assert!(
2376 ws.resolve("secrets.env").is_err(),
2377 "basename fuzzy resolution must honor .deepseekignore"
2378 );
2379 assert!(
2380 ws.resolve(".generated/specs/secrets.env").is_ok(),
2381 "exact user-specified paths should still resolve"
2382 );
2383 }
2384
2385 #[test]
2386 fn fuzzy_index_finds_files_and_directories() {
2387 let tmp = TempDir::new().unwrap();
2388 std::fs::create_dir_all(tmp.path().join("a/b/target_dir")).unwrap();
2389 std::fs::write(tmp.path().join("a/b/needle.rs"), "fn main(){}").unwrap();
2390
2391 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2392
2393 // Basename-only mention triggers fuzzy fallback for both files and dirs.
2394 let f = ws.resolve("needle.rs").unwrap();
2395 assert!(f.ends_with("a/b/needle.rs"));
2396 let d = ws.resolve("target_dir").unwrap();
2397 assert!(d.ends_with("a/b/target_dir"));
2398
2399 // Index was populated exactly once (subsequent lookups reuse it).
2400 assert!(ws.file_index.get().is_some());
2401 }
2402
2403 /// Regression: `@`-mention completion must discover files inside
2404 /// `.deepseek/`, `.cursor/`, `.claude/`, `.agents/` even when
2405 /// those directories are excluded by `.gitignore` (or `.ignore`).
2406 /// The `discovery_walk_builder` override un-ignores them.
2407 #[test]
2408 fn completions_discovers_files_inside_gitignored_dot_dirs() {
2409 let tmp = TempDir::new().unwrap();
2410 let root = tmp.path();
2411
2412 // `.ignore` works even outside a git repo; use it to simulate
2413 // a project that gitignores its AI-tool dot-directories.
2414 std::fs::write(
2415 root.join(".ignore"),
2416 ".deepseek/\n.cursor/\n.claude/\n.agents/\n",
2417 )
2418 .unwrap();
2419
2420 // Create files inside each dot-dir.
2421 std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
2422 std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
2423 std::fs::create_dir_all(root.join(".cursor/commands")).unwrap();
2424 std::fs::write(root.join(".cursor/commands/run.md"), "run cmd").unwrap();
2425 std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
2426 std::fs::write(root.join(".claude/commands/test.md"), "test cmd").unwrap();
2427 std::fs::create_dir_all(root.join(".agents/skills/example")).unwrap();
2428 std::fs::write(
2429 root.join(".agents/skills/example/SKILL.md"),
2430 "name: example\n",
2431 )
2432 .unwrap();
2433
2434 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2435
2436 // Completions should find entries inside the dot-dirs.
2437 {
2438 let entries = ws.completions("build", 16);
2439 assert!(
2440 entries.iter().any(|e| e.contains("build.md")),
2441 "expected build.md in completions although .deepseek/ is ignored; got: {entries:?}"
2442 );
2443 }
2444 {
2445 let entries = ws.completions("run", 16);
2446 assert!(
2447 entries.iter().any(|e| e.contains("run.md")),
2448 "expected run.md from .cursor/; got: {entries:?}"
2449 );
2450 }
2451 {
2452 let entries = ws.completions("test", 16);
2453 assert!(
2454 entries.iter().any(|e| e.contains("test.md")),
2455 "expected test.md from .claude/; got: {entries:?}"
2456 );
2457 }
2458
2459 // Fuzzy resolution should also work.
2460 let f = ws.resolve("build.md").unwrap();
2461 assert!(f.ends_with("build.md"));
2462 let f2 = ws.resolve("SKILL.md").unwrap();
2463 assert!(f2.ends_with("SKILL.md"));
2464 }
2465
2466 /// Regression: the dot-dir walk must NOT index `.deepseek/snapshots/`,
2467 /// which is the snapshot side repo that can grow to hundreds of GB.
2468 /// Indexing it would re-create the same OOM/hang that #1112 was built
2469 /// to prevent.
2470 #[test]
2471 fn dot_dir_walk_excludes_snapshot_side_repo() {
2472 let tmp = TempDir::new().unwrap();
2473 let root = tmp.path();
2474
2475 // Create a snapshot-like directory tree.
2476 std::fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects"))
2477 .unwrap();
2478 std::fs::write(
2479 root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects/snapshot.pack"),
2480 b"fake pack data",
2481 )
2482 .unwrap();
2483 // Also create a legitimate file in .deepseek/ that should be found.
2484 std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
2485 std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
2486
2487 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2488
2489 // Searching for "build" must find build.md.
2490 let entries = ws.completions("build", 16);
2491 assert!(
2492 entries.iter().any(|e| e.contains("build.md")),
2493 "build.md must still be found; got: {entries:?}"
2494 );
2495 // Searching for "snapshot" must NOT return snapshot files.
2496 let snap_entries = ws.completions("snapshot", 16);
2497 assert!(
2498 !snap_entries.iter().any(|e| e.contains("snapshot")),
2499 "snapshot files must NOT appear in completions; got: {snap_entries:?}"
2500 );
2501
2502 // Fuzzy index must also exclude snapshots.
2503 let f = ws.resolve("build.md").unwrap();
2504 assert!(f.ends_with("build.md"));
2505 // snapshot.pack should NOT resolve.
2506 let result = ws.resolve("snapshot.pack");
2507 assert!(
2508 result.is_err(),
2509 "snapshot.pack must not resolve via fuzzy index"
2510 );
2511 }
2512
2513 /// Regression for #1921 — typing `@/` (or `@.`) must NOT trigger the
2514 /// `local_reference_paths` walk, which scans up to
2515 /// `LOCAL_REFERENCE_SCAN_LIMIT` paths on the UI thread. On WSL2 with a
2516 /// `/mnt/c/...` workspace this hangs the composer for seconds to minutes.
2517 #[test]
2518 fn should_try_local_reference_completion_skips_bare_separators_and_dots() {
2519 // The trigger gate must reject bare separators/dots.
2520 assert!(!should_try_local_reference_completion("/"));
2521 assert!(!should_try_local_reference_completion("\\"));
2522 assert!(!should_try_local_reference_completion("."));
2523 assert!(!should_try_local_reference_completion(".."));
2524 // Empty string was already rejected; keep that.
2525 assert!(!should_try_local_reference_completion(""));
2526
2527 // Actionable references must still trigger.
2528 assert!(should_try_local_reference_completion("./foo"));
2529 assert!(should_try_local_reference_completion("../bar"));
2530 assert!(should_try_local_reference_completion(".env"));
2531 assert!(should_try_local_reference_completion("path/"));
2532 assert!(should_try_local_reference_completion("path/to/file"));
2533 assert!(should_try_local_reference_completion("/usr"));
2534 }
2535
2536 #[test]
2537 fn cached_candidates_rank_like_live_completions() {
2538 // #3757: the composer caches one full candidate walk and ranks per
2539 // keystroke in memory; the ranked result must match what the live
2540 // walk would return for non-path-like needles.
2541 let tmp = TempDir::new().unwrap();
2542 let root = tmp.path();
2543 std::fs::create_dir_all(root.join("src")).unwrap();
2544 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2545 std::fs::write(root.join("src/mention.rs"), "// m").unwrap();
2546 std::fs::write(root.join("README.md"), "# readme").unwrap();
2547 std::fs::write(root.join("Makefile"), "all:").unwrap();
2548
2549 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2550 let candidates = ws.completion_candidates();
2551 assert!(
2552 candidates.iter().any(|c| c == "src/main.rs"),
2553 "{candidates:?}"
2554 );
2555
2556 for needle in ["ma", "readme", "men", ""] {
2557 let live = ws.completions(needle, 16);
2558 let ranked = rank_completion_candidates(&candidates, needle, 16);
2559 assert_eq!(ranked, live, "needle {needle:?}");
2560 }
2561
2562 // Limit truncation applies after prefix/substring bucketing.
2563 let ranked = rank_completion_candidates(&candidates, "ma", 1);
2564 assert_eq!(ranked.len(), 1);
2565 assert!(ranked[0].to_lowercase().starts_with("ma"), "{ranked:?}");
2566 }
2567
2568 #[test]
2569 fn background_completion_discovery_is_hard_capped_on_large_trees() {
2570 let tmp = TempDir::new().unwrap();
2571 for i in 0..256 {
2572 std::fs::write(tmp.path().join(format!("candidate_{i:03}.rs")), "x").unwrap();
2573 }
2574 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2575 let never_cancelled = || false;
2576
2577 let candidates = ws.completion_discovery_candidates(32, &never_cancelled);
2578
2579 assert_eq!(
2580 candidates.len(),
2581 32,
2582 "the background cache must stop at its hard candidate limit"
2583 );
2584 }
2585
2586 /// Regression for #1921 — `completions("/", N)` must return without
2587 /// invoking `local_reference_paths`, even on a workspace large enough
2588 /// to expose the original 4096-path walk. We can't assert "doesn't
2589 /// touch the disk", but we can assert the call completes promptly and
2590 /// stays within the requested limit.
2591 #[test]
2592 fn completions_for_bare_slash_does_not_trigger_local_reference_walk() {
2593 let tmp = TempDir::new().unwrap();
2594 let root = tmp.path();
2595 // Lay out enough files that a runaway walk would be visibly slow,
2596 // but the bounded path returns near-instantly. Depth-1 entries are
2597 // enough; we don't need to stress the filesystem.
2598 for i in 0..40 {
2599 std::fs::write(root.join(format!("file_{i}.txt")), "x").unwrap();
2600 }
2601 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2602
2603 let start = std::time::Instant::now();
2604 let entries = ws.completions("/", 64);
2605 let elapsed = start.elapsed();
2606
2607 // Behavioral assertions:
2608 // 1. The call returns within a generous bound. Real freezes on
2609 // WSL2 were tens of seconds; a 2s budget is comfortable for a
2610 // 40-file tmp dir on any CI host.
2611 assert!(
2612 elapsed < std::time::Duration::from_secs(2),
2613 "completions(\"/\") took too long: {elapsed:?} (likely re-introduced #1921)"
2614 );
2615 // 2. Results stay within the requested cap.
2616 assert!(entries.len() <= 64);
2617 }
2618 }
2619
2619 lines RUST