| 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 |