1use std::io;
17use std::path::PathBuf;
18
19use pi_ai::ImageContent;
20use thiserror::Error;
21use tokio::fs;
22use tokio::io::{AsyncRead, AsyncReadExt};
23
24use crate::core::tools::path_utils::{PathResolveError, resolve_read_path_async};
25use crate::core::tools::read::{detect_supported_image_mime_type, process_image_bytes};
26
27#[derive(Debug, Error)]
29pub enum PrintInputError {
30 #[error("File not found: {0}")]
32 FileNotFound(PathBuf),
33 #[error("Could not read file {path}: {message}")]
35 FileNotReadable {
36 path: PathBuf,
38 message: String,
40 },
41 #[error(transparent)]
43 PathResolve(#[from] PathResolveError),
44 #[error(transparent)]
46 Io(#[from] io::Error),
47}
48
49#[derive(Clone, Debug, Default, PartialEq)]
51pub struct ProcessedFiles {
52 pub text: String,
54 pub images: Vec<ImageContent>,
56}
57
58#[derive(Clone, Copy, Debug)]
60pub struct ProcessFileOptions {
61 pub auto_resize_images: bool,
63}
64
65impl Default for ProcessFileOptions {
66 fn default() -> Self {
67 Self {
68 auto_resize_images: true,
69 }
70 }
71}
72
73#[derive(Clone, Debug, Default)]
79pub struct PromptSource {
80 pub initial_message: Option<String>,
82 pub initial_images: Vec<ImageContent>,
84 pub remaining_messages: Vec<String>,
86}
87
88pub fn build_initial_message(
99 messages: &mut Vec<String>,
100 stdin_content: Option<&str>,
101 file_text: Option<&str>,
102 file_images: Vec<ImageContent>,
103) -> PromptSource {
104 let mut parts: Vec<String> = Vec::new();
105 if let Some(stdin) = stdin_content {
106 parts.push(stdin.to_owned());
107 }
108 if let Some(text) = file_text
109 && !text.is_empty()
110 {
111 parts.push(text.to_owned());
112 }
113 if !messages.is_empty() {
114 parts.push(messages.remove(0));
115 }
116
117 let initial_message = if parts.is_empty() {
118 None
119 } else {
120 Some(parts.concat())
121 };
122 let initial_images = if file_images.is_empty() {
123 Vec::new()
124 } else {
125 file_images
126 };
127
128 PromptSource {
129 initial_message,
130 initial_images,
131 remaining_messages: Vec::new(),
132 }
133}
134
135pub async fn process_file_arguments(
153 file_args: &[String],
154 cwd: &str,
155 options: ProcessFileOptions,
156) -> Result<ProcessedFiles, PrintInputError> {
157 let mut text = String::new();
158 let mut images: Vec<ImageContent> = Vec::new();
159
160 for file_arg in file_args {
161 if let Some(literal) = file_arg.strip_prefix('@') {
164 text.push('@');
165 text.push_str(literal);
166 continue;
167 }
168
169 let resolved = resolve_read_path_async(file_arg, cwd).await?;
170 let absolute_path = PathBuf::from(&resolved);
171
172 if !fs::try_exists(&absolute_path).await.unwrap_or(false) {
173 return Err(PrintInputError::FileNotFound(absolute_path));
174 }
175
176 let metadata = match fs::metadata(&absolute_path).await {
177 Ok(meta) => meta,
178 Err(err) if err.kind() == io::ErrorKind::NotFound => {
179 return Err(PrintInputError::FileNotFound(absolute_path));
180 }
181 Err(err) => {
182 return Err(PrintInputError::FileNotReadable {
183 path: absolute_path,
184 message: err.to_string(),
185 });
186 }
187 };
188 if metadata.len() == 0 {
189 continue;
191 }
192
193 let bytes = match fs::read(&absolute_path).await {
194 Ok(bytes) => bytes,
195 Err(err) => {
196 return Err(PrintInputError::FileNotReadable {
197 path: absolute_path,
198 message: err.to_string(),
199 });
200 }
201 };
202
203 let Some(mime_type) = detect_supported_image_mime_type(&bytes) else {
204 let content = String::from_utf8_lossy(&bytes);
207 text.push_str("<file name=\"");
208 text.push_str(&resolved);
209 text.push_str("\">\n");
210 text.push_str(&content);
211 text.push_str("\n</file>\n");
212 continue;
213 };
214
215 match process_image_bytes(&bytes, &mime_type, options.auto_resize_images) {
216 crate::core::tools::read::ProcessImageResult::Ok(processed) => {
217 images.push(ImageContent::new(processed.data, processed.mime_type));
218 text.push_str("<file name=\"");
219 text.push_str(&resolved);
220 text.push_str("\">");
221 if !processed.hints.is_empty() {
222 text.push_str(&processed.hints.join("\n"));
223 }
224 text.push_str("</file>\n");
225 }
226 crate::core::tools::read::ProcessImageResult::Failed(failed) => {
227 text.push_str("<file name=\"");
228 text.push_str(&resolved);
229 text.push_str("\">");
230 text.push_str(&failed.message);
231 text.push_str("</file>\n");
232 }
233 }
234 }
235
236 Ok(ProcessedFiles { text, images })
237}
238
239pub async fn read_piped_stdin<R>(is_tty: bool, reader: R) -> io::Result<Option<String>>
251where
252 R: AsyncRead + Unpin,
253{
254 if is_tty {
255 return Ok(None);
256 }
257 let mut buf = Vec::new();
258 let mut reader = reader;
259 reader.read_to_end(&mut buf).await?;
260 let lossy = String::from_utf8_lossy(&buf).into_owned();
261 let trimmed = lossy.trim();
262 if trimmed.is_empty() {
263 Ok(None)
264 } else {
265 Ok(Some(trimmed.to_owned()))
266 }
267}
268
269#[cfg(test)]
270mod tests {
271 use super::*;
272 use std::io::Cursor;
273
274 type TestResult = Result<(), Box<dyn std::error::Error>>;
275
276 #[test]
277 fn build_initial_message_empty_returns_none() {
278 let mut messages = Vec::new();
279 let src = build_initial_message(&mut messages, None, None, Vec::new());
280 assert!(src.initial_message.is_none());
281 assert!(src.initial_images.is_empty());
282 assert!(src.remaining_messages.is_empty());
283 }
284
285 #[test]
286 fn build_initial_message_joins_stdin_file_text_first_message() {
287 let mut messages = vec!["third".to_owned()];
288 let src = build_initial_message(
289 &mut messages,
290 Some("stdin"),
291 Some("<file>body</file>\n"),
292 Vec::new(),
293 );
294 assert_eq!(
295 src.initial_message.as_deref(),
296 Some("stdin<file>body</file>\nthird")
297 );
298 assert!(messages.is_empty(), "first message consumed");
299 }
300
301 #[test]
302 fn build_initial_message_empty_stdin_still_joined() {
303 let mut messages = vec!["msg".to_owned()];
304 let src = build_initial_message(&mut messages, Some(""), None, Vec::new());
305 assert_eq!(src.initial_message.as_deref(), Some("msg"));
306 }
307
308 #[test]
309 fn build_initial_message_preserves_remaining_messages() {
310 let mut messages = vec!["first".to_owned(), "second".to_owned()];
311 let src = build_initial_message(&mut messages, None, None, Vec::new());
312 assert_eq!(src.initial_message.as_deref(), Some("first"));
313 assert_eq!(messages, vec!["second".to_owned()]);
314 }
315
316 #[test]
317 fn build_initial_message_carries_images() {
318 let img = ImageContent::new("AA==", "image/png");
319 let mut messages = Vec::new();
320 let src = build_initial_message(&mut messages, None, None, vec![img.clone()]);
321 assert_eq!(src.initial_images, vec![img]);
322 }
323
324 #[tokio::test]
325 async fn process_file_arguments_missing_file_typed_error() {
326 let result = process_file_arguments(
327 &["definitely/missing/file.xyz".to_owned()],
328 "/tmp",
329 ProcessFileOptions::default(),
330 )
331 .await;
332 assert!(
333 matches!(result, Err(PrintInputError::FileNotFound(_))),
334 "{result:?}"
335 );
336 }
337
338 #[tokio::test]
339 async fn process_file_arguments_text_file_wrapped() -> TestResult {
340 let dir = tempfile::tempdir()?;
341 let path = dir.path().join("note.txt");
342 fs::write(&path, "hello world").await?;
343 let arg = path.to_string_lossy().to_string();
344 let processed = process_file_arguments(&[arg], "/", ProcessFileOptions::default()).await?;
345 assert!(processed.images.is_empty());
346 assert_eq!(
347 processed.text,
348 format!("<file name=\"{}\">\nhello world\n</file>\n", path.display())
349 );
350 Ok(())
351 }
352
353 #[tokio::test]
354 async fn process_file_arguments_skips_empty_file() -> TestResult {
355 let dir = tempfile::tempdir()?;
356 let path = dir.path().join("empty.txt");
357 fs::write(&path, "").await?;
358 let arg = path.to_string_lossy().to_string();
359 let processed = process_file_arguments(&[arg], "/", ProcessFileOptions::default()).await?;
360 assert!(processed.text.is_empty());
361 assert!(processed.images.is_empty());
362 Ok(())
363 }
364
365 #[tokio::test]
366 async fn process_file_arguments_at_at_is_literal() -> TestResult {
367 let processed = process_file_arguments(
369 &["@literal".to_owned()],
370 "/tmp",
371 ProcessFileOptions::default(),
372 )
373 .await?;
374 assert_eq!(processed.text, "@literal");
375 assert!(processed.images.is_empty());
376 Ok(())
377 }
378
379 #[tokio::test]
380 async fn process_file_arguments_unreadable_file_typed_error() -> TestResult {
381 let dir = tempfile::tempdir()?;
383 let arg = dir.path().to_string_lossy().to_string();
384 let result = process_file_arguments(&[arg], "/", ProcessFileOptions::default()).await;
385 assert!(
386 matches!(
387 result,
388 Err(PrintInputError::Io(_) | PrintInputError::FileNotReadable { .. })
389 ),
390 "{result:?}"
391 );
392 Ok(())
393 }
394
395 #[tokio::test]
396 async fn read_piped_stdin_tty_returns_none() -> TestResult {
397 let result = read_piped_stdin(true, Cursor::new(b"ignored")).await?;
398 assert_eq!(result, None);
399 Ok(())
400 }
401
402 #[tokio::test]
403 async fn read_piped_stdin_reads_and_trims() -> TestResult {
404 let result = read_piped_stdin(false, Cursor::new(b" hello\n ")).await?;
405 assert_eq!(result.as_deref(), Some("hello"));
406 Ok(())
407 }
408
409 #[tokio::test]
410 async fn read_piped_stdin_empty_returns_none() -> TestResult {
411 let result = read_piped_stdin(false, Cursor::new(b" \n")).await?;
412 assert_eq!(result, None);
413 Ok(())
414 }
415}