返回 CodeWhale
file_mention.rs
根目录 / crates / tui / src / tui / file_mention.rs
1 //! `@`-mention parsing, completion, and expansion for the composer.
2 //!
3 //! Two responsibilities live here:
4 //!
5 //! 1. **Tab-completion** at the cursor — `try_autocomplete_file_mention` is
6 //! called by the composer's Tab handler. Walks the workspace, ranks
7 //! candidates by prefix-then-substring match, and either splices the
8 //! completion in directly (single match), extends to a shared prefix, or
9 //! surfaces options in the status line.
10 //! 2. **Expansion before send** — when the user hits Enter on a message that
11 //! contains `@<path>` references, `user_request_with_file_mentions`
12 //! appends a "Local context from @mentions" block with the file contents
13 //! (or directory listings, or media-attachment hints) so the model can see
14 //! what the user pointed at. Capped per-message and per-file.
15 //!
16 //! The module is deliberately self-contained: nothing inside reaches into UI
17 //! widgets or rendering, so it stays unit-testable from `ui/tests.rs` and
18 //! from its own module-level tests.
19 //!
20 //! Pulled out of `ui.rs` to shrink the 5,500-line monolith and to give the
21 //! mention logic a single home that future maintainers can find without
22 //! grepping for `@` across half the codebase.
23
24 use std::fmt::Write;
25 use std::io::Read;
26 use std::path::{Path, PathBuf};
27
28 use crate::tui::app::{App, MentionCompletionCache};
29 use crate::tui::git_mention::{self, GitMentionCache, GitMentionKind};
30 use crate::tui::mention_completion::{MentionDiscoveryBehavior, MentionDiscoveryKey};
31 use crate::working_set::Workspace;
32 use codewhale_core::{
33 ContextReference, ContextReferenceKind, ContextReferenceSource, MediaAttachmentReference,
34 media_attachment_references,
35 };
36
37 /// Maximum number of `@`-mentions whose contents are inlined into one user
38 /// message. Beyond this we stop appending blocks but the raw `@token` text
39 /// remains in the message.
40 pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
41 /// Per-file byte ceiling when inlining mention contents.
42 pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
43 /// Per-directory entry ceiling when inlining a directory listing.
44 pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
45
46 /// Maximum file-mention completion candidates to consider per keypress. Caps
47 /// the cost of walking large workspaces; subsequent keystrokes narrow further.
48 const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
49
50 /// Compact composer preview row for local context. `included=false` also
51 /// covers lexical `@` mentions whose exact inclusion is resolved on send.
52 #[derive(Debug, Clone, PartialEq, Eq)]
53 pub struct FileMentionPreview {
54 pub kind: String,
55 pub label: String,
56 pub detail: Option<String>,
57 pub included: bool,
58 pub removable: bool,
59 }
60
61 // ---------------------------------------------------------------------------
62 // Tab-completion
63 // ---------------------------------------------------------------------------
64
65 /// If the cursor sits inside a `@<partial>` token in the input, return the
66 /// byte offset where the `@` starts (so we can splice in a completion) and
67 /// the partial path the user has typed so far. The token stops at whitespace
68 /// or the end of input. Returns `None` when the cursor is outside any mention
69 /// or the token is empty (`@` with nothing after it).
70 pub fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
71 let chars: Vec<char> = input.chars().collect();
72 if cursor_chars > chars.len() {
73 return None;
74 }
75 // Walk left from the cursor until we find an `@` or a whitespace; if
76 // whitespace comes first the cursor isn't inside a mention.
77 let mut start_chars = cursor_chars;
78 while start_chars > 0 {
79 let prev = chars[start_chars - 1];
80 if prev == '@' {
81 start_chars -= 1;
82 break;
83 }
84 if prev.is_whitespace() {
85 return None;
86 }
87 start_chars -= 1;
88 }
89 if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
90 return None;
91 }
92 // Confirm the `@` itself is at a valid mention boundary.
93 if !is_file_mention_start(&chars, start_chars) {
94 return None;
95 }
96 // Consume from the `@` to the next whitespace (the end of the token).
97 let mut end_chars = start_chars + 1;
98 while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
99 end_chars += 1;
100 }
101 let partial: String = chars[start_chars + 1..end_chars].iter().collect();
102 let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
103 Some((byte_start, partial))
104 }
105
106 /// The mention body for `path`: bare when [`extract_file_mentions`] reads
107 /// it back unchanged, otherwise wrapped in the quoted form it also parses
108 /// (`@"My Docs/notes.md"`). A bare path with a space would split into a
109 /// missing-file mention for its first word.
110 pub(crate) fn file_mention_body(path: &str) -> String {
111 let bare_round_trips = !path.is_empty()
112 && !path.chars().any(char::is_whitespace)
113 && !path.starts_with(['"', '\''])
114 && trim_unquoted_mention(path) == path;
115 if bare_round_trips {
116 path.to_string()
117 } else if !path.contains('"') {
118 format!("\"{path}\"")
119 } else if !path.contains('\'') {
120 format!("'{path}'")
121 } else {
122 path.to_string()
123 }
124 }
125
126 /// Cwd-aware completion entry point. Shares its walker with the future
127 /// Ctrl+P fuzzy picker (#97); see [`Workspace::completions`] for the
128 /// ranking + display rules.
129 #[cfg(test)]
130 pub fn find_file_mention_completions(
131 workspace: &Workspace,
132 partial: &str,
133 limit: usize,
134 ) -> Vec<String> {
135 let entries = workspace.completions(partial, limit);
136 // #441: re-rank by frecency so files the user mentions a lot float up.
137 // Never-mentioned candidates fall back to the workspace ranker's order.
138 let entries = super::file_frecency::rerank_by_frecency(entries);
139 tracing::debug!(
140 target: "codewhale_tui::file_mention",
141 partial = %partial,
142 workspace = %workspace.root.display(),
143 cwd = ?std::env::current_dir().ok(),
144 match_count = entries.len(),
145 "file mention completion walk",
146 );
147 entries
148 }
149
150 /// Resolve the `@`-mention completion popup contents for the current
151 /// composer state. Returns an empty `Vec` when:
152 ///
153 /// - The popup is suppressed (`app.mention_menu_hidden`).
154 /// - The cursor is not inside an `@<partial>` token.
155 /// - The workspace walk produced no candidates.
156 ///
157 /// Mirrors `visible_slash_menu_entries` so the composer widget can treat
158 /// both menus identically (one `Vec<String>` of entries, one selected index).
159 ///
160 /// Once the composer widget is extended to render this as a popup, it will
161 /// pair with `apply_mention_menu_selection` for the Up/Down/Enter flow.
162 #[must_use]
163 pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String> {
164 if app.mention_menu_hidden {
165 app.composer.mention_discovery.cancel();
166 return Vec::new();
167 }
168 let Some((_byte_start, partial)) =
169 partial_file_mention_at_cursor(&app.input, app.cursor_position)
170 else {
171 app.composer.mention_discovery.cancel();
172 return Vec::new();
173 };
174 if limit == 0 {
175 app.composer.mention_discovery.cancel();
176 return Vec::new();
177 }
178
179 mention_menu_entries(app, &partial, limit).0
180 }
181
182 /// Drain a completed discovery result without waiting. The event loop calls
183 /// this once per tick so a finished scan repaints the popup even when the user
184 /// has stopped typing.
185 pub(crate) fn poll_background_mention_discovery(app: &mut App) -> bool {
186 if app.composer.mention_discovery.poll() {
187 app.composer.mention_completion_cache = None;
188 return true;
189 }
190 false
191 }
192
193 /// Return `(entries, ready)`. `ready = false` means discovery is still in the
194 /// background; callers must not misreport that temporary empty result as "no
195 /// matches".
196 fn mention_menu_entries(app: &mut App, partial: &str, limit: usize) -> (Vec<String>, bool) {
197 if poll_background_mention_discovery(app) {
198 app.needs_redraw = true;
199 }
200
201 let workspace = app.workspace.clone();
202 let cwd = app.composer.mention_cwd.clone();
203 let walk_depth = app.mention_walk_depth;
204 let behavior = app.mention_menu_behavior.clone();
205 let follow_links = app.workspace_follow_symlinks;
206 let discovery_key = if behavior == "browser" {
207 MentionDiscoveryKey::browser(
208 workspace.clone(),
209 cwd.clone(),
210 walk_depth,
211 follow_links,
212 partial.to_string(),
213 )
214 } else {
215 MentionDiscoveryKey::fuzzy(workspace.clone(), cwd.clone(), walk_depth, follow_links)
216 };
217 app.composer
218 .mention_discovery
219 .ensure_requested(discovery_key.clone());
220
221 if let Some(ref cache) = app.composer.mention_completion_cache
222 && cache.workspace == workspace
223 && cache.cwd == cwd
224 && cache.partial == partial
225 && cache.limit == limit
226 && cache.walk_depth == walk_depth
227 && cache.behavior == behavior
228 && cache.follow_links == follow_links
229 {
230 return (cache.entries.clone(), true);
231 }
232
233 let Some(candidates) = app
234 .composer
235 .mention_discovery
236 .cached_entries(&discovery_key)
237 else {
238 return (Vec::new(), false);
239 };
240 let entries = match &discovery_key.behavior {
241 MentionDiscoveryBehavior::Fuzzy => {
242 let ranked = crate::working_set::rank_completion_candidates(candidates, partial, limit);
243 super::file_frecency::rerank_by_frecency(ranked)
244 }
245 MentionDiscoveryBehavior::Browser { .. } => {
246 candidates.iter().take(limit).cloned().collect()
247 }
248 };
249
250 let entries = with_git_mention_entries(entries, partial, limit);
251
252 app.composer.mention_completion_cache = Some(MentionCompletionCache {
253 workspace,
254 cwd,
255 partial: partial.to_string(),
256 limit,
257 walk_depth,
258 behavior,
259 follow_links,
260 entries: entries.clone(),
261 });
262
263 (entries, true)
264 }
265
266 /// Prepend the `@git` / `@diff` tokens that prefix-match `partial` to the path
267 /// completions, so curated git context is discoverable from the same menu as
268 /// files (#4067). They lead because a two-entry prefix match is what the user
269 /// meant when they typed `gi` or `di`, and paths still fill the rest.
270 ///
271 /// A bare `@` is deliberately left alone: that menu is the file picker, and
272 /// pushing two fixed tokens above every path would cost a slot on every
273 /// mention the user makes. The tokens appear as soon as a matching character
274 /// is typed.
275 fn with_git_mention_entries(entries: Vec<String>, partial: &str, limit: usize) -> Vec<String> {
276 // `mention_menu_limit = 0` is a documented way to disable the popup
277 // entirely. The git tokens are menu entries like any other and must
278 // respect the same cap, or setting 0 would still pop a one-entry menu.
279 if limit == 0 {
280 return Vec::new();
281 }
282 let needle = partial.trim().to_lowercase();
283 if needle.is_empty() {
284 return entries;
285 }
286 let matching: Vec<String> = GitMentionKind::iter_all()
287 .filter(|kind| kind.token().starts_with(&needle))
288 .map(|kind| kind.token().to_string())
289 .collect();
290 if matching.is_empty() {
291 return entries;
292 }
293 let mut combined = matching;
294 combined.truncate(limit);
295 for entry in entries {
296 if combined.len() >= limit {
297 break;
298 }
299 if !combined.contains(&entry) {
300 combined.push(entry);
301 }
302 }
303 combined
304 }
305
306 /// Apply the currently selected `@`-mention popup entry to the composer
307 /// input, splicing it in place of the `@<partial>` token at the cursor.
308 /// Returns `true` if a substitution occurred.
309 ///
310 /// Designed to be invoked by the same keybinding that drives
311 /// `apply_slash_menu_selection` (Enter / Tab); the caller is responsible
312 /// for choosing which menu is "active" based on cursor context.
313 pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool {
314 if entries.is_empty() {
315 return false;
316 }
317 let Some((byte_start, partial)) =
318 partial_file_mention_at_cursor(&app.input, app.cursor_position)
319 else {
320 return false;
321 };
322 let selected_idx = app
323 .mention_menu_selected
324 .min(entries.len().saturating_sub(1));
325 let replacement = &entries[selected_idx];
326 // #441: bump this path's frecency before we splice it in. The store
327 // persists asynchronously, so this never blocks input handling.
328 super::file_frecency::record_mention(replacement);
329 replace_file_mention(app, byte_start, &partial, &file_mention_body(replacement));
330 app.mention_menu_hidden = false;
331 app.status_message = Some(format!("Attached @{replacement}"));
332 true
333 }
334
335 /// Tab-completion handler for `@file` mentions. Mirrors the slash-command
336 /// flow: a single match is applied directly; multiple matches with a longer
337 /// shared prefix extend the partial; otherwise the first few candidates are
338 /// surfaced via the status line. Returns true when the input was modified or
339 /// a suggestion was offered, so the caller can short-circuit other handlers.
340 pub fn try_autocomplete_file_mention(app: &mut App) -> bool {
341 let Some((byte_start, partial)) =
342 partial_file_mention_at_cursor(&app.input, app.cursor_position)
343 else {
344 return false;
345 };
346 let (candidates, ready) = mention_menu_entries(app, &partial, FILE_MENTION_COMPLETION_LIMIT);
347 if !ready {
348 return true;
349 }
350 if candidates.is_empty() {
351 app.status_message = Some(no_file_mention_matches_status(
352 &partial,
353 app.mention_walk_depth,
354 ));
355 return true;
356 }
357 if candidates.len() == 1 {
358 // #441: a unique-match completion is also a "mention" for ranking.
359 super::file_frecency::record_mention(&candidates[0]);
360 replace_file_mention(
361 app,
362 byte_start,
363 &partial,
364 &file_mention_body(&candidates[0]),
365 );
366 app.status_message = Some(format!("Attached @{}", candidates[0]));
367 return true;
368 }
369 let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
370 // Extend only up to the first whitespace: the partial token ends there,
371 // so `@My Do` would leave a missing `@My` mention and a dead next Tab.
372 // Paths with spaces are completed whole (and quoted) by the unique-match
373 // branch or the mention menu.
374 let shared = longest_common_prefix(&candidate_refs);
375 let shared = shared
376 .find(char::is_whitespace)
377 .map_or(shared, |idx| &shared[..idx]);
378 if shared.len() > partial.len() {
379 replace_file_mention(app, byte_start, &partial, shared);
380 app.status_message = Some(format!("@{shared}…"));
381 return true;
382 }
383 let preview = candidates
384 .iter()
385 .take(5)
386 .map(|c| format!("@{c}"))
387 .collect::<Vec<_>>()
388 .join(", ");
389 app.status_message = Some(format!("Matches: {preview}"));
390 true
391 }
392
393 fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String {
394 if path_partial_reaches_walk_depth(partial, walk_depth) {
395 format!(
396 "No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)"
397 )
398 } else {
399 format!("No files match @{partial}")
400 }
401 }
402
403 fn path_partial_reaches_walk_depth(partial: &str, walk_depth: usize) -> bool {
404 if walk_depth == 0 {
405 return false;
406 }
407 let component_count = partial
408 .split(['/', '\\'])
409 .filter(|component| !component.is_empty())
410 .count();
411 component_count >= walk_depth
412 }
413
414 /// Splice a completion into the input, replacing the `@<partial>` token at
415 /// `byte_start` with `@<replacement>`. Cursor moves to the end of the new
416 /// token so further keystrokes extend (or escape via space) naturally.
417 fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
418 let original_token_len = '@'.len_utf8() + partial.len();
419 let original_token_end = byte_start + original_token_len;
420 let mut new_input =
421 String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
422 new_input.push_str(&app.input[..byte_start]);
423 new_input.push('@');
424 new_input.push_str(replacement);
425 if original_token_end < app.input.len() {
426 new_input.push_str(&app.input[original_token_end..]);
427 }
428 let new_cursor_chars =
429 app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
430 app.input = new_input;
431 app.cursor_position = new_cursor_chars;
432 }
433
434 pub fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
435 let Some(first) = values.first().copied() else {
436 return "";
437 };
438 let mut end = first.len();
439
440 for value in values.iter().skip(1) {
441 while end > 0 && !value.starts_with(&first[..end]) {
442 end -= 1;
443 // Ensure we land on a valid UTF-8 char boundary.
444 while end > 0 && !first.is_char_boundary(end) {
445 end -= 1;
446 }
447 }
448 if end == 0 {
449 return "";
450 }
451 }
452
453 &first[..end]
454 }
455
456 // ---------------------------------------------------------------------------
457 // Expansion at send-time
458 // ---------------------------------------------------------------------------
459
460 /// Append a "Local context from @mentions" block to the user's message when
461 /// any `@path` references are present. Returns the input unchanged when
462 /// there are none.
463 ///
464 /// `cwd` carries the user's launch directory and drives the second
465 /// resolution pass (issue #101): relative `@<path>` mentions resolve under
466 /// `cwd` when `workspace.join(path)` doesn't exist, so the user's mental
467 /// anchor (their shell's pwd) wins when it diverges from `--workspace`.
468 /// Pass `None` to disable the cwd pass entirely (workspace-only).
469 ///
470 /// Resolution here never walks the tree on submit (#4365). A miss on the
471 /// exact two-pass lookup falls back to a bounded, unique-match-only search of
472 /// the composer's already-built background completion index
473 /// (`completion_index`, when the caller has one cached); the winning
474 /// candidate must still exist on disk. Ambiguous, stale, or absent matches
475 /// stay an honest `<missing-file>` that names only what the user typed —
476 /// never a fabricated workspace-root path.
477 ///
478 /// Convenience wrapper that allocates a throwaway cache. Test-only: the real
479 /// send paths share one cache across the references and payload passes.
480 #[cfg(test)]
481 pub fn user_request_with_file_mentions(
482 input: &str,
483 workspace: &Path,
484 cwd: Option<PathBuf>,
485 ) -> String {
486 user_request_with_file_mentions_cached(
487 input,
488 workspace,
489 cwd,
490 &mut GitMentionCache::default(),
491 None,
492 )
493 }
494
495 pub fn user_request_with_file_mentions_cached(
496 input: &str,
497 workspace: &Path,
498 cwd: Option<PathBuf>,
499 git_cache: &mut GitMentionCache,
500 completion_index: Option<&[String]>,
501 ) -> String {
502 let Some(context) =
503 local_context_from_file_mentions(input, workspace, cwd, git_cache, completion_index)
504 else {
505 return input.to_string();
506 };
507 format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
508 }
509
510 #[must_use]
511 pub fn pending_context_previews(input: &str) -> Vec<FileMentionPreview> {
512 let mut previews = Vec::new();
513 let mut seen = std::collections::HashSet::new();
514 for mention in extract_file_mentions(input)
515 .into_iter()
516 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
517 {
518 if !seen.insert(mention.clone()) {
519 continue;
520 }
521 // Composer previews stay lexical (no git subprocess from the render
522 // loop, same rule as #4365 for path stats); the payload is resolved
523 // once at submit time.
524 if let Some(kind) = git_mention::git_mention_kind(&mention) {
525 previews.push(FileMentionPreview {
526 kind: "git".to_string(),
527 label: kind.label().to_string(),
528 detail: Some("resolved on send".to_string()),
529 included: false,
530 removable: false,
531 });
532 continue;
533 }
534 let media = is_media_path(Path::new(&mention));
535 previews.push(FileMentionPreview {
536 kind: if media { "media" } else { "mention" }.to_string(),
537 label: mention,
538 detail: Some(if media {
539 "use /attach for media bytes".to_string()
540 } else {
541 "resolved on send".to_string()
542 }),
543 // Lexical preview deliberately does not stat the path while the
544 // user types. Exact inclusion/missing metadata is resolved once,
545 // at submit time, rather than from the render loop (#4365).
546 included: false,
547 removable: false,
548 });
549 }
550
551 for attachment in extract_media_attachment_references(input) {
552 previews.push(FileMentionPreview {
553 kind: attachment.kind,
554 label: attachment.path,
555 detail: Some("attached media".to_string()),
556 included: true,
557 removable: true,
558 });
559 }
560 previews
561 }
562
563 /// Convenience wrapper that allocates a throwaway cache. Test-only, as above.
564 #[cfg(test)]
565 #[must_use]
566 pub fn context_references_from_input(
567 input: &str,
568 workspace: &Path,
569 cwd: Option<PathBuf>,
570 ) -> Vec<ContextReference> {
571 context_references_from_input_cached(
572 input,
573 workspace,
574 cwd,
575 &mut GitMentionCache::default(),
576 None,
577 )
578 }
579
580 #[must_use]
581 pub fn context_references_from_input_cached(
582 input: &str,
583 workspace: &Path,
584 cwd: Option<PathBuf>,
585 git_cache: &mut GitMentionCache,
586 completion_index: Option<&[String]>,
587 ) -> Vec<ContextReference> {
588 let mut references = Vec::new();
589 let mut seen = std::collections::HashSet::new();
590 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
591
592 for mention in extract_file_mentions(input)
593 .into_iter()
594 .take(MAX_FILE_MENTIONS_PER_MESSAGE)
595 {
596 // Git mentions resolve against the working tree, not the path index,
597 // so the inspector reports their real size and budget (#4067).
598 if let Some(kind) = git_mention::git_mention_kind(&mention) {
599 let payload = git_cache.resolve(kind, workspace).clone();
600 let detail = match payload.unavailable_reason.as_deref() {
601 Some(reason) => format!("{}, {reason}", kind.label()),
602 None if payload.truncated => format!(
603 "{}, {} bytes truncated at {} budget",
604 kind.label(),
605 payload.bytes,
606 kind.byte_budget()
607 ),
608 None => format!("{}, {} bytes", kind.label(), payload.bytes),
609 };
610 let reference = ContextReference {
611 kind: ContextReferenceKind::GitContext,
612 source: ContextReferenceSource::AtMention,
613 badge: "git".to_string(),
614 label: kind.token().to_string(),
615 target: workspace.display().to_string(),
616 included: payload.unavailable_reason.is_none(),
617 expanded: false,
618 detail: Some(detail),
619 };
620 if seen.insert(format!("git-mention:{}", kind.token())) {
621 references.push(reference);
622 }
623 continue;
624 }
625
626 let (path, display_path, exists) =
627 resolve_mention_for_send(&ws, &mention, completion_index);
628 let reference = context_reference_for_mention(&mention, &path, &display_path, exists);
629 if !seen.insert(format!(
630 "{:?}:{:?}:{}:{}",
631 reference.source, reference.kind, reference.target, reference.label
632 )) {
633 continue;
634 }
635 references.push(reference);
636 }
637
638 for reference in extract_media_attachment_references(input) {
639 let context_reference = ContextReference {
640 kind: ContextReferenceKind::MediaAttachment,
641 source: ContextReferenceSource::Attachment,
642 badge: reference.kind,
643 label: reference.path.clone(),
644 target: reference.path,
645 included: true,
646 expanded: false,
647 detail: Some("attached media".to_string()),
648 };
649 if !seen.insert(format!(
650 "{:?}:{:?}:{}:{}",
651 context_reference.source,
652 context_reference.kind,
653 context_reference.target,
654 context_reference.label
655 )) {
656 continue;
657 }
658 references.push(context_reference);
659 }
660
661 references
662 }
663
664 fn context_reference_for_mention(
665 raw: &str,
666 path: &Path,
667 display_path: &str,
668 exists: bool,
669 ) -> ContextReference {
670 if !exists {
671 return ContextReference {
672 kind: ContextReferenceKind::Missing,
673 source: ContextReferenceSource::AtMention,
674 badge: "missing".to_string(),
675 label: raw.to_string(),
676 // No resolved target exists; naming the workspace-root guess here
677 // would present a path we already know is wrong.
678 target: raw.to_string(),
679 included: false,
680 expanded: false,
681 detail: Some("not found".to_string()),
682 };
683 }
684 if path.is_dir() {
685 return ContextReference {
686 kind: ContextReferenceKind::Directory,
687 source: ContextReferenceSource::AtMention,
688 badge: "dir".to_string(),
689 label: raw.to_string(),
690 target: display_path.to_string(),
691 included: true,
692 expanded: true,
693 detail: Some("directory listing".to_string()),
694 };
695 }
696 if !path.is_file() {
697 return ContextReference {
698 kind: ContextReferenceKind::Unsupported,
699 source: ContextReferenceSource::AtMention,
700 badge: "skipped".to_string(),
701 label: raw.to_string(),
702 target: display_path.to_string(),
703 included: false,
704 expanded: false,
705 detail: Some("unsupported path".to_string()),
706 };
707 }
708 if is_media_path(path) {
709 return ContextReference {
710 kind: ContextReferenceKind::MediaMention,
711 source: ContextReferenceSource::AtMention,
712 badge: "media".to_string(),
713 label: raw.to_string(),
714 target: display_path.to_string(),
715 included: false,
716 expanded: false,
717 detail: Some("use /attach for media bytes".to_string()),
718 };
719 }
720
721 let detail = match std::fs::metadata(path) {
722 Ok(metadata) if metadata.len() > MAX_MENTION_FILE_BYTES => {
723 Some("included truncated".to_string())
724 }
725 Ok(_) => Some("included".to_string()),
726 Err(err) => Some(format!("metadata: {err}")),
727 };
728
729 ContextReference {
730 kind: ContextReferenceKind::File,
731 source: ContextReferenceSource::AtMention,
732 badge: "file".to_string(),
733 label: raw.to_string(),
734 target: display_path.to_string(),
735 included: true,
736 expanded: true,
737 detail: detail.or_else(|| Some(display_path.to_string())),
738 }
739 }
740
741 fn extract_media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
742 media_attachment_references(input)
743 }
744
745 // ---------------------------------------------------------------------------
746 // macOS screencapture-temp stabilization
747 // ---------------------------------------------------------------------------
748 //
749 // macOS parks dragged-out screenshots under a per-capture temp directory like
750 // `/var/folders/…/T/Temporary Items/NSIRD_screencaptureui_XXXX/` and deletes
751 // it minutes later. Inbound references to such files are copied to a stable
752 // directory the moment the message is received, so the agent later reads a
753 // path that still exists.
754
755 /// Marker fragments of the macOS screencapture temp directory layout.
756 const SCREENCAPTURE_TEMP_DIR_MARKERS: [&str; 2] = ["Temporary Items", "screencaptureui"];
757
758 /// Stable per-session directory for stabilized screencapture files. Follows
759 /// the same home-first convention as `clipboard.rs`'s clipboard-images dir.
760 pub(crate) fn screenshot_stabilization_dir(workspace: &Path) -> PathBuf {
761 match crate::config::effective_home_dir() {
762 Some(home) => home.join(".codewhale").join("attachments"),
763 None => workspace.join("attachments"),
764 }
765 }
766
767 /// Whether a path lives under a macOS screencapture "Temporary Items" dir:
768 /// it must have a `Temporary Items` component and a `screencaptureui`-named
769 /// component (e.g. `NSIRD_screencaptureui_XXXX`), so ordinary user paths are
770 /// never touched even when their names contain either fragment.
771 fn is_screencapture_temp_path(path: &Path) -> bool {
772 let components: Vec<String> = path
773 .components()
774 .map(|c| c.as_os_str().to_string_lossy().into_owned())
775 .collect();
776 components
777 .iter()
778 .any(|c| c == SCREENCAPTURE_TEMP_DIR_MARKERS[0])
779 && components
780 .iter()
781 .any(|c| c.contains(SCREENCAPTURE_TEMP_DIR_MARKERS[1]))
782 }
783
784 /// Keep " at " out of a stable copy's name: `[Attached …: <desc> at <path>]`
785 /// uses it as the separator, and older parsers split at its last occurrence.
786 fn stable_attachment_name(file_name: &std::ffi::OsStr) -> String {
787 file_name.to_string_lossy().replace(" at ", "-")
788 }
789
790 /// Largest screenshot copied to a stable location.
791 const MAX_STABLE_ATTACHMENT_BYTES: u64 = 64 * 1024 * 1024;
792
793 /// Copy a screencapture temp file to `artifact_dir` and return the stable
794 /// destination. Returns `None` when the path is not a screencapture temp
795 /// file, not a regular file, or the copy fails — callers keep the original
796 /// reference then. Idempotent: an existing destination is reused without a
797 /// second copy.
798 ///
799 /// `artifact_dir` is `<root>/<name>`: the destination is written through the
800 /// pinned no-follow writer anchored at `root`, so a linked `artifact_dir`, a
801 /// linked destination, or a dangling link at the destination name is refused
802 /// instead of being written through. `root` itself may be a user-selected link
803 /// (a relocated `~/.codewhale`).
804 fn stabilize_screencapture_file(path: &Path, artifact_dir: &Path) -> Option<PathBuf> {
805 if !is_screencapture_temp_path(path) || !path.is_file() {
806 return None;
807 }
808 let name = stable_attachment_name(path.file_name()?);
809 let dest = artifact_dir.join(&name);
810 let relative = Path::new(artifact_dir.file_name()?).join(&name);
811 let target =
812 crate::fleet::files::WorkspaceFile::open(artifact_dir.parent()?, &relative, true).ok()?;
813 // An existing regular destination is reused; a link there fails to open.
814 if target.open_file().is_ok() {
815 return Some(dest);
816 }
817 if std::fs::metadata(path).ok()?.len() > MAX_STABLE_ATTACHMENT_BYTES {
818 return None;
819 }
820 let bytes = std::fs::read(path).ok()?;
821 match target.publish(&bytes) {
822 Ok(()) => Some(dest),
823 // Lost a race to another writer of the same name: reuse only if what
824 // is there is a regular file we may open.
825 Err(error)
826 if error.kind() == std::io::ErrorKind::AlreadyExists && target.open_file().is_ok() =>
827 {
828 Some(dest)
829 }
830 Err(_) => None,
831 }
832 }
833
834 /// A path reference found in inbound text: its byte span, the path text, and
835 /// how it was carried (`@`-mention prefix and/or surrounding quotes).
836 struct ScreencaptureCandidate {
837 byte_start: usize,
838 byte_end: usize,
839 path: String,
840 quote: Option<char>,
841 mention: bool,
842 }
843
844 /// Reconstruct a path that an unquoted paste split across whitespace tokens
845 /// (the "Temporary Items" component contains a space). Returns the char span
846 /// and joined path of the window that names an existing screencapture temp
847 /// file, bounded to a handful of tokens either side of `seed`.
848 fn screencapture_window(
849 chars: &[char],
850 tokens: &[(usize, usize)],
851 seed: usize,
852 ) -> Option<(usize, usize, String)> {
853 let max_span = tokens.len().min(12);
854 for left in 0..=seed.min(max_span) {
855 for right in 0..=max_span {
856 let end = (seed + right).min(tokens.len() - 1);
857 let (mut ws, mut we) = (tokens[seed - left].0, tokens[end].1);
858 let raw: String = chars[ws..we].iter().collect();
859 // Trim leading delimiters and trailing punctuation against the
860 // raw span (advancing both ends), then collapse interior
861 // whitespace runs so the probe path matches the file.
862 let lead = raw
863 .chars()
864 .take_while(|&ch| matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '@'))
865 .count();
866 let trimmed = trim_unquoted_mention(&raw);
867 ws += lead;
868 we -= raw.chars().count() - trimmed.chars().count();
869 let joined = trimmed
870 .chars()
871 .skip(lead)
872 .collect::<String>()
873 .split_whitespace()
874 .collect::<Vec<_>>()
875 .join(" ");
876 let path = Path::new(&joined);
877 if is_screencapture_temp_path(path) && path.is_file() {
878 return Some((ws, we, joined));
879 }
880 }
881 }
882 None
883 }
884
885 /// Rewrite every inbound reference to a macOS screencapture temp file to a
886 /// stable copy under `artifact_dir`. Non-matching references — not a
887 /// screencapture path, missing, or an unresolvable copy — are left untouched;
888 /// the function never fails and never changes the message otherwise.
889 pub(crate) fn stabilize_screenshot_references(input: &str, artifact_dir: &Path) -> String {
890 let chars: Vec<char> = input.chars().collect();
891 let offsets: Vec<usize> = input.char_indices().map(|(i, _)| i).collect();
892 let mut candidates: Vec<ScreencaptureCandidate> = Vec::new();
893 let mut i = 0;
894 while i < chars.len() {
895 match chars[i] {
896 // `@"quoted path"` / `@bare/path` mentions (mirror `extract_file_mentions`).
897 '@' if is_file_mention_start(&chars, i) => {
898 let byte_start = offsets[i];
899 if let Some(quote @ ('"' | '\'')) = chars.get(i + 1).copied()
900 && let Some(rel) = chars[i + 2..].iter().position(|&ch| ch == quote)
901 {
902 let end = i + 2 + rel;
903 let path: String = chars[i + 2..end].iter().collect();
904 if !path.trim().is_empty() {
905 candidates.push(ScreencaptureCandidate {
906 byte_start,
907 byte_end: offsets.get(end + 1).copied().unwrap_or(input.len()),
908 path: path.trim().to_string(),
909 quote: Some(quote),
910 mention: true,
911 });
912 }
913 i = end + 1;
914 } else {
915 let mut end = i + 1;
916 while end < chars.len() && !chars[end].is_whitespace() {
917 end += 1;
918 }
919 let raw: String = chars[i + 1..end].iter().collect();
920 let trimmed = trim_unquoted_mention(&raw);
921 if !trimmed.is_empty() {
922 candidates.push(ScreencaptureCandidate {
923 byte_start,
924 byte_end: offsets.get(end).copied().unwrap_or(input.len()),
925 path: trimmed.to_string(),
926 quote: None,
927 mention: true,
928 });
929 }
930 i = end;
931 }
932 }
933 // Quoted strings: terminals quote drag-dropped paths with spaces.
934 // A closing quote is only sought on the same line, so a lone
935 // apostrophe in prose never swallows the rest of the message.
936 quote @ ('"' | '\'') => {
937 if let Some(rel) = chars[i + 1..]
938 .iter()
939 .take_while(|&&ch| ch != '\n')
940 .position(|&ch| ch == quote)
941 {
942 let end = i + 1 + rel;
943 let path: String = chars[i + 1..end].iter().collect();
944 if !path.trim().is_empty() {
945 candidates.push(ScreencaptureCandidate {
946 byte_start: offsets[i],
947 byte_end: offsets.get(end + 1).copied().unwrap_or(input.len()),
948 path: path.trim().to_string(),
949 quote: Some(quote),
950 mention: false,
951 });
952 }
953 i = end + 1;
954 } else {
955 i += 1;
956 }
957 }
958 _ => i += 1,
959 }
960 }
961
962 // Bare unquoted pastes: rebuild the path across whitespace tokens.
963 let tokens: Vec<(usize, usize)> = {
964 let mut tokens = Vec::new();
965 let mut start = None;
966 for (idx, ch) in chars.iter().enumerate() {
967 match (ch.is_whitespace(), start) {
968 (true, Some(s)) => {
969 tokens.push((s, idx));
970 start = None;
971 }
972 (false, None) => start = Some(idx),
973 _ => {}
974 }
975 }
976 if let Some(s) = start {
977 tokens.push((s, chars.len()));
978 }
979 tokens
980 };
981 for (seed, &(token_start, token_end)) in tokens.iter().enumerate() {
982 let token: String = chars[token_start..token_end].iter().collect();
983 if !(token.contains("screencaptureui")
984 || token.contains("Temporary")
985 || token.contains("Items"))
986 {
987 continue;
988 }
989 let Some((ws, we, path)) = screencapture_window(&chars, &tokens, seed) else {
990 continue;
991 };
992 let byte_end = offsets.get(we).copied().unwrap_or(input.len());
993 // Only suppress the window when it overlaps a candidate that is
994 // itself a confirmed screencapture temp file (that candidate will
995 // rewrite it); prose quoted with `'` never blocks a real reference.
996 let confirmed = |c: &ScreencaptureCandidate| {
997 is_screencapture_temp_path(Path::new(&c.path)) && Path::new(&c.path).is_file()
998 };
999 let blocked = candidates
1000 .iter()
1001 .any(|c| c.byte_start < byte_end && offsets[ws] < c.byte_end && confirmed(c));
1002 if !blocked {
1003 candidates.push(ScreencaptureCandidate {
1004 byte_start: offsets[ws],
1005 byte_end,
1006 path,
1007 quote: None,
1008 mention: false,
1009 });
1010 }
1011 }
1012
1013 // Apply last-to-first so byte spans stay valid.
1014 candidates.sort_by_key(|c| c.byte_start);
1015 let mut output = input.to_string();
1016 for candidate in candidates.iter().rev() {
1017 let Some(dest) = stabilize_screencapture_file(Path::new(&candidate.path), artifact_dir)
1018 else {
1019 continue;
1020 };
1021 let stable = dest.to_string_lossy();
1022 let mut replacement = String::new();
1023 if candidate.mention {
1024 replacement.push('@');
1025 }
1026 if let Some(quote) = candidate.quote {
1027 replacement.push(quote);
1028 }
1029 replacement.push_str(stable.as_ref());
1030 if let Some(quote) = candidate.quote {
1031 replacement.push(quote);
1032 }
1033 output.replace_range(candidate.byte_start..candidate.byte_end, &replacement);
1034 }
1035 output
1036 }
1037
1038 fn local_context_from_file_mentions(
1039 input: &str,
1040 workspace: &Path,
1041 cwd: Option<PathBuf>,
1042 git_cache: &mut GitMentionCache,
1043 completion_index: Option<&[String]>,
1044 ) -> Option<String> {
1045 let mentions = extract_file_mentions(input);
1046 if mentions.is_empty() {
1047 return None;
1048 }
1049
1050 let mut blocks = Vec::new();
1051 let mut seen = std::collections::HashSet::new();
1052 let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
1053
1054 for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
1055 // `@git` / `@diff` resolve to curated git context, not to a path, so
1056 // they short-circuit before any workspace path resolution (#4067).
1057 if let Some(kind) = git_mention::git_mention_kind(&mention) {
1058 if !seen.insert(format!("git-mention:{}", kind.token())) {
1059 continue;
1060 }
1061 blocks.push(git_cache.resolve(kind, workspace).block.clone());
1062 continue;
1063 }
1064
1065 // `@path:START-END` attaches a line range of a file (issue #5550).
1066 // Exact resolution wins first: a file that literally contains a colon
1067 // is treated as its full self. Only a genuine miss re-reads the token
1068 // as `path:START-END`.
1069 let mut range = None;
1070 let mut mention_path = mention.as_str();
1071 // `Workspace::resolve_exact` already returns absolute paths when the root
1072 // is absolute (TUI always runs from an absolute workspace), so we
1073 // skip `canonicalize()` here — it's per-mention I/O on the
1074 // message-send hot path. Accept the rare symlink-aliasing dedup
1075 // miss as the cost of avoiding a syscall (Gemini code-review).
1076 let (mut path, mut display_path, mut exists) =
1077 resolve_mention_for_send(&ws, mention_path, completion_index);
1078 if !exists && let Some((path_part, parsed)) = split_mention_range(&mention) {
1079 let (ranged_path, ranged_display, ranged_exists) =
1080 resolve_mention_for_send(&ws, path_part, completion_index);
1081 if ranged_exists {
1082 range = Some(parsed);
1083 mention_path = path_part;
1084 (path, display_path, exists) = (ranged_path, ranged_display, ranged_exists);
1085 }
1086 }
1087 tracing::debug!(
1088 target: "codewhale_tui::file_mention",
1089 raw_typed = %mention,
1090 workspace = %workspace.display(),
1091 cwd = ?std::env::current_dir().ok(),
1092 resolved = %display_path,
1093 exists,
1094 "file mention resolution",
1095 );
1096
1097 // Gate every block — including <missing-file> — through the dedup
1098 // set so a user typing the same non-existent file twice doesn't
1099 // waste tokens on duplicate missing-file blocks (Devin code-review).
1100 // Missing mentions dedup on the typed token: there is no resolved
1101 // path to key on, and the workspace-root guess must not become one.
1102 let dedup_key = if exists {
1103 display_path.clone()
1104 } else {
1105 format!("missing:{mention}")
1106 };
1107 if !seen.insert(dedup_key) {
1108 continue;
1109 }
1110
1111 if exists {
1112 blocks.push(render_file_mention_context(
1113 mention_path,
1114 &path,
1115 &display_path,
1116 range,
1117 ));
1118 } else {
1119 // Honest miss: name only what the user typed. Emitting the
1120 // workspace-root join as `path=` presented a non-existent file
1121 // as if it were the resolved target.
1122 blocks.push(format!("<missing-file mention=\"@{mention}\" />"));
1123 }
1124 }
1125
1126 if blocks.is_empty() {
1127 None
1128 } else {
1129 Some(blocks.join("\n\n"))
1130 }
1131 }
1132
1133 /// Endpoints of a `@path:START-END` mention (1-based, inclusive).
1134 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1135 pub(crate) struct FileRange {
1136 pub start: u32,
1137 pub end: u32,
1138 }
1139
1140 /// Split a trailing `:START-END` range off a mention token.
1141 ///
1142 /// Only an exact `digits-digits` suffix after a non-empty path part is
1143 /// treated as a range, so `notes.txt`, `x:1`, `x:a-b`, `:1-2`, and Windows
1144 /// `C:\\dir\\file` (digits requirement) all stay whole. The caller must have
1145 /// already failed an exact path resolution for the full token before calling.
1146 fn split_mention_range(raw: &str) -> Option<(&str, FileRange)> {
1147 let (path_part, range_part) = raw.rsplit_once(':')?;
1148 if path_part.is_empty() {
1149 return None;
1150 }
1151 let (start, end) = range_part.split_once('-')?;
1152 if start.is_empty()
1153 || end.is_empty()
1154 || !start.bytes().all(|b| b.is_ascii_digit())
1155 || !end.bytes().all(|b| b.is_ascii_digit())
1156 || start.len() > 5
1157 || end.len() > 5
1158 {
1159 return None;
1160 }
1161 let start: u32 = start.parse().ok()?;
1162 let end: u32 = end.parse().ok()?;
1163 if start == 0 || end < start {
1164 return None;
1165 }
1166 Some((path_part, FileRange { start, end }))
1167 }
1168
1169 /// Send-time mention resolution: exact two-pass lookup first (workspace root,
1170 /// then launch cwd), then a bounded fallback against the composer's cached
1171 /// background completion index. Returns the path, its display form, and
1172 /// whether it exists. On a miss the returned path is the workspace-root guess
1173 /// — callers must not present it as resolved when `exists` is false.
1174 fn resolve_mention_for_send(
1175 ws: &Workspace,
1176 mention: &str,
1177 completion_index: Option<&[String]>,
1178 ) -> (PathBuf, String, bool) {
1179 let guess = match ws.resolve_exact(mention) {
1180 Ok(path) => {
1181 let display = path.display().to_string();
1182 return (path, display, true);
1183 }
1184 Err(guess) => guess,
1185 };
1186 if let Some(resolved) = completion_index
1187 .and_then(|candidates| resolve_mention_in_completion_index(mention, candidates, ws))
1188 {
1189 let display = resolved.display().to_string();
1190 return (resolved, display, true);
1191 }
1192 let display = guess.display().to_string();
1193 (guess, display, false)
1194 }
1195
1196 /// Bounded send-time fallback for `@`-mention misses.
1197 ///
1198 /// #4365 keeps filesystem walks off the submit path, so instead of walking we
1199 /// match the typed token against the composer's already-built background
1200 /// completion index (workspace- or cwd-relative display strings). A candidate
1201 /// wins only when it is the *unique* path-suffix or basename match and the
1202 /// winning path still resolves on disk — the index may be a few seconds
1203 /// stale. Anything else (no hit, ambiguous hit, stale hit) returns `None` so
1204 /// the caller emits an honest `<missing-file>` instead of attaching an
1205 /// arbitrary same-name file from a nested directory.
1206 fn resolve_mention_in_completion_index(
1207 mention: &str,
1208 candidates: &[String],
1209 ws: &Workspace,
1210 ) -> Option<PathBuf> {
1211 // Absolute and home-anchored mentions name an exact location; a basename
1212 // lookalike elsewhere in the tree would be a different file, not a fix-up.
1213 // A leading separator is rooted on every platform, but `Path::is_absolute`
1214 // is false for `/foo` on Windows (no drive prefix), which would otherwise
1215 // let a rooted miss fall through to the index and attach an unrelated
1216 // same-name file. Test the root marker directly so the guard holds there.
1217 // `\` is a root marker only on Windows; on Unix it is an ordinary
1218 // (if unusual) leading filename character, so leave that case alone.
1219 let rooted = mention.starts_with('/') || (cfg!(windows) && mention.starts_with('\\'));
1220 if mention.starts_with('~') || rooted || Path::new(mention).is_absolute() {
1221 return None;
1222 }
1223 let needle = mention.replace('\\', "/");
1224 let needle = needle.trim_matches('/');
1225 if needle.is_empty() {
1226 return None;
1227 }
1228 let needle_lower = needle.to_lowercase();
1229 let basename_lower = needle_lower.rsplit('/').next()?;
1230
1231 let normalized = |candidate: &str| candidate.replace('\\', "/");
1232 let suffix_match = |candidate: &str| {
1233 let cand = normalized(candidate);
1234 let cand = cand.trim_end_matches('/').to_lowercase();
1235 !cand.is_empty() && (cand == needle_lower || cand.ends_with(&format!("/{needle_lower}")))
1236 };
1237 let basename_match = |candidate: &str| {
1238 let cand = normalized(candidate);
1239 let cand = cand.trim_end_matches('/').to_lowercase();
1240 !cand.is_empty() && cand.rsplit('/').next() == Some(basename_lower)
1241 };
1242
1243 // Prefer path-suffix hits: they carry the user's typed directory context.
1244 // Fall back to basename hits only when no suffix hit exists. Either way,
1245 // more than one distinct winner is ambiguous and resolves nothing.
1246 let predicates: [&dyn Fn(&str) -> bool; 2] = [&suffix_match, &basename_match];
1247 for predicate in predicates {
1248 let mut winner: Option<&str> = None;
1249 for candidate in candidates {
1250 if !predicate(candidate) {
1251 continue;
1252 }
1253 match winner {
1254 None => winner = Some(candidate.as_str()),
1255 Some(existing) if existing == candidate.as_str() => {}
1256 Some(_) => return None,
1257 }
1258 }
1259 if let Some(winner) = winner {
1260 // The index can be stale; only a path that still resolves exists.
1261 // Display strings are workspace- or cwd-relative, which
1262 // `resolve_exact` re-anchors exactly like a typed path.
1263 //
1264 // Rejoin the components with this platform's separator first.
1265 // Index strings are `/`-separated, and `root.join("ops/f.md")`
1266 // keeps that slash verbatim on Windows, yielding a mixed
1267 // `C:\ws\ops/f.md` that we then hand to the model and print in
1268 // the context inspector. Same path, inconsistent rendering.
1269 let native: PathBuf = winner.split('/').filter(|part| !part.is_empty()).collect();
1270 return ws.resolve_exact(&native.to_string_lossy()).ok();
1271 }
1272 }
1273 None
1274 }
1275
1276 fn extract_file_mentions(input: &str) -> Vec<String> {
1277 let chars: Vec<char> = input.chars().collect();
1278 let mut mentions = Vec::new();
1279 let mut idx = 0;
1280
1281 while idx < chars.len() {
1282 if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
1283 idx += 1;
1284 continue;
1285 }
1286
1287 let Some(next) = chars.get(idx + 1).copied() else {
1288 break;
1289 };
1290 if next.is_whitespace() {
1291 idx += 1;
1292 continue;
1293 }
1294
1295 if matches!(next, '"' | '\'') {
1296 let quote = next;
1297 let mut end = idx + 2;
1298 let mut raw = String::new();
1299 while end < chars.len() && chars[end] != quote {
1300 raw.push(chars[end]);
1301 end += 1;
1302 }
1303 if !raw.trim().is_empty() {
1304 mentions.push(raw.trim().to_string());
1305 }
1306 idx = end.saturating_add(1);
1307 continue;
1308 }
1309
1310 let mut end = idx + 1;
1311 let mut raw = String::new();
1312 while end < chars.len() && !chars[end].is_whitespace() {
1313 raw.push(chars[end]);
1314 end += 1;
1315 }
1316 let trimmed = trim_unquoted_mention(&raw);
1317 if !trimmed.is_empty() {
1318 mentions.push(trimmed.to_string());
1319 }
1320 idx = end;
1321 }
1322
1323 mentions
1324 }
1325
1326 fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
1327 if idx == 0 {
1328 return true;
1329 }
1330 chars
1331 .get(idx.saturating_sub(1))
1332 .is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
1333 }
1334
1335 fn trim_unquoted_mention(raw: &str) -> &str {
1336 let mut trimmed = raw.trim();
1337 while trimmed.chars().count() > 1
1338 && trimmed
1339 .chars()
1340 .last()
1341 .is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
1342 {
1343 trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
1344 }
1345 trimmed
1346 }
1347
1348 fn render_file_mention_context(
1349 raw: &str,
1350 path: &Path,
1351 display_path: &str,
1352 range: Option<FileRange>,
1353 ) -> String {
1354 if !path.exists() {
1355 return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
1356 }
1357 if path.is_dir() {
1358 return render_directory_mention_context(raw, path, display_path);
1359 }
1360 if !path.is_file() {
1361 return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
1362 }
1363 if is_media_path(path) {
1364 return format!(
1365 "<media-file mention=\"@{raw}\" path=\"{display_path}\">\nUse /attach {raw} when the intent is to attach this image or video to the next message.\n</media-file>"
1366 );
1367 }
1368
1369 let range_attr = match range {
1370 Some(FileRange { start, end }) => format!(r#" lines="{start}-{end}""#),
1371 None => String::new(),
1372 };
1373 match read_file_content(path, range) {
1374 Ok((text, truncated, beyond_eof)) => {
1375 let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
1376 let beyond_attr = if beyond_eof {
1377 " beyond-eof=\"true\""
1378 } else {
1379 ""
1380 };
1381 format!(
1382 "<file mention=\"@{raw}\" path=\"{display_path}\"{range_attr}{truncated_attr}{beyond_attr}>\n{text}\n</file>"
1383 )
1384 }
1385 Err(err) => {
1386 format!(
1387 "<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
1388 )
1389 }
1390 }
1391 }
1392
1393 fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
1394 let entries = match std::fs::read_dir(path) {
1395 Ok(entries) => entries,
1396 Err(err) => {
1397 return format!(
1398 "<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
1399 );
1400 }
1401 };
1402
1403 // Keep only the first MAX entries in sort order while counting the rest:
1404 // a max-heap of that size bounds memory to the output instead of
1405 // materializing and sorting the whole directory (U07-03).
1406 let mut total = 0usize;
1407 let mut kept = std::collections::BinaryHeap::with_capacity(MAX_DIRECTORY_MENTION_ENTRIES + 1);
1408 for entry in entries.filter_map(|entry| entry.ok()) {
1409 total += 1;
1410 let marker = entry
1411 .file_type()
1412 .ok()
1413 .filter(|ty| ty.is_dir())
1414 .map_or("", |_| "/");
1415 kept.push(format!("{}{}", entry.file_name().to_string_lossy(), marker));
1416 if kept.len() > MAX_DIRECTORY_MENTION_ENTRIES {
1417 kept.pop();
1418 }
1419 }
1420 let names = kept.into_sorted_vec();
1421 let mut body = names.join("\n");
1422 if total > MAX_DIRECTORY_MENTION_ENTRIES {
1423 let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
1424 let _ = write!(body, "\n... {omitted} more entries");
1425 }
1426 format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
1427 }
1428
1429 /// Bounded read of a mention's file content, optionally sliced to a line
1430 /// range. Returns `(text, truncated, beyond_eof)`: `truncated` mirrors the
1431 /// full-file byte bound, except for a range that starts past that bound,
1432 /// where it says whether the streamed range itself was cut; `beyond_eof` is
1433 /// set only when the requested range starts past the end of the file.
1434 fn read_file_content(
1435 path: &Path,
1436 range: Option<FileRange>,
1437 ) -> std::io::Result<(String, bool, bool)> {
1438 let mut file = std::fs::File::open(path)?;
1439 let (text, truncated) = read_text_prefix(&mut file)?;
1440 let Some(FileRange { start, end }) = range else {
1441 return Ok((text, truncated, false));
1442 };
1443 let mut lines: Vec<&str> = text.split('\n').collect();
1444 if lines.last().copied() == Some("") {
1445 lines.pop();
1446 }
1447 let start_idx = usize::try_from(start.saturating_sub(1)).unwrap_or(usize::MAX);
1448 if start_idx >= lines.len() {
1449 if truncated {
1450 // The prefix ended, not the file: read the range from the file
1451 // itself instead of calling it past EOF (U07-04).
1452 std::io::Seek::rewind(&mut file)?;
1453 return read_line_range_past_prefix(file, u64::from(start), u64::from(end));
1454 }
1455 return Ok((String::new(), truncated, true));
1456 }
1457 let end_idx = usize::try_from(end).unwrap_or(usize::MAX).min(lines.len());
1458 Ok((lines[start_idx..end_idx].join("\n"), truncated, false))
1459 }
1460
1461 /// Stream a 1-based inclusive line range that starts beyond the bounded
1462 /// prefix. Skipped lines are scanned, never stored; the range text itself is
1463 /// held to the same byte budget as a whole-file mention.
1464 fn read_line_range_past_prefix(
1465 file: impl Read,
1466 start: u64,
1467 end: u64,
1468 ) -> std::io::Result<(String, bool, bool)> {
1469 use std::io::BufRead;
1470 let mut reader = std::io::BufReader::new(file);
1471 let mut line = 1u64;
1472 while line < start {
1473 let buf = reader.fill_buf()?;
1474 if buf.is_empty() {
1475 return Ok((String::new(), false, true));
1476 }
1477 let consumed = match buf.iter().position(|byte| *byte == b'\n') {
1478 Some(newline) => {
1479 line += 1;
1480 newline + 1
1481 }
1482 None => buf.len(),
1483 };
1484 reader.consume(consumed);
1485 }
1486 if reader.fill_buf()?.is_empty() {
1487 return Ok((String::new(), false, true));
1488 }
1489 let budget = usize::try_from(MAX_MENTION_FILE_BYTES).unwrap_or(usize::MAX);
1490 let mut buffer = Vec::new();
1491 let mut truncated = false;
1492 while line <= end {
1493 let buf = reader.fill_buf()?;
1494 if buf.is_empty() {
1495 break;
1496 }
1497 let (take, newline) = match buf.iter().position(|byte| *byte == b'\n') {
1498 Some(newline) => (newline + 1, true),
1499 None => (buf.len(), false),
1500 };
1501 let room = budget - buffer.len();
1502 if take > room {
1503 buffer.extend_from_slice(&buf[..room]);
1504 truncated = true;
1505 break;
1506 }
1507 buffer.extend_from_slice(&buf[..take]);
1508 reader.consume(take);
1509 if newline {
1510 line += 1;
1511 }
1512 }
1513 let mut text = decode_bounded_text(buffer, truncated)?;
1514 if text.ends_with('\n') {
1515 text.pop();
1516 }
1517 Ok((text, truncated, false))
1518 }
1519
1520 fn read_text_prefix(file: &mut impl Read) -> std::io::Result<(String, bool)> {
1521 let mut buffer = Vec::new();
1522 file.by_ref()
1523 .take(MAX_MENTION_FILE_BYTES + 1)
1524 .read_to_end(&mut buffer)?;
1525 let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
1526 if truncated {
1527 buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
1528 }
1529 Ok((decode_bounded_text(buffer, truncated)?, truncated))
1530 }
1531
1532 /// Decode a budget-bounded read as UTF-8 text, rejecting binary content.
1533 fn decode_bounded_text(mut buffer: Vec<u8>, truncated: bool) -> std::io::Result<String> {
1534 if truncated {
1535 // Round down to the nearest valid UTF-8 character boundary so a
1536 // multi-byte sequence (CJK, emoji, etc.) is never split at the cut point.
1537 // Only adjust when error_len() is None — that means truncation landed
1538 // mid-sequence (incomplete tail). A Some(_) error_len means the file
1539 // genuinely contains invalid UTF-8 bytes; leave the buffer intact so
1540 // the from_utf8 call below returns the correct "file is not UTF-8" error.
1541 if let Err(e) = std::str::from_utf8(&buffer)
1542 && e.error_len().is_none()
1543 {
1544 buffer.truncate(e.valid_up_to());
1545 }
1546 }
1547 if buffer.contains(&0) {
1548 return Err(std::io::Error::new(
1549 std::io::ErrorKind::InvalidData,
1550 "file appears to be binary",
1551 ));
1552 }
1553 let text = std::str::from_utf8(&buffer)
1554 .map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
1555 .to_string();
1556 Ok(text)
1557 }
1558
1559 fn is_media_path(path: &Path) -> bool {
1560 let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
1561 return false;
1562 };
1563 matches!(
1564 ext.to_ascii_lowercase().as_str(),
1565 "png"
1566 | "jpg"
1567 | "jpeg"
1568 | "gif"
1569 | "webp"
1570 | "bmp"
1571 | "tif"
1572 | "tiff"
1573 | "ppm"
1574 | "mp4"
1575 | "mov"
1576 | "m4v"
1577 | "webm"
1578 | "avi"
1579 | "mkv"
1580 )
1581 }
1582
1583 // ---------------------------------------------------------------------------
1584 // #101 regression repros
1585 // ---------------------------------------------------------------------------
1586 //
1587 // The bug being guarded: typing `@<some/file>` resolved under `--workspace`,
1588 // not the user's launch CWD. When the two diverged (the canonical case is
1589 // `--workspace=/repo` with `pwd=/repo/sub`), every relative `@` token routed
1590 // to the wrong root and the prompt got `<missing-file>` blocks.
1591 #[cfg(test)]
1592 mod tests {
1593 use super::*;
1594 use tempfile::TempDir;
1595
1596 #[test]
1597 fn inserted_mention_bodies_parse_back_to_the_whole_path() {
1598 for path in [
1599 "src/main.rs",
1600 "My Docs/notes.md",
1601 "Screenshot 2026-09-28 at 10.00.00.png",
1602 "notes)",
1603 "say \"hi\" now.md",
1604 ] {
1605 let input = format!("look at @{} please", file_mention_body(path));
1606 assert_eq!(
1607 extract_file_mentions(&input),
1608 vec![path.to_string()],
1609 "{input}"
1610 );
1611 }
1612 assert_eq!(file_mention_body("src/main.rs"), "src/main.rs");
1613 }
1614
1615 /// #101 regression — workspace-vs-cwd divergence: `@bar.txt` typed from
1616 /// the cwd `<root>/sub` MUST resolve to `<root>/sub/bar.txt`, never to
1617 /// `<root>/bar.txt` (which doesn't exist).
1618 #[test]
1619 fn cwd_pass_resolves_when_workspace_pass_misses() {
1620 let tmp = TempDir::new().expect("tempdir");
1621 let sub = tmp.path().join("sub");
1622 std::fs::create_dir_all(&sub).expect("mkdir");
1623 let bar = sub.join("bar.txt");
1624 std::fs::write(&bar, "hello bar").expect("write bar");
1625
1626 let content =
1627 user_request_with_file_mentions("look at @bar.txt", tmp.path(), Some(sub.clone()));
1628
1629 // The block must reference the cwd-rooted path with the file's body —
1630 // and crucially it must NOT collapse to <missing-file>.
1631 assert!(
1632 content.contains("hello bar"),
1633 "expected file body to be inlined; got: {content}",
1634 );
1635 assert!(
1636 !content.contains("<missing-file"),
1637 "must not surface <missing-file> for a path that exists under cwd; got: {content}",
1638 );
1639 let bar_disp = bar.display().to_string();
1640 assert!(
1641 content.contains(&bar_disp),
1642 "expected resolved path {bar_disp} in content; got: {content}",
1643 );
1644 // Belt-and-suspenders: the workspace-rooted path doesn't exist and
1645 // must not appear in the rendered <file path="..."> attribute.
1646 let wrong = tmp.path().join("bar.txt").display().to_string();
1647 assert!(
1648 !content.contains(&format!("path=\"{wrong}\"")),
1649 "should NOT have routed to {wrong}; got: {content}",
1650 );
1651 }
1652
1653 /// #101 regression — nested workspace path: `@nested/deep/file.md` with
1654 /// the file at workspace root resolves through the workspace pass.
1655 #[test]
1656 fn workspace_pass_resolves_nested_path() {
1657 let tmp = TempDir::new().expect("tempdir");
1658 let nested = tmp.path().join("nested/deep");
1659 std::fs::create_dir_all(&nested).expect("mkdir");
1660 let file_md = nested.join("file.md");
1661 std::fs::write(&file_md, "# nested deep").expect("write file_md");
1662
1663 // Cwd is irrelevant; an unrelated tempdir would do. Pass `None` so we
1664 // are unambiguously testing the workspace-pass path.
1665 let content = user_request_with_file_mentions("see @nested/deep/file.md", tmp.path(), None);
1666
1667 assert!(content.contains("# nested deep"), "got: {content}");
1668 assert!(!content.contains("<missing-file"), "got: {content}");
1669 // Path-separator-portable check: the resolved path's filename is the
1670 // most reliable cross-platform anchor (Windows mixes `/` and `\` when
1671 // join() preserves user-typed separators).
1672 let basename = file_md
1673 .file_name()
1674 .and_then(|n| n.to_str())
1675 .expect("file_name utf-8");
1676 assert!(
1677 content.contains(basename),
1678 "basename {basename} not in path; got: {content}",
1679 );
1680 }
1681
1682 /// Snapshot-style check: the rendered `<file>` block for a resolvable
1683 /// mention must include the expected attributes and contents, and must
1684 /// NOT contain `<missing-file>`.
1685 #[test]
1686 fn resolvable_mention_renders_file_block_not_missing_file() {
1687 let tmp = TempDir::new().expect("tempdir");
1688 std::fs::write(tmp.path().join("guide.md"), "# Guide\nUse the fast path.\n")
1689 .expect("write");
1690
1691 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
1692
1693 // Header + tag presence.
1694 assert!(content.contains("Local context from @mentions:"));
1695 assert!(content.contains("<file mention=\"@guide.md\""));
1696 assert!(content.contains("# Guide\nUse the fast path."));
1697 assert!(content.ends_with("</file>"), "got: {content}");
1698 // The bug fingerprint MUST be absent.
1699 assert!(!content.contains("<missing-file"), "got: {content}");
1700 }
1701
1702 /// Negative test: a truly missing path still produces `<missing-file>`
1703 /// so the user gets an explicit signal instead of silent failure.
1704 #[test]
1705 fn truly_missing_mention_still_renders_missing_file() {
1706 let tmp = TempDir::new().expect("tempdir");
1707
1708 let content = user_request_with_file_mentions(
1709 "huh @does/not/exist.txt",
1710 tmp.path(),
1711 Some(tmp.path().to_path_buf()),
1712 );
1713
1714 assert!(
1715 content.contains("<missing-file mention=\"@does/not/exist.txt\""),
1716 "got: {content}",
1717 );
1718 }
1719
1720 #[test]
1721 fn pending_context_preview_is_lexical_and_does_not_probe_paths() {
1722 let previews = pending_context_previews("read @guide.md and @missing.md");
1723
1724 assert_eq!(previews.len(), 2);
1725 assert_eq!(previews[0].kind, "mention");
1726 assert_eq!(previews[0].label, "guide.md");
1727 assert!(!previews[0].included);
1728 assert_eq!(previews[0].detail.as_deref(), Some("resolved on send"));
1729 assert_eq!(previews[1].kind, "mention");
1730 assert_eq!(previews[1].label, "missing.md");
1731 assert!(!previews[1].included);
1732 assert_eq!(previews[1].detail.as_deref(), Some("resolved on send"));
1733 }
1734
1735 #[test]
1736 fn pending_context_preview_distinguishes_attach_media_from_at_media() {
1737 let tmp = TempDir::new().expect("tempdir");
1738 std::fs::write(tmp.path().join("photo.png"), b"png").expect("write");
1739 let attached = tmp.path().join("photo.png").display().to_string();
1740 let input = format!("inspect @photo.png\n[Attached image: {attached}]");
1741
1742 let previews = pending_context_previews(&input);
1743
1744 assert!(
1745 previews
1746 .iter()
1747 .any(|item| item.kind == "media" && !item.included),
1748 "at-mention media should be hint-only: {previews:?}"
1749 );
1750 assert!(
1751 previews
1752 .iter()
1753 .any(|item| item.kind == "image" && item.included),
1754 "/attach media should be included: {previews:?}"
1755 );
1756 }
1757
1758 #[test]
1759 fn manually_typed_basename_does_not_fuzzy_attach_nested_file() {
1760 let tmp = TempDir::new().expect("tempdir");
1761 let nested = tmp.path().join("nested");
1762 std::fs::create_dir_all(&nested).expect("mkdir");
1763 std::fs::write(nested.join("guide.md"), "nested secret").expect("write");
1764
1765 // With no completion index on hand there is no send-time fallback:
1766 // the miss stays an explicit <missing-file> rather than attaching an
1767 // arbitrary same-name file from a nested directory (#4365).
1768 let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
1769
1770 assert!(
1771 content.contains("<missing-file mention=\"@guide.md\""),
1772 "a manually typed basename should remain exact: {content}",
1773 );
1774 assert!(
1775 !content.contains("nested secret"),
1776 "exact resolution must not silently attach a fuzzy nested match: {content}",
1777 );
1778 }
1779
1780 // ---------------------------------------------------------------------
1781 // Send-time completion-index fallback
1782 // ---------------------------------------------------------------------
1783 //
1784 // The dogfood failure this guards: `@FINISH-0.9.4.md` typed at the
1785 // workspace root resolved "not found" and injected a <missing-file> block
1786 // carrying the wrong (workspace-root) path, even though the file sat one
1787 // directory down. Misses now fall back to a bounded unique-match search
1788 // of the composer's background completion index; unresolvable misses emit
1789 // an honest block that names only what the user typed.
1790
1791 fn expand_with_index(
1792 input: &str,
1793 workspace: &Path,
1794 cwd: Option<PathBuf>,
1795 index: &[String],
1796 ) -> String {
1797 user_request_with_file_mentions_cached(
1798 input,
1799 workspace,
1800 cwd,
1801 &mut GitMentionCache::default(),
1802 Some(index),
1803 )
1804 }
1805
1806 /// A unique basename hit in the completion index resolves a nested file
1807 /// and injects its real path.
1808 #[test]
1809 fn mention_miss_resolves_via_unique_index_basename() {
1810 let tmp = TempDir::new().expect("tempdir");
1811 let nested = tmp.path().join("ops");
1812 std::fs::create_dir_all(&nested).expect("mkdir");
1813 std::fs::write(nested.join("FINISH-0.9.4.md"), "ship list").expect("write");
1814
1815 let index = vec!["ops/FINISH-0.9.4.md".to_string(), "README.md".to_string()];
1816 let content = expand_with_index("finish @FINISH-0.9.4.md", tmp.path(), None, &index);
1817
1818 assert!(content.contains("ship list"), "got: {content}");
1819 assert!(!content.contains("<missing-file"), "got: {content}");
1820 let real = nested.join("FINISH-0.9.4.md").display().to_string();
1821 assert!(
1822 content.contains(&real),
1823 "expected resolved path {real} in content; got: {content}",
1824 );
1825 }
1826
1827 /// A typed partial path resolves through a unique path-suffix hit.
1828 #[test]
1829 fn mention_miss_resolves_via_unique_index_suffix() {
1830 let tmp = TempDir::new().expect("tempdir");
1831 let nested = tmp.path().join("nested/deep");
1832 std::fs::create_dir_all(&nested).expect("mkdir");
1833 std::fs::write(nested.join("file.md"), "deep body").expect("write");
1834 // A same-basename file elsewhere must not make the suffix hit
1835 // ambiguous: the typed directory context disambiguates.
1836 let other = tmp.path().join("other");
1837 std::fs::create_dir_all(&other).expect("mkdir");
1838 std::fs::write(other.join("file.md"), "other body").expect("write");
1839
1840 let index = vec![
1841 "nested/deep/file.md".to_string(),
1842 "other/file.md".to_string(),
1843 ];
1844 let content = expand_with_index("see @deep/file.md", tmp.path(), None, &index);
1845
1846 assert!(content.contains("deep body"), "got: {content}");
1847 assert!(!content.contains("other body"), "got: {content}");
1848 assert!(!content.contains("<missing-file"), "got: {content}");
1849 }
1850
1851 /// Two same-basename candidates with no typed directory context are
1852 /// ambiguous: nothing is attached and the miss stays explicit.
1853 #[test]
1854 fn ambiguous_index_basename_stays_missing() {
1855 let tmp = TempDir::new().expect("tempdir");
1856 for dir in ["a", "b"] {
1857 std::fs::create_dir_all(tmp.path().join(dir)).expect("mkdir");
1858 std::fs::write(tmp.path().join(dir).join("guide.md"), format!("body {dir}"))
1859 .expect("write");
1860 }
1861
1862 let index = vec!["a/guide.md".to_string(), "b/guide.md".to_string()];
1863 let content = expand_with_index("read @guide.md", tmp.path(), None, &index);
1864
1865 assert!(
1866 content.contains("<missing-file mention=\"@guide.md\""),
1867 "an ambiguous basename must not attach an arbitrary winner: {content}",
1868 );
1869 assert!(!content.contains("body a"), "got: {content}");
1870 assert!(!content.contains("body b"), "got: {content}");
1871 }
1872
1873 /// A stale index entry (file deleted after the scan) must not attach.
1874 #[test]
1875 fn stale_index_entry_stays_missing() {
1876 let tmp = TempDir::new().expect("tempdir");
1877
1878 let index = vec!["ghost.md".to_string()];
1879 let content = expand_with_index("boo @ghost.md", tmp.path(), None, &index);
1880
1881 assert!(
1882 content.contains("<missing-file mention=\"@ghost.md\" />"),
1883 "got: {content}",
1884 );
1885 }
1886
1887 /// Absolute mentions name an exact location; the index must never
1888 /// substitute a same-basename file from inside the workspace.
1889 #[test]
1890 fn absolute_mention_miss_never_uses_index() {
1891 let tmp = TempDir::new().expect("tempdir");
1892 std::fs::write(tmp.path().join("guide.md"), "workspace guide").expect("write");
1893
1894 let index = vec!["guide.md".to_string()];
1895 let content = expand_with_index(
1896 "read @/definitely/absent/guide.md",
1897 tmp.path(),
1898 None,
1899 &index,
1900 );
1901
1902 assert!(
1903 content.contains("<missing-file mention=\"@/definitely/absent/guide.md\" />"),
1904 "got: {content}",
1905 );
1906 assert!(!content.contains("workspace guide"), "got: {content}");
1907 }
1908
1909 /// The honest miss format: the block names only the typed mention and
1910 /// never the non-existent workspace-root join.
1911 #[test]
1912 fn missing_file_block_names_only_the_typed_mention() {
1913 let tmp = TempDir::new().expect("tempdir");
1914
1915 let content = user_request_with_file_mentions(
1916 "huh @does/not/exist.txt",
1917 tmp.path(),
1918 Some(tmp.path().to_path_buf()),
1919 );
1920
1921 assert!(
1922 content.contains("<missing-file mention=\"@does/not/exist.txt\" />"),
1923 "got: {content}",
1924 );
1925 let wrong = tmp.path().join("does/not/exist.txt").display().to_string();
1926 assert!(
1927 !content.contains(&wrong),
1928 "must not inject the wrong workspace-root path {wrong}; got: {content}",
1929 );
1930 }
1931
1932 /// The context inspector mirrors the payload: index-resolved mentions
1933 /// report their real path, unresolved ones report the typed token.
1934 #[test]
1935 fn context_references_reflect_index_resolution() {
1936 let tmp = TempDir::new().expect("tempdir");
1937 let nested = tmp.path().join("ops");
1938 std::fs::create_dir_all(&nested).expect("mkdir");
1939 std::fs::write(nested.join("runbook.md"), "steps").expect("write");
1940
1941 let index = vec!["ops/runbook.md".to_string()];
1942 let references = context_references_from_input_cached(
1943 "read @runbook.md and @absent.md",
1944 tmp.path(),
1945 None,
1946 &mut GitMentionCache::default(),
1947 Some(&index),
1948 );
1949
1950 let resolved = references
1951 .iter()
1952 .find(|r| r.label == "runbook.md")
1953 .expect("runbook reference");
1954 assert_eq!(resolved.kind, ContextReferenceKind::File);
1955 assert!(resolved.included);
1956 let real = nested.join("runbook.md").display().to_string();
1957 assert_eq!(resolved.target, real, "{resolved:?}");
1958
1959 let missing = references
1960 .iter()
1961 .find(|r| r.label == "absent.md")
1962 .expect("absent reference");
1963 assert_eq!(missing.kind, ContextReferenceKind::Missing);
1964 assert!(!missing.included);
1965 assert_eq!(
1966 missing.target, "absent.md",
1967 "a missing mention must not report the workspace-root guess as its target: {missing:?}",
1968 );
1969 }
1970
1971 #[test]
1972 fn media_attachment_references_include_removable_line_ranges() {
1973 let input = "before\n[Attached image: 8x4 PNG at /tmp/pasted.png]\nafter";
1974
1975 let references = media_attachment_references(input);
1976
1977 assert_eq!(references.len(), 1);
1978 let reference = &references[0];
1979 assert_eq!(reference.kind, "image");
1980 assert_eq!(reference.path, "/tmp/pasted.png");
1981 assert_eq!(
1982 &input[reference.start_byte..reference.end_byte],
1983 "[Attached image: 8x4 PNG at /tmp/pasted.png]\n"
1984 );
1985 }
1986
1987 #[test]
1988 fn context_references_preserve_exact_targets_and_roundtrip() {
1989 let tmp = TempDir::new().expect("tempdir");
1990 std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
1991 std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
1992 let input = "read @src/main.rs";
1993
1994 let references =
1995 context_references_from_input(input, tmp.path(), Some(tmp.path().to_path_buf()));
1996
1997 assert_eq!(references.len(), 1);
1998 let reference = &references[0];
1999 assert_eq!(reference.kind, ContextReferenceKind::File);
2000 assert_eq!(reference.source, ContextReferenceSource::AtMention);
2001 assert_eq!(reference.label, "src/main.rs");
2002 assert!(reference.target.ends_with("src/main.rs"));
2003 assert!(reference.included);
2004 assert!(reference.expanded);
2005
2006 let encoded = serde_json::to_string(reference).expect("serialize");
2007 let decoded: ContextReference = serde_json::from_str(&encoded).expect("deserialize");
2008 assert_eq!(&decoded, reference);
2009 }
2010
2011 /// Regression test for #1441: truncating at MAX_MENTION_FILE_BYTES must not
2012 /// split a multi-byte UTF-8 sequence, which previously produced U+FFFD
2013 /// replacement characters in the TUI output.
2014 #[test]
2015 fn read_text_prefix_truncation_respects_utf8_char_boundary() {
2016 use std::io::Write;
2017
2018 // Build a file that is MAX_MENTION_FILE_BYTES - 1 ASCII bytes followed
2019 // by a 3-byte CJK character (U+4E2D, '中'). The naive truncate at
2020 // MAX_MENTION_FILE_BYTES cuts after the first byte of '中', producing
2021 // an invalid sequence.
2022 let tmp = TempDir::new().expect("tempdir");
2023 let path = tmp.path().join("cjk.txt");
2024 let mut f = std::fs::File::create(&path).expect("create");
2025 let padding = vec![b'a'; MAX_MENTION_FILE_BYTES as usize - 1];
2026 f.write_all(&padding).expect("write padding");
2027 f.write_all("中".as_bytes()).expect("write CJK");
2028
2029 let (text, truncated, beyond_eof) = read_file_content(&path, None).expect("should succeed");
2030 assert!(!beyond_eof);
2031 assert!(
2032 truncated,
2033 "file exceeds limit so should be marked truncated"
2034 );
2035 assert!(
2036 !text.contains('\u{FFFD}'),
2037 "truncated text must not contain replacement characters; got: {text:?}",
2038 );
2039 }
2040
2041 #[test]
2042 fn mention_range_splitting_accepts_only_exact_digit_pairs() {
2043 assert_eq!(
2044 split_mention_range("src/lib.rs:120-160"),
2045 Some((
2046 "src/lib.rs",
2047 FileRange {
2048 start: 120,
2049 end: 160
2050 }
2051 )),
2052 );
2053 assert_eq!(
2054 split_mention_range("x:1-2"),
2055 Some(("x", FileRange { start: 1, end: 2 })),
2056 );
2057 for whole in [
2058 "notes.txt",
2059 "x:1",
2060 "x:a-b",
2061 ":1-2",
2062 "x:1-a",
2063 "x:0-2",
2064 "x:2-1",
2065 ] {
2066 assert_eq!(split_mention_range(whole), None, "{whole} must stay whole");
2067 }
2068 }
2069
2070 /// U07-04: a range that starts past the bounded prefix is read from the
2071 /// file, not reported as past EOF; a range past the real end still is.
2072 #[test]
2073 fn ranged_file_mention_reads_past_the_bounded_prefix() {
2074 let tmp = TempDir::new().expect("tempdir");
2075 let path = tmp.path().join("long.txt");
2076 let body: String = (1..=20_000).map(|n| format!("line {n}\n")).collect();
2077 assert!(body.len() as u64 > MAX_MENTION_FILE_BYTES);
2078 std::fs::write(&path, body).expect("write");
2079
2080 let (text, truncated, beyond_eof) = read_file_content(
2081 &path,
2082 Some(FileRange {
2083 start: 19_998,
2084 end: 20_000,
2085 }),
2086 )
2087 .expect("range read");
2088 assert!(!beyond_eof, "the file has these lines");
2089 assert!(!truncated, "three short lines fit the budget");
2090 assert_eq!(text, "line 19998\nline 19999\nline 20000");
2091
2092 let (_, _, beyond_eof) = read_file_content(
2093 &path,
2094 Some(FileRange {
2095 start: 20_001,
2096 end: 20_002,
2097 }),
2098 )
2099 .expect("range read");
2100 assert!(beyond_eof, "the file really ends at line 20000");
2101 }
2102
2103 #[test]
2104 fn ranged_file_mention_slices_lines_and_reports_beyond_eof() {
2105 let tmp = TempDir::new().expect("tempdir");
2106 let path = tmp.path().join("lines.rs");
2107 std::fs::write(&path, "one\ntwo\nthree\nfour\nfive\n").expect("write");
2108
2109 let (text, truncated, beyond_eof) =
2110 read_file_content(&path, Some(FileRange { start: 2, end: 4 })).expect("range read");
2111 assert!(!truncated);
2112 assert!(!beyond_eof);
2113 assert_eq!(text, "two\nthree\nfour");
2114
2115 // An end past the file clamps to what exists.
2116 let (text, _, beyond_eof) =
2117 read_file_content(&path, Some(FileRange { start: 4, end: 99 })).expect("clamped range");
2118 assert!(!beyond_eof);
2119 assert_eq!(text, "four\nfive");
2120
2121 // A start past the end is flagged honestly.
2122 let (text, _, beyond_eof) =
2123 read_file_content(&path, Some(FileRange { start: 9, end: 12 })).expect("beyond range");
2124 assert!(beyond_eof);
2125 assert!(text.is_empty());
2126 }
2127
2128 #[test]
2129 fn ranged_mention_render_annotates_lines_and_honours_the_byte_bound() {
2130 let tmp = TempDir::new().expect("tempdir");
2131 let path = tmp.path().join("notes.rs");
2132 std::fs::write(&path, "a\nb\nc\nd\n").expect("write");
2133 let rendered = render_file_mention_context(
2134 "notes.rs:2-3",
2135 &path,
2136 "notes.rs",
2137 Some(FileRange { start: 2, end: 3 }),
2138 );
2139 assert!(rendered.contains(r#"lines="2-3""#), "{rendered}");
2140 assert!(rendered.contains("\nb\nc\n"));
2141
2142 let beyond = render_file_mention_context(
2143 "notes.rs:80-90",
2144 &path,
2145 "notes.rs",
2146 Some(FileRange { start: 80, end: 90 }),
2147 );
2148 assert!(beyond.contains(r#"beyond-eof="true""#), "{beyond}");
2149 }
2150 // ---------------------------------------------------------------------
2151 // #4067 — @git / @diff composer mentions
2152 // ---------------------------------------------------------------------
2153
2154 fn init_test_repo(dir: &Path) {
2155 for args in [
2156 vec!["init", "--initial-branch=main"],
2157 vec!["config", "user.email", "test@example.com"],
2158 vec!["config", "user.name", "Test"],
2159 ] {
2160 let out = std::process::Command::new("git")
2161 .args(&args)
2162 .current_dir(dir)
2163 .output()
2164 .expect("git available in tests");
2165 assert!(out.status.success(), "git {args:?} failed");
2166 }
2167 }
2168
2169 fn commit_test_repo(dir: &Path) {
2170 for args in [vec!["add", "-A"], vec!["commit", "-m", "initial"]] {
2171 std::process::Command::new("git")
2172 .args(&args)
2173 .current_dir(dir)
2174 .output()
2175 .expect("git available in tests");
2176 }
2177 }
2178
2179 #[test]
2180 fn git_and_diff_mentions_inline_curated_context_not_paths() {
2181 let tmp = TempDir::new().expect("tempdir");
2182 init_test_repo(tmp.path());
2183 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2184 commit_test_repo(tmp.path());
2185 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
2186
2187 let expanded = user_request_with_file_mentions(
2188 "look at @git and @diff",
2189 tmp.path(),
2190 Some(tmp.path().to_path_buf()),
2191 );
2192
2193 assert!(expanded.contains("<git-status"), "{expanded}");
2194 assert!(expanded.contains("<git-diff"), "{expanded}");
2195 assert!(expanded.contains("a.txt"), "{expanded}");
2196 // The tokens are not treated as paths, so no missing-file block.
2197 assert!(!expanded.contains("<missing-file"), "{expanded}");
2198 }
2199
2200 #[test]
2201 fn git_mentions_outside_a_repository_say_so_explicitly() {
2202 let tmp = TempDir::new().expect("tempdir");
2203 let expanded = user_request_with_file_mentions(
2204 "status? @git",
2205 tmp.path(),
2206 Some(tmp.path().to_path_buf()),
2207 );
2208 assert!(expanded.contains("<git-unavailable"), "{expanded}");
2209 assert!(expanded.contains("not a git repository"), "{expanded}");
2210 }
2211
2212 #[test]
2213 fn git_mention_is_deduplicated_within_one_message() {
2214 let tmp = TempDir::new().expect("tempdir");
2215 init_test_repo(tmp.path());
2216 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2217 commit_test_repo(tmp.path());
2218 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
2219
2220 let expanded = user_request_with_file_mentions(
2221 "@diff and again @diff",
2222 tmp.path(),
2223 Some(tmp.path().to_path_buf()),
2224 );
2225 assert_eq!(expanded.matches("<git-diff").count(), 1, "{expanded}");
2226 }
2227
2228 #[test]
2229 fn paths_that_merely_start_with_git_stay_file_mentions() {
2230 let tmp = TempDir::new().expect("tempdir");
2231 std::fs::write(tmp.path().join("diff.txt"), "plain file").expect("write");
2232
2233 let expanded = user_request_with_file_mentions(
2234 "see @diff.txt",
2235 tmp.path(),
2236 Some(tmp.path().to_path_buf()),
2237 );
2238 assert!(expanded.contains("plain file"), "{expanded}");
2239 assert!(!expanded.contains("<git-diff"), "{expanded}");
2240 }
2241
2242 #[test]
2243 fn large_diff_is_truncated_and_the_inspector_reports_the_budget() {
2244 let tmp = TempDir::new().expect("tempdir");
2245 init_test_repo(tmp.path());
2246 std::fs::write(tmp.path().join("big.txt"), "seed\n").expect("write");
2247 commit_test_repo(tmp.path());
2248 let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
2249 std::fs::write(tmp.path().join("big.txt"), bulk).expect("write");
2250
2251 let expanded =
2252 user_request_with_file_mentions("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
2253 assert!(
2254 expanded.contains("truncated=\"true\""),
2255 "expected truncation marker"
2256 );
2257
2258 let references =
2259 context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
2260 let git_ref = references
2261 .iter()
2262 .find(|r| r.kind == ContextReferenceKind::GitContext)
2263 .expect("git reference present in the inspector");
2264 assert_eq!(git_ref.label, "diff");
2265 assert!(git_ref.included);
2266 let detail = git_ref.detail.clone().unwrap_or_default();
2267 assert!(detail.contains("truncated at"), "{detail}");
2268 assert!(
2269 detail.contains(&crate::tui::git_mention::MAX_GIT_DIFF_MENTION_BYTES.to_string()),
2270 "{detail}"
2271 );
2272 }
2273
2274 #[test]
2275 fn empty_repository_reference_is_visible_but_not_included() {
2276 let tmp = TempDir::new().expect("tempdir");
2277 init_test_repo(tmp.path());
2278 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2279 commit_test_repo(tmp.path());
2280
2281 let references =
2282 context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
2283 let git_ref = references
2284 .iter()
2285 .find(|r| r.kind == ContextReferenceKind::GitContext)
2286 .expect("git reference present even when there is nothing to show");
2287 assert!(!git_ref.included);
2288 assert!(
2289 git_ref
2290 .detail
2291 .as_deref()
2292 .is_some_and(|d| d.contains("no working-tree changes")),
2293 "{:?}",
2294 git_ref.detail
2295 );
2296 }
2297
2298 #[test]
2299 fn composer_preview_lists_git_mentions_without_running_git() {
2300 let previews = pending_context_previews("@git @diff");
2301 let kinds: Vec<&str> = previews.iter().map(|p| p.kind.as_str()).collect();
2302 assert_eq!(kinds, vec!["git", "git"]);
2303 assert!(previews.iter().all(|p| !p.included));
2304 }
2305
2306 /// #4067 review follow-up: `mention_menu_limit = 0` is a documented way to
2307 /// disable the popup. The git tokens are menu entries like any other and
2308 /// must respect the same cap — otherwise setting 0 still pops a one-entry
2309 /// menu the moment the user types `@g`.
2310 #[test]
2311 fn git_mention_entries_respect_a_zero_menu_limit() {
2312 let paths = vec!["src/main.rs".to_string()];
2313 assert!(with_git_mention_entries(paths.clone(), "g", 0).is_empty());
2314 assert!(with_git_mention_entries(paths.clone(), "d", 0).is_empty());
2315 assert!(with_git_mention_entries(paths.clone(), "", 0).is_empty());
2316 assert!(with_git_mention_entries(Vec::new(), "gi", 0).is_empty());
2317 }
2318
2319 /// A small non-zero limit must cap the token list this function builds.
2320 ///
2321 /// The empty-partial branch is pass-through by design — those entries were
2322 /// already capped upstream by `rank_completion_candidates`, and shrinking
2323 /// them here would silently drop paths the caller asked for.
2324 #[test]
2325 fn git_mention_entries_never_exceed_the_menu_limit() {
2326 let paths = vec!["a.rs".to_string(), "b.rs".to_string()];
2327 for limit in 1..=4 {
2328 let matched = with_git_mention_entries(paths.clone(), "d", limit);
2329 assert!(matched.len() <= limit, "limit {limit}: {matched:?}");
2330 // Both tokens match a bare prefix that hits `git` and `diff`
2331 // through separate entries; the cap still holds.
2332 let both = with_git_mention_entries(Vec::new(), "", limit);
2333 assert!(both.len() <= limit, "limit {limit}: {both:?}");
2334 }
2335 // Pass-through: the caller's already-capped paths survive untouched.
2336 assert_eq!(with_git_mention_entries(paths.clone(), "", 1), paths);
2337 }
2338
2339 /// #4067 review follow-up: one submit resolves a git mention once, not
2340 /// once per surface. `@diff` makes git compute the whole working-tree diff
2341 /// before the byte budget applies, so a repeat is real wasted work.
2342 #[test]
2343 fn a_submit_resolves_each_git_mention_only_once() {
2344 let tmp = TempDir::new().expect("tempdir");
2345 init_test_repo(tmp.path());
2346 std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
2347 commit_test_repo(tmp.path());
2348 std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
2349
2350 let mut cache = crate::tui::git_mention::GitMentionCache::default();
2351 let references = context_references_from_input_cached(
2352 "@diff",
2353 tmp.path(),
2354 Some(tmp.path().to_path_buf()),
2355 &mut cache,
2356 None,
2357 );
2358 let expanded = user_request_with_file_mentions_cached(
2359 "@diff",
2360 tmp.path(),
2361 Some(tmp.path().to_path_buf()),
2362 &mut cache,
2363 None,
2364 );
2365
2366 // Both surfaces describe the same resolution.
2367 let git_ref = references
2368 .iter()
2369 .find(|r| r.kind == ContextReferenceKind::GitContext)
2370 .expect("git reference");
2371 assert!(git_ref.included);
2372 assert!(expanded.contains("<git-diff"), "{expanded}");
2373
2374 // And the shared cache holds exactly one entry for it.
2375 assert_eq!(cache.len(), 1, "the diff must be resolved once per submit");
2376 }
2377
2378 #[test]
2379 fn completion_offers_git_and_diff_alongside_paths() {
2380 let paths = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
2381 // A bare `@` stays the file picker.
2382 assert_eq!(with_git_mention_entries(paths.clone(), "", 8), paths);
2383
2384 let narrowed = with_git_mention_entries(paths.clone(), "di", 8);
2385 assert_eq!(narrowed.first().map(String::as_str), Some("diff"));
2386 assert!(narrowed.contains(&"src/main.rs".to_string()));
2387
2388 let git_only = with_git_mention_entries(paths.clone(), "g", 8);
2389 assert_eq!(git_only.first().map(String::as_str), Some("git"));
2390
2391 // A partial that matches no token leaves path completion untouched.
2392 assert_eq!(with_git_mention_entries(paths.clone(), "src", 8), paths);
2393 }
2394
2395 // ------------------------------------------------------------------
2396 // macOS screencapture-temp stabilization
2397 // ------------------------------------------------------------------
2398
2399 /// A fake macOS screencapture temp tree. Returns the tempdir (alive for
2400 /// the test's duration), the screenshot source path, and the artifact dir
2401 /// stabilization should copy into.
2402 fn screencapture_fixture() -> (TempDir, PathBuf, PathBuf) {
2403 let tmp = TempDir::new().expect("tempdir");
2404 let source_dir = tmp
2405 .path()
2406 .join("Temporary Items")
2407 .join("NSIRD_screencaptureui_7F3A");
2408 std::fs::create_dir_all(&source_dir).expect("mkdir");
2409 let source = source_dir.join("Screenshot 2026-08-10 at 01.09.39 截图.png");
2410 std::fs::write(&source, b"fake screenshot bytes").expect("write");
2411 let artifact_dir = tmp.path().join("attachments");
2412 (tmp, source, artifact_dir)
2413 }
2414
2415 #[test]
2416 fn detects_screencapture_temp_paths() {
2417 assert!(is_screencapture_temp_path(Path::new(
2418 "/var/folders/x/T/Temporary Items/NSIRD_screencaptureui_ABC/Shot.png"
2419 )));
2420 let (tmp, source, _) = screencapture_fixture();
2421 assert!(is_screencapture_temp_path(&source));
2422 // Only one marker is not a screencapture temp location.
2423 assert!(!is_screencapture_temp_path(Path::new(
2424 "/tmp/Temporary Items/Shot.png"
2425 )));
2426 assert!(!is_screencapture_temp_path(Path::new("/tmp/Shot.png")));
2427 assert!(!is_screencapture_temp_path(tmp.path()));
2428 }
2429
2430 #[test]
2431 fn stabilizes_a_quoted_paste_with_spaces_and_unicode() {
2432 let (tmp, source, artifact_dir) = screencapture_fixture();
2433 let input = format!("take a look at \"{}\" please", source.display());
2434 let out = stabilize_screenshot_references(&input, &artifact_dir);
2435
2436 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2437 assert_eq!(
2438 std::fs::read(&stable).expect("stable copy"),
2439 b"fake screenshot bytes"
2440 );
2441 assert!(out.contains(&stable.display().to_string()), "got: {out}");
2442 assert!(!out.contains(&source.display().to_string()), "got: {out}");
2443 assert!(source.is_file(), "source must be left in place");
2444 // Idempotent: the stable path is not a screencapture reference, and a
2445 // second pass over the rewritten text changes nothing.
2446 assert_eq!(stabilize_screenshot_references(&out, &artifact_dir), out);
2447 let _ = tmp;
2448 }
2449
2450 #[test]
2451 fn stabilizes_mention_attached_and_unquoted_references() {
2452 let (tmp, source, artifact_dir) = screencapture_fixture();
2453 let disp = source.display().to_string();
2454 let input = format!("see @\"{disp}\", also [Attached image: {disp}] and bare {disp} here");
2455 let out = stabilize_screenshot_references(&input, &artifact_dir);
2456
2457 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2458 let stable_disp = stable.display().to_string();
2459 // Three different carriers, one stable destination each time, and
2460 // exactly one physical copy despite the duplicates.
2461 assert_eq!(out.matches(&stable_disp).count(), 3, "got: {out}");
2462 assert!(!out.contains(&disp), "got: {out}");
2463 assert_eq!(
2464 std::fs::read_dir(&artifact_dir).expect("artifacts").count(),
2465 1
2466 );
2467 let _ = tmp;
2468 }
2469
2470 #[test]
2471 fn leaves_missing_and_non_screencapture_paths_alone() {
2472 let (tmp, source, artifact_dir) = screencapture_fixture();
2473 let ghost = source.with_file_name("Screenshot 1999-01-01 at 00.00.00.png");
2474 let input = format!(
2475 "missing {} regular /tmp/notes.txt mention @README.md quote \"no file\"",
2476 ghost.display()
2477 );
2478 let out = stabilize_screenshot_references(&input, &artifact_dir);
2479 assert_eq!(out, input);
2480 assert!(std::fs::read_dir(&artifact_dir).is_err());
2481 let _ = tmp;
2482 }
2483
2484 #[test]
2485 fn single_quoted_prose_does_not_block_a_real_reference() {
2486 let (tmp, source, artifact_dir) = screencapture_fixture();
2487 let disp = source.display().to_string();
2488 // The `'` pair encloses the path but is prose, not a quoted path.
2489 let input = format!("'check {disp} is here' please");
2490 let out = stabilize_screenshot_references(&input, &artifact_dir);
2491 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2492 assert!(out.contains(&stable.display().to_string()), "got: {out}");
2493 assert!(!out.contains(&disp), "got: {out}");
2494 let _ = tmp;
2495 }
2496
2497 #[test]
2498 fn handles_leading_delimiters_on_unquoted_pastes() {
2499 let (tmp, source, artifact_dir) = screencapture_fixture();
2500 let disp = source.display().to_string();
2501 // Paren-wrapped paste: the `(` rides on the first path token, and an
2502 // unquoted `@`-prefixed paste keeps its `@` in the rewritten text.
2503 let input = format!("see ({disp}) and also @{disp} thanks");
2504 let out = stabilize_screenshot_references(&input, &artifact_dir);
2505 let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
2506 let stable_disp = stable.display().to_string();
2507 assert_eq!(out.matches(&stable_disp).count(), 2, "got: {out}");
2508 assert!(out.contains(&format!("({stable_disp})")), "got: {out}");
2509 assert!(out.contains(&format!("@{stable_disp}")), "got: {out}");
2510 assert!(!out.contains(&disp), "got: {out}");
2511 let _ = tmp;
2512 }
2513
2514 #[test]
2515 fn handles_a_multibyte_final_filename_char() {
2516 let tmp = TempDir::new().expect("tempdir");
2517 let source_dir = tmp
2518 .path()
2519 .join("Temporary Items")
2520 .join("NSIRD_screencaptureui_9B2C");
2521 std::fs::create_dir_all(&source_dir).expect("mkdir");
2522 // No ASCII extension: the reference span ends on a CJK code point.
2523 let source = source_dir.join("截图");
2524 std::fs::write(&source, b"screenshot").expect("write");
2525 let artifact_dir = tmp.path().join("attachments");
2526 let input = format!("here {} it is", source.display());
2527 let out = stabilize_screenshot_references(&input, &artifact_dir);
2528 let stable = artifact_dir.join("截图");
2529 assert!(out.contains(&stable.display().to_string()), "got: {out}");
2530 assert!(!out.contains(&source.display().to_string()), "got: {out}");
2531 let _ = tmp;
2532 }
2533
2534 /// The stable copy is written through a no-follow writer: a dangling link
2535 /// at the destination name, a linked destination, and a linked directory
2536 /// are all refused, and nothing is created behind them.
2537 #[cfg(unix)]
2538 #[test]
2539 fn stable_copies_are_never_written_through_a_link() {
2540 use std::os::unix::fs::symlink;
2541 let (tmp, source, artifact_dir) = screencapture_fixture();
2542 let outside = TempDir::new().expect("outside");
2543 let input = format!("see \"{}\"", source.display());
2544 let name = "Screenshot 2026-08-10-01.09.39 截图.png";
2545
2546 // Dangling link at the destination name.
2547 std::fs::create_dir_all(&artifact_dir).expect("mkdir");
2548 let behind = outside.path().join("created-by-the-link");
2549 symlink(&behind, artifact_dir.join(name)).expect("link");
2550 assert_eq!(
2551 stabilize_screenshot_references(&input, &artifact_dir),
2552 input
2553 );
2554 assert!(
2555 !behind.exists(),
2556 "a dangling link must not be written through"
2557 );
2558
2559 // A linked destination directory.
2560 let linked_dir = tmp.path().join("linked-attachments");
2561 symlink(outside.path(), &linked_dir).expect("link");
2562 assert_eq!(stabilize_screenshot_references(&input, &linked_dir), input);
2563 assert_eq!(std::fs::read_dir(outside.path()).unwrap().count(), 0);
2564 let _ = tmp;
2565 }
2566
2567 #[test]
2568 fn keeps_the_original_reference_when_the_copy_fails() {
2569 let (tmp, source, _) = screencapture_fixture();
2570 let blocker = tmp.path().join("blocker");
2571 std::fs::write(&blocker, b"x").expect("write");
2572 // create_dir_all under a regular file must fail.
2573 let bad_artifact_dir = blocker.join("attachments");
2574 let input = format!("see \"{}\"", source.display());
2575 let out = stabilize_screenshot_references(&input, &bad_artifact_dir);
2576 assert_eq!(out, input);
2577 assert!(source.is_file());
2578 let _ = tmp;
2579 }
2580 }
2581
2581 lines RUST