返回 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::workspace_discovery::{
10 DISCOVERY_ALWAYS_DIRS, path_is_excluded_from_discovery, should_skip_unignored_discovery_entry,
11 };
12 use codewhale_models::{ContentBlock, Message};
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_cached_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 // A walker follows a symlink passed as its root even when ordinary
617 // symlink following is off. Keep that opt-out for the extra roots too.
618 if !dot_dir.is_dir() || (!follow_links && dot_dir.is_symlink()) {
619 continue;
620 }
621 let mut builder = WalkBuilder::new(&dot_dir);
622 builder
623 .hidden(true)
624 .follow_links(follow_links)
625 .git_ignore(false)
626 .ignore(false);
627 if let Some(depth) = max_depth {
628 builder.max_depth(Some(depth.saturating_sub(1)));
629 }
630 for entry in builder.build().flatten() {
631 if ctx.should_stop() {
632 break;
633 }
634 let path = entry.path();
635 // Exclude machine-generated bulk (e.g. .deepseek/snapshots/)
636 // even though gitignore is disabled for this walk.
637 if path_is_excluded_from_discovery(walk_root, path) {
638 continue;
639 }
640 let Ok(rel) = path.strip_prefix(display_root) else {
641 continue;
642 };
643 let rel_str = rel.to_string_lossy().replace('\\', "/");
644 if rel_str.is_empty() {
645 continue;
646 }
647 let abs = path.to_path_buf();
648 if !ctx.remember(abs) {
649 continue;
650 }
651 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
652 let candidate = if is_dir {
653 format!("{rel_str}/")
654 } else {
655 rel_str.clone()
656 };
657 ctx.push_match(candidate);
658 }
659 }
660 }
661
662 fn walk_for_completions(
663 walk_root: &Path,
664 display_root: &Path,
665 ctx: &mut SearchContext<'_>,
666 max_depth: Option<usize>,
667 follow_links: bool,
668 ) {
669 let builder = discovery_walk_builder(walk_root, max_depth, follow_links);
670
671 for entry in builder.build().flatten() {
672 if ctx.should_stop() {
673 break;
674 }
675 let path = entry.path();
676 let Ok(rel) = path.strip_prefix(display_root) else {
677 continue;
678 };
679 let rel_str = rel.to_string_lossy().replace('\\', "/");
680 if rel_str.is_empty() {
681 continue;
682 }
683 // Dedup across the (cwd, workspace) double-walk by absolute path; we
684 // want the cwd-relative display when both walks see the same file.
685 let abs = path.to_path_buf();
686 if !ctx.remember(abs) {
687 continue;
688 }
689 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
690 let candidate = if is_dir {
691 format!("{rel_str}/")
692 } else {
693 rel_str.clone()
694 };
695 ctx.push_match(candidate);
696 }
697
698 // Also walk the AI-tool dot-directories with gitignore disabled so
699 // `.deepseek/`, `.cursor/`, etc. are always discoverable.
700 walk_always_discoverable_dirs(walk_root, display_root, ctx, max_depth, follow_links);
701 }
702
703 const LOCAL_REFERENCE_SCAN_LIMIT: usize = 4096;
704
705 #[cfg(test)]
706 fn add_local_reference_completions(
707 root: &Path,
708 display_root: &Path,
709 ctx: &mut SearchContext<'_>,
710 max_depth: Option<usize>,
711 follow_links: bool,
712 ) {
713 if !should_try_local_reference_completion(ctx.needle) {
714 return;
715 }
716
717 for path in local_reference_paths(root, LOCAL_REFERENCE_SCAN_LIMIT, max_depth, follow_links) {
718 if ctx.should_stop() {
719 break;
720 }
721 let Ok(rel) = path.strip_prefix(display_root) else {
722 continue;
723 };
724 let rel_str = rel.to_string_lossy().replace('\\', "/");
725 if rel_str.is_empty() || !ctx.remember(path.clone()) {
726 continue;
727 }
728 ctx.push_match(rel_str);
729 }
730 }
731
732 /// Add hidden/ignored candidates to the background cache even when the
733 /// current needle is empty. The old UI path could defer this walk until a
734 /// path-like query existed; the background cache must be self-contained so no
735 /// later keystroke falls back to synchronous discovery.
736 fn add_all_local_reference_completions(
737 root: &Path,
738 display_root: &Path,
739 ctx: &mut SearchContext<'_>,
740 max_depth: Option<usize>,
741 follow_links: bool,
742 ) {
743 if ctx.should_stop() {
744 return;
745 }
746 let paths = local_reference_paths_with_cancel(
747 root,
748 LOCAL_REFERENCE_SCAN_LIMIT,
749 max_depth,
750 follow_links,
751 ctx.cancelled,
752 );
753 for path in paths {
754 if ctx.should_stop() {
755 break;
756 }
757 let Ok(rel) = path.strip_prefix(display_root) else {
758 continue;
759 };
760 let rel_str = rel.to_string_lossy().replace('\\', "/");
761 if rel_str.is_empty() || !ctx.remember(path.clone()) {
762 continue;
763 }
764 ctx.push_match(rel_str);
765 }
766 }
767
768 /// Rank pre-collected completion candidates for `partial` the same way
769 /// `Workspace::completions` ranks live walk hits: case-insensitive prefix
770 /// matches first, then substring matches, each bucket alphabetical, truncated
771 /// to `limit` (#3757).
772 #[must_use]
773 pub fn rank_completion_candidates(
774 candidates: &[String],
775 partial: &str,
776 limit: usize,
777 ) -> Vec<String> {
778 if limit == 0 {
779 return Vec::new();
780 }
781 let needle = partial.to_lowercase();
782 let mut prefix_hits: Vec<String> = Vec::new();
783 let mut substring_hits: Vec<String> = Vec::new();
784 for candidate in candidates {
785 let lower = candidate.to_lowercase();
786 if needle.is_empty() || lower.starts_with(&needle) {
787 prefix_hits.push(candidate.clone());
788 } else if lower.contains(&needle) {
789 substring_hits.push(candidate.clone());
790 }
791 }
792 prefix_hits.sort();
793 substring_hits.sort();
794 prefix_hits.extend(substring_hits);
795 prefix_hits.truncate(limit);
796 prefix_hits
797 }
798
799 #[cfg(test)]
800 fn should_try_local_reference_completion(needle: &str) -> bool {
801 if needle.is_empty() {
802 return false;
803 }
804 // A bare separator or dot isn't an actionable path yet. Without this
805 // guard, a single `@/` keystroke triggers a `LOCAL_REFERENCE_SCAN_LIMIT`
806 // (4096-path) walk on the UI thread for #1921 — on WSL2 with a
807 // `/mnt/c/...` workspace each entry crosses Windows-host I/O and the
808 // composer appears frozen for seconds to minutes.
809 if matches!(needle, "/" | "\\" | "." | "..") {
810 return false;
811 }
812 needle.starts_with('.') || needle.contains('/') || needle.contains('\\')
813 }
814
815 #[cfg(test)]
816 fn local_reference_paths(
817 root: &Path,
818 limit: usize,
819 max_depth: Option<usize>,
820 follow_links: bool,
821 ) -> Vec<PathBuf> {
822 let never_cancelled = || false;
823 local_reference_paths_with_cancel(root, limit, max_depth, follow_links, &never_cancelled)
824 }
825
826 fn local_reference_paths_with_cancel(
827 root: &Path,
828 limit: usize,
829 max_depth: Option<usize>,
830 follow_links: bool,
831 cancelled: &dyn Fn() -> bool,
832 ) -> Vec<PathBuf> {
833 let mut out = Vec::new();
834 let mut builder = WalkBuilder::new(root);
835 builder
836 .hidden(false)
837 .follow_links(follow_links)
838 .git_ignore(false)
839 .git_global(false)
840 .git_exclude(false);
841 if let Some(depth) = max_depth {
842 builder.max_depth(Some(depth));
843 }
844 let _ = builder.add_custom_ignore_filename(".deepseekignore");
845 let root_for_filter = root.to_path_buf();
846 builder.filter_entry(move |entry| {
847 !should_skip_unignored_discovery_entry(&root_for_filter, entry.path())
848 });
849
850 for entry in builder.build().flatten() {
851 if out.len() >= limit || cancelled() {
852 break;
853 }
854 let path = entry.path();
855 if path == root {
856 continue;
857 }
858 if entry
859 .file_type()
860 .is_some_and(|ft| ft.is_file() || ft.is_dir())
861 {
862 out.push(path.to_path_buf());
863 }
864 }
865 out
866 }
867
868 impl Clone for Workspace {
869 fn clone(&self) -> Self {
870 // Don't carry the cached file_index — clones get a fresh OnceLock so
871 // they don't pin a stale snapshot of the previous owner's tree.
872 Self {
873 root: self.root.clone(),
874 cwd: self.cwd.clone(),
875 #[cfg(test)]
876 file_index: OnceLock::new(),
877 completion_walk_depth: self.completion_walk_depth,
878 follow_links: self.follow_links,
879 }
880 }
881 }
882
883 fn expand_mention_home(path: &str) -> PathBuf {
884 if path == "~"
885 && let Some(home) = std::env::var_os("HOME")
886 {
887 return PathBuf::from(home);
888 }
889 if let Some(rest) = path.strip_prefix("~/")
890 && let Some(home) = std::env::var_os("HOME")
891 {
892 return PathBuf::from(home).join(rest);
893 }
894 PathBuf::from(path)
895 }
896
897 /// Truncate `s` to at most `max_bytes`, snapping down to a UTF-8 char
898 /// boundary so the result is always valid. Returns the slice and whether any
899 /// truncation happened.
900 fn truncate_on_char_boundary(s: &str, max_bytes: usize) -> (&str, bool) {
901 if s.len() <= max_bytes {
902 return (s, false);
903 }
904 let mut end = max_bytes;
905 while end > 0 && !s.is_char_boundary(end) {
906 end -= 1;
907 }
908 (&s[..end], true)
909 }
910
911 /// Configuration for working-set tracking.
912 #[derive(Debug, Clone, Serialize, Deserialize)]
913 pub struct WorkingSetConfig {
914 /// Maximum number of entries to keep.
915 pub max_entries: usize,
916 /// Maximum number of paths to pin during compaction.
917 pub max_pinned_paths: usize,
918 /// Maximum characters to scan per text block when pinning messages.
919 pub max_scan_chars: usize,
920 /// Maximum entries to show in the system prompt block.
921 pub max_prompt_entries: usize,
922 /// Cache-maximal context mode (#528): when enabled, the working-set block
923 /// materializes the full current contents of the top active files into the
924 /// system prompt (deterministic order, size-bounded) instead of only a
925 /// path list. The contents stay byte-stable while the files are unchanged,
926 /// so DeepSeek's KV prefix cache keeps hitting; editing a file cache-misses
927 /// from that file's block onward. Off by default — existing behavior is the
928 /// path list only.
929 #[serde(default)]
930 pub cache_maximal: bool,
931 /// Per-file byte cap for materialized contents in cache-maximal mode.
932 #[serde(default = "default_max_resident_file_bytes")]
933 pub max_resident_file_bytes: usize,
934 /// Total byte cap across all materialized files in cache-maximal mode.
935 #[serde(default = "default_max_total_resident_bytes")]
936 pub max_total_resident_bytes: usize,
937 }
938
939 fn default_max_resident_file_bytes() -> usize {
940 24_000
941 }
942
943 fn default_max_total_resident_bytes() -> usize {
944 96_000
945 }
946
947 impl Default for WorkingSetConfig {
948 fn default() -> Self {
949 Self {
950 max_entries: 16,
951 max_pinned_paths: 8,
952 max_scan_chars: 2_000,
953 max_prompt_entries: 8,
954 cache_maximal: false,
955 max_resident_file_bytes: default_max_resident_file_bytes(),
956 max_total_resident_bytes: default_max_total_resident_bytes(),
957 }
958 }
959 }
960
961 /// The source that most recently updated an entry.
962 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
963 pub enum WorkingSetSource {
964 UserMessage,
965 ToolInput,
966 ToolOutput,
967 Rebuild,
968 }
969
970 /// A single working-set entry.
971 #[derive(Debug, Clone, Serialize, Deserialize)]
972 pub struct WorkingSetEntry {
973 /// Workspace-relative path string.
974 pub path: String,
975 /// Whether the path is a directory (best-effort).
976 pub is_dir: bool,
977 /// Whether the path exists on disk (best-effort).
978 pub exists: bool,
979 /// Number of times this path was observed.
980 pub touches: u32,
981 /// The last observed turn index.
982 pub last_turn: u64,
983 /// The last update source.
984 pub last_source: WorkingSetSource,
985 }
986
987 impl WorkingSetEntry {
988 fn new(path: String, exists: bool, is_dir: bool, turn: u64, source: WorkingSetSource) -> Self {
989 Self {
990 path,
991 is_dir,
992 exists,
993 touches: 1,
994 last_turn: turn,
995 last_source: source,
996 }
997 }
998 }
999
1000 /// Repo-aware working-set state.
1001 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
1002 pub struct WorkingSet {
1003 /// Tracking configuration.
1004 pub config: WorkingSetConfig,
1005 /// Monotonic turn counter (increments on user messages).
1006 pub turn: u64,
1007 /// Path entries keyed by workspace-relative path.
1008 pub entries: HashMap<String, WorkingSetEntry>,
1009 }
1010
1011 impl WorkingSet {
1012 /// Advance to the next turn.
1013 pub fn next_turn(&mut self) {
1014 self.turn = self.turn.saturating_add(1);
1015 }
1016
1017 /// Observe a user message and update the working set.
1018 pub fn observe_user_message(&mut self, text: &str, workspace: &Path) {
1019 self.next_turn();
1020 let paths = extract_paths_from_text(text);
1021 self.record_candidates(paths, workspace, WorkingSetSource::UserMessage);
1022 }
1023
1024 /// Observe a tool call (input and optional output).
1025 pub fn observe_tool_call(
1026 &mut self,
1027 tool_name: &str,
1028 input: &Value,
1029 output: Option<&str>,
1030 workspace: &Path,
1031 ) {
1032 let input_candidates = extract_paths_from_value(input, Some(tool_name));
1033 self.record_candidates(input_candidates, workspace, WorkingSetSource::ToolInput);
1034
1035 if let Some(text) = output {
1036 let output_candidates = extract_paths_from_text(text);
1037 self.record_candidates(output_candidates, workspace, WorkingSetSource::ToolOutput);
1038 }
1039 }
1040
1041 /// Rebuild the working set from existing messages (best effort).
1042 ///
1043 /// This is used when syncing a resumed session.
1044 pub fn rebuild_from_messages(&mut self, messages: &[Message], workspace: &Path) {
1045 self.entries.clear();
1046 self.turn = 0;
1047
1048 for message in messages {
1049 if message.role == "user" {
1050 self.next_turn();
1051 }
1052 let candidates = extract_paths_from_message(message);
1053 if candidates.is_empty() {
1054 continue;
1055 }
1056 self.record_candidates(candidates, workspace, WorkingSetSource::Rebuild);
1057 }
1058 }
1059
1060 /// Render a compact working-set block for the system prompt.
1061 ///
1062 /// Byte-stable across `next_turn()` calls when no new paths are observed
1063 /// (#280): the rendered lines drop the turn-relative `touches` and
1064 /// `last seen N turn(s) ago` fields, and the order is taken from
1065 /// `sorted_for_prompt` (turn-agnostic) instead of `sorted_entries`.
1066 /// The block lands in the system prompt before the historical
1067 /// conversation; any byte that drifts here cache-misses everything that
1068 /// follows in DeepSeek's KV prefix cache.
1069 pub fn summary_block(&self, workspace: &Path) -> Option<String> {
1070 // Only stat-verified paths reach the model. Prose observation happily
1071 // records tokens that merely look like paths ("120x40",
1072 // "Hmbown/CodeWhale"), and a fabricated Active-paths line teaches the
1073 // model false workspace facts it then spends turns disproving.
1074 // Re-statting at render time also drops files deleted mid-session.
1075 // Bytes only change when the filesystem genuinely changed — the same
1076 // exception the #280 stability contract already makes for newly
1077 // observed paths.
1078 let prompt_entries: Vec<(&WorkingSetEntry, bool)> = self
1079 .sorted_for_prompt()
1080 .into_iter()
1081 .filter_map(|entry| {
1082 let metadata = fs::metadata(workspace.join(&entry.path)).ok()?;
1083 Some((entry, metadata.is_dir()))
1084 })
1085 .take(self.config.max_prompt_entries)
1086 .collect();
1087
1088 let repo_summary = summarize_repo_root(workspace);
1089
1090 if repo_summary.is_none() && prompt_entries.is_empty() {
1091 return None;
1092 }
1093
1094 let mut lines: Vec<String> = Vec::new();
1095 lines.push("## Repo Working Set".to_string());
1096
1097 if let Some(summary) = repo_summary {
1098 lines.push(summary);
1099 }
1100
1101 if !prompt_entries.is_empty() {
1102 lines.push("Active paths (prioritize these):".to_string());
1103 for (entry, is_dir) in &prompt_entries {
1104 let kind = if *is_dir { "dir" } else { "file" };
1105 lines.push(format!("- {} ({kind})", entry.path));
1106 }
1107 }
1108
1109 lines.push(
1110 "When in doubt, use tools to verify and keep changes focused on the working set."
1111 .to_string(),
1112 );
1113
1114 // Cache-maximal mode (#528): append the full current contents of the
1115 // top active files so the model reads live source each turn instead of
1116 // re-fetching it with tools. Kept after the path list and bounded by
1117 // per-file and total byte caps; order follows `sorted_for_prompt` so
1118 // the block is byte-stable while the files are unchanged.
1119 if self.cache_maximal_enabled() && !prompt_entries.is_empty() {
1120 let content_entries: Vec<&WorkingSetEntry> =
1121 prompt_entries.iter().map(|(entry, _)| *entry).collect();
1122 self.append_resident_file_contents(&mut lines, workspace, &content_entries);
1123 }
1124
1125 Some(lines.join("\n"))
1126 }
1127
1128 /// Whether cache-maximal context mode is active: explicit config, or the
1129 /// `CODEWHALE_CACHE_MAXIMAL` env toggle (`1`/`true`/`on`/`yes`). The env
1130 /// value is constant for the process, so the rendered block stays
1131 /// byte-stable turn-over-turn.
1132 fn cache_maximal_enabled(&self) -> bool {
1133 if self.config.cache_maximal {
1134 return true;
1135 }
1136 match std::env::var("CODEWHALE_CACHE_MAXIMAL") {
1137 Ok(v) => matches!(
1138 v.trim().to_ascii_lowercase().as_str(),
1139 "1" | "true" | "on" | "yes"
1140 ),
1141 Err(_) => false,
1142 }
1143 }
1144
1145 /// Render `### Active file contents` blocks for the resident files, honoring
1146 /// the per-file and total byte caps. Unreadable or non-UTF-8 files are noted
1147 /// rather than skipped silently so the omission is visible to the model.
1148 fn append_resident_file_contents(
1149 &self,
1150 lines: &mut Vec<String>,
1151 workspace: &Path,
1152 prompt_entries: &[&WorkingSetEntry],
1153 ) {
1154 let mut header_pushed = false;
1155 let mut total_bytes: usize = 0;
1156 let mut omitted: usize = 0;
1157
1158 for entry in prompt_entries {
1159 if entry.is_dir || !entry.exists {
1160 continue;
1161 }
1162 if total_bytes >= self.config.max_total_resident_bytes {
1163 omitted += 1;
1164 continue;
1165 }
1166
1167 let abs = workspace.join(&entry.path);
1168 let body = match std::fs::read_to_string(&abs) {
1169 Ok(text) => text,
1170 Err(_) => {
1171 if !header_pushed {
1172 lines.push("### Active file contents (cache-resident)".to_string());
1173 header_pushed = true;
1174 }
1175 lines.push(format!(
1176 "<!-- file: {} (unreadable, skipped) -->",
1177 entry.path
1178 ));
1179 continue;
1180 }
1181 };
1182
1183 if !header_pushed {
1184 lines.push("### Active file contents (cache-resident)".to_string());
1185 header_pushed = true;
1186 }
1187
1188 let remaining_total = self
1189 .config
1190 .max_total_resident_bytes
1191 .saturating_sub(total_bytes);
1192 let cap = self.config.max_resident_file_bytes.min(remaining_total);
1193 let (shown, truncated) = truncate_on_char_boundary(&body, cap);
1194 total_bytes += shown.len();
1195
1196 lines.push(format!("<!-- file: {} -->", entry.path));
1197 lines.push("```".to_string());
1198 lines.push(shown.to_string());
1199 if truncated {
1200 lines.push(format!(
1201 "<!-- ...{} more bytes truncated for prompt budget -->",
1202 body.len().saturating_sub(shown.len())
1203 ));
1204 }
1205 lines.push("```".to_string());
1206 }
1207
1208 if omitted > 0 {
1209 lines.push(format!(
1210 "<!-- {omitted} additional active file(s) omitted from the cache-resident budget -->"
1211 ));
1212 }
1213 }
1214
1215 fn record_candidates(
1216 &mut self,
1217 candidates: Vec<String>,
1218 workspace: &Path,
1219 source: WorkingSetSource,
1220 ) {
1221 if candidates.is_empty() {
1222 return;
1223 }
1224
1225 let workspace_canon = workspace.canonicalize().ok();
1226
1227 for raw in candidates {
1228 let Some(normalized) = normalize_candidate(&raw) else {
1229 continue;
1230 };
1231 let Some((rel, exists, is_dir)) =
1232 relativize_candidate(&normalized, workspace, workspace_canon.as_deref())
1233 else {
1234 continue;
1235 };
1236 self.record_path(rel, exists, is_dir, source);
1237 }
1238
1239 self.prune();
1240 }
1241
1242 fn record_path(&mut self, rel: String, exists: bool, is_dir: bool, source: WorkingSetSource) {
1243 match self.entries.get_mut(&rel) {
1244 Some(entry) => {
1245 entry.exists |= exists;
1246 entry.is_dir |= is_dir;
1247 entry.touches = entry.touches.saturating_add(1);
1248 entry.last_turn = self.turn;
1249 entry.last_source = source;
1250 }
1251 None => {
1252 let entry = WorkingSetEntry::new(rel.clone(), exists, is_dir, self.turn, source);
1253 let _ = self.entries.insert(rel, entry);
1254 }
1255 }
1256 }
1257
1258 fn prune(&mut self) {
1259 let max_entries = self.config.max_entries;
1260 if self.entries.len() <= max_entries {
1261 return;
1262 }
1263
1264 // Rank by score ascending and drop the lowest until within bounds.
1265 let mut ranked: Vec<(String, i64)> = self
1266 .entries
1267 .values()
1268 .map(|entry| (entry.path.clone(), score_entry(entry, self.turn)))
1269 .collect();
1270 ranked.sort_by_key(|a| a.1);
1271
1272 let to_remove = self.entries.len().saturating_sub(max_entries);
1273 for (path, _) in ranked.into_iter().take(to_remove) {
1274 let _ = self.entries.remove(&path);
1275 }
1276 }
1277
1278 /// `sorted_entries` mixes in a recency bonus from `self.turn`, so its
1279 /// output reorders as turns advance even when no new paths are touched —
1280 /// that movement would cross `max_prompt_entries` boundaries and bust the
1281 /// KV prefix cache (#280). Compaction pinning still uses the recency-aware
1282 /// `sorted_entries`; only the prompt-facing surface is stabilised here.
1283 fn sorted_for_prompt(&self) -> Vec<&WorkingSetEntry> {
1284 let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect();
1285 entries.sort_by(|a, b| b.touches.cmp(&a.touches).then_with(|| a.path.cmp(&b.path)));
1286 entries
1287 }
1288 }
1289
1290 fn score_entry(entry: &WorkingSetEntry, current_turn: u64) -> i64 {
1291 let age = current_turn.saturating_sub(entry.last_turn);
1292 let recency_bonus = match age {
1293 0 => 6,
1294 1 => 4,
1295 2 => 3,
1296 3..=5 => 2,
1297 6..=10 => 1,
1298 _ => 0,
1299 };
1300 i64::from(entry.touches) * 4 + recency_bonus
1301 }
1302
1303 fn normalize_candidate(raw: &str) -> Option<String> {
1304 let trimmed = raw.trim().trim_matches(|c: char| {
1305 matches!(
1306 c,
1307 '"' | '\'' | '`' | ',' | ';' | ':' | '(' | ')' | '[' | ']'
1308 )
1309 });
1310 if trimmed.is_empty() {
1311 return None;
1312 }
1313 Some(trimmed.to_string())
1314 }
1315
1316 fn relativize_candidate(
1317 candidate: &str,
1318 workspace: &Path,
1319 workspace_canon: Option<&Path>,
1320 ) -> Option<(String, bool, bool)> {
1321 let candidate_path = Path::new(candidate);
1322
1323 // Reject obvious URLs and non-paths early.
1324 if candidate.contains("://") {
1325 return None;
1326 }
1327
1328 let (rel_path, abs_path) = if candidate_path.is_absolute() {
1329 let within_workspace = workspace_canon
1330 .map(|ws| candidate_path.starts_with(ws))
1331 .unwrap_or_else(|| candidate_path.starts_with(workspace));
1332 if !within_workspace {
1333 return None;
1334 }
1335 let rel = candidate_path.strip_prefix(workspace).ok()?.to_path_buf();
1336 (rel, candidate_path.to_path_buf())
1337 } else {
1338 if starts_with_parent_dir(candidate_path) {
1339 return None;
1340 }
1341 let rel = clean_relative(candidate_path);
1342 let abs = workspace.join(&rel);
1343 (rel, abs)
1344 };
1345
1346 let metadata = fs::metadata(&abs_path).ok();
1347 let exists = metadata.is_some();
1348 let is_dir = metadata
1349 .as_ref()
1350 .map(fs::Metadata::is_dir)
1351 .unwrap_or_else(|| candidate.ends_with('/'));
1352
1353 let rel_string = path_to_string(&rel_path)?;
1354 Some((rel_string, exists, is_dir))
1355 }
1356
1357 fn starts_with_parent_dir(path: &Path) -> bool {
1358 matches!(
1359 path.components().next(),
1360 Some(std::path::Component::ParentDir)
1361 )
1362 }
1363
1364 fn clean_relative(path: &Path) -> PathBuf {
1365 use std::path::Component;
1366
1367 let mut parts: Vec<PathBuf> = Vec::new();
1368 for comp in path.components() {
1369 match comp {
1370 Component::CurDir => {}
1371 Component::ParentDir => {
1372 let _ = parts.pop();
1373 }
1374 Component::Normal(p) => parts.push(PathBuf::from(p)),
1375 Component::RootDir | Component::Prefix(_) => {}
1376 }
1377 }
1378 let mut out = PathBuf::new();
1379 for part in parts {
1380 out.push(part);
1381 }
1382 out
1383 }
1384
1385 fn path_to_string(path: &Path) -> Option<String> {
1386 path.as_os_str().to_str().map(|s| s.replace('\\', "/"))
1387 }
1388
1389 fn extract_paths_from_message(message: &Message) -> Vec<String> {
1390 let mut paths = Vec::new();
1391 for block in &message.content {
1392 match block {
1393 ContentBlock::Text { text, .. } => {
1394 paths.extend(extract_paths_from_text(text));
1395 }
1396 ContentBlock::ToolUse { input, .. } => {
1397 paths.extend(extract_paths_from_value(input, None));
1398 }
1399 ContentBlock::ToolResult { content, .. } => {
1400 paths.extend(extract_paths_from_text(content));
1401 }
1402 ContentBlock::Thinking { .. }
1403 | ContentBlock::ServerToolUse { .. }
1404 | ContentBlock::ToolSearchToolResult { .. }
1405 | ContentBlock::CodeExecutionToolResult { .. }
1406 | ContentBlock::ImageUrl { .. } => {}
1407 }
1408 }
1409 paths
1410 }
1411
1412 fn extract_paths_from_value(value: &Value, tool_hint: Option<&str>) -> Vec<String> {
1413 let mut out = Vec::new();
1414 extract_paths_from_value_inner(value, tool_hint, None, &mut out);
1415 out
1416 }
1417
1418 fn extract_paths_from_value_inner(
1419 value: &Value,
1420 tool_hint: Option<&str>,
1421 key_hint: Option<&str>,
1422 out: &mut Vec<String>,
1423 ) {
1424 match value {
1425 Value::String(s) => {
1426 let key_suggests_path = key_hint.map(key_is_path_like).unwrap_or(false);
1427 if key_suggests_path || looks_like_path(s) {
1428 out.extend(extract_paths_from_text(s));
1429 if key_suggests_path && !s.contains('/') && !s.contains('\\') {
1430 out.push(s.to_string());
1431 }
1432 } else if tool_hint == Some("exec_shell") && s.len() < 400 {
1433 out.extend(extract_paths_from_text(s));
1434 }
1435 }
1436 Value::Array(arr) => {
1437 for item in arr {
1438 extract_paths_from_value_inner(item, tool_hint, key_hint, out);
1439 }
1440 }
1441 Value::Object(map) => {
1442 for (k, v) in map {
1443 extract_paths_from_value_inner(v, tool_hint, Some(k.as_str()), out);
1444 }
1445 }
1446 Value::Null | Value::Bool(_) | Value::Number(_) => {}
1447 }
1448 }
1449
1450 fn key_is_path_like(key: &str) -> bool {
1451 let lower = key.to_ascii_lowercase();
1452 lower.contains("path")
1453 || lower.contains("file")
1454 || lower.contains("dir")
1455 || lower.contains("cwd")
1456 || lower.contains("workspace")
1457 || lower.contains("root")
1458 || lower == "target"
1459 }
1460
1461 fn looks_like_path(text: &str) -> bool {
1462 let trimmed = text.trim();
1463 if trimmed.is_empty() {
1464 return false;
1465 }
1466 if trimmed.contains('/') || trimmed.contains('\\') {
1467 return true;
1468 }
1469 match Path::new(trimmed).extension().and_then(OsStr::to_str) {
1470 Some(ext) => COMMON_EXTENSIONS.contains(&ext),
1471 None => false,
1472 }
1473 }
1474
1475 const COMMON_EXTENSIONS: &[&str] = &[
1476 "rs", "toml", "md", "txt", "json", "yaml", "yml", "ts", "tsx", "js", "jsx", "py", "go", "java",
1477 "c", "cc", "cpp", "h", "hpp", "sh", "bash", "zsh", "sql", "html", "css", "scss",
1478 ];
1479
1480 fn extract_paths_from_text(text: &str) -> Vec<String> {
1481 if text.trim().is_empty() {
1482 return Vec::new();
1483 }
1484
1485 let re = path_regex();
1486 re.find_iter(text)
1487 .map(|m| m.as_str().to_string())
1488 .filter(|s| looks_like_path(s))
1489 .collect()
1490 }
1491
1492 fn path_regex() -> &'static Regex {
1493 static RE: OnceLock<Regex> = OnceLock::new();
1494 RE.get_or_init(|| {
1495 // Path-ish tokens with separators or file extensions.
1496 Regex::new(
1497 r#"(?x)
1498 (?:
1499 (?:[A-Za-z]:\\)? # optional Windows drive
1500 (?:\./|\../|/)? # optional leading
1501 [A-Za-z0-9._-]+
1502 (?:[/\\][A-Za-z0-9._-]+)+
1503 (?:\.[A-Za-z0-9]{1,8})? # optional extension
1504 )
1505 |
1506 (?:
1507 [A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}
1508 )
1509 "#,
1510 )
1511 .expect("path regex should compile")
1512 })
1513 }
1514
1515 fn summarize_repo_root(workspace: &Path) -> Option<String> {
1516 let key_files = detect_key_files(workspace);
1517 let top_dirs = list_top_level_dirs(workspace, 8);
1518
1519 if key_files.is_empty() && top_dirs.is_empty() {
1520 return None;
1521 }
1522
1523 let mut parts: Vec<String> = Vec::new();
1524 if !key_files.is_empty() {
1525 parts.push(format!("Key files: {}", key_files.join(", ")));
1526 }
1527 if !top_dirs.is_empty() {
1528 parts.push(format!("Top-level dirs: {}", top_dirs.join(", ")));
1529 }
1530 Some(parts.join("\n"))
1531 }
1532
1533 fn detect_key_files(workspace: &Path) -> Vec<String> {
1534 const CANDIDATES: &[&str] = &[
1535 "Cargo.toml",
1536 "README.md",
1537 "AGENTS.md",
1538 "CLAUDE.md",
1539 "package.json",
1540 "pyproject.toml",
1541 "go.mod",
1542 "Makefile",
1543 ];
1544
1545 CANDIDATES
1546 .iter()
1547 .filter_map(|name| {
1548 let path = workspace.join(name);
1549 if path.exists() {
1550 Some((*name).to_string())
1551 } else {
1552 None
1553 }
1554 })
1555 .collect()
1556 }
1557
1558 fn list_top_level_dirs(workspace: &Path, limit: usize) -> Vec<String> {
1559 let mut dirs = Vec::new();
1560 let entries = match fs::read_dir(workspace) {
1561 Ok(entries) => entries,
1562 Err(_) => return dirs,
1563 };
1564
1565 for entry in entries.flatten() {
1566 let file_name = entry.file_name();
1567 let Some(name) = file_name.to_str() else {
1568 continue;
1569 };
1570
1571 if name.starts_with('.') || IGNORED_ROOT_DIRS.contains(&name) {
1572 continue;
1573 }
1574
1575 if let Ok(meta) = entry.metadata()
1576 && meta.is_dir()
1577 {
1578 dirs.push(name.to_string());
1579 }
1580
1581 if dirs.len() >= limit {
1582 break;
1583 }
1584 }
1585
1586 dirs.sort();
1587 dirs
1588 }
1589
1590 const IGNORED_ROOT_DIRS: &[&str] = &["target", "node_modules", "dist", "build", ".git"];
1591
1592 #[cfg(test)]
1593 mod tests {
1594 use super::*;
1595 use codewhale_models::Role;
1596 use tempfile::TempDir;
1597
1598 #[cfg(unix)]
1599 #[test]
1600 fn workspace_file_search_discovery_respects_symlink_opt_out_for_ai_directories() {
1601 let workspace = TempDir::new().unwrap();
1602 let external = TempDir::new().unwrap();
1603 fs::write(external.path().join("outside.rs"), "").unwrap();
1604 std::os::unix::fs::symlink(external.path(), workspace.path().join(".agents")).unwrap();
1605 for follow_links in [false, true] {
1606 let resolver = Workspace::with_cwd_depth_and_follow_links(
1607 workspace.path().to_path_buf(),
1608 None,
1609 DEFAULT_COMPLETIONS_WALK_DEPTH,
1610 follow_links,
1611 );
1612 let candidates = resolver.completion_discovery_candidates(100, &|| false);
1613 assert_eq!(
1614 candidates.iter().any(|path| path == ".agents/outside.rs"),
1615 follow_links
1616 );
1617 }
1618 }
1619
1620 fn make_message(role: &str, text: &str) -> Message {
1621 Message {
1622 role: Role::from(role),
1623 content: vec![ContentBlock::Text {
1624 text: text.to_string(),
1625 cache_control: None,
1626 }],
1627 }
1628 }
1629
1630 #[test]
1631 fn observe_user_message_tracks_paths() {
1632 let tmp = TempDir::new().expect("tempdir");
1633 let src = tmp.path().join("src");
1634 let file = src.join("lib.rs");
1635 fs::create_dir_all(&src).expect("mkdir");
1636 fs::write(&file, "pub fn x() {}").expect("write");
1637
1638 let mut ws = WorkingSet::default();
1639 ws.observe_user_message("Please check src/lib.rs", tmp.path());
1640
1641 assert!(ws.entries.contains_key("src/lib.rs"));
1642 let entry = ws.entries.get("src/lib.rs").expect("entry");
1643 assert!(entry.exists);
1644 assert!(!entry.is_dir);
1645 }
1646
1647 #[test]
1648 fn observe_tool_call_extracts_paths_from_input() {
1649 let tmp = TempDir::new().expect("tempdir");
1650 let file = tmp.path().join("Cargo.toml");
1651 fs::write(&file, "[package]\nname = \"x\"").expect("write");
1652
1653 let mut ws = WorkingSet::default();
1654 let input = serde_json::json!({ "path": "Cargo.toml" });
1655 ws.observe_tool_call("read_file", &input, None, tmp.path());
1656
1657 assert!(ws.entries.contains_key("Cargo.toml"));
1658 }
1659
1660 #[test]
1661 fn summary_block_includes_repo_and_working_set() {
1662 let tmp = TempDir::new().expect("tempdir");
1663 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1664 let src = tmp.path().join("src");
1665 fs::create_dir_all(&src).expect("mkdir");
1666 fs::write(src.join("lib.rs"), "pub fn x() {}").expect("write");
1667
1668 let mut ws = WorkingSet::default();
1669 ws.observe_user_message("src/lib.rs", tmp.path());
1670 let block = ws.summary_block(tmp.path()).expect("block");
1671
1672 assert!(block.contains("Repo Working Set"));
1673 assert!(!block.contains("Workspace:"));
1674 assert!(block.contains("Cargo.toml"));
1675 assert!(block.contains("src"));
1676 assert!(block.contains("src/lib.rs"));
1677 }
1678
1679 /// #280 regression: `summary_block` must produce byte-identical output
1680 /// across `next_turn()` advances when no new paths are touched. Prior to
1681 /// the fix, the rendered lines interpolated `entry.touches` and
1682 /// `self.turn - entry.last_turn`, both of which drift turn-over-turn even
1683 /// when the path set is unchanged. The drift busted DeepSeek's KV prefix
1684 /// cache on every user message because the working-set block lands in the
1685 /// system prompt before the historical conversation.
1686 #[test]
1687 fn summary_block_is_byte_stable_across_next_turn_when_no_new_paths_observed() {
1688 use crate::test_support::assert_byte_identical;
1689
1690 let tmp = TempDir::new().expect("tempdir");
1691 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1692 let src = tmp.path().join("src");
1693 fs::create_dir_all(&src).expect("mkdir");
1694 fs::write(src.join("a.rs"), "a").expect("write");
1695 fs::write(src.join("b.rs"), "b").expect("write");
1696
1697 let mut ws = WorkingSet::default();
1698 ws.observe_user_message("Edit src/a.rs and src/b.rs", tmp.path());
1699
1700 let before = ws.summary_block(tmp.path()).expect("block before");
1701 ws.next_turn();
1702 let after = ws.summary_block(tmp.path()).expect("block after");
1703
1704 assert_byte_identical(
1705 "summary_block must be stable across next_turn when no new paths touched",
1706 &before,
1707 &after,
1708 );
1709 }
1710
1711 /// Companion to the byte-stability test: a fresh path *should* invalidate
1712 /// the block (the KV cache is allowed to miss when there's genuinely new
1713 /// signal), so the model still sees newly touched paths after the block
1714 /// stabilises across no-op turns.
1715 #[test]
1716 fn summary_block_changes_when_a_new_path_is_observed() {
1717 let tmp = TempDir::new().expect("tempdir");
1718 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1719 let src = tmp.path().join("src");
1720 fs::create_dir_all(&src).expect("mkdir");
1721 fs::write(src.join("a.rs"), "a").expect("write");
1722 fs::write(src.join("c.rs"), "c").expect("write");
1723
1724 let mut ws = WorkingSet::default();
1725 ws.observe_user_message("src/a.rs", tmp.path());
1726 let before = ws.summary_block(tmp.path()).expect("block before");
1727
1728 ws.observe_user_message("src/c.rs", tmp.path());
1729 let after = ws.summary_block(tmp.path()).expect("block after");
1730
1731 assert_ne!(before, after, "new path must update the rendered summary");
1732 assert!(after.contains("src/c.rs"));
1733 }
1734
1735 #[test]
1736 fn summary_block_renders_only_paths_that_stat_verify() {
1737 // Prose observation records tokens that merely look like paths
1738 // ("120x40", "Hmbown/CodeWhale"); the rendered Active-paths list must
1739 // never teach the model a workspace fact the filesystem contradicts.
1740 let tmp = TempDir::new().expect("tempdir");
1741 let src = tmp.path().join("src");
1742 fs::create_dir_all(&src).expect("mkdir");
1743 fs::write(src.join("real.rs"), "real").expect("write");
1744
1745 let mut ws = WorkingSet::default();
1746 ws.observe_user_message(
1747 "Fix src/real.rs, test at 120x40/80x24, and check Hmbown/CodeWhale",
1748 tmp.path(),
1749 );
1750
1751 let block = ws.summary_block(tmp.path()).expect("block");
1752 assert!(block.contains("- src/real.rs (file)"), "{block}");
1753 assert!(!block.contains("120x40"), "{block}");
1754 assert!(!block.contains("Hmbown/CodeWhale"), "{block}");
1755
1756 // A file deleted mid-session falls out on the next render — the same
1757 // filesystem-changed exception #280 makes for newly observed paths.
1758 fs::remove_file(src.join("real.rs")).expect("remove");
1759 let after_delete = ws.summary_block(tmp.path());
1760 assert!(
1761 after_delete
1762 .as_deref()
1763 .is_none_or(|block| !block.contains("src/real.rs")),
1764 "{after_delete:?}"
1765 );
1766 }
1767
1768 // ── Cache-maximal context mode (#528) ──
1769 // Tests drive the flag through `config.cache_maximal` directly so they
1770 // don't touch the process-wide `CODEWHALE_CACHE_MAXIMAL` env var (which
1771 // would race with parallel tests).
1772
1773 fn cache_maximal_ws() -> WorkingSet {
1774 let mut ws = WorkingSet::default();
1775 ws.config.cache_maximal = true;
1776 ws
1777 }
1778
1779 #[test]
1780 fn cache_maximal_off_keeps_path_list_only() {
1781 let tmp = TempDir::new().expect("tempdir");
1782 let src = tmp.path().join("src");
1783 fs::create_dir_all(&src).expect("mkdir");
1784 fs::write(src.join("lib.rs"), "pub fn hello() {}").expect("write");
1785
1786 let mut ws = WorkingSet::default(); // cache_maximal defaults to false
1787 ws.observe_user_message("src/lib.rs", tmp.path());
1788 let block = ws.summary_block(tmp.path()).expect("block");
1789
1790 assert!(block.contains("src/lib.rs"), "path list still present");
1791 assert!(
1792 !block.contains("Active file contents"),
1793 "no materialized contents when the flag is off"
1794 );
1795 assert!(!block.contains("pub fn hello"));
1796 }
1797
1798 #[test]
1799 fn cache_maximal_on_materializes_file_contents() {
1800 let tmp = TempDir::new().expect("tempdir");
1801 let src = tmp.path().join("src");
1802 fs::create_dir_all(&src).expect("mkdir");
1803 fs::write(src.join("lib.rs"), "pub fn hello() {}").expect("write");
1804
1805 let mut ws = cache_maximal_ws();
1806 ws.observe_user_message("src/lib.rs", tmp.path());
1807 let block = ws.summary_block(tmp.path()).expect("block");
1808
1809 assert!(block.contains("Active file contents (cache-resident)"));
1810 assert!(block.contains("<!-- file: src/lib.rs -->"));
1811 assert!(block.contains("pub fn hello() {}"));
1812 }
1813
1814 #[test]
1815 fn cache_maximal_directories_are_not_materialized() {
1816 let tmp = TempDir::new().expect("tempdir");
1817 let src = tmp.path().join("src");
1818 fs::create_dir_all(&src).expect("mkdir");
1819
1820 let mut ws = cache_maximal_ws();
1821 ws.observe_user_message("look in src/", tmp.path());
1822 let block = ws.summary_block(tmp.path()).expect("block");
1823
1824 // `src` is a dir; it appears in the path list but has no content block.
1825 assert!(!block.contains("<!-- file: src -->"));
1826 }
1827
1828 #[test]
1829 fn cache_maximal_respects_per_file_byte_cap() {
1830 let tmp = TempDir::new().expect("tempdir");
1831 let src = tmp.path().join("src");
1832 fs::create_dir_all(&src).expect("mkdir");
1833 let big = "x".repeat(10_000);
1834 fs::write(src.join("big.rs"), &big).expect("write");
1835
1836 let mut ws = cache_maximal_ws();
1837 ws.config.max_resident_file_bytes = 100;
1838 ws.config.max_total_resident_bytes = 10_000;
1839 ws.observe_user_message("src/big.rs", tmp.path());
1840 let block = ws.summary_block(tmp.path()).expect("block");
1841
1842 assert!(block.contains("truncated for prompt budget"));
1843 // The full 10k body must not be inlined.
1844 assert!(!block.contains(&big));
1845 }
1846
1847 #[test]
1848 fn cache_maximal_total_cap_omits_extra_files() {
1849 let tmp = TempDir::new().expect("tempdir");
1850 let src = tmp.path().join("src");
1851 fs::create_dir_all(&src).expect("mkdir");
1852 fs::write(src.join("a.rs"), "a".repeat(200)).expect("write");
1853 fs::write(src.join("b.rs"), "b".repeat(200)).expect("write");
1854
1855 let mut ws = cache_maximal_ws();
1856 ws.config.max_resident_file_bytes = 200;
1857 ws.config.max_total_resident_bytes = 200; // only one file fits
1858 ws.observe_user_message("Edit src/a.rs and src/b.rs", tmp.path());
1859 let block = ws.summary_block(tmp.path()).expect("block");
1860
1861 assert!(
1862 block.contains("omitted from the cache-resident budget"),
1863 "second file should be reported as omitted:\n{block}"
1864 );
1865 }
1866
1867 #[test]
1868 fn cache_maximal_is_byte_stable_when_files_unchanged() {
1869 use crate::test_support::assert_byte_identical;
1870
1871 let tmp = TempDir::new().expect("tempdir");
1872 let src = tmp.path().join("src");
1873 fs::create_dir_all(&src).expect("mkdir");
1874 fs::write(src.join("a.rs"), "fn a() {}").expect("write");
1875
1876 let mut ws = cache_maximal_ws();
1877 ws.observe_user_message("src/a.rs", tmp.path());
1878 let before = ws.summary_block(tmp.path()).expect("before");
1879 ws.next_turn();
1880 let after = ws.summary_block(tmp.path()).expect("after");
1881
1882 assert_byte_identical(
1883 "cache-maximal block must be stable while files are unchanged (KV cache hit)",
1884 &before,
1885 &after,
1886 );
1887 }
1888
1889 #[test]
1890 fn cache_maximal_changes_when_file_edited() {
1891 let tmp = TempDir::new().expect("tempdir");
1892 let src = tmp.path().join("src");
1893 fs::create_dir_all(&src).expect("mkdir");
1894 let file = src.join("a.rs");
1895 fs::write(&file, "fn a() {}").expect("write");
1896
1897 let mut ws = cache_maximal_ws();
1898 ws.observe_user_message("src/a.rs", tmp.path());
1899 let before = ws.summary_block(tmp.path()).expect("before");
1900
1901 fs::write(&file, "fn a() { todo!() }").expect("rewrite");
1902 let after = ws.summary_block(tmp.path()).expect("after");
1903
1904 assert_ne!(before, after, "editing the file must change the block");
1905 assert!(after.contains("todo!()"));
1906 }
1907
1908 #[test]
1909 fn extract_paths_from_message_picks_up_tool_results() {
1910 let msg = Message {
1911 role: Role::User,
1912 content: vec![ContentBlock::ToolResult {
1913 tool_use_id: "tool_1".to_string(),
1914 content: "Changed src/compaction.rs".to_string(),
1915 is_error: None,
1916 content_blocks: None,
1917 }],
1918 };
1919
1920 let paths = extract_paths_from_message(&msg);
1921 assert!(paths.iter().any(|p| p.contains("src/compaction.rs")));
1922 }
1923
1924 #[test]
1925 fn pinning_prefers_high_signal_paths() {
1926 let tmp = TempDir::new().expect("tempdir");
1927 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1928 fs::write(tmp.path().join("src/a.rs"), "a").expect("write");
1929 fs::write(tmp.path().join("src/b.rs"), "b").expect("write");
1930
1931 let mut ws = WorkingSet::default();
1932 ws.observe_user_message("src/a.rs", tmp.path());
1933 ws.observe_tool_call(
1934 "read_file",
1935 &serde_json::json!({ "path": "src/a.rs" }),
1936 Some("src/a.rs"),
1937 tmp.path(),
1938 );
1939 ws.observe_user_message("src/b.rs", tmp.path());
1940
1941 let a_score = score_entry(ws.entries.get("src/a.rs").expect("a"), ws.turn);
1942 let b_score = score_entry(ws.entries.get("src/b.rs").expect("b"), ws.turn);
1943 assert!(a_score >= b_score);
1944 }
1945
1946 #[test]
1947 fn estimate_tokens_is_available_for_future_budgeting() {
1948 use crate::compaction::estimate_tokens;
1949 let messages = vec![make_message("user", "src/main.rs")];
1950 assert!(estimate_tokens(&messages) > 0);
1951 }
1952
1953 #[test]
1954 fn workspace_resolve_respects_cwd_and_workspace() {
1955 let tmp = TempDir::new().unwrap();
1956
1957 let sub = tmp.path().join("sub");
1958 std::fs::create_dir_all(&sub).unwrap();
1959 let bar = sub.join("bar.txt");
1960 std::fs::write(&bar, "bar").unwrap();
1961
1962 let nested = tmp.path().join("nested/deep");
1963 std::fs::create_dir_all(&nested).unwrap();
1964 let file_md = nested.join("file.md");
1965 std::fs::write(&file_md, "md").unwrap();
1966
1967 // Construct with an explicit cwd so the test doesn't race with other
1968 // tests that mutate the real process cwd.
1969 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(sub.clone()));
1970
1971 // #101 repro #1: @bar.txt with cwd=sub MUST resolve via the cwd pass,
1972 // never to the bogus workspace path tmp/bar.txt (which doesn't exist).
1973 let res1 = ws.resolve("bar.txt").unwrap();
1974 assert_eq!(
1975 res1.canonicalize().unwrap_or(res1.clone()),
1976 bar.canonicalize().unwrap_or(bar.clone())
1977 );
1978 let wrong = tmp.path().join("bar.txt");
1979 assert_ne!(res1, wrong, "must not have routed to workspace fallback");
1980
1981 // #101 repro #2: @nested/deep/file.md falls through to workspace root.
1982 let res2 = ws.resolve("nested/deep/file.md").unwrap();
1983 assert_eq!(
1984 res2.canonicalize().unwrap_or(res2),
1985 file_md.canonicalize().unwrap_or(file_md)
1986 );
1987 }
1988
1989 /// Negative test (#101): a truly missing path returns `Err` with a path
1990 /// that callers can show to the user as a signal of failure.
1991 #[test]
1992 fn workspace_resolve_returns_err_for_truly_missing_path() {
1993 let tmp = TempDir::new().unwrap();
1994 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf()));
1995
1996 let res = ws.resolve("does/not/exist.txt");
1997 assert!(res.is_err(), "expected Err for missing path, got: {res:?}");
1998 }
1999
2000 /// `Workspace::completions` returns workspace-relative entries for files
2001 /// under the root, and cwd-relative entries when the cwd-only file lives
2002 /// outside the workspace tree. Honors `.gitignore`.
2003 #[test]
2004 fn workspace_completions_walk_surfaces_workspace_and_cwd() {
2005 let tmp = TempDir::new().unwrap();
2006 // Two trees: a workspace under `ws/` and a cwd under `cwd/` that is
2007 // NOT inside the workspace, so the two walks are disjoint and we can
2008 // assert each branch contributed.
2009 let ws_root = tmp.path().join("ws");
2010 let cwd_root = tmp.path().join("cwd");
2011 std::fs::create_dir_all(&ws_root).unwrap();
2012 std::fs::create_dir_all(&cwd_root).unwrap();
2013 std::fs::write(ws_root.join("alpha.txt"), "a").unwrap();
2014 std::fs::write(cwd_root.join("alphabeta.txt"), "b").unwrap();
2015
2016 let ws = Workspace::with_cwd(ws_root.clone(), Some(cwd_root.clone()));
2017 let entries = ws.completions("alpha", 16);
2018 assert!(
2019 entries.iter().any(|e| e == "alpha.txt"),
2020 "expected workspace entry alpha.txt; got: {entries:?}",
2021 );
2022 assert!(
2023 entries.iter().any(|e| e == "alphabeta.txt"),
2024 "expected cwd entry alphabeta.txt; got: {entries:?}",
2025 );
2026 }
2027
2028 #[test]
2029 fn workspace_completions_honor_configured_walk_depth() {
2030 let tmp = TempDir::new().unwrap();
2031 // Sits at component depth 12, past the default walk depth (10) but
2032 // within the explicit deeper walk (16) below.
2033 let deep_dir = tmp.path().join("a/b/c/d/e/f/g/h/i/j/k");
2034 std::fs::create_dir_all(&deep_dir).unwrap();
2035 std::fs::write(deep_dir.join("target.txt"), "target").unwrap();
2036
2037 let default_ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2038 let default_entries = default_ws.completions("target", 16);
2039 assert!(
2040 !default_entries
2041 .iter()
2042 .any(|entry| entry.ends_with("target.txt")),
2043 "default depth should keep very deep entries out of the hot completion path: {default_entries:?}",
2044 );
2045
2046 let deep_ws = Workspace::with_cwd_and_depth(tmp.path().to_path_buf(), None, 16);
2047 let deep_entries = deep_ws.completions("target", 16);
2048 assert!(
2049 deep_entries
2050 .iter()
2051 .any(|entry| entry.ends_with("target.txt")),
2052 "configured deeper walk should surface the nested file: {deep_entries:?}",
2053 );
2054
2055 let unlimited_ws = Workspace::with_cwd_and_depth(tmp.path().to_path_buf(), None, 0);
2056 let unlimited_entries = unlimited_ws.completions("target", 16);
2057 assert!(
2058 unlimited_entries
2059 .iter()
2060 .any(|entry| entry.ends_with("target.txt")),
2061 "depth 0 should disable the completion walk depth limit: {unlimited_entries:?}",
2062 );
2063 }
2064
2065 #[test]
2066 fn browser_completions_show_only_immediate_children() {
2067 let tmp = TempDir::new().unwrap();
2068 std::fs::create_dir_all(tmp.path().join("src/nested")).unwrap();
2069 std::fs::write(tmp.path().join("src/lib.rs"), "lib").unwrap();
2070 std::fs::write(tmp.path().join("src/nested/deep.rs"), "deep").unwrap();
2071 std::fs::write(tmp.path().join("README.md"), "readme").unwrap();
2072
2073 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2074
2075 let root_entries = ws.browser_completions("", 16);
2076 assert_eq!(root_entries, vec!["README.md", "src/"]);
2077
2078 let src_entries = ws.browser_completions("src/", 16);
2079 assert_eq!(src_entries, vec!["src/lib.rs", "src/nested/"]);
2080 assert!(
2081 !src_entries.iter().any(|entry| entry.ends_with("deep.rs")),
2082 "browser mode must not walk past immediate children: {src_entries:?}",
2083 );
2084 }
2085
2086 #[test]
2087 fn browser_completions_hide_dot_entries_until_dot_query() {
2088 let tmp = TempDir::new().unwrap();
2089 std::fs::create_dir_all(tmp.path().join(".agents")).unwrap();
2090 std::fs::write(tmp.path().join(".env"), "secret-ish fixture").unwrap();
2091 std::fs::write(tmp.path().join("app.rs"), "app").unwrap();
2092
2093 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2094
2095 let default_entries = ws.browser_completions("", 16);
2096 assert_eq!(default_entries, vec!["app.rs"]);
2097
2098 let dot_entries = ws.browser_completions(".", 16);
2099 assert_eq!(dot_entries, vec![".agents/", ".env"]);
2100 }
2101
2102 #[test]
2103 fn browser_completions_reject_path_escape_segments() {
2104 let tmp = TempDir::new().unwrap();
2105 let workspace = tmp.path().join("workspace");
2106 let sibling = tmp.path().join("outside");
2107 std::fs::create_dir_all(&workspace).unwrap();
2108 std::fs::create_dir_all(&sibling).unwrap();
2109 std::fs::write(workspace.join("inside.rs"), "inside").unwrap();
2110 std::fs::write(sibling.join("secret.rs"), "outside").unwrap();
2111
2112 let ws = Workspace::with_cwd(workspace, None);
2113
2114 assert_eq!(ws.browser_completions("", 16), vec!["inside.rs"]);
2115 assert!(
2116 ws.browser_completions("../", 16).is_empty(),
2117 "browser mode must not list workspace siblings",
2118 );
2119 assert!(
2120 ws.browser_completions("../outside", 16).is_empty(),
2121 "browser mode must not complete names from outside the workspace",
2122 );
2123 }
2124
2125 #[test]
2126 fn workspace_completions_surface_explicit_hidden_and_ignored_paths() {
2127 let tmp = TempDir::new().unwrap();
2128 std::fs::write(tmp.path().join(".gitignore"), ".deepseek/\n.generated/\n").unwrap();
2129 std::fs::write(
2130 tmp.path().join(".deepseekignore"),
2131 ".generated/specs/secrets.env\n",
2132 )
2133 .unwrap();
2134 let deepseek_commands = tmp.path().join(".deepseek").join("commands");
2135 let generated_specs = tmp.path().join(".generated").join("specs");
2136 std::fs::create_dir_all(&deepseek_commands).unwrap();
2137 std::fs::create_dir_all(&generated_specs).unwrap();
2138 std::fs::write(deepseek_commands.join("start-task.md"), "start").unwrap();
2139 std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap();
2140 std::fs::write(generated_specs.join("secrets.env"), "secret").unwrap();
2141
2142 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf()));
2143
2144 let start_entries = ws.completions(".deepseek/commands", 16);
2145 assert!(
2146 start_entries
2147 .iter()
2148 .any(|e| e == ".deepseek/commands/start-task.md"),
2149 "expected explicitly addressed hidden command file in completions: {start_entries:?}",
2150 );
2151
2152 let generated_entries = ws.completions(".generated/specs", 16);
2153 assert!(
2154 generated_entries
2155 .iter()
2156 .any(|e| e == ".generated/specs/device-layout.md"),
2157 "expected explicitly addressed ignored user folder in completions: {generated_entries:?}",
2158 );
2159 assert!(
2160 !generated_entries
2161 .iter()
2162 .any(|e| e == ".generated/specs/secrets.env"),
2163 ".deepseekignore entries must not be reintroduced by local fallback: {generated_entries:?}",
2164 );
2165 }
2166
2167 #[test]
2168 fn workspace_completions_skip_hidden_worktrees_and_build_bulk() {
2169 let tmp = TempDir::new().unwrap();
2170 let root = tmp.path();
2171 std::fs::write(root.join(".gitignore"), ".worktrees/\n.generated/\n").unwrap();
2172
2173 std::fs::create_dir_all(root.join(".worktrees/release/src")).unwrap();
2174 std::fs::write(
2175 root.join(".worktrees/release/src/worktree-only.rs"),
2176 "fn main() {}",
2177 )
2178 .unwrap();
2179 std::fs::create_dir_all(root.join(".worktrees/release/target/debug")).unwrap();
2180 std::fs::write(
2181 root.join(".worktrees/release/target/debug/generated.o"),
2182 "object",
2183 )
2184 .unwrap();
2185
2186 std::fs::create_dir_all(root.join(".claude/worktrees/agent/src")).unwrap();
2187 std::fs::write(
2188 root.join(".claude/worktrees/agent/src/agent-only.md"),
2189 "agent note",
2190 )
2191 .unwrap();
2192 std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
2193 std::fs::write(root.join(".claude/commands/keep.md"), "command").unwrap();
2194
2195 std::fs::create_dir_all(root.join(".generated/specs")).unwrap();
2196 std::fs::write(root.join(".generated/specs/device-layout.md"), "layout").unwrap();
2197
2198 let ws = Workspace::with_cwd(root.to_path_buf(), Some(root.to_path_buf()));
2199
2200 let worktree_entries = ws.completions(".worktrees", 32);
2201 assert!(
2202 worktree_entries
2203 .iter()
2204 .all(|entry| !entry.starts_with(".worktrees/")),
2205 "hidden release worktrees must stay out of completions: {worktree_entries:?}",
2206 );
2207
2208 let claude_worktree_entries = ws.completions(".claude/worktrees", 32);
2209 assert!(
2210 claude_worktree_entries
2211 .iter()
2212 .all(|entry| !entry.starts_with(".claude/worktrees/")),
2213 ".claude/worktrees must stay out of completions: {claude_worktree_entries:?}",
2214 );
2215
2216 let generated_entries = ws.completions(".generated/specs", 32);
2217 assert!(
2218 generated_entries
2219 .iter()
2220 .any(|entry| entry == ".generated/specs/device-layout.md"),
2221 "explicit user-generated hidden folders should still complete: {generated_entries:?}",
2222 );
2223
2224 let command_entries = ws.completions(".claude/commands", 32);
2225 assert!(
2226 command_entries
2227 .iter()
2228 .any(|entry| entry == ".claude/commands/keep.md"),
2229 "normal .claude command files should still complete: {command_entries:?}",
2230 );
2231
2232 assert!(
2233 ws.resolve("worktree-only.rs").is_err(),
2234 "fuzzy resolution must not index files from hidden release worktrees"
2235 );
2236 assert!(
2237 ws.resolve("agent-only.md").is_err(),
2238 "fuzzy resolution must not index files from .claude/worktrees"
2239 );
2240 assert!(ws.resolve("keep.md").is_ok());
2241 }
2242
2243 #[test]
2244 fn fuzzy_index_resolves_hidden_and_ignored_files_except_deepseekignored() {
2245 let tmp = TempDir::new().unwrap();
2246 std::fs::write(tmp.path().join(".gitignore"), ".generated/\n").unwrap();
2247 std::fs::write(
2248 tmp.path().join(".deepseekignore"),
2249 ".generated/specs/secrets.env\n",
2250 )
2251 .unwrap();
2252 let generated_specs = tmp.path().join(".generated").join("specs");
2253 std::fs::create_dir_all(&generated_specs).unwrap();
2254 std::fs::write(generated_specs.join("device-layout.md"), "layout").unwrap();
2255 std::fs::write(generated_specs.join("secrets.env"), "secret").unwrap();
2256
2257 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2258 let resolved = ws.resolve("device-layout.md").unwrap();
2259
2260 assert!(resolved.ends_with(".generated/specs/device-layout.md"));
2261 assert!(
2262 ws.resolve("secrets.env").is_err(),
2263 "basename fuzzy resolution must honor .deepseekignore"
2264 );
2265 assert!(
2266 ws.resolve(".generated/specs/secrets.env").is_ok(),
2267 "exact user-specified paths should still resolve"
2268 );
2269 }
2270
2271 #[test]
2272 fn fuzzy_index_finds_files_and_directories() {
2273 let tmp = TempDir::new().unwrap();
2274 std::fs::create_dir_all(tmp.path().join("a/b/target_dir")).unwrap();
2275 std::fs::write(tmp.path().join("a/b/needle.rs"), "fn main(){}").unwrap();
2276
2277 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2278
2279 // Basename-only mention triggers fuzzy fallback for both files and dirs.
2280 let f = ws.resolve("needle.rs").unwrap();
2281 assert!(f.ends_with("a/b/needle.rs"));
2282 let d = ws.resolve("target_dir").unwrap();
2283 assert!(d.ends_with("a/b/target_dir"));
2284
2285 // Index was populated exactly once (subsequent lookups reuse it).
2286 assert!(ws.file_index.get().is_some());
2287 }
2288
2289 /// Regression: `@`-mention completion must discover files inside
2290 /// `.deepseek/`, `.cursor/`, `.claude/`, `.agents/` even when
2291 /// those directories are excluded by `.gitignore` (or `.ignore`).
2292 /// The `discovery_walk_builder` override un-ignores them.
2293 #[test]
2294 fn completions_discovers_files_inside_gitignored_dot_dirs() {
2295 let tmp = TempDir::new().unwrap();
2296 let root = tmp.path();
2297
2298 // `.ignore` works even outside a git repo; use it to simulate
2299 // a project that gitignores its AI-tool dot-directories.
2300 std::fs::write(
2301 root.join(".ignore"),
2302 ".deepseek/\n.cursor/\n.claude/\n.agents/\n",
2303 )
2304 .unwrap();
2305
2306 // Create files inside each dot-dir.
2307 std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
2308 std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
2309 std::fs::create_dir_all(root.join(".cursor/commands")).unwrap();
2310 std::fs::write(root.join(".cursor/commands/run.md"), "run cmd").unwrap();
2311 std::fs::create_dir_all(root.join(".claude/commands")).unwrap();
2312 std::fs::write(root.join(".claude/commands/test.md"), "test cmd").unwrap();
2313 std::fs::create_dir_all(root.join(".agents/skills/example")).unwrap();
2314 std::fs::write(
2315 root.join(".agents/skills/example/SKILL.md"),
2316 "name: example\n",
2317 )
2318 .unwrap();
2319
2320 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2321
2322 // Completions should find entries inside the dot-dirs.
2323 {
2324 let entries = ws.completions("build", 16);
2325 assert!(
2326 entries.iter().any(|e| e.contains("build.md")),
2327 "expected build.md in completions although .deepseek/ is ignored; got: {entries:?}"
2328 );
2329 }
2330 {
2331 let entries = ws.completions("run", 16);
2332 assert!(
2333 entries.iter().any(|e| e.contains("run.md")),
2334 "expected run.md from .cursor/; got: {entries:?}"
2335 );
2336 }
2337 {
2338 let entries = ws.completions("test", 16);
2339 assert!(
2340 entries.iter().any(|e| e.contains("test.md")),
2341 "expected test.md from .claude/; got: {entries:?}"
2342 );
2343 }
2344
2345 // Fuzzy resolution should also work.
2346 let f = ws.resolve("build.md").unwrap();
2347 assert!(f.ends_with("build.md"));
2348 let f2 = ws.resolve("SKILL.md").unwrap();
2349 assert!(f2.ends_with("SKILL.md"));
2350 }
2351
2352 /// Regression: the dot-dir walk must NOT index `.deepseek/snapshots/`,
2353 /// which is the snapshot side repo that can grow to hundreds of GB.
2354 /// Indexing it would re-create the same OOM/hang that #1112 was built
2355 /// to prevent.
2356 #[test]
2357 fn dot_dir_walk_excludes_snapshot_side_repo() {
2358 let tmp = TempDir::new().unwrap();
2359 let root = tmp.path();
2360
2361 // Create a snapshot-like directory tree.
2362 std::fs::create_dir_all(root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects"))
2363 .unwrap();
2364 std::fs::write(
2365 root.join(".deepseek/snapshots/deadbeef/deadbeef/.git/objects/snapshot.pack"),
2366 b"fake pack data",
2367 )
2368 .unwrap();
2369 // Also create a legitimate file in .deepseek/ that should be found.
2370 std::fs::create_dir_all(root.join(".deepseek/commands")).unwrap();
2371 std::fs::write(root.join(".deepseek/commands/build.md"), "build cmd").unwrap();
2372
2373 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2374
2375 // Searching for "build" must find build.md.
2376 let entries = ws.completions("build", 16);
2377 assert!(
2378 entries.iter().any(|e| e.contains("build.md")),
2379 "build.md must still be found; got: {entries:?}"
2380 );
2381 // Searching for "snapshot" must NOT return snapshot files.
2382 let snap_entries = ws.completions("snapshot", 16);
2383 assert!(
2384 !snap_entries.iter().any(|e| e.contains("snapshot")),
2385 "snapshot files must NOT appear in completions; got: {snap_entries:?}"
2386 );
2387
2388 // Fuzzy index must also exclude snapshots.
2389 let f = ws.resolve("build.md").unwrap();
2390 assert!(f.ends_with("build.md"));
2391 // snapshot.pack should NOT resolve.
2392 let result = ws.resolve("snapshot.pack");
2393 assert!(
2394 result.is_err(),
2395 "snapshot.pack must not resolve via fuzzy index"
2396 );
2397 }
2398
2399 /// Regression for #1921 — typing `@/` (or `@.`) must NOT trigger the
2400 /// `local_reference_paths` walk, which scans up to
2401 /// `LOCAL_REFERENCE_SCAN_LIMIT` paths on the UI thread. On WSL2 with a
2402 /// `/mnt/c/...` workspace this hangs the composer for seconds to minutes.
2403 #[test]
2404 fn should_try_local_reference_completion_skips_bare_separators_and_dots() {
2405 // The trigger gate must reject bare separators/dots.
2406 assert!(!should_try_local_reference_completion("/"));
2407 assert!(!should_try_local_reference_completion("\\"));
2408 assert!(!should_try_local_reference_completion("."));
2409 assert!(!should_try_local_reference_completion(".."));
2410 // Empty string was already rejected; keep that.
2411 assert!(!should_try_local_reference_completion(""));
2412
2413 // Actionable references must still trigger.
2414 assert!(should_try_local_reference_completion("./foo"));
2415 assert!(should_try_local_reference_completion("../bar"));
2416 assert!(should_try_local_reference_completion(".env"));
2417 assert!(should_try_local_reference_completion("path/"));
2418 assert!(should_try_local_reference_completion("path/to/file"));
2419 assert!(should_try_local_reference_completion("/usr"));
2420 }
2421
2422 #[test]
2423 fn cached_candidates_rank_like_live_completions() {
2424 // #3757: the composer caches one full candidate walk and ranks per
2425 // keystroke in memory; the ranked result must match what the live
2426 // walk would return for non-path-like needles.
2427 let tmp = TempDir::new().unwrap();
2428 let root = tmp.path();
2429 std::fs::create_dir_all(root.join("src")).unwrap();
2430 std::fs::write(root.join("src/main.rs"), "fn main() {}").unwrap();
2431 std::fs::write(root.join("src/mention.rs"), "// m").unwrap();
2432 std::fs::write(root.join("README.md"), "# readme").unwrap();
2433 std::fs::write(root.join("Makefile"), "all:").unwrap();
2434
2435 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2436 let candidates = ws.completion_candidates();
2437 assert!(
2438 candidates.iter().any(|c| c == "src/main.rs"),
2439 "{candidates:?}"
2440 );
2441
2442 for needle in ["ma", "readme", "men", ""] {
2443 let live = ws.completions(needle, 16);
2444 let ranked = rank_completion_candidates(&candidates, needle, 16);
2445 assert_eq!(ranked, live, "needle {needle:?}");
2446 }
2447
2448 // Limit truncation applies after prefix/substring bucketing.
2449 let ranked = rank_completion_candidates(&candidates, "ma", 1);
2450 assert_eq!(ranked.len(), 1);
2451 assert!(ranked[0].to_lowercase().starts_with("ma"), "{ranked:?}");
2452 }
2453
2454 #[test]
2455 fn background_completion_discovery_is_hard_capped_on_large_trees() {
2456 let tmp = TempDir::new().unwrap();
2457 for i in 0..256 {
2458 std::fs::write(tmp.path().join(format!("candidate_{i:03}.rs")), "x").unwrap();
2459 }
2460 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
2461 let never_cancelled = || false;
2462
2463 let candidates = ws.completion_discovery_candidates(32, &never_cancelled);
2464
2465 assert_eq!(
2466 candidates.len(),
2467 32,
2468 "the background cache must stop at its hard candidate limit"
2469 );
2470 }
2471
2472 /// Regression for #1921 — `completions("/", N)` must return without
2473 /// invoking `local_reference_paths`, even on a workspace large enough
2474 /// to expose the original 4096-path walk. We can't assert "doesn't
2475 /// touch the disk", but we can assert the call completes promptly and
2476 /// stays within the requested limit.
2477 #[test]
2478 fn completions_for_bare_slash_does_not_trigger_local_reference_walk() {
2479 let tmp = TempDir::new().unwrap();
2480 let root = tmp.path();
2481 // Lay out enough files that a runaway walk would be visibly slow,
2482 // but the bounded path returns near-instantly. Depth-1 entries are
2483 // enough; we don't need to stress the filesystem.
2484 for i in 0..40 {
2485 std::fs::write(root.join(format!("file_{i}.txt")), "x").unwrap();
2486 }
2487 let ws = Workspace::with_cwd(root.to_path_buf(), None);
2488
2489 let start = std::time::Instant::now();
2490 let entries = ws.completions("/", 64);
2491 let elapsed = start.elapsed();
2492
2493 // Behavioral assertions:
2494 // 1. The call returns within a generous bound. Real freezes on
2495 // WSL2 were tens of seconds; a 2s budget is comfortable for a
2496 // 40-file tmp dir on any CI host.
2497 assert!(
2498 elapsed < std::time::Duration::from_secs(2),
2499 "completions(\"/\") took too long: {elapsed:?} (likely re-introduced #1921)"
2500 );
2501 // 2. Results stay within the requested cap.
2502 assert!(entries.len() <= 64);
2503 }
2504 }
2505
2505 lines RUST