use std::path::{Path, PathBuf};
use base64::Engine;
use rig_core::completion::Message;
use rig_core::conversation::Conversation;
use rig_core::message::{ImageMediaType, UserContent};
use crate::workspace::{Stat, Workspace};
use crate::{MAX_LINES, show_lines};
const MOST_BYTES: u64 = 5 * 1024 * 1024;
const MOST_TEXT_BYTES: usize = 32 * 1024;
const FILE_OPEN: &str = "<file path=\"";
const FILE_CLOSE: &str = "</file>";
const EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"];
pub fn is_image_path(path: &Path) -> bool {
path.extension()
.and_then(|extension| extension.to_str())
.is_some_and(|extension| {
EXTENSIONS
.iter()
.any(|known| extension.eq_ignore_ascii_case(known))
})
}
#[derive(Clone, Debug, Default, PartialEq)]
pub struct Attached {
pub files: Vec<(String, UserContent)>,
pub notes: Vec<String>,
pub missing: Vec<String>,
}
pub fn attachments(text: &str, base: &Path, workspace: &dyn Workspace) -> Attached {
let mut attached = Attached::default();
let mut seen: Vec<PathBuf> = Vec::new();
for token in text.split_whitespace() {
let Some(reference) = token.strip_prefix('@') else {
continue;
};
let trimmed = reference.trim_end_matches([',', '.', ';', ':', '!', '?', ')', '"', '\'']);
if trimmed.starts_with("~/") && workspace.host().is_some() {
attached.notes.push(format!(
"{trimmed} is not attached: home paths are this machine's."
));
continue;
}
let found = [reference, trimmed].into_iter().find_map(|named| {
let label = expand(named);
let path = base.join(&label);
matches!(workspace.stat(&path), Ok(Stat::File { .. }))
.then(|| (label.display().to_string(), path))
});
let Some((label, path)) = found else {
if is_path_reference(trimmed) && !attached.missing.iter().any(|seen| seen == trimmed) {
attached.missing.push(trimmed.to_owned());
}
continue;
};
if seen.contains(&path) {
continue;
}
let read = if is_image_path(&path) {
read_image(workspace, &path)
} else {
read_file(workspace, &path, &label)
};
match read {
Ok(content) => attached.files.push((label, content)),
Err(why) => attached
.notes
.push(format!("{label} is not attached: {why}.")),
}
seen.push(path);
}
attached
}
pub fn is_path_reference(reference: &str) -> bool {
reference.contains('/')
|| reference.rsplit_once('.').is_some_and(|(name, extension)| {
!name.is_empty()
&& (1..=4).contains(&extension.len())
&& extension.chars().all(|c| c.is_ascii_alphanumeric())
})
}
pub fn attached_file(text: &str) -> Option<(&str, &str)> {
let rest = text.strip_prefix(FILE_OPEN)?.strip_suffix(FILE_CLOSE)?;
rest.split_once("\">\n")
}
fn expand(path: &str) -> PathBuf {
match (path.strip_prefix("~/"), std::env::home_dir()) {
(Some(rest), Some(home)) => home.join(rest),
_ => PathBuf::from(path),
}
}
pub fn read_image(workspace: &dyn Workspace, path: &Path) -> Result<UserContent, String> {
let contents = workspace
.read(path, MOST_BYTES)
.map_err(|failure| failure.to_string())?;
if contents.size > MOST_BYTES {
return Err(format!(
"it has {} KB, more than the {} KB a provider takes",
contents.size / 1024,
MOST_BYTES / 1024
));
}
let bytes = contents.bytes;
let media_type = ImageMediaType::sniff(&bytes)
.ok_or("it is not a PNG, JPEG, GIF or WebP image".to_owned())?;
let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
Ok(UserContent::image_base64(data, Some(media_type), None))
}
fn read_file(workspace: &dyn Workspace, path: &Path, label: &str) -> Result<UserContent, String> {
let text = workspace
.read_text(path)
.map_err(|failure| failure.to_string())?;
if text.contains('\0') {
return Err("it is not a text file".to_owned());
}
let lines = show_lines(&text, 1, MAX_LINES, MOST_TEXT_BYTES);
Ok(UserContent::text(format!(
"{FILE_OPEN}{label}\">\n{lines}{FILE_CLOSE}"
)))
}
pub fn typed(conversation: &Conversation, at: usize) -> Option<String> {
let entry = conversation.entries().nth(at)?;
let Message::User { content } = entry.message else {
return None;
};
if !entry.is_users_own() {
return None;
}
let typed: Vec<&str> = content
.iter()
.filter_map(|content| match content {
UserContent::Text(text) if attached_file(&text.text).is_none() => {
Some(text.text.trim())
}
_ => None,
})
.filter(|text| !text.is_empty())
.collect();
(!typed.is_empty()).then(|| typed.join("\n\n"))
}
pub fn first_typed(conversation: &Conversation) -> Option<String> {
(0..conversation.messages().len()).find_map(|at| typed(conversation, at))
}
#[cfg(test)]
mod tests;