返回 DeepSeek-TUI-2026
working_set.rs
根目录 / crates / tui / src / working_set.rs
1 //! Repo-aware working set tracking and prompt context packing.
2 //!
3 //! The goal of this module is to keep a small, high-signal list of
4 //! "active" paths that the assistant should prioritize. It observes
5 //! user messages and tool calls, extracts likely paths, and produces:
6 //! - a compact working-set summary block for the system prompt
7 //! - pinned message indices that compaction should preserve
8
9 use crate::models::{ContentBlock, Message};
10 use ignore::WalkBuilder;
11 use regex::Regex;
12 use serde::{Deserialize, Serialize};
13 use serde_json::Value;
14 use std::collections::{HashMap, HashSet};
15 use std::ffi::OsStr;
16 use std::fs;
17 use std::path::{Path, PathBuf};
18 use std::sync::OnceLock;
19
20 /// Repo-aware resolver for `@`-mentions and file pickers.
21 ///
22 /// `cwd` is captured at construction; if the host's current directory changes
23 /// during a session, build a fresh `Workspace`. Fuzzy lookups are backed by a
24 /// lazy basename → paths index built once on first miss and reused for the
25 /// rest of the session — without it, every mis-typed mention triggered a full
26 /// `WalkBuilder` traversal up to depth 6 (Gemini code-review feedback).
27 #[derive(Debug)]
28 pub struct Workspace {
29 pub root: PathBuf,
30 cwd: Option<PathBuf>,
31 file_index: OnceLock<HashMap<String, Vec<PathBuf>>>,
32 }
33
34 impl Workspace {
35 /// Construct a workspace anchored at `root`, capturing the process CWD as
36 /// the secondary resolution pass. Convenience entry point intended for
37 /// callers that don't already have a CWD on hand; the App routes through
38 /// [`Workspace::with_cwd`] with its own captured launch directory.
39 #[allow(dead_code)] // Keeps the surface stable for #97 (Ctrl+P picker).
40 pub fn new(root: PathBuf) -> Self {
41 Self::with_cwd(root, std::env::current_dir().ok())
42 }
43
44 /// Construct with an explicit cwd. Used by tests that need deterministic
45 /// resolution against a known directory without depending on (and
46 /// mutating) the process's real working directory.
47 pub fn with_cwd(root: PathBuf, cwd: Option<PathBuf>) -> Self {
48 Self {
49 root,
50 cwd,
51 file_index: OnceLock::new(),
52 }
53 }
54
55 /// Two-pass resolution: workspace, then cwd, then fuzzy fallback.
56 pub fn resolve(&self, raw_path: &str) -> Result<PathBuf, PathBuf> {
57 let path = expand_mention_home(raw_path);
58 if path.is_absolute() {
59 if path.exists() {
60 return Ok(path);
61 }
62 return Err(path);
63 }
64
65 let ws_path = self.root.join(&path);
66 if ws_path.exists() {
67 return Ok(ws_path);
68 }
69
70 if let Some(cwd) = self.cwd.as_ref() {
71 let cwd_path = cwd.join(&path);
72 if cwd_path.exists() {
73 return Ok(cwd_path);
74 }
75 }
76
77 if let Some(fuzzy) = self.fuzzy_resolve(&path) {
78 return Ok(fuzzy);
79 }
80
81 Err(ws_path)
82 }
83
84 fn fuzzy_resolve(&self, path: &Path) -> Option<PathBuf> {
85 let needle = path.file_name()?.to_string_lossy().to_lowercase();
86 if needle.is_empty() {
87 return None;
88 }
89
90 let index = self.file_index.get_or_init(|| self.build_file_index());
91 index.get(&needle).and_then(|paths| paths.first()).cloned()
92 }
93
94 fn build_file_index(&self) -> HashMap<String, Vec<PathBuf>> {
95 let mut index: HashMap<String, Vec<PathBuf>> = HashMap::new();
96 let mut builder = WalkBuilder::new(&self.root);
97 builder.hidden(true).follow_links(false).max_depth(Some(6));
98 // Honor `.deepseekignore` in addition to the defaults the `ignore` crate
99 // already respects (`.gitignore`, `.git/info/exclude`, `.ignore`).
100 let _ = builder.add_custom_ignore_filename(".deepseekignore");
101
102 for entry in builder.build().flatten() {
103 if entry
104 .file_type()
105 .is_some_and(|ft| ft.is_file() || ft.is_dir())
106 {
107 let name = entry.file_name().to_string_lossy().to_lowercase();
108 index
109 .entry(name)
110 .or_default()
111 .push(entry.path().to_path_buf());
112 }
113 }
114 index
115 }
116
117 /// Walk the workspace (and the recorded `cwd` when it diverges) and
118 /// return relative paths whose representation matches `partial`.
119 ///
120 /// Ranking: a candidate matches when its case-insensitive display string
121 /// starts with `partial` (prefix hit) or contains it as a substring; prefix
122 /// hits sort first so `docs/de` lands `docs/deepseek_v4.pdf` ahead of any
123 /// path that merely shares those bytes.
124 ///
125 /// Display strings are workspace-relative for files under `root`, and
126 /// cwd-relative for files only under the recorded `cwd` — so what the user
127 /// Tab-completes matches what their shell would have shown them.
128 ///
129 /// Honors `.gitignore`, `.git/info/exclude`, `.ignore`, and
130 /// `.deepseekignore`. Capped at `limit` results.
131 #[must_use]
132 pub fn completions(&self, partial: &str, limit: usize) -> Vec<String> {
133 if limit == 0 {
134 return Vec::new();
135 }
136 let needle = partial.to_lowercase();
137 let mut prefix_hits: Vec<String> = Vec::new();
138 let mut substring_hits: Vec<String> = Vec::new();
139 let mut seen: HashSet<PathBuf> = HashSet::new();
140
141 // Walk the recorded cwd first when it diverges from the workspace
142 // root, so cwd-relative entries appear ahead of duplicates surfaced by
143 // the workspace walk.
144 let cwd_diverges = self
145 .cwd
146 .as_deref()
147 .map(|c| c != self.root.as_path())
148 .unwrap_or(false);
149 if cwd_diverges && let Some(cwd) = self.cwd.as_deref() {
150 walk_for_completions(
151 cwd,
152 cwd,
153 &needle,
154 limit,
155 &mut prefix_hits,
156 &mut substring_hits,
157 &mut seen,
158 );
159 }
160 walk_for_completions(
161 &self.root,
162 &self.root,
163 &needle,
164 limit,
165 &mut prefix_hits,
166 &mut substring_hits,
167 &mut seen,
168 );
169
170 prefix_hits.sort();
171 substring_hits.sort();
172 prefix_hits.extend(substring_hits);
173 prefix_hits.truncate(limit);
174 prefix_hits
175 }
176 }
177
178 /// Maximum directory depth walked when surfacing file-mention completions.
179 /// Mirrors the existing `project_tree` cutoff and keeps Tab snappy in deep
180 /// monorepos.
181 const COMPLETIONS_WALK_DEPTH: usize = 6;
182
183 #[allow(clippy::too_many_arguments)]
184 fn walk_for_completions(
185 walk_root: &Path,
186 display_root: &Path,
187 needle: &str,
188 limit: usize,
189 prefix_hits: &mut Vec<String>,
190 substring_hits: &mut Vec<String>,
191 seen: &mut HashSet<PathBuf>,
192 ) {
193 let mut builder = WalkBuilder::new(walk_root);
194 builder
195 .hidden(true)
196 .follow_links(false)
197 .max_depth(Some(COMPLETIONS_WALK_DEPTH));
198 let _ = builder.add_custom_ignore_filename(".deepseekignore");
199
200 for entry in builder.build().flatten() {
201 if prefix_hits.len() + substring_hits.len() >= limit {
202 break;
203 }
204 let path = entry.path();
205 let Ok(rel) = path.strip_prefix(display_root) else {
206 continue;
207 };
208 let rel_str = rel.to_string_lossy().replace('\\', "/");
209 if rel_str.is_empty() {
210 continue;
211 }
212 // Dedup across the (cwd, workspace) double-walk by absolute path; we
213 // want the cwd-relative display when both walks see the same file.
214 let abs = path.to_path_buf();
215 if !seen.insert(abs) {
216 continue;
217 }
218 let is_dir = entry.file_type().is_some_and(|ft| ft.is_dir());
219 let candidate = if is_dir {
220 format!("{rel_str}/")
221 } else {
222 rel_str.clone()
223 };
224 let lower = candidate.to_lowercase();
225 if needle.is_empty() || lower.starts_with(needle) {
226 prefix_hits.push(candidate);
227 } else if lower.contains(needle) {
228 substring_hits.push(candidate);
229 }
230 }
231 }
232
233 impl Clone for Workspace {
234 fn clone(&self) -> Self {
235 // Don't carry the cached file_index — clones get a fresh OnceLock so
236 // they don't pin a stale snapshot of the previous owner's tree.
237 Self {
238 root: self.root.clone(),
239 cwd: self.cwd.clone(),
240 file_index: OnceLock::new(),
241 }
242 }
243 }
244
245 fn expand_mention_home(path: &str) -> PathBuf {
246 if path == "~"
247 && let Some(home) = std::env::var_os("HOME")
248 {
249 return PathBuf::from(home);
250 }
251 if let Some(rest) = path.strip_prefix("~/")
252 && let Some(home) = std::env::var_os("HOME")
253 {
254 return PathBuf::from(home).join(rest);
255 }
256 PathBuf::from(path)
257 }
258
259 /// Configuration for working-set tracking.
260 #[derive(Debug, Clone, Serialize, Deserialize)]
261 pub struct WorkingSetConfig {
262 /// Maximum number of entries to keep.
263 pub max_entries: usize,
264 /// Maximum number of paths to pin during compaction.
265 pub max_pinned_paths: usize,
266 /// Maximum characters to scan per text block when pinning messages.
267 pub max_scan_chars: usize,
268 /// Maximum entries to show in the system prompt block.
269 pub max_prompt_entries: usize,
270 }
271
272 impl Default for WorkingSetConfig {
273 fn default() -> Self {
274 Self {
275 max_entries: 16,
276 max_pinned_paths: 8,
277 max_scan_chars: 2_000,
278 max_prompt_entries: 8,
279 }
280 }
281 }
282
283 /// The source that most recently updated an entry.
284 #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
285 pub enum WorkingSetSource {
286 UserMessage,
287 ToolInput,
288 ToolOutput,
289 Rebuild,
290 }
291
292 /// A single working-set entry.
293 #[derive(Debug, Clone, Serialize, Deserialize)]
294 pub struct WorkingSetEntry {
295 /// Workspace-relative path string.
296 pub path: String,
297 /// Whether the path is a directory (best-effort).
298 pub is_dir: bool,
299 /// Whether the path exists on disk (best-effort).
300 pub exists: bool,
301 /// Number of times this path was observed.
302 pub touches: u32,
303 /// The last observed turn index.
304 pub last_turn: u64,
305 /// The last update source.
306 pub last_source: WorkingSetSource,
307 }
308
309 impl WorkingSetEntry {
310 fn new(path: String, exists: bool, is_dir: bool, turn: u64, source: WorkingSetSource) -> Self {
311 Self {
312 path,
313 is_dir,
314 exists,
315 touches: 1,
316 last_turn: turn,
317 last_source: source,
318 }
319 }
320 }
321
322 /// Repo-aware working-set state.
323 #[derive(Debug, Clone, Serialize, Deserialize, Default)]
324 pub struct WorkingSet {
325 /// Tracking configuration.
326 pub config: WorkingSetConfig,
327 /// Monotonic turn counter (increments on user messages).
328 pub turn: u64,
329 /// Path entries keyed by workspace-relative path.
330 pub entries: HashMap<String, WorkingSetEntry>,
331 }
332
333 impl WorkingSet {
334 /// Advance to the next turn.
335 pub fn next_turn(&mut self) {
336 self.turn = self.turn.saturating_add(1);
337 }
338
339 /// Observe a user message and update the working set.
340 pub fn observe_user_message(&mut self, text: &str, workspace: &Path) {
341 self.next_turn();
342 let paths = extract_paths_from_text(text);
343 self.record_candidates(paths, workspace, WorkingSetSource::UserMessage);
344 }
345
346 /// Observe a tool call (input and optional output).
347 pub fn observe_tool_call(
348 &mut self,
349 tool_name: &str,
350 input: &Value,
351 output: Option<&str>,
352 workspace: &Path,
353 ) {
354 let input_candidates = extract_paths_from_value(input, Some(tool_name));
355 self.record_candidates(input_candidates, workspace, WorkingSetSource::ToolInput);
356
357 if let Some(text) = output {
358 let output_candidates = extract_paths_from_text(text);
359 self.record_candidates(output_candidates, workspace, WorkingSetSource::ToolOutput);
360 }
361 }
362
363 /// Rebuild the working set from existing messages (best effort).
364 ///
365 /// This is used when syncing a resumed session.
366 pub fn rebuild_from_messages(&mut self, messages: &[Message], workspace: &Path) {
367 self.entries.clear();
368 self.turn = 0;
369
370 for message in messages {
371 if message.role == "user" {
372 self.next_turn();
373 }
374 let candidates = extract_paths_from_message(message);
375 if candidates.is_empty() {
376 continue;
377 }
378 self.record_candidates(candidates, workspace, WorkingSetSource::Rebuild);
379 }
380 }
381
382 /// Render a compact working-set block for the system prompt.
383 ///
384 /// Byte-stable across `next_turn()` calls when no new paths are observed
385 /// (#280): the rendered lines drop the turn-relative `touches` and
386 /// `last seen N turn(s) ago` fields, and the order is taken from
387 /// `sorted_for_prompt` (turn-agnostic) instead of `sorted_entries`.
388 /// The block lands in the system prompt before the historical
389 /// conversation; any byte that drifts here cache-misses everything that
390 /// follows in DeepSeek's KV prefix cache.
391 pub fn summary_block(&self, workspace: &Path) -> Option<String> {
392 let prompt_entries: Vec<&WorkingSetEntry> = self
393 .sorted_for_prompt()
394 .into_iter()
395 .take(self.config.max_prompt_entries)
396 .collect();
397
398 let repo_summary = summarize_repo_root(workspace);
399
400 if repo_summary.is_none() && prompt_entries.is_empty() {
401 return None;
402 }
403
404 let mut lines: Vec<String> = Vec::new();
405 lines.push("## Repo Working Set".to_string());
406 lines.push(format!("Workspace: {}", workspace.display()));
407
408 if let Some(summary) = repo_summary {
409 lines.push(summary);
410 }
411
412 if !prompt_entries.is_empty() {
413 lines.push("Active paths (prioritize these):".to_string());
414 for entry in prompt_entries {
415 let kind = if entry.is_dir { "dir" } else { "file" };
416 lines.push(format!("- {} ({kind})", entry.path));
417 }
418 }
419
420 lines.push(
421 "When in doubt, use tools to verify and keep changes focused on the working set."
422 .to_string(),
423 );
424
425 Some(lines.join("\n"))
426 }
427
428 /// Return the most relevant paths in score order.
429 pub fn top_paths(&self, limit: usize) -> Vec<String> {
430 self.sorted_entries()
431 .into_iter()
432 .take(limit)
433 .map(|entry| entry.path.clone())
434 .collect()
435 }
436
437 /// Identify message indices that should be pinned during compaction.
438 pub fn pinned_message_indices(&self, messages: &[Message], workspace: &Path) -> Vec<usize> {
439 if messages.is_empty() || self.entries.is_empty() {
440 return Vec::new();
441 }
442
443 let pinned_paths: Vec<&WorkingSetEntry> = self
444 .sorted_entries()
445 .into_iter()
446 .take(self.config.max_pinned_paths)
447 .collect();
448 if pinned_paths.is_empty() {
449 return Vec::new();
450 }
451
452 let needles = build_search_needles(&pinned_paths, workspace);
453 if needles.is_empty() {
454 return Vec::new();
455 }
456
457 let mut pinned: Vec<usize> = Vec::new();
458 for (idx, message) in messages.iter().enumerate() {
459 if message_mentions_any_path(message, &needles, self.config.max_scan_chars) {
460 pinned.push(idx);
461 }
462 }
463 pinned
464 }
465
466 fn record_candidates(
467 &mut self,
468 candidates: Vec<String>,
469 workspace: &Path,
470 source: WorkingSetSource,
471 ) {
472 if candidates.is_empty() {
473 return;
474 }
475
476 let workspace_canon = workspace.canonicalize().ok();
477
478 for raw in candidates {
479 let Some(normalized) = normalize_candidate(&raw) else {
480 continue;
481 };
482 let Some((rel, exists, is_dir)) =
483 relativize_candidate(&normalized, workspace, workspace_canon.as_deref())
484 else {
485 continue;
486 };
487 self.record_path(rel, exists, is_dir, source);
488 }
489
490 self.prune();
491 }
492
493 fn record_path(&mut self, rel: String, exists: bool, is_dir: bool, source: WorkingSetSource) {
494 match self.entries.get_mut(&rel) {
495 Some(entry) => {
496 entry.exists |= exists;
497 entry.is_dir |= is_dir;
498 entry.touches = entry.touches.saturating_add(1);
499 entry.last_turn = self.turn;
500 entry.last_source = source;
501 }
502 None => {
503 let entry = WorkingSetEntry::new(rel.clone(), exists, is_dir, self.turn, source);
504 let _ = self.entries.insert(rel, entry);
505 }
506 }
507 }
508
509 fn prune(&mut self) {
510 let max_entries = self.config.max_entries;
511 if self.entries.len() <= max_entries {
512 return;
513 }
514
515 // Rank by score ascending and drop the lowest until within bounds.
516 let mut ranked: Vec<(String, i64)> = self
517 .entries
518 .values()
519 .map(|entry| (entry.path.clone(), score_entry(entry, self.turn)))
520 .collect();
521 ranked.sort_by_key(|a| a.1);
522
523 let to_remove = self.entries.len().saturating_sub(max_entries);
524 for (path, _) in ranked.into_iter().take(to_remove) {
525 let _ = self.entries.remove(&path);
526 }
527 }
528
529 fn sorted_entries(&self) -> Vec<&WorkingSetEntry> {
530 let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect();
531 entries.sort_by(|a, b| {
532 let sb = score_entry(b, self.turn);
533 let sa = score_entry(a, self.turn);
534 sb.cmp(&sa).then_with(|| a.path.cmp(&b.path))
535 });
536 entries
537 }
538
539 /// Turn-agnostic ordering used when rendering the prompt summary block.
540 /// `sorted_entries` mixes in a recency bonus from `self.turn`, so its
541 /// output reorders as turns advance even when no new paths are touched —
542 /// that movement would cross `max_prompt_entries` boundaries and bust the
543 /// KV prefix cache (#280). Compaction pinning still uses the recency-aware
544 /// `sorted_entries`; only the prompt-facing surface is stabilised here.
545 fn sorted_for_prompt(&self) -> Vec<&WorkingSetEntry> {
546 let mut entries: Vec<&WorkingSetEntry> = self.entries.values().collect();
547 entries.sort_by(|a, b| b.touches.cmp(&a.touches).then_with(|| a.path.cmp(&b.path)));
548 entries
549 }
550 }
551
552 fn score_entry(entry: &WorkingSetEntry, current_turn: u64) -> i64 {
553 let age = current_turn.saturating_sub(entry.last_turn);
554 let recency_bonus = match age {
555 0 => 6,
556 1 => 4,
557 2 => 3,
558 3..=5 => 2,
559 6..=10 => 1,
560 _ => 0,
561 };
562 i64::from(entry.touches) * 4 + recency_bonus
563 }
564
565 fn normalize_candidate(raw: &str) -> Option<String> {
566 let trimmed = raw.trim().trim_matches(|c: char| {
567 matches!(
568 c,
569 '"' | '\'' | '`' | ',' | ';' | ':' | '(' | ')' | '[' | ']'
570 )
571 });
572 if trimmed.is_empty() {
573 return None;
574 }
575 Some(trimmed.to_string())
576 }
577
578 fn relativize_candidate(
579 candidate: &str,
580 workspace: &Path,
581 workspace_canon: Option<&Path>,
582 ) -> Option<(String, bool, bool)> {
583 let candidate_path = Path::new(candidate);
584
585 // Reject obvious URLs and non-paths early.
586 if candidate.contains("://") {
587 return None;
588 }
589
590 let (rel_path, abs_path) = if candidate_path.is_absolute() {
591 let within_workspace = workspace_canon
592 .map(|ws| candidate_path.starts_with(ws))
593 .unwrap_or_else(|| candidate_path.starts_with(workspace));
594 if !within_workspace {
595 return None;
596 }
597 let rel = candidate_path.strip_prefix(workspace).ok()?.to_path_buf();
598 (rel, candidate_path.to_path_buf())
599 } else {
600 if starts_with_parent_dir(candidate_path) {
601 return None;
602 }
603 let rel = clean_relative(candidate_path);
604 let abs = workspace.join(&rel);
605 (rel, abs)
606 };
607
608 let metadata = fs::metadata(&abs_path).ok();
609 let exists = metadata.is_some();
610 let is_dir = metadata
611 .as_ref()
612 .map(fs::Metadata::is_dir)
613 .unwrap_or_else(|| candidate.ends_with('/'));
614
615 let rel_string = path_to_string(&rel_path)?;
616 Some((rel_string, exists, is_dir))
617 }
618
619 fn starts_with_parent_dir(path: &Path) -> bool {
620 matches!(
621 path.components().next(),
622 Some(std::path::Component::ParentDir)
623 )
624 }
625
626 fn clean_relative(path: &Path) -> PathBuf {
627 use std::path::Component;
628
629 let mut parts: Vec<PathBuf> = Vec::new();
630 for comp in path.components() {
631 match comp {
632 Component::CurDir => {}
633 Component::ParentDir => {
634 let _ = parts.pop();
635 }
636 Component::Normal(p) => parts.push(PathBuf::from(p)),
637 Component::RootDir | Component::Prefix(_) => {}
638 }
639 }
640 let mut out = PathBuf::new();
641 for part in parts {
642 out.push(part);
643 }
644 out
645 }
646
647 fn path_to_string(path: &Path) -> Option<String> {
648 path.as_os_str().to_str().map(|s| s.replace('\\', "/"))
649 }
650
651 fn extract_paths_from_message(message: &Message) -> Vec<String> {
652 let mut paths = Vec::new();
653 for block in &message.content {
654 match block {
655 ContentBlock::Text { text, .. } => {
656 paths.extend(extract_paths_from_text(text));
657 }
658 ContentBlock::ToolUse { input, .. } => {
659 paths.extend(extract_paths_from_value(input, None));
660 }
661 ContentBlock::ToolResult { content, .. } => {
662 paths.extend(extract_paths_from_text(content));
663 }
664 ContentBlock::Thinking { .. }
665 | ContentBlock::ServerToolUse { .. }
666 | ContentBlock::ToolSearchToolResult { .. }
667 | ContentBlock::CodeExecutionToolResult { .. } => {}
668 }
669 }
670 paths
671 }
672
673 fn extract_paths_from_value(value: &Value, tool_hint: Option<&str>) -> Vec<String> {
674 let mut out = Vec::new();
675 extract_paths_from_value_inner(value, tool_hint, None, &mut out);
676 out
677 }
678
679 fn extract_paths_from_value_inner(
680 value: &Value,
681 tool_hint: Option<&str>,
682 key_hint: Option<&str>,
683 out: &mut Vec<String>,
684 ) {
685 match value {
686 Value::String(s) => {
687 let key_suggests_path = key_hint.map(key_is_path_like).unwrap_or(false);
688 if key_suggests_path || looks_like_path(s) {
689 out.extend(extract_paths_from_text(s));
690 if key_suggests_path && !s.contains('/') && !s.contains('\\') {
691 out.push(s.to_string());
692 }
693 } else if tool_hint == Some("exec_shell") && s.len() < 400 {
694 out.extend(extract_paths_from_text(s));
695 }
696 }
697 Value::Array(arr) => {
698 for item in arr {
699 extract_paths_from_value_inner(item, tool_hint, key_hint, out);
700 }
701 }
702 Value::Object(map) => {
703 for (k, v) in map {
704 extract_paths_from_value_inner(v, tool_hint, Some(k.as_str()), out);
705 }
706 }
707 Value::Null | Value::Bool(_) | Value::Number(_) => {}
708 }
709 }
710
711 fn key_is_path_like(key: &str) -> bool {
712 let lower = key.to_ascii_lowercase();
713 lower.contains("path")
714 || lower.contains("file")
715 || lower.contains("dir")
716 || lower.contains("cwd")
717 || lower.contains("workspace")
718 || lower.contains("root")
719 || lower == "target"
720 }
721
722 fn looks_like_path(text: &str) -> bool {
723 let trimmed = text.trim();
724 if trimmed.is_empty() {
725 return false;
726 }
727 if trimmed.contains('/') || trimmed.contains('\\') {
728 return true;
729 }
730 match Path::new(trimmed).extension().and_then(OsStr::to_str) {
731 Some(ext) => COMMON_EXTENSIONS.contains(&ext),
732 None => false,
733 }
734 }
735
736 const COMMON_EXTENSIONS: &[&str] = &[
737 "rs", "toml", "md", "txt", "json", "yaml", "yml", "ts", "tsx", "js", "jsx", "py", "go", "java",
738 "c", "cc", "cpp", "h", "hpp", "sh", "bash", "zsh", "sql", "html", "css", "scss",
739 ];
740
741 fn extract_paths_from_text(text: &str) -> Vec<String> {
742 if text.trim().is_empty() {
743 return Vec::new();
744 }
745
746 let re = path_regex();
747 re.find_iter(text)
748 .map(|m| m.as_str().to_string())
749 .filter(|s| looks_like_path(s))
750 .collect()
751 }
752
753 fn path_regex() -> &'static Regex {
754 static RE: OnceLock<Regex> = OnceLock::new();
755 RE.get_or_init(|| {
756 // Path-ish tokens with separators or file extensions.
757 Regex::new(
758 r#"(?x)
759 (?:
760 (?:[A-Za-z]:\\)? # optional Windows drive
761 (?:\./|\../|/)? # optional leading
762 [A-Za-z0-9._-]+
763 (?:[/\\][A-Za-z0-9._-]+)+
764 (?:\.[A-Za-z0-9]{1,8})? # optional extension
765 )
766 |
767 (?:
768 [A-Za-z0-9._-]+\.[A-Za-z0-9]{1,8}
769 )
770 "#,
771 )
772 .expect("path regex should compile")
773 })
774 }
775
776 fn truncate_chars(text: &str, max_chars: usize) -> &str {
777 if max_chars == 0 {
778 return "";
779 }
780 match text.char_indices().nth(max_chars) {
781 Some((idx, _)) => &text[..idx],
782 None => text,
783 }
784 }
785
786 fn build_search_needles(entries: &[&WorkingSetEntry], workspace: &Path) -> Vec<String> {
787 let mut needles: HashSet<String> = HashSet::new();
788 for entry in entries {
789 let rel = entry.path.clone();
790 if rel.is_empty() {
791 continue;
792 }
793 let abs = workspace.join(&rel);
794 let abs_str = abs.as_os_str().to_str().map(ToOwned::to_owned);
795
796 let _ = needles.insert(rel.clone());
797 if let Some(abs_str) = abs_str {
798 let _ = needles.insert(abs_str);
799 }
800 }
801 needles.into_iter().collect()
802 }
803
804 fn message_mentions_any_path(message: &Message, needles: &[String], max_scan_chars: usize) -> bool {
805 if needles.is_empty() {
806 return false;
807 }
808 for block in &message.content {
809 match block {
810 ContentBlock::Text { text, .. } => {
811 let snippet = truncate_chars(text, max_scan_chars);
812 if contains_any(snippet, needles) {
813 return true;
814 }
815 }
816 ContentBlock::ToolUse { input, .. } => {
817 if let Ok(json) = serde_json::to_string(input)
818 && contains_any(&json, needles)
819 {
820 return true;
821 }
822 }
823 ContentBlock::ToolResult { content, .. } => {
824 let snippet = truncate_chars(content, max_scan_chars);
825 if contains_any(snippet, needles) {
826 return true;
827 }
828 }
829 ContentBlock::Thinking { .. }
830 | ContentBlock::ServerToolUse { .. }
831 | ContentBlock::ToolSearchToolResult { .. }
832 | ContentBlock::CodeExecutionToolResult { .. } => {}
833 }
834 }
835 false
836 }
837
838 fn contains_any(text: &str, needles: &[String]) -> bool {
839 needles
840 .iter()
841 .any(|needle| !needle.is_empty() && text.contains(needle))
842 }
843
844 fn summarize_repo_root(workspace: &Path) -> Option<String> {
845 let key_files = detect_key_files(workspace);
846 let top_dirs = list_top_level_dirs(workspace, 8);
847
848 if key_files.is_empty() && top_dirs.is_empty() {
849 return None;
850 }
851
852 let mut parts: Vec<String> = Vec::new();
853 if !key_files.is_empty() {
854 parts.push(format!("Key files: {}", key_files.join(", ")));
855 }
856 if !top_dirs.is_empty() {
857 parts.push(format!("Top-level dirs: {}", top_dirs.join(", ")));
858 }
859 Some(parts.join("\n"))
860 }
861
862 fn detect_key_files(workspace: &Path) -> Vec<String> {
863 const CANDIDATES: &[&str] = &[
864 "Cargo.toml",
865 "README.md",
866 "AGENTS.md",
867 "CLAUDE.md",
868 "package.json",
869 "pyproject.toml",
870 "go.mod",
871 "Makefile",
872 ];
873
874 CANDIDATES
875 .iter()
876 .filter_map(|name| {
877 let path = workspace.join(name);
878 if path.exists() {
879 Some((*name).to_string())
880 } else {
881 None
882 }
883 })
884 .collect()
885 }
886
887 fn list_top_level_dirs(workspace: &Path, limit: usize) -> Vec<String> {
888 let mut dirs = Vec::new();
889 let entries = match fs::read_dir(workspace) {
890 Ok(entries) => entries,
891 Err(_) => return dirs,
892 };
893
894 for entry in entries.flatten() {
895 let file_name = entry.file_name();
896 let Some(name) = file_name.to_str() else {
897 continue;
898 };
899
900 if name.starts_with('.') || IGNORED_ROOT_DIRS.contains(&name) {
901 continue;
902 }
903
904 if let Ok(meta) = entry.metadata()
905 && meta.is_dir()
906 {
907 dirs.push(name.to_string());
908 }
909
910 if dirs.len() >= limit {
911 break;
912 }
913 }
914
915 dirs.sort();
916 dirs
917 }
918
919 const IGNORED_ROOT_DIRS: &[&str] = &["target", "node_modules", "dist", "build", ".git"];
920
921 #[cfg(test)]
922 mod tests {
923 use super::*;
924 use tempfile::TempDir;
925
926 fn make_message(role: &str, text: &str) -> Message {
927 Message {
928 role: role.to_string(),
929 content: vec![ContentBlock::Text {
930 text: text.to_string(),
931 cache_control: None,
932 }],
933 }
934 }
935
936 #[test]
937 fn observe_user_message_tracks_paths() {
938 let tmp = TempDir::new().expect("tempdir");
939 let src = tmp.path().join("src");
940 let file = src.join("lib.rs");
941 fs::create_dir_all(&src).expect("mkdir");
942 fs::write(&file, "pub fn x() {}").expect("write");
943
944 let mut ws = WorkingSet::default();
945 ws.observe_user_message("Please check src/lib.rs", tmp.path());
946
947 assert!(ws.entries.contains_key("src/lib.rs"));
948 let entry = ws.entries.get("src/lib.rs").expect("entry");
949 assert!(entry.exists);
950 assert!(!entry.is_dir);
951 }
952
953 #[test]
954 fn observe_tool_call_extracts_paths_from_input() {
955 let tmp = TempDir::new().expect("tempdir");
956 let file = tmp.path().join("Cargo.toml");
957 fs::write(&file, "[package]\nname = \"x\"").expect("write");
958
959 let mut ws = WorkingSet::default();
960 let input = serde_json::json!({ "path": "Cargo.toml" });
961 ws.observe_tool_call("read_file", &input, None, tmp.path());
962
963 assert!(ws.entries.contains_key("Cargo.toml"));
964 }
965
966 #[test]
967 fn pinned_message_indices_respects_working_set() {
968 let tmp = TempDir::new().expect("tempdir");
969 let src = tmp.path().join("src");
970 fs::create_dir_all(&src).expect("mkdir");
971 let file = src.join("main.rs");
972 fs::write(&file, "fn main() {}").expect("write");
973
974 let mut ws = WorkingSet::default();
975 ws.observe_user_message("Edit src/main.rs", tmp.path());
976
977 let messages = vec![
978 make_message("user", "Unrelated text"),
979 make_message("assistant", "I will read src/main.rs next."),
980 make_message("user", "More unrelated text"),
981 ];
982
983 let pinned = ws.pinned_message_indices(&messages, tmp.path());
984 assert_eq!(pinned, vec![1]);
985 }
986
987 #[test]
988 fn summary_block_includes_repo_and_working_set() {
989 let tmp = TempDir::new().expect("tempdir");
990 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
991 let src = tmp.path().join("src");
992 fs::create_dir_all(&src).expect("mkdir");
993 fs::write(src.join("lib.rs"), "pub fn x() {}").expect("write");
994
995 let mut ws = WorkingSet::default();
996 ws.observe_user_message("src/lib.rs", tmp.path());
997 let block = ws.summary_block(tmp.path()).expect("block");
998
999 assert!(block.contains("Repo Working Set"));
1000 assert!(block.contains("Cargo.toml"));
1001 assert!(block.contains("src"));
1002 assert!(block.contains("src/lib.rs"));
1003 }
1004
1005 /// #280 regression: `summary_block` must produce byte-identical output
1006 /// across `next_turn()` advances when no new paths are touched. Prior to
1007 /// the fix, the rendered lines interpolated `entry.touches` and
1008 /// `self.turn - entry.last_turn`, both of which drift turn-over-turn even
1009 /// when the path set is unchanged. The drift busted DeepSeek's KV prefix
1010 /// cache on every user message because the working-set block lands in the
1011 /// system prompt before the historical conversation.
1012 #[test]
1013 fn summary_block_is_byte_stable_across_next_turn_when_no_new_paths_observed() {
1014 use crate::test_support::assert_byte_identical;
1015
1016 let tmp = TempDir::new().expect("tempdir");
1017 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1018 let src = tmp.path().join("src");
1019 fs::create_dir_all(&src).expect("mkdir");
1020 fs::write(src.join("a.rs"), "a").expect("write");
1021 fs::write(src.join("b.rs"), "b").expect("write");
1022
1023 let mut ws = WorkingSet::default();
1024 ws.observe_user_message("Edit src/a.rs and src/b.rs", tmp.path());
1025
1026 let before = ws.summary_block(tmp.path()).expect("block before");
1027 ws.next_turn();
1028 let after = ws.summary_block(tmp.path()).expect("block after");
1029
1030 assert_byte_identical(
1031 "summary_block must be stable across next_turn when no new paths touched",
1032 &before,
1033 &after,
1034 );
1035 }
1036
1037 /// Companion to the byte-stability test: a fresh path *should* invalidate
1038 /// the block (the KV cache is allowed to miss when there's genuinely new
1039 /// signal), so the model still sees newly touched paths after the block
1040 /// stabilises across no-op turns.
1041 #[test]
1042 fn summary_block_changes_when_a_new_path_is_observed() {
1043 let tmp = TempDir::new().expect("tempdir");
1044 fs::write(tmp.path().join("Cargo.toml"), "[package]\nname = \"x\"").expect("write");
1045 let src = tmp.path().join("src");
1046 fs::create_dir_all(&src).expect("mkdir");
1047 fs::write(src.join("a.rs"), "a").expect("write");
1048 fs::write(src.join("c.rs"), "c").expect("write");
1049
1050 let mut ws = WorkingSet::default();
1051 ws.observe_user_message("src/a.rs", tmp.path());
1052 let before = ws.summary_block(tmp.path()).expect("block before");
1053
1054 ws.observe_user_message("src/c.rs", tmp.path());
1055 let after = ws.summary_block(tmp.path()).expect("block after");
1056
1057 assert_ne!(before, after, "new path must update the rendered summary");
1058 assert!(after.contains("src/c.rs"));
1059 }
1060
1061 #[test]
1062 fn extract_paths_from_message_picks_up_tool_results() {
1063 let msg = Message {
1064 role: "user".to_string(),
1065 content: vec![ContentBlock::ToolResult {
1066 tool_use_id: "tool_1".to_string(),
1067 content: "Changed src/compaction.rs".to_string(),
1068 is_error: None,
1069 content_blocks: None,
1070 }],
1071 };
1072
1073 let paths = extract_paths_from_message(&msg);
1074 assert!(paths.iter().any(|p| p.contains("src/compaction.rs")));
1075 }
1076
1077 #[test]
1078 fn pinning_prefers_high_signal_paths() {
1079 let tmp = TempDir::new().expect("tempdir");
1080 fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1081 fs::write(tmp.path().join("src/a.rs"), "a").expect("write");
1082 fs::write(tmp.path().join("src/b.rs"), "b").expect("write");
1083
1084 let mut ws = WorkingSet::default();
1085 ws.observe_user_message("src/a.rs", tmp.path());
1086 ws.observe_tool_call(
1087 "read_file",
1088 &serde_json::json!({ "path": "src/a.rs" }),
1089 Some("src/a.rs"),
1090 tmp.path(),
1091 );
1092 ws.observe_user_message("src/b.rs", tmp.path());
1093
1094 let a_score = score_entry(ws.entries.get("src/a.rs").expect("a"), ws.turn);
1095 let b_score = score_entry(ws.entries.get("src/b.rs").expect("b"), ws.turn);
1096 assert!(a_score >= b_score);
1097 }
1098
1099 #[test]
1100 fn estimate_tokens_is_available_for_future_budgeting() {
1101 use crate::compaction::estimate_tokens;
1102 let messages = vec![make_message("user", "src/main.rs")];
1103 assert!(estimate_tokens(&messages) > 0);
1104 }
1105
1106 #[test]
1107 fn workspace_resolve_respects_cwd_and_workspace() {
1108 let tmp = TempDir::new().unwrap();
1109
1110 let sub = tmp.path().join("sub");
1111 std::fs::create_dir_all(&sub).unwrap();
1112 let bar = sub.join("bar.txt");
1113 std::fs::write(&bar, "bar").unwrap();
1114
1115 let nested = tmp.path().join("nested/deep");
1116 std::fs::create_dir_all(&nested).unwrap();
1117 let file_md = nested.join("file.md");
1118 std::fs::write(&file_md, "md").unwrap();
1119
1120 // Construct with an explicit cwd so the test doesn't race with other
1121 // tests that mutate the real process cwd.
1122 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(sub.clone()));
1123
1124 // #101 repro #1: @bar.txt with cwd=sub MUST resolve via the cwd pass,
1125 // never to the bogus workspace path tmp/bar.txt (which doesn't exist).
1126 let res1 = ws.resolve("bar.txt").unwrap();
1127 assert_eq!(
1128 res1.canonicalize().unwrap_or(res1.clone()),
1129 bar.canonicalize().unwrap_or(bar.clone())
1130 );
1131 let wrong = tmp.path().join("bar.txt");
1132 assert_ne!(res1, wrong, "must not have routed to workspace fallback");
1133
1134 // #101 repro #2: @nested/deep/file.md falls through to workspace root.
1135 let res2 = ws.resolve("nested/deep/file.md").unwrap();
1136 assert_eq!(
1137 res2.canonicalize().unwrap_or(res2),
1138 file_md.canonicalize().unwrap_or(file_md)
1139 );
1140 }
1141
1142 /// Negative test (#101): a truly missing path returns `Err` with a path
1143 /// that callers can show to the user as a signal of failure.
1144 #[test]
1145 fn workspace_resolve_returns_err_for_truly_missing_path() {
1146 let tmp = TempDir::new().unwrap();
1147 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), Some(tmp.path().to_path_buf()));
1148
1149 let res = ws.resolve("does/not/exist.txt");
1150 assert!(res.is_err(), "expected Err for missing path, got: {res:?}");
1151 }
1152
1153 /// `Workspace::completions` returns workspace-relative entries for files
1154 /// under the root, and cwd-relative entries when the cwd-only file lives
1155 /// outside the workspace tree. Honors `.gitignore`.
1156 #[test]
1157 fn workspace_completions_walk_surfaces_workspace_and_cwd() {
1158 let tmp = TempDir::new().unwrap();
1159 // Two trees: a workspace under `ws/` and a cwd under `cwd/` that is
1160 // NOT inside the workspace, so the two walks are disjoint and we can
1161 // assert each branch contributed.
1162 let ws_root = tmp.path().join("ws");
1163 let cwd_root = tmp.path().join("cwd");
1164 std::fs::create_dir_all(&ws_root).unwrap();
1165 std::fs::create_dir_all(&cwd_root).unwrap();
1166 std::fs::write(ws_root.join("alpha.txt"), "a").unwrap();
1167 std::fs::write(cwd_root.join("alphabeta.txt"), "b").unwrap();
1168
1169 let ws = Workspace::with_cwd(ws_root.clone(), Some(cwd_root.clone()));
1170 let entries = ws.completions("alpha", 16);
1171 assert!(
1172 entries.iter().any(|e| e == "alpha.txt"),
1173 "expected workspace entry alpha.txt; got: {entries:?}",
1174 );
1175 assert!(
1176 entries.iter().any(|e| e == "alphabeta.txt"),
1177 "expected cwd entry alphabeta.txt; got: {entries:?}",
1178 );
1179 }
1180
1181 #[test]
1182 fn fuzzy_index_finds_files_and_directories() {
1183 let tmp = TempDir::new().unwrap();
1184 std::fs::create_dir_all(tmp.path().join("a/b/target_dir")).unwrap();
1185 std::fs::write(tmp.path().join("a/b/needle.rs"), "fn main(){}").unwrap();
1186
1187 let ws = Workspace::with_cwd(tmp.path().to_path_buf(), None);
1188
1189 // Basename-only mention triggers fuzzy fallback for both files and dirs.
1190 let f = ws.resolve("needle.rs").unwrap();
1191 assert!(f.ends_with("a/b/needle.rs"));
1192 let d = ws.resolve("target_dir").unwrap();
1193 assert!(d.ends_with("a/b/target_dir"));
1194
1195 // Index was populated exactly once (subsequent lookups reuse it).
1196 assert!(ws.file_index.get().is_some());
1197 }
1198 }
1199
1199 lines RUST