use shuvarie_llm::AttachmentKind;
use std::path::Path;
use super::normalize_path;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DirectiveProbe {
pub kind: Option<AttachmentKind>,
pub size: Option<u64>,
pub error: Option<String>,
}
pub fn run_directive_probe(workspace_root: &Path, path: &str) -> DirectiveProbe {
let resolved = normalize_path(workspace_root, path);
let metadata = match std::fs::metadata(&resolved) {
Ok(metadata) => metadata,
Err(_) => {
return DirectiveProbe {
kind: None,
size: None,
error: Some("missing file".to_string()),
};
}
};
if metadata.is_dir() {
return DirectiveProbe {
kind: None,
size: None,
error: Some("is a directory".to_string()),
};
}
DirectiveProbe {
kind: ext_kind(&resolved),
size: Some(metadata.len()),
error: None,
}
}
fn ext_kind(path: &Path) -> Option<AttachmentKind> {
let ext = path
.extension()
.and_then(|ext| ext.to_str())
.map(str::to_ascii_lowercase)?;
if matches!(ext.as_str(), "png" | "jpg" | "jpeg" | "gif" | "webp") {
return Some(AttachmentKind::Image);
}
shuvarie_doc::detect(&[], Some(&ext)).map(|_| AttachmentKind::Document)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct PathCandidate {
pub name: String,
pub is_dir: bool,
}
const MAX_COMPLETIONS: usize = 40;
pub fn path_candidates(workspace_root: &Path, query: &str) -> Vec<PathCandidate> {
path_candidates_in(workspace_root, dirs::home_dir().as_deref(), query)
}
pub fn path_candidates_in(
workspace_root: &Path,
home: Option<&Path>,
query: &str,
) -> Vec<PathCandidate> {
let query = query.trim().strip_prefix('@').unwrap_or(query.trim());
if query == "~" {
return vec![PathCandidate {
name: "~/".to_string(),
is_dir: true,
}];
}
let (dir_raw, prefix) = match query.strip_suffix('/') {
Some(dir) => (dir, ""),
None => match query.rsplit_once('/') {
Some((dir, prefix)) => (dir, prefix),
None => ("", query),
},
};
let dir = match resolve_query_dir(workspace_root, dir_raw, home) {
Some(dir) => dir,
None => return Vec::new(),
};
let Ok(entries) = std::fs::read_dir(&dir) else {
return Vec::new();
};
let prefix = prefix.to_ascii_lowercase();
let mut out: Vec<PathCandidate> = entries
.filter_map(|entry| {
let entry = entry.ok()?;
let is_dir = entry.file_type().map(|t| t.is_dir()).unwrap_or(false);
let mut name = entry.file_name().to_string_lossy().into_owned();
if is_dir {
name.push('/');
}
if prefix.is_empty() && name.starts_with('.') {
return None;
}
if !name.to_ascii_lowercase().starts_with(&prefix) {
return None;
}
Some(PathCandidate { name, is_dir })
})
.collect();
out.sort_by(|a, b| {
b.is_dir
.cmp(&a.is_dir)
.then_with(|| a.name.to_lowercase().cmp(&b.name.to_lowercase()))
});
out.truncate(MAX_COMPLETIONS);
out
}
fn resolve_query_dir(
workspace_root: &Path,
dir_raw: &str,
home: Option<&Path>,
) -> Option<std::path::PathBuf> {
if Path::new(dir_raw).is_absolute() {
return Some(std::path::PathBuf::from(dir_raw));
}
if dir_raw == "~" || dir_raw.starts_with("~/") {
let home = home?;
let rest = dir_raw.strip_prefix("~/").unwrap_or("");
return Some(home.join(rest));
}
Some(workspace_root.join(dir_raw))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn probe_reports_exists_size_and_extension_kind() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("shot.png"), b"png bytes").unwrap();
std::fs::write(dir.path().join("plan.pdf"), b"%PDF-1.4 body").unwrap();
std::fs::write(dir.path().join("notes"), b"").unwrap();
let root = dir.path();
let image = run_directive_probe(root, "shot.png");
assert_eq!(image.kind, Some(AttachmentKind::Image));
assert!(image.error.is_none());
assert!(image.size.unwrap() > 0);
let doc = run_directive_probe(root, "plan.pdf");
assert_eq!(doc.kind, Some(AttachmentKind::Document));
let plain = run_directive_probe(root, "notes");
assert_eq!(plain.kind, None, "extension-less stays a neutral chip");
}
#[test]
fn probe_reports_missing_and_directories() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir(dir.path().join("sub")).unwrap();
std::fs::write(dir.path().join("shot.png"), b"\x89PNG\r\n\x1a\n").unwrap();
let missing = run_directive_probe(dir.path(), "nope.png");
assert_eq!(missing.error.as_deref(), Some("missing file"));
let directory = run_directive_probe(dir.path(), "sub");
assert_eq!(directory.error.as_deref(), Some("is a directory"));
let absolute = run_directive_probe(
dir.path(),
dir.path().join("shot.png").to_string_lossy().as_ref(),
);
assert!(absolute.error.is_none());
}
#[test]
fn probe_sniffs_image_extensions_case_insensitively() {
let dir = tempfile::tempdir().unwrap();
for ext in ["JPG", "Png", "WEBP", "gif"] {
let path = dir.path().join(format!("f.{ext}"));
std::fs::write(&path, b"x").unwrap();
let probe = run_directive_probe(dir.path(), &format!("f.{ext}"));
assert_eq!(probe.kind, Some(AttachmentKind::Image), "ext {ext}");
}
}
#[test]
fn completions_list_dirs_first_then_files() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/tools")).unwrap();
std::fs::create_dir(dir.path().join("tests")).unwrap();
std::fs::write(dir.path().join("setup.rs"), "").unwrap();
std::fs::write(dir.path().join("main.rs"), "").unwrap();
std::fs::write(dir.path().join(".hidden"), "").unwrap();
let root = dir.path();
assert_eq!(
path_candidates(root, ""),
vec!["src/", "tests/", "main.rs", "setup.rs"]
.into_iter()
.map(|name| PathCandidate {
name: name.to_string(),
is_dir: name.ends_with('/'),
})
.collect::<Vec<_>>(),
"directories sort first, then files, both alphabetically"
);
}
#[test]
fn completions_prefix_match_is_case_insensitive_and_hidden_aware() {
let dir = tempfile::tempdir().unwrap();
std::fs::write(dir.path().join("Cargo.toml"), "").unwrap();
std::fs::write(dir.path().join("code.md"), "").unwrap();
std::fs::write(dir.path().join(".cargo"), "").unwrap();
let root = dir.path();
let dot = path_candidates(root, ".car");
assert_eq!(
dot.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
vec![".cargo"],
);
let visible = path_candidates(root, "c");
assert_eq!(
visible.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
vec!["Cargo.toml", "code.md"],
"prefix match is case-insensitive; dotfiles stay hidden"
);
}
#[test]
fn completions_resolve_directory_parts_under_the_root() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("src/tools")).unwrap();
std::fs::write(dir.path().join("src/tools/tool.rs"), "").unwrap();
std::fs::write(dir.path().join("src/tools/walk.rs"), "").unwrap();
std::fs::write(dir.path().join("src/other.rs"), "").unwrap();
let root = dir.path();
let found = path_candidates(root, "src/tools/tool");
assert_eq!(
found.iter().map(|c| c.name.as_str()).collect::<Vec<_>>(),
vec!["tool.rs"]
);
assert_eq!(
path_candidates(root, "src/tools/")
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["tool.rs", "walk.rs"]
);
assert_eq!(
path_candidates(root, "src/to")
.iter()
.map(|c| c.name.as_str())
.collect::<Vec<_>>(),
vec!["tools/"]
);
assert!(path_candidates(root, "does-not-exist/").is_empty());
}
#[test]
fn completions_resolve_tilde_directories_against_the_home() {
let dir = tempfile::tempdir().unwrap();
std::fs::create_dir_all(dir.path().join("docs/deep")).unwrap();
std::fs::create_dir_all(dir.path().join("docs/tools")).unwrap();
std::fs::write(dir.path().join("docs/deep/plan.pdf"), "").unwrap();
std::fs::write(dir.path().join("docs/notes.md"), "").unwrap();
let home = dir.path();
let root = home;
let names = |candidates: Vec<PathCandidate>| -> Vec<String> {
candidates.into_iter().map(|c| c.name).collect()
};
assert!(
names(path_candidates_in(root, Some(home), "~/docs/")).contains(&"deep/".to_string())
);
assert_eq!(
names(path_candidates_in(root, Some(home), "~/docs/de")),
vec!["deep/"]
);
assert_eq!(names(path_candidates_in(root, Some(home), "~")), vec!["~/"]);
assert!(path_candidates_in(root, None, "~/docs/").is_empty());
assert_eq!(
names(path_candidates_in(root, None, "docs/t")),
vec!["tools/"]
);
}
}