use std::fmt::Write;
use std::io::Read;
use std::path::{Path, PathBuf};
use serde::{Deserialize, Serialize};
use crate::tui::app::{App, MentionCompletionCache};
use crate::tui::git_mention::{self, GitMentionCache, GitMentionKind};
use crate::tui::mention_completion::{MentionDiscoveryBehavior, MentionDiscoveryKey};
use crate::working_set::Workspace;
pub const MAX_FILE_MENTIONS_PER_MESSAGE: usize = 8;
pub const MAX_MENTION_FILE_BYTES: u64 = 128 * 1024;
pub const MAX_DIRECTORY_MENTION_ENTRIES: usize = 80;
const FILE_MENTION_COMPLETION_LIMIT: usize = 64;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct FileMentionPreview {
pub kind: String,
pub label: String,
pub detail: Option<String>,
pub included: bool,
pub removable: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ContextReference {
pub kind: ContextReferenceKind,
pub source: ContextReferenceSource,
pub badge: String,
pub label: String,
pub target: String,
pub included: bool,
pub expanded: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub detail: Option<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextReferenceKind {
File,
Directory,
Missing,
Unsupported,
MediaMention,
MediaAttachment,
GitContext,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ContextReferenceSource {
AtMention,
Attachment,
}
pub fn partial_file_mention_at_cursor(input: &str, cursor_chars: usize) -> Option<(usize, String)> {
let chars: Vec<char> = input.chars().collect();
if cursor_chars > chars.len() {
return None;
}
let mut start_chars = cursor_chars;
while start_chars > 0 {
let prev = chars[start_chars - 1];
if prev == '@' {
start_chars -= 1;
break;
}
if prev.is_whitespace() {
return None;
}
start_chars -= 1;
}
if start_chars == cursor_chars || chars.get(start_chars) != Some(&'@') {
return None;
}
if !is_file_mention_start(&chars, start_chars) {
return None;
}
let mut end_chars = start_chars + 1;
while end_chars < chars.len() && !chars[end_chars].is_whitespace() {
end_chars += 1;
}
let partial: String = chars[start_chars + 1..end_chars].iter().collect();
let byte_start: usize = chars[..start_chars].iter().map(|c| c.len_utf8()).sum();
Some((byte_start, partial))
}
#[cfg(test)]
pub fn find_file_mention_completions(
workspace: &Workspace,
partial: &str,
limit: usize,
) -> Vec<String> {
let entries = workspace.completions(partial, limit);
let entries = super::file_frecency::rerank_by_frecency(entries);
tracing::debug!(
target: "codewhale_tui::file_mention",
partial = %partial,
workspace = %workspace.root.display(),
cwd = ?std::env::current_dir().ok(),
match_count = entries.len(),
"file mention completion walk",
);
entries
}
#[must_use]
pub fn visible_mention_menu_entries(app: &mut App, limit: usize) -> Vec<String> {
if app.mention_menu_hidden {
app.composer.mention_discovery.cancel();
return Vec::new();
}
let Some((_byte_start, partial)) =
partial_file_mention_at_cursor(&app.input, app.cursor_position)
else {
app.composer.mention_discovery.cancel();
return Vec::new();
};
if limit == 0 {
app.composer.mention_discovery.cancel();
return Vec::new();
}
mention_menu_entries(app, &partial, limit).0
}
pub(crate) fn poll_background_mention_discovery(app: &mut App) -> bool {
if app.composer.mention_discovery.poll() {
app.composer.mention_completion_cache = None;
return true;
}
false
}
fn mention_menu_entries(app: &mut App, partial: &str, limit: usize) -> (Vec<String>, bool) {
if poll_background_mention_discovery(app) {
app.needs_redraw = true;
}
let workspace = app.workspace.clone();
let cwd = app.composer.mention_cwd.clone();
let walk_depth = app.mention_walk_depth;
let behavior = app.mention_menu_behavior.clone();
let follow_links = app.workspace_follow_symlinks;
let discovery_key = if behavior == "browser" {
MentionDiscoveryKey::browser(
workspace.clone(),
cwd.clone(),
walk_depth,
follow_links,
partial.to_string(),
)
} else {
MentionDiscoveryKey::fuzzy(workspace.clone(), cwd.clone(), walk_depth, follow_links)
};
app.composer
.mention_discovery
.ensure_requested(discovery_key.clone());
if let Some(ref cache) = app.composer.mention_completion_cache
&& cache.workspace == workspace
&& cache.cwd == cwd
&& cache.partial == partial
&& cache.limit == limit
&& cache.walk_depth == walk_depth
&& cache.behavior == behavior
&& cache.follow_links == follow_links
{
return (cache.entries.clone(), true);
}
let Some(candidates) = app
.composer
.mention_discovery
.cached_entries(&discovery_key)
else {
return (Vec::new(), false);
};
let entries = match &discovery_key.behavior {
MentionDiscoveryBehavior::Fuzzy => {
let ranked = crate::working_set::rank_completion_candidates(candidates, partial, limit);
super::file_frecency::rerank_by_frecency(ranked)
}
MentionDiscoveryBehavior::Browser { .. } => {
candidates.iter().take(limit).cloned().collect()
}
};
let entries = with_git_mention_entries(entries, partial, limit);
app.composer.mention_completion_cache = Some(MentionCompletionCache {
workspace,
cwd,
partial: partial.to_string(),
limit,
walk_depth,
behavior,
follow_links,
entries: entries.clone(),
});
(entries, true)
}
fn with_git_mention_entries(entries: Vec<String>, partial: &str, limit: usize) -> Vec<String> {
if limit == 0 {
return Vec::new();
}
let needle = partial.trim().to_lowercase();
if needle.is_empty() {
return entries;
}
let matching: Vec<String> = GitMentionKind::iter_all()
.filter(|kind| kind.token().starts_with(&needle))
.map(|kind| kind.token().to_string())
.collect();
if matching.is_empty() {
return entries;
}
let mut combined = matching;
combined.truncate(limit);
for entry in entries {
if combined.len() >= limit {
break;
}
if !combined.contains(&entry) {
combined.push(entry);
}
}
combined
}
pub fn apply_mention_menu_selection(app: &mut App, entries: &[String]) -> bool {
if entries.is_empty() {
return false;
}
let Some((byte_start, partial)) =
partial_file_mention_at_cursor(&app.input, app.cursor_position)
else {
return false;
};
let selected_idx = app
.mention_menu_selected
.min(entries.len().saturating_sub(1));
let replacement = &entries[selected_idx];
super::file_frecency::record_mention(replacement);
replace_file_mention(app, byte_start, &partial, replacement);
app.mention_menu_hidden = false;
app.status_message = Some(format!("Attached @{replacement}"));
true
}
pub fn try_autocomplete_file_mention(app: &mut App) -> bool {
let Some((byte_start, partial)) =
partial_file_mention_at_cursor(&app.input, app.cursor_position)
else {
return false;
};
let (candidates, ready) = mention_menu_entries(app, &partial, FILE_MENTION_COMPLETION_LIMIT);
if !ready {
return true;
}
if candidates.is_empty() {
app.status_message = Some(no_file_mention_matches_status(
&partial,
app.mention_walk_depth,
));
return true;
}
if candidates.len() == 1 {
super::file_frecency::record_mention(&candidates[0]);
replace_file_mention(app, byte_start, &partial, &candidates[0]);
app.status_message = Some(format!("Attached @{}", candidates[0]));
return true;
}
let candidate_refs: Vec<&str> = candidates.iter().map(String::as_str).collect();
let shared = longest_common_prefix(&candidate_refs);
if shared.len() > partial.len() {
replace_file_mention(app, byte_start, &partial, shared);
app.status_message = Some(format!("@{shared}…"));
return true;
}
let preview = candidates
.iter()
.take(5)
.map(|c| format!("@{c}"))
.collect::<Vec<_>>()
.join(", ");
app.status_message = Some(format!("Matches: {preview}"));
true
}
fn no_file_mention_matches_status(partial: &str, walk_depth: usize) -> String {
if path_partial_reaches_walk_depth(partial, walk_depth) {
format!(
"No files match @{partial} (mention_walk_depth={walk_depth}; use /config set mention_walk_depth 0 to search deeper)"
)
} else {
format!("No files match @{partial}")
}
}
fn path_partial_reaches_walk_depth(partial: &str, walk_depth: usize) -> bool {
if walk_depth == 0 {
return false;
}
let component_count = partial
.split(['/', '\\'])
.filter(|component| !component.is_empty())
.count();
component_count >= walk_depth
}
fn replace_file_mention(app: &mut App, byte_start: usize, partial: &str, replacement: &str) {
let original_token_len = '@'.len_utf8() + partial.len();
let original_token_end = byte_start + original_token_len;
let mut new_input =
String::with_capacity(app.input.len() - original_token_len + 1 + replacement.len());
new_input.push_str(&app.input[..byte_start]);
new_input.push('@');
new_input.push_str(replacement);
if original_token_end < app.input.len() {
new_input.push_str(&app.input[original_token_end..]);
}
let new_cursor_chars =
app.input[..byte_start].chars().count() + 1 + replacement.chars().count();
app.input = new_input;
app.cursor_position = new_cursor_chars;
}
pub fn longest_common_prefix<'a>(values: &[&'a str]) -> &'a str {
let Some(first) = values.first().copied() else {
return "";
};
let mut end = first.len();
for value in values.iter().skip(1) {
while end > 0 && !value.starts_with(&first[..end]) {
end -= 1;
while end > 0 && !first.is_char_boundary(end) {
end -= 1;
}
}
if end == 0 {
return "";
}
}
&first[..end]
}
#[cfg(test)]
pub fn user_request_with_file_mentions(
input: &str,
workspace: &Path,
cwd: Option<PathBuf>,
) -> String {
user_request_with_file_mentions_cached(
input,
workspace,
cwd,
&mut GitMentionCache::default(),
None,
)
}
pub fn user_request_with_file_mentions_cached(
input: &str,
workspace: &Path,
cwd: Option<PathBuf>,
git_cache: &mut GitMentionCache,
completion_index: Option<&[String]>,
) -> String {
let Some(context) =
local_context_from_file_mentions(input, workspace, cwd, git_cache, completion_index)
else {
return input.to_string();
};
format!("{input}\n\n---\n\nLocal context from @mentions:\n{context}")
}
#[must_use]
pub fn pending_context_previews(input: &str) -> Vec<FileMentionPreview> {
let mut previews = Vec::new();
let mut seen = std::collections::HashSet::new();
for mention in extract_file_mentions(input)
.into_iter()
.take(MAX_FILE_MENTIONS_PER_MESSAGE)
{
if !seen.insert(mention.clone()) {
continue;
}
if let Some(kind) = git_mention::git_mention_kind(&mention) {
previews.push(FileMentionPreview {
kind: "git".to_string(),
label: kind.label().to_string(),
detail: Some("resolved on send".to_string()),
included: false,
removable: false,
});
continue;
}
let media = is_media_path(Path::new(&mention));
previews.push(FileMentionPreview {
kind: if media { "media" } else { "mention" }.to_string(),
label: mention,
detail: Some(if media {
"use /attach for media bytes".to_string()
} else {
"resolved on send".to_string()
}),
included: false,
removable: false,
});
}
for attachment in extract_media_attachment_references(input) {
previews.push(FileMentionPreview {
kind: attachment.kind,
label: attachment.path,
detail: Some("attached media".to_string()),
included: true,
removable: true,
});
}
previews
}
#[cfg(test)]
#[must_use]
pub fn context_references_from_input(
input: &str,
workspace: &Path,
cwd: Option<PathBuf>,
) -> Vec<ContextReference> {
context_references_from_input_cached(
input,
workspace,
cwd,
&mut GitMentionCache::default(),
None,
)
}
#[must_use]
pub fn context_references_from_input_cached(
input: &str,
workspace: &Path,
cwd: Option<PathBuf>,
git_cache: &mut GitMentionCache,
completion_index: Option<&[String]>,
) -> Vec<ContextReference> {
let mut references = Vec::new();
let mut seen = std::collections::HashSet::new();
let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
for mention in extract_file_mentions(input)
.into_iter()
.take(MAX_FILE_MENTIONS_PER_MESSAGE)
{
if let Some(kind) = git_mention::git_mention_kind(&mention) {
let payload = git_cache.resolve(kind, workspace).clone();
let detail = match payload.unavailable_reason.as_deref() {
Some(reason) => format!("{}, {reason}", kind.label()),
None if payload.truncated => format!(
"{}, {} bytes truncated at {} budget",
kind.label(),
payload.bytes,
kind.byte_budget()
),
None => format!("{}, {} bytes", kind.label(), payload.bytes),
};
let reference = ContextReference {
kind: ContextReferenceKind::GitContext,
source: ContextReferenceSource::AtMention,
badge: "git".to_string(),
label: kind.token().to_string(),
target: workspace.display().to_string(),
included: payload.unavailable_reason.is_none(),
expanded: false,
detail: Some(detail),
};
if seen.insert(format!("git-mention:{}", kind.token())) {
references.push(reference);
}
continue;
}
let (path, display_path, exists) =
resolve_mention_for_send(&ws, &mention, completion_index);
let reference = context_reference_for_mention(&mention, &path, &display_path, exists);
if !seen.insert(format!(
"{:?}:{:?}:{}:{}",
reference.source, reference.kind, reference.target, reference.label
)) {
continue;
}
references.push(reference);
}
for reference in extract_media_attachment_references(input) {
let context_reference = ContextReference {
kind: ContextReferenceKind::MediaAttachment,
source: ContextReferenceSource::Attachment,
badge: reference.kind,
label: reference.path.clone(),
target: reference.path,
included: true,
expanded: false,
detail: Some("attached media".to_string()),
};
if !seen.insert(format!(
"{:?}:{:?}:{}:{}",
context_reference.source,
context_reference.kind,
context_reference.target,
context_reference.label
)) {
continue;
}
references.push(context_reference);
}
references
}
fn context_reference_for_mention(
raw: &str,
path: &Path,
display_path: &str,
exists: bool,
) -> ContextReference {
if !exists {
return ContextReference {
kind: ContextReferenceKind::Missing,
source: ContextReferenceSource::AtMention,
badge: "missing".to_string(),
label: raw.to_string(),
target: raw.to_string(),
included: false,
expanded: false,
detail: Some("not found".to_string()),
};
}
if path.is_dir() {
return ContextReference {
kind: ContextReferenceKind::Directory,
source: ContextReferenceSource::AtMention,
badge: "dir".to_string(),
label: raw.to_string(),
target: display_path.to_string(),
included: true,
expanded: true,
detail: Some("directory listing".to_string()),
};
}
if !path.is_file() {
return ContextReference {
kind: ContextReferenceKind::Unsupported,
source: ContextReferenceSource::AtMention,
badge: "skipped".to_string(),
label: raw.to_string(),
target: display_path.to_string(),
included: false,
expanded: false,
detail: Some("unsupported path".to_string()),
};
}
if is_media_path(path) {
return ContextReference {
kind: ContextReferenceKind::MediaMention,
source: ContextReferenceSource::AtMention,
badge: "media".to_string(),
label: raw.to_string(),
target: display_path.to_string(),
included: false,
expanded: false,
detail: Some("use /attach for media bytes".to_string()),
};
}
let detail = match std::fs::metadata(path) {
Ok(metadata) if metadata.len() > MAX_MENTION_FILE_BYTES => {
Some("included truncated".to_string())
}
Ok(_) => Some("included".to_string()),
Err(err) => Some(format!("metadata: {err}")),
};
ContextReference {
kind: ContextReferenceKind::File,
source: ContextReferenceSource::AtMention,
badge: "file".to_string(),
label: raw.to_string(),
target: display_path.to_string(),
included: true,
expanded: true,
detail: detail.or_else(|| Some(display_path.to_string())),
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MediaAttachmentReference {
pub kind: String,
pub path: String,
pub start_byte: usize,
pub end_byte: usize,
}
pub fn media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
let mut out = Vec::new();
let mut offset = 0usize;
for line in input.split_inclusive('\n') {
let start_byte = offset;
let end_byte = offset + line.len();
offset = end_byte;
let trimmed = line.trim();
let Some(body) = trimmed
.strip_prefix("[Attached ")
.and_then(|value| value.strip_suffix(']'))
else {
continue;
};
let Some((kind, rest)) = body.split_once(": ") else {
continue;
};
let path = rest
.rsplit_once(" at ")
.map_or(rest, |(_, path)| path)
.trim();
if !path.is_empty() {
out.push(MediaAttachmentReference {
kind: kind.trim().to_string(),
path: path.to_string(),
start_byte,
end_byte,
});
}
}
out
}
fn extract_media_attachment_references(input: &str) -> Vec<MediaAttachmentReference> {
media_attachment_references(input)
}
const SCREENCAPTURE_TEMP_DIR_MARKERS: [&str; 2] = ["Temporary Items", "screencaptureui"];
pub(crate) fn screenshot_stabilization_dir(workspace: &Path) -> PathBuf {
match crate::config::effective_home_dir() {
Some(home) => home.join(".codewhale").join("attachments"),
None => workspace.join("attachments"),
}
}
fn is_screencapture_temp_path(path: &Path) -> bool {
let components: Vec<String> = path
.components()
.map(|c| c.as_os_str().to_string_lossy().into_owned())
.collect();
components
.iter()
.any(|c| c == SCREENCAPTURE_TEMP_DIR_MARKERS[0])
&& components
.iter()
.any(|c| c.contains(SCREENCAPTURE_TEMP_DIR_MARKERS[1]))
}
fn stable_attachment_name(file_name: &std::ffi::OsStr) -> String {
file_name.to_string_lossy().replace(" at ", "-")
}
fn stabilize_screencapture_file(path: &Path, artifact_dir: &Path) -> Option<PathBuf> {
if !is_screencapture_temp_path(path) || !path.is_file() {
return None;
}
let dest = artifact_dir.join(stable_attachment_name(path.file_name()?));
if !dest.exists()
&& (std::fs::create_dir_all(artifact_dir).is_err() || std::fs::copy(path, &dest).is_err())
{
return None;
}
Some(dest)
}
struct ScreencaptureCandidate {
byte_start: usize,
byte_end: usize,
path: String,
quote: Option<char>,
mention: bool,
}
fn screencapture_window(
chars: &[char],
tokens: &[(usize, usize)],
seed: usize,
) -> Option<(usize, usize, String)> {
let max_span = tokens.len().min(12);
for left in 0..=seed.min(max_span) {
for right in 0..=max_span {
let end = (seed + right).min(tokens.len() - 1);
let (mut ws, mut we) = (tokens[seed - left].0, tokens[end].1);
let raw: String = chars[ws..we].iter().collect();
let lead = raw
.chars()
.take_while(|&ch| matches!(ch, '(' | '[' | '{' | '<' | '"' | '\'' | '@'))
.count();
let trimmed = trim_unquoted_mention(&raw);
ws += lead;
we -= raw.chars().count() - trimmed.chars().count();
let joined = trimmed
.chars()
.skip(lead)
.collect::<String>()
.split_whitespace()
.collect::<Vec<_>>()
.join(" ");
let path = Path::new(&joined);
if is_screencapture_temp_path(path) && path.is_file() {
return Some((ws, we, joined));
}
}
}
None
}
pub(crate) fn stabilize_screenshot_references(input: &str, artifact_dir: &Path) -> String {
let chars: Vec<char> = input.chars().collect();
let offsets: Vec<usize> = input.char_indices().map(|(i, _)| i).collect();
let mut candidates: Vec<ScreencaptureCandidate> = Vec::new();
let mut i = 0;
while i < chars.len() {
match chars[i] {
'@' if is_file_mention_start(&chars, i) => {
let byte_start = offsets[i];
if let Some(quote @ ('"' | '\'')) = chars.get(i + 1).copied()
&& let Some(rel) = chars[i + 2..].iter().position(|&ch| ch == quote)
{
let end = i + 2 + rel;
let path: String = chars[i + 2..end].iter().collect();
if !path.trim().is_empty() {
candidates.push(ScreencaptureCandidate {
byte_start,
byte_end: offsets.get(end + 1).copied().unwrap_or(input.len()),
path: path.trim().to_string(),
quote: Some(quote),
mention: true,
});
}
i = end + 1;
} else {
let mut end = i + 1;
while end < chars.len() && !chars[end].is_whitespace() {
end += 1;
}
let raw: String = chars[i + 1..end].iter().collect();
let trimmed = trim_unquoted_mention(&raw);
if !trimmed.is_empty() {
candidates.push(ScreencaptureCandidate {
byte_start,
byte_end: offsets.get(end).copied().unwrap_or(input.len()),
path: trimmed.to_string(),
quote: None,
mention: true,
});
}
i = end;
}
}
quote @ ('"' | '\'') => {
if let Some(rel) = chars[i + 1..]
.iter()
.take_while(|&&ch| ch != '\n')
.position(|&ch| ch == quote)
{
let end = i + 1 + rel;
let path: String = chars[i + 1..end].iter().collect();
if !path.trim().is_empty() {
candidates.push(ScreencaptureCandidate {
byte_start: offsets[i],
byte_end: offsets.get(end + 1).copied().unwrap_or(input.len()),
path: path.trim().to_string(),
quote: Some(quote),
mention: false,
});
}
i = end + 1;
} else {
i += 1;
}
}
_ => i += 1,
}
}
let tokens: Vec<(usize, usize)> = {
let mut tokens = Vec::new();
let mut start = None;
for (idx, ch) in chars.iter().enumerate() {
match (ch.is_whitespace(), start) {
(true, Some(s)) => {
tokens.push((s, idx));
start = None;
}
(false, None) => start = Some(idx),
_ => {}
}
}
if let Some(s) = start {
tokens.push((s, chars.len()));
}
tokens
};
for (seed, &(token_start, token_end)) in tokens.iter().enumerate() {
let token: String = chars[token_start..token_end].iter().collect();
if !(token.contains("screencaptureui")
|| token.contains("Temporary")
|| token.contains("Items"))
{
continue;
}
let Some((ws, we, path)) = screencapture_window(&chars, &tokens, seed) else {
continue;
};
let byte_end = offsets.get(we).copied().unwrap_or(input.len());
let confirmed = |c: &ScreencaptureCandidate| {
is_screencapture_temp_path(Path::new(&c.path)) && Path::new(&c.path).is_file()
};
let blocked = candidates
.iter()
.any(|c| c.byte_start < byte_end && offsets[ws] < c.byte_end && confirmed(c));
if !blocked {
candidates.push(ScreencaptureCandidate {
byte_start: offsets[ws],
byte_end,
path,
quote: None,
mention: false,
});
}
}
candidates.sort_by_key(|c| c.byte_start);
let mut output = input.to_string();
for candidate in candidates.iter().rev() {
let Some(dest) = stabilize_screencapture_file(Path::new(&candidate.path), artifact_dir)
else {
continue;
};
let stable = dest.to_string_lossy();
let mut replacement = String::new();
if candidate.mention {
replacement.push('@');
}
if let Some(quote) = candidate.quote {
replacement.push(quote);
}
replacement.push_str(stable.as_ref());
if let Some(quote) = candidate.quote {
replacement.push(quote);
}
output.replace_range(candidate.byte_start..candidate.byte_end, &replacement);
}
output
}
fn local_context_from_file_mentions(
input: &str,
workspace: &Path,
cwd: Option<PathBuf>,
git_cache: &mut GitMentionCache,
completion_index: Option<&[String]>,
) -> Option<String> {
let mentions = extract_file_mentions(input);
if mentions.is_empty() {
return None;
}
let mut blocks = Vec::new();
let mut seen = std::collections::HashSet::new();
let ws = Workspace::with_cwd(workspace.to_path_buf(), cwd);
for mention in mentions.into_iter().take(MAX_FILE_MENTIONS_PER_MESSAGE) {
if let Some(kind) = git_mention::git_mention_kind(&mention) {
if !seen.insert(format!("git-mention:{}", kind.token())) {
continue;
}
blocks.push(git_cache.resolve(kind, workspace).block.clone());
continue;
}
let (path, display_path, exists) =
resolve_mention_for_send(&ws, &mention, completion_index);
tracing::debug!(
target: "codewhale_tui::file_mention",
raw_typed = %mention,
workspace = %workspace.display(),
cwd = ?std::env::current_dir().ok(),
resolved = %display_path,
exists,
"file mention resolution",
);
let dedup_key = if exists {
display_path.clone()
} else {
format!("missing:{mention}")
};
if !seen.insert(dedup_key) {
continue;
}
if exists {
blocks.push(render_file_mention_context(&mention, &path, &display_path));
} else {
blocks.push(format!("<missing-file mention=\"@{mention}\" />"));
}
}
if blocks.is_empty() {
None
} else {
Some(blocks.join("\n\n"))
}
}
fn resolve_mention_for_send(
ws: &Workspace,
mention: &str,
completion_index: Option<&[String]>,
) -> (PathBuf, String, bool) {
let guess = match ws.resolve_exact(mention) {
Ok(path) => {
let display = path.display().to_string();
return (path, display, true);
}
Err(guess) => guess,
};
if let Some(resolved) = completion_index
.and_then(|candidates| resolve_mention_in_completion_index(mention, candidates, ws))
{
let display = resolved.display().to_string();
return (resolved, display, true);
}
let display = guess.display().to_string();
(guess, display, false)
}
fn resolve_mention_in_completion_index(
mention: &str,
candidates: &[String],
ws: &Workspace,
) -> Option<PathBuf> {
let rooted = mention.starts_with('/') || (cfg!(windows) && mention.starts_with('\\'));
if mention.starts_with('~') || rooted || Path::new(mention).is_absolute() {
return None;
}
let needle = mention.replace('\\', "/");
let needle = needle.trim_matches('/');
if needle.is_empty() {
return None;
}
let needle_lower = needle.to_lowercase();
let basename_lower = needle_lower.rsplit('/').next()?;
let normalized = |candidate: &str| candidate.replace('\\', "/");
let suffix_match = |candidate: &str| {
let cand = normalized(candidate);
let cand = cand.trim_end_matches('/').to_lowercase();
!cand.is_empty() && (cand == needle_lower || cand.ends_with(&format!("/{needle_lower}")))
};
let basename_match = |candidate: &str| {
let cand = normalized(candidate);
let cand = cand.trim_end_matches('/').to_lowercase();
!cand.is_empty() && cand.rsplit('/').next() == Some(basename_lower)
};
let predicates: [&dyn Fn(&str) -> bool; 2] = [&suffix_match, &basename_match];
for predicate in predicates {
let mut winner: Option<&str> = None;
for candidate in candidates {
if !predicate(candidate) {
continue;
}
match winner {
None => winner = Some(candidate.as_str()),
Some(existing) if existing == candidate.as_str() => {}
Some(_) => return None,
}
}
if let Some(winner) = winner {
let native: PathBuf = winner.split('/').filter(|part| !part.is_empty()).collect();
return ws.resolve_exact(&native.to_string_lossy()).ok();
}
}
None
}
fn extract_file_mentions(input: &str) -> Vec<String> {
let chars: Vec<char> = input.chars().collect();
let mut mentions = Vec::new();
let mut idx = 0;
while idx < chars.len() {
if chars[idx] != '@' || !is_file_mention_start(&chars, idx) {
idx += 1;
continue;
}
let Some(next) = chars.get(idx + 1).copied() else {
break;
};
if next.is_whitespace() {
idx += 1;
continue;
}
if matches!(next, '"' | '\'') {
let quote = next;
let mut end = idx + 2;
let mut raw = String::new();
while end < chars.len() && chars[end] != quote {
raw.push(chars[end]);
end += 1;
}
if !raw.trim().is_empty() {
mentions.push(raw.trim().to_string());
}
idx = end.saturating_add(1);
continue;
}
let mut end = idx + 1;
let mut raw = String::new();
while end < chars.len() && !chars[end].is_whitespace() {
raw.push(chars[end]);
end += 1;
}
let trimmed = trim_unquoted_mention(&raw);
if !trimmed.is_empty() {
mentions.push(trimmed.to_string());
}
idx = end;
}
mentions
}
fn is_file_mention_start(chars: &[char], idx: usize) -> bool {
if idx == 0 {
return true;
}
chars
.get(idx.saturating_sub(1))
.is_some_and(|ch| ch.is_whitespace() || matches!(ch, '(' | '[' | '{' | '<' | '"' | '\''))
}
fn trim_unquoted_mention(raw: &str) -> &str {
let mut trimmed = raw.trim();
while trimmed.chars().count() > 1
&& trimmed
.chars()
.last()
.is_some_and(|ch| matches!(ch, ',' | ';' | ':' | '!' | '?' | ')' | ']' | '}'))
{
trimmed = &trimmed[..trimmed.len() - trimmed.chars().last().unwrap().len_utf8()];
}
trimmed
}
fn render_file_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
if !path.exists() {
return format!("<missing-file mention=\"@{raw}\" path=\"{display_path}\" />");
}
if path.is_dir() {
return render_directory_mention_context(raw, path, display_path);
}
if !path.is_file() {
return format!("<unsupported-path mention=\"@{raw}\" path=\"{display_path}\" />");
}
if is_media_path(path) {
return format!(
"<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>"
);
}
match read_text_prefix(path) {
Ok((text, truncated)) => {
let truncated_attr = if truncated { " truncated=\"true\"" } else { "" };
format!(
"<file mention=\"@{raw}\" path=\"{display_path}\"{truncated_attr}>\n{text}\n</file>"
)
}
Err(err) => {
format!(
"<unreadable-file mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-file>"
)
}
}
}
fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str) -> String {
let entries = match std::fs::read_dir(path) {
Ok(entries) => entries,
Err(err) => {
return format!(
"<unreadable-directory mention=\"@{raw}\" path=\"{display_path}\">\n{err}\n</unreadable-directory>"
);
}
};
let mut names = entries
.filter_map(|entry| entry.ok())
.map(|entry| {
let marker = entry
.file_type()
.ok()
.filter(|ty| ty.is_dir())
.map_or("", |_| "/");
format!("{}{}", entry.file_name().to_string_lossy(), marker)
})
.collect::<Vec<_>>();
names.sort();
let total = names.len();
names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
let mut body = names.join("\n");
if total > MAX_DIRECTORY_MENTION_ENTRIES {
let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
let _ = write!(body, "\n... {omitted} more entries");
}
format!("<directory mention=\"@{raw}\" path=\"{display_path}\">\n{body}\n</directory>")
}
fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
let mut file = std::fs::File::open(path)?;
let mut buffer = Vec::new();
file.by_ref()
.take(MAX_MENTION_FILE_BYTES + 1)
.read_to_end(&mut buffer)?;
let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
if truncated {
buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
if let Err(e) = std::str::from_utf8(&buffer)
&& e.error_len().is_none()
{
buffer.truncate(e.valid_up_to());
}
}
if buffer.contains(&0) {
return Err(std::io::Error::new(
std::io::ErrorKind::InvalidData,
"file appears to be binary",
));
}
let text = std::str::from_utf8(&buffer)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
.to_string();
Ok((text, truncated))
}
fn is_media_path(path: &Path) -> bool {
let Some(ext) = path.extension().and_then(|ext| ext.to_str()) else {
return false;
};
matches!(
ext.to_ascii_lowercase().as_str(),
"png"
| "jpg"
| "jpeg"
| "gif"
| "webp"
| "bmp"
| "tif"
| "tiff"
| "ppm"
| "mp4"
| "mov"
| "m4v"
| "webm"
| "avi"
| "mkv"
)
}
#[cfg(test)]
mod tests {
use super::*;
use tempfile::TempDir;
#[test]
fn cwd_pass_resolves_when_workspace_pass_misses() {
let tmp = TempDir::new().expect("tempdir");
let sub = tmp.path().join("sub");
std::fs::create_dir_all(&sub).expect("mkdir");
let bar = sub.join("bar.txt");
std::fs::write(&bar, "hello bar").expect("write bar");
let content =
user_request_with_file_mentions("look at @bar.txt", tmp.path(), Some(sub.clone()));
assert!(
content.contains("hello bar"),
"expected file body to be inlined; got: {content}",
);
assert!(
!content.contains("<missing-file"),
"must not surface <missing-file> for a path that exists under cwd; got: {content}",
);
let bar_disp = bar.display().to_string();
assert!(
content.contains(&bar_disp),
"expected resolved path {bar_disp} in content; got: {content}",
);
let wrong = tmp.path().join("bar.txt").display().to_string();
assert!(
!content.contains(&format!("path=\"{wrong}\"")),
"should NOT have routed to {wrong}; got: {content}",
);
}
#[test]
fn workspace_pass_resolves_nested_path() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("nested/deep");
std::fs::create_dir_all(&nested).expect("mkdir");
let file_md = nested.join("file.md");
std::fs::write(&file_md, "# nested deep").expect("write file_md");
let content = user_request_with_file_mentions("see @nested/deep/file.md", tmp.path(), None);
assert!(content.contains("# nested deep"), "got: {content}");
assert!(!content.contains("<missing-file"), "got: {content}");
let basename = file_md
.file_name()
.and_then(|n| n.to_str())
.expect("file_name utf-8");
assert!(
content.contains(basename),
"basename {basename} not in path; got: {content}",
);
}
#[test]
fn resolvable_mention_renders_file_block_not_missing_file() {
let tmp = TempDir::new().expect("tempdir");
std::fs::write(tmp.path().join("guide.md"), "# Guide\nUse the fast path.\n")
.expect("write");
let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
assert!(content.contains("Local context from @mentions:"));
assert!(content.contains("<file mention=\"@guide.md\""));
assert!(content.contains("# Guide\nUse the fast path."));
assert!(content.ends_with("</file>"), "got: {content}");
assert!(!content.contains("<missing-file"), "got: {content}");
}
#[test]
fn truly_missing_mention_still_renders_missing_file() {
let tmp = TempDir::new().expect("tempdir");
let content = user_request_with_file_mentions(
"huh @does/not/exist.txt",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert!(
content.contains("<missing-file mention=\"@does/not/exist.txt\""),
"got: {content}",
);
}
#[test]
fn pending_context_preview_is_lexical_and_does_not_probe_paths() {
let previews = pending_context_previews("read @guide.md and @missing.md");
assert_eq!(previews.len(), 2);
assert_eq!(previews[0].kind, "mention");
assert_eq!(previews[0].label, "guide.md");
assert!(!previews[0].included);
assert_eq!(previews[0].detail.as_deref(), Some("resolved on send"));
assert_eq!(previews[1].kind, "mention");
assert_eq!(previews[1].label, "missing.md");
assert!(!previews[1].included);
assert_eq!(previews[1].detail.as_deref(), Some("resolved on send"));
}
#[test]
fn pending_context_preview_distinguishes_attach_media_from_at_media() {
let tmp = TempDir::new().expect("tempdir");
std::fs::write(tmp.path().join("photo.png"), b"png").expect("write");
let attached = tmp.path().join("photo.png").display().to_string();
let input = format!("inspect @photo.png\n[Attached image: {attached}]");
let previews = pending_context_previews(&input);
assert!(
previews
.iter()
.any(|item| item.kind == "media" && !item.included),
"at-mention media should be hint-only: {previews:?}"
);
assert!(
previews
.iter()
.any(|item| item.kind == "image" && item.included),
"/attach media should be included: {previews:?}"
);
}
#[test]
fn manually_typed_basename_does_not_fuzzy_attach_nested_file() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("nested");
std::fs::create_dir_all(&nested).expect("mkdir");
std::fs::write(nested.join("guide.md"), "nested secret").expect("write");
let content = user_request_with_file_mentions("read @guide.md", tmp.path(), None);
assert!(
content.contains("<missing-file mention=\"@guide.md\""),
"a manually typed basename should remain exact: {content}",
);
assert!(
!content.contains("nested secret"),
"exact resolution must not silently attach a fuzzy nested match: {content}",
);
}
fn expand_with_index(
input: &str,
workspace: &Path,
cwd: Option<PathBuf>,
index: &[String],
) -> String {
user_request_with_file_mentions_cached(
input,
workspace,
cwd,
&mut GitMentionCache::default(),
Some(index),
)
}
#[test]
fn mention_miss_resolves_via_unique_index_basename() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("ops");
std::fs::create_dir_all(&nested).expect("mkdir");
std::fs::write(nested.join("FINISH-0.9.4.md"), "ship list").expect("write");
let index = vec!["ops/FINISH-0.9.4.md".to_string(), "README.md".to_string()];
let content = expand_with_index("finish @FINISH-0.9.4.md", tmp.path(), None, &index);
assert!(content.contains("ship list"), "got: {content}");
assert!(!content.contains("<missing-file"), "got: {content}");
let real = nested.join("FINISH-0.9.4.md").display().to_string();
assert!(
content.contains(&real),
"expected resolved path {real} in content; got: {content}",
);
}
#[test]
fn mention_miss_resolves_via_unique_index_suffix() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("nested/deep");
std::fs::create_dir_all(&nested).expect("mkdir");
std::fs::write(nested.join("file.md"), "deep body").expect("write");
let other = tmp.path().join("other");
std::fs::create_dir_all(&other).expect("mkdir");
std::fs::write(other.join("file.md"), "other body").expect("write");
let index = vec![
"nested/deep/file.md".to_string(),
"other/file.md".to_string(),
];
let content = expand_with_index("see @deep/file.md", tmp.path(), None, &index);
assert!(content.contains("deep body"), "got: {content}");
assert!(!content.contains("other body"), "got: {content}");
assert!(!content.contains("<missing-file"), "got: {content}");
}
#[test]
fn ambiguous_index_basename_stays_missing() {
let tmp = TempDir::new().expect("tempdir");
for dir in ["a", "b"] {
std::fs::create_dir_all(tmp.path().join(dir)).expect("mkdir");
std::fs::write(tmp.path().join(dir).join("guide.md"), format!("body {dir}"))
.expect("write");
}
let index = vec!["a/guide.md".to_string(), "b/guide.md".to_string()];
let content = expand_with_index("read @guide.md", tmp.path(), None, &index);
assert!(
content.contains("<missing-file mention=\"@guide.md\""),
"an ambiguous basename must not attach an arbitrary winner: {content}",
);
assert!(!content.contains("body a"), "got: {content}");
assert!(!content.contains("body b"), "got: {content}");
}
#[test]
fn stale_index_entry_stays_missing() {
let tmp = TempDir::new().expect("tempdir");
let index = vec!["ghost.md".to_string()];
let content = expand_with_index("boo @ghost.md", tmp.path(), None, &index);
assert!(
content.contains("<missing-file mention=\"@ghost.md\" />"),
"got: {content}",
);
}
#[test]
fn absolute_mention_miss_never_uses_index() {
let tmp = TempDir::new().expect("tempdir");
std::fs::write(tmp.path().join("guide.md"), "workspace guide").expect("write");
let index = vec!["guide.md".to_string()];
let content = expand_with_index(
"read @/definitely/absent/guide.md",
tmp.path(),
None,
&index,
);
assert!(
content.contains("<missing-file mention=\"@/definitely/absent/guide.md\" />"),
"got: {content}",
);
assert!(!content.contains("workspace guide"), "got: {content}");
}
#[test]
fn missing_file_block_names_only_the_typed_mention() {
let tmp = TempDir::new().expect("tempdir");
let content = user_request_with_file_mentions(
"huh @does/not/exist.txt",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert!(
content.contains("<missing-file mention=\"@does/not/exist.txt\" />"),
"got: {content}",
);
let wrong = tmp.path().join("does/not/exist.txt").display().to_string();
assert!(
!content.contains(&wrong),
"must not inject the wrong workspace-root path {wrong}; got: {content}",
);
}
#[test]
fn context_references_reflect_index_resolution() {
let tmp = TempDir::new().expect("tempdir");
let nested = tmp.path().join("ops");
std::fs::create_dir_all(&nested).expect("mkdir");
std::fs::write(nested.join("runbook.md"), "steps").expect("write");
let index = vec!["ops/runbook.md".to_string()];
let references = context_references_from_input_cached(
"read @runbook.md and @absent.md",
tmp.path(),
None,
&mut GitMentionCache::default(),
Some(&index),
);
let resolved = references
.iter()
.find(|r| r.label == "runbook.md")
.expect("runbook reference");
assert_eq!(resolved.kind, ContextReferenceKind::File);
assert!(resolved.included);
let real = nested.join("runbook.md").display().to_string();
assert_eq!(resolved.target, real, "{resolved:?}");
let missing = references
.iter()
.find(|r| r.label == "absent.md")
.expect("absent reference");
assert_eq!(missing.kind, ContextReferenceKind::Missing);
assert!(!missing.included);
assert_eq!(
missing.target, "absent.md",
"a missing mention must not report the workspace-root guess as its target: {missing:?}",
);
}
#[test]
fn media_attachment_references_include_removable_line_ranges() {
let input = "before\n[Attached image: 8x4 PNG at /tmp/pasted.png]\nafter";
let references = media_attachment_references(input);
assert_eq!(references.len(), 1);
let reference = &references[0];
assert_eq!(reference.kind, "image");
assert_eq!(reference.path, "/tmp/pasted.png");
assert_eq!(
&input[reference.start_byte..reference.end_byte],
"[Attached image: 8x4 PNG at /tmp/pasted.png]\n"
);
}
#[test]
fn context_references_preserve_exact_targets_and_roundtrip() {
let tmp = TempDir::new().expect("tempdir");
std::fs::create_dir_all(tmp.path().join("src")).expect("mkdir");
std::fs::write(tmp.path().join("src/main.rs"), "fn main() {}").expect("write");
let input = "read @src/main.rs";
let references =
context_references_from_input(input, tmp.path(), Some(tmp.path().to_path_buf()));
assert_eq!(references.len(), 1);
let reference = &references[0];
assert_eq!(reference.kind, ContextReferenceKind::File);
assert_eq!(reference.source, ContextReferenceSource::AtMention);
assert_eq!(reference.label, "src/main.rs");
assert!(reference.target.ends_with("src/main.rs"));
assert!(reference.included);
assert!(reference.expanded);
let encoded = serde_json::to_string(reference).expect("serialize");
let decoded: ContextReference = serde_json::from_str(&encoded).expect("deserialize");
assert_eq!(&decoded, reference);
}
#[test]
fn read_text_prefix_truncation_respects_utf8_char_boundary() {
use std::io::Write;
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("cjk.txt");
let mut f = std::fs::File::create(&path).expect("create");
let padding = vec![b'a'; MAX_MENTION_FILE_BYTES as usize - 1];
f.write_all(&padding).expect("write padding");
f.write_all("中".as_bytes()).expect("write CJK");
let (text, truncated) = read_text_prefix(&path).expect("should succeed");
assert!(
truncated,
"file exceeds limit so should be marked truncated"
);
assert!(
!text.contains('\u{FFFD}'),
"truncated text must not contain replacement characters; got: {text:?}",
);
}
fn init_test_repo(dir: &Path) {
for args in [
vec!["init", "--initial-branch=main"],
vec!["config", "user.email", "test@example.com"],
vec!["config", "user.name", "Test"],
] {
let out = std::process::Command::new("git")
.args(&args)
.current_dir(dir)
.output()
.expect("git available in tests");
assert!(out.status.success(), "git {args:?} failed");
}
}
fn commit_test_repo(dir: &Path) {
for args in [vec!["add", "-A"], vec!["commit", "-m", "initial"]] {
std::process::Command::new("git")
.args(&args)
.current_dir(dir)
.output()
.expect("git available in tests");
}
}
#[test]
fn git_and_diff_mentions_inline_curated_context_not_paths() {
let tmp = TempDir::new().expect("tempdir");
init_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
commit_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
let expanded = user_request_with_file_mentions(
"look at @git and @diff",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert!(expanded.contains("<git-status"), "{expanded}");
assert!(expanded.contains("<git-diff"), "{expanded}");
assert!(expanded.contains("a.txt"), "{expanded}");
assert!(!expanded.contains("<missing-file"), "{expanded}");
}
#[test]
fn git_mentions_outside_a_repository_say_so_explicitly() {
let tmp = TempDir::new().expect("tempdir");
let expanded = user_request_with_file_mentions(
"status? @git",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert!(expanded.contains("<git-unavailable"), "{expanded}");
assert!(expanded.contains("not a git repository"), "{expanded}");
}
#[test]
fn git_mention_is_deduplicated_within_one_message() {
let tmp = TempDir::new().expect("tempdir");
init_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
commit_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
let expanded = user_request_with_file_mentions(
"@diff and again @diff",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert_eq!(expanded.matches("<git-diff").count(), 1, "{expanded}");
}
#[test]
fn paths_that_merely_start_with_git_stay_file_mentions() {
let tmp = TempDir::new().expect("tempdir");
std::fs::write(tmp.path().join("diff.txt"), "plain file").expect("write");
let expanded = user_request_with_file_mentions(
"see @diff.txt",
tmp.path(),
Some(tmp.path().to_path_buf()),
);
assert!(expanded.contains("plain file"), "{expanded}");
assert!(!expanded.contains("<git-diff"), "{expanded}");
}
#[test]
fn large_diff_is_truncated_and_the_inspector_reports_the_budget() {
let tmp = TempDir::new().expect("tempdir");
init_test_repo(tmp.path());
std::fs::write(tmp.path().join("big.txt"), "seed\n").expect("write");
commit_test_repo(tmp.path());
let bulk: String = (0..40_000).map(|i| format!("line {i}\n")).collect();
std::fs::write(tmp.path().join("big.txt"), bulk).expect("write");
let expanded =
user_request_with_file_mentions("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
assert!(
expanded.contains("truncated=\"true\""),
"expected truncation marker"
);
let references =
context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
let git_ref = references
.iter()
.find(|r| r.kind == ContextReferenceKind::GitContext)
.expect("git reference present in the inspector");
assert_eq!(git_ref.label, "diff");
assert!(git_ref.included);
let detail = git_ref.detail.clone().unwrap_or_default();
assert!(detail.contains("truncated at"), "{detail}");
assert!(
detail.contains(&crate::tui::git_mention::MAX_GIT_DIFF_MENTION_BYTES.to_string()),
"{detail}"
);
}
#[test]
fn empty_repository_reference_is_visible_but_not_included() {
let tmp = TempDir::new().expect("tempdir");
init_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
commit_test_repo(tmp.path());
let references =
context_references_from_input("@diff", tmp.path(), Some(tmp.path().to_path_buf()));
let git_ref = references
.iter()
.find(|r| r.kind == ContextReferenceKind::GitContext)
.expect("git reference present even when there is nothing to show");
assert!(!git_ref.included);
assert!(
git_ref
.detail
.as_deref()
.is_some_and(|d| d.contains("no working-tree changes")),
"{:?}",
git_ref.detail
);
}
#[test]
fn composer_preview_lists_git_mentions_without_running_git() {
let previews = pending_context_previews("@git @diff");
let kinds: Vec<&str> = previews.iter().map(|p| p.kind.as_str()).collect();
assert_eq!(kinds, vec!["git", "git"]);
assert!(previews.iter().all(|p| !p.included));
}
#[test]
fn git_mention_entries_respect_a_zero_menu_limit() {
let paths = vec!["src/main.rs".to_string()];
assert!(with_git_mention_entries(paths.clone(), "g", 0).is_empty());
assert!(with_git_mention_entries(paths.clone(), "d", 0).is_empty());
assert!(with_git_mention_entries(paths.clone(), "", 0).is_empty());
assert!(with_git_mention_entries(Vec::new(), "gi", 0).is_empty());
}
#[test]
fn git_mention_entries_never_exceed_the_menu_limit() {
let paths = vec!["a.rs".to_string(), "b.rs".to_string()];
for limit in 1..=4 {
let matched = with_git_mention_entries(paths.clone(), "d", limit);
assert!(matched.len() <= limit, "limit {limit}: {matched:?}");
let both = with_git_mention_entries(Vec::new(), "", limit);
assert!(both.len() <= limit, "limit {limit}: {both:?}");
}
assert_eq!(with_git_mention_entries(paths.clone(), "", 1), paths);
}
#[test]
fn a_submit_resolves_each_git_mention_only_once() {
let tmp = TempDir::new().expect("tempdir");
init_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "one\n").expect("write");
commit_test_repo(tmp.path());
std::fs::write(tmp.path().join("a.txt"), "two\n").expect("write");
let mut cache = crate::tui::git_mention::GitMentionCache::default();
let references = context_references_from_input_cached(
"@diff",
tmp.path(),
Some(tmp.path().to_path_buf()),
&mut cache,
None,
);
let expanded = user_request_with_file_mentions_cached(
"@diff",
tmp.path(),
Some(tmp.path().to_path_buf()),
&mut cache,
None,
);
let git_ref = references
.iter()
.find(|r| r.kind == ContextReferenceKind::GitContext)
.expect("git reference");
assert!(git_ref.included);
assert!(expanded.contains("<git-diff"), "{expanded}");
assert_eq!(cache.len(), 1, "the diff must be resolved once per submit");
}
#[test]
fn completion_offers_git_and_diff_alongside_paths() {
let paths = vec!["src/main.rs".to_string(), "docs/guide.md".to_string()];
assert_eq!(with_git_mention_entries(paths.clone(), "", 8), paths);
let narrowed = with_git_mention_entries(paths.clone(), "di", 8);
assert_eq!(narrowed.first().map(String::as_str), Some("diff"));
assert!(narrowed.contains(&"src/main.rs".to_string()));
let git_only = with_git_mention_entries(paths.clone(), "g", 8);
assert_eq!(git_only.first().map(String::as_str), Some("git"));
assert_eq!(with_git_mention_entries(paths.clone(), "src", 8), paths);
}
fn screencapture_fixture() -> (TempDir, PathBuf, PathBuf) {
let tmp = TempDir::new().expect("tempdir");
let source_dir = tmp
.path()
.join("Temporary Items")
.join("NSIRD_screencaptureui_7F3A");
std::fs::create_dir_all(&source_dir).expect("mkdir");
let source = source_dir.join("Screenshot 2026-08-10 at 01.09.39 截图.png");
std::fs::write(&source, b"fake screenshot bytes").expect("write");
let artifact_dir = tmp.path().join("attachments");
(tmp, source, artifact_dir)
}
#[test]
fn detects_screencapture_temp_paths() {
assert!(is_screencapture_temp_path(Path::new(
"/var/folders/x/T/Temporary Items/NSIRD_screencaptureui_ABC/Shot.png"
)));
let (tmp, source, _) = screencapture_fixture();
assert!(is_screencapture_temp_path(&source));
assert!(!is_screencapture_temp_path(Path::new(
"/tmp/Temporary Items/Shot.png"
)));
assert!(!is_screencapture_temp_path(Path::new("/tmp/Shot.png")));
assert!(!is_screencapture_temp_path(tmp.path()));
}
#[test]
fn stabilizes_a_quoted_paste_with_spaces_and_unicode() {
let (tmp, source, artifact_dir) = screencapture_fixture();
let input = format!("take a look at \"{}\" please", source.display());
let out = stabilize_screenshot_references(&input, &artifact_dir);
let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
assert_eq!(
std::fs::read(&stable).expect("stable copy"),
b"fake screenshot bytes"
);
assert!(out.contains(&stable.display().to_string()), "got: {out}");
assert!(!out.contains(&source.display().to_string()), "got: {out}");
assert!(source.is_file(), "source must be left in place");
assert_eq!(stabilize_screenshot_references(&out, &artifact_dir), out);
let _ = tmp;
}
#[test]
fn stabilizes_mention_attached_and_unquoted_references() {
let (tmp, source, artifact_dir) = screencapture_fixture();
let disp = source.display().to_string();
let input = format!("see @\"{disp}\", also [Attached image: {disp}] and bare {disp} here");
let out = stabilize_screenshot_references(&input, &artifact_dir);
let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
let stable_disp = stable.display().to_string();
assert_eq!(out.matches(&stable_disp).count(), 3, "got: {out}");
assert!(!out.contains(&disp), "got: {out}");
assert_eq!(
std::fs::read_dir(&artifact_dir).expect("artifacts").count(),
1
);
let _ = tmp;
}
#[test]
fn leaves_missing_and_non_screencapture_paths_alone() {
let (tmp, source, artifact_dir) = screencapture_fixture();
let ghost = source.with_file_name("Screenshot 1999-01-01 at 00.00.00.png");
let input = format!(
"missing {} regular /tmp/notes.txt mention @README.md quote \"no file\"",
ghost.display()
);
let out = stabilize_screenshot_references(&input, &artifact_dir);
assert_eq!(out, input);
assert!(std::fs::read_dir(&artifact_dir).is_err());
let _ = tmp;
}
#[test]
fn single_quoted_prose_does_not_block_a_real_reference() {
let (tmp, source, artifact_dir) = screencapture_fixture();
let disp = source.display().to_string();
let input = format!("'check {disp} is here' please");
let out = stabilize_screenshot_references(&input, &artifact_dir);
let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
assert!(out.contains(&stable.display().to_string()), "got: {out}");
assert!(!out.contains(&disp), "got: {out}");
let _ = tmp;
}
#[test]
fn handles_leading_delimiters_on_unquoted_pastes() {
let (tmp, source, artifact_dir) = screencapture_fixture();
let disp = source.display().to_string();
let input = format!("see ({disp}) and also @{disp} thanks");
let out = stabilize_screenshot_references(&input, &artifact_dir);
let stable = artifact_dir.join("Screenshot 2026-08-10-01.09.39 截图.png");
let stable_disp = stable.display().to_string();
assert_eq!(out.matches(&stable_disp).count(), 2, "got: {out}");
assert!(out.contains(&format!("({stable_disp})")), "got: {out}");
assert!(out.contains(&format!("@{stable_disp}")), "got: {out}");
assert!(!out.contains(&disp), "got: {out}");
let _ = tmp;
}
#[test]
fn handles_a_multibyte_final_filename_char() {
let tmp = TempDir::new().expect("tempdir");
let source_dir = tmp
.path()
.join("Temporary Items")
.join("NSIRD_screencaptureui_9B2C");
std::fs::create_dir_all(&source_dir).expect("mkdir");
let source = source_dir.join("截图");
std::fs::write(&source, b"screenshot").expect("write");
let artifact_dir = tmp.path().join("attachments");
let input = format!("here {} it is", source.display());
let out = stabilize_screenshot_references(&input, &artifact_dir);
let stable = artifact_dir.join("截图");
assert!(out.contains(&stable.display().to_string()), "got: {out}");
assert!(!out.contains(&source.display().to_string()), "got: {out}");
let _ = tmp;
}
#[test]
fn keeps_the_original_reference_when_the_copy_fails() {
let (tmp, source, _) = screencapture_fixture();
let blocker = tmp.path().join("blocker");
std::fs::write(&blocker, b"x").expect("write");
let bad_artifact_dir = blocker.join("attachments");
let input = format!("see \"{}\"", source.display());
let out = stabilize_screenshot_references(&input, &bad_artifact_dir);
assert_eq!(out, input);
assert!(source.is_file());
let _ = tmp;
}
}