use std::fmt::Write;
use std::path::{Path, PathBuf};
#[derive(Debug, Clone)]
pub struct FileMention {
pub raw: String,
pub path: String,
pub is_dir: bool,
}
pub fn extract_mentions(input: &str) -> Vec<FileMention> {
extract_mentions_inner(input, None)
}
pub fn extract_mentions_with_cwd(input: &str, working_dir: &str) -> Vec<FileMention> {
extract_mentions_inner(input, Some(working_dir))
}
fn extract_mentions_inner(input: &str, working_dir: Option<&str>) -> Vec<FileMention> {
let mut mentions = Vec::new();
for word in input.split_whitespace() {
if !word.starts_with('@') || word.len() < 2 {
continue;
}
let path_str = &word[1..];
if path_str.contains('@') {
continue;
}
let path_str = path_str.trim_end_matches([',', '.', ';', ':', ')', ']']);
if path_str.is_empty() {
continue;
}
let looks_like_path = path_str.contains('.') || path_str.contains('/');
if looks_like_path {
let is_dir = working_dir
.map(|wd| {
let full = if path_str.starts_with('/') {
PathBuf::from(path_str)
} else {
Path::new(wd).join(path_str)
};
full.is_dir()
})
.unwrap_or(false);
mentions.push(FileMention {
raw: format!("@{path_str}"),
path: path_str.to_string(),
is_dir,
});
} else if let Some(wd) = working_dir {
let full = Path::new(wd).join(path_str);
if full.exists() {
mentions.push(FileMention {
raw: format!("@{path_str}"),
path: path_str.to_string(),
is_dir: full.is_dir(),
});
}
}
}
mentions
}
pub fn exists_on_disk(name: &str, working_dir: &str) -> bool {
Path::new(working_dir).join(name).exists()
}
pub fn resolve_mentions(
input: &str,
working_dir: &str,
mentions: &[FileMention],
) -> (String, Vec<String>) {
if mentions.is_empty() {
return (input.to_string(), Vec::new());
}
tracing::debug!(
mentions = ?mentions.iter().map(|m| m.raw.as_str()).collect::<Vec<_>>(),
"Resolving @file mentions"
);
let mut file_contexts = Vec::new();
let mut resolved_paths = Vec::new();
for mention in mentions {
let full_path = if mention.path.starts_with('/') {
PathBuf::from(&mention.path)
} else {
Path::new(working_dir).join(&mention.path)
};
if mention.is_dir || full_path.is_dir() {
match build_dir_context(&full_path, &mention.path) {
Ok(tree) => {
file_contexts.push(tree);
resolved_paths.push(mention.path.clone());
}
Err(e) => {
file_contexts.push(format!(
"<directory path=\"{}\" error=\"{e}\"/>",
mention.path,
));
}
}
} else {
match std::fs::read_to_string(&full_path) {
Ok(content) => {
let content = if content.len() > 50_000 {
format!(
"{}...\n\n(truncated, {} total bytes)",
crate::util::truncate_bytes(&content, 50_000),
content.len(),
)
} else {
content
};
let rel_path = &mention.path;
file_contexts.push(format!("<file path=\"{rel_path}\">\n{content}\n</file>"));
resolved_paths.push(mention.path.clone());
}
Err(e) => {
file_contexts.push(format!("<file path=\"{}\" error=\"{e}\"/>", mention.path,));
}
}
}
}
let augmented = format!(
"{input}\n\n---\n**Referenced files:**\n\n{}",
file_contexts.join("\n\n"),
);
(augmented, resolved_paths)
}
fn build_dir_context(dir_path: &Path, rel_prefix: &str) -> Result<String, std::io::Error> {
let mut output = String::new();
let _ = writeln!(output, "<directory path=\"{rel_prefix}\">");
let mut entries = Vec::new();
collect_dir_entries(dir_path, rel_prefix, 0, 3, &mut entries)?;
entries.sort_by(|a, b| {
let a_dir = a.ends_with('/');
let b_dir = b.ends_with('/');
b_dir.cmp(&a_dir).then(a.cmp(b))
});
let total = entries.len();
for entry in entries.iter().take(200) {
let _ = writeln!(output, " {entry}");
}
if total > 200 {
let _ = writeln!(output, " ... and {} more entries", total - 200);
}
let _ = writeln!(output, "</directory>");
Ok(output)
}
fn collect_dir_entries(
dir: &Path,
prefix: &str,
depth: usize,
max_depth: usize,
entries: &mut Vec<String>,
) -> Result<(), std::io::Error> {
if depth > max_depth {
return Ok(());
}
let mut dir_entries: Vec<_> = std::fs::read_dir(dir)?.filter_map(|e| e.ok()).collect();
dir_entries.sort_by_key(|e| e.file_name());
for entry in dir_entries {
let name = entry.file_name();
let name_str = name.to_string_lossy();
if name_str.starts_with('.') || name_str == "node_modules" || name_str == "target" {
continue;
}
let rel = format!("{prefix}/{name_str}");
let ft = entry.file_type()?;
if ft.is_dir() {
entries.push(format!("{rel}/"));
collect_dir_entries(&entry.path(), &rel, depth + 1, max_depth, entries)?;
} else if ft.is_file() {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
let size_display = if size > 1_000_000 {
format!("{:.1}MB", size as f64 / 1_000_000.0)
} else if size > 1_000 {
format!("{:.1}KB", size as f64 / 1_000.0)
} else {
format!("{size}B")
};
entries.push(format!("{rel} ({size_display})"));
}
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn test_extract_mentions() {
let input = "Look at @src/main.rs and @Cargo.toml for details";
let mentions = extract_mentions(input);
assert_eq!(mentions.len(), 2);
assert_eq!(mentions[0].path, "src/main.rs");
assert_eq!(mentions[1].path, "Cargo.toml");
}
#[test]
fn test_skip_non_file_mentions() {
let input = "Talk to @username about the code";
let mentions = extract_mentions(input);
assert!(mentions.is_empty());
}
#[test]
fn test_mention_with_trailing_punctuation() {
let input = "Check @src/lib.rs, then @Cargo.toml.";
let mentions = extract_mentions(input);
assert_eq!(mentions.len(), 2);
assert_eq!(mentions[0].path, "src/lib.rs");
assert_eq!(mentions[1].path, "Cargo.toml");
}
#[test]
fn test_extract_mentions_with_cwd_bare_dir() {
let tmp = std::env::temp_dir().join("collet_mention_test_dir");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("mydir")).unwrap();
fs::write(tmp.join("myfile.txt"), "hello").unwrap();
let wd = tmp.to_str().unwrap();
let mentions = extract_mentions_with_cwd("look at @mydir", wd);
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].path, "mydir");
assert!(mentions[0].is_dir);
let mentions = extract_mentions_with_cwd("look at @nonexistent", wd);
assert!(mentions.is_empty());
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_extract_mentions_with_cwd_bare_file() {
let tmp = std::env::temp_dir().join("collet_mention_test_file");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(&tmp).unwrap();
fs::write(tmp.join("Makefile"), "all: build").unwrap();
let wd = tmp.to_str().unwrap();
let mentions = extract_mentions_with_cwd("check @Makefile", wd);
assert_eq!(mentions.len(), 1);
assert_eq!(mentions[0].path, "Makefile");
assert!(!mentions[0].is_dir);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_resolve_dir_mention() {
let tmp = std::env::temp_dir().join("collet_mention_resolve_dir");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("src")).unwrap();
fs::write(tmp.join("src/main.rs"), "fn main() {}").unwrap();
fs::write(tmp.join("src/lib.rs"), "pub mod foo;").unwrap();
let wd = tmp.to_str().unwrap();
let mentions = vec![FileMention {
raw: "@src".to_string(),
path: "src".to_string(),
is_dir: true,
}];
let (augmented, resolved) = resolve_mentions("look at @src", wd, &mentions);
assert_eq!(resolved.len(), 1);
assert!(augmented.contains("<directory"));
assert!(augmented.contains("src/lib.rs"));
assert!(augmented.contains("src/main.rs"));
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn test_exists_on_disk() {
let tmp = std::env::temp_dir().join("collet_mention_exists");
let _ = fs::remove_dir_all(&tmp);
fs::create_dir_all(tmp.join("subdir")).unwrap();
fs::write(tmp.join("afile"), "content").unwrap();
let wd = tmp.to_str().unwrap();
assert!(exists_on_disk("subdir", wd));
assert!(exists_on_disk("afile", wd));
assert!(!exists_on_disk("nope", wd));
let _ = fs::remove_dir_all(&tmp);
}
}