1use std::path::{Path, PathBuf};
6
7use base64::Engine;
8use rig_core::completion::Message;
9use rig_core::conversation::Conversation;
10use rig_core::message::{ImageMediaType, UserContent};
11
12use crate::workspace::{Stat, Workspace};
13use crate::{MAX_LINES, show_lines};
14
15const MOST_BYTES: u64 = 5 * 1024 * 1024;
18
19const MOST_TEXT_BYTES: usize = 32 * 1024;
21
22const FILE_OPEN: &str = "<file path=\"";
24
25const FILE_CLOSE: &str = "</file>";
27
28const EXTENSIONS: [&str; 5] = ["png", "jpg", "jpeg", "gif", "webp"];
30
31pub fn is_image_path(path: &Path) -> bool {
33 path.extension()
34 .and_then(|extension| extension.to_str())
35 .is_some_and(|extension| {
36 EXTENSIONS
37 .iter()
38 .any(|known| extension.eq_ignore_ascii_case(known))
39 })
40}
41
42#[derive(Clone, Debug, Default, PartialEq)]
44pub struct Attached {
45 pub files: Vec<(String, UserContent)>,
48 pub notes: Vec<String>,
51 pub missing: Vec<String>,
54}
55
56pub fn attachments(text: &str, base: &Path, workspace: &dyn Workspace) -> Attached {
68 let mut attached = Attached::default();
69 let mut seen: Vec<PathBuf> = Vec::new();
70 for token in text.split_whitespace() {
71 let Some(reference) = token.strip_prefix('@') else {
72 continue;
73 };
74 let trimmed = reference.trim_end_matches([',', '.', ';', ':', '!', '?', ')', '"', '\'']);
75 if trimmed.starts_with("~/") && workspace.host().is_some() {
76 attached.notes.push(format!(
77 "{trimmed} is not attached: home paths are this machine's."
78 ));
79 continue;
80 }
81 let found = [reference, trimmed].into_iter().find_map(|named| {
82 let label = expand(named);
83 let path = base.join(&label);
84 matches!(workspace.stat(&path), Ok(Stat::File { .. }))
85 .then(|| (label.display().to_string(), path))
86 });
87 let Some((label, path)) = found else {
88 if is_path_reference(trimmed) && !attached.missing.iter().any(|seen| seen == trimmed) {
89 attached.missing.push(trimmed.to_owned());
90 }
91 continue;
92 };
93 if seen.contains(&path) {
94 continue;
95 }
96 let read = if is_image_path(&path) {
97 read_image(workspace, &path)
98 } else {
99 read_file(workspace, &path, &label)
100 };
101 match read {
102 Ok(content) => attached.files.push((label, content)),
103 Err(why) => attached
104 .notes
105 .push(format!("{label} is not attached: {why}.")),
106 }
107 seen.push(path);
108 }
109 attached
110}
111
112pub fn is_path_reference(reference: &str) -> bool {
127 reference.contains('/')
128 || reference.rsplit_once('.').is_some_and(|(name, extension)| {
129 !name.is_empty()
130 && (1..=4).contains(&extension.len())
131 && extension.chars().all(|c| c.is_ascii_alphanumeric())
132 })
133}
134
135pub fn attached_file(text: &str) -> Option<(&str, &str)> {
138 let rest = text.strip_prefix(FILE_OPEN)?.strip_suffix(FILE_CLOSE)?;
139 rest.split_once("\">\n")
140}
141
142fn expand(path: &str) -> PathBuf {
144 match (path.strip_prefix("~/"), std::env::home_dir()) {
145 (Some(rest), Some(home)) => home.join(rest),
146 _ => PathBuf::from(path),
147 }
148}
149
150pub fn read_image(workspace: &dyn Workspace, path: &Path) -> Result<UserContent, String> {
154 let contents = workspace
155 .read(path, MOST_BYTES)
156 .map_err(|failure| failure.to_string())?;
157 if contents.size > MOST_BYTES {
158 return Err(format!(
159 "it has {} KB, more than the {} KB a provider takes",
160 contents.size / 1024,
161 MOST_BYTES / 1024
162 ));
163 }
164 let bytes = contents.bytes;
165 let media_type = ImageMediaType::sniff(&bytes)
166 .ok_or("it is not a PNG, JPEG, GIF or WebP image".to_owned())?;
167 let data = base64::engine::general_purpose::STANDARD.encode(&bytes);
168 Ok(UserContent::image_base64(data, Some(media_type), None))
169}
170
171fn read_file(workspace: &dyn Workspace, path: &Path, label: &str) -> Result<UserContent, String> {
175 let text = workspace
176 .read_text(path)
177 .map_err(|failure| failure.to_string())?;
178 if text.contains('\0') {
179 return Err("it is not a text file".to_owned());
180 }
181 let lines = show_lines(&text, 1, MAX_LINES, MOST_TEXT_BYTES);
182 Ok(UserContent::text(format!(
183 "{FILE_OPEN}{label}\">\n{lines}{FILE_CLOSE}"
184 )))
185}
186
187pub fn typed(conversation: &Conversation, at: usize) -> Option<String> {
194 let entry = conversation.entries().nth(at)?;
195 let Message::User { content } = entry.message else {
196 return None;
197 };
198 if !entry.is_users_own() {
199 return None;
200 }
201 let typed: Vec<&str> = content
202 .iter()
203 .filter_map(|content| match content {
204 UserContent::Text(text) if attached_file(&text.text).is_none() => {
205 Some(text.text.trim())
206 }
207 _ => None,
208 })
209 .filter(|text| !text.is_empty())
210 .collect();
211 (!typed.is_empty()).then(|| typed.join("\n\n"))
212}
213
214pub fn first_typed(conversation: &Conversation) -> Option<String> {
216 (0..conversation.messages().len()).find_map(|at| typed(conversation, at))
217}
218
219#[cfg(test)]
220mod tests;