Skip to main content

pi/modes/print/
input.rs

1//! Non-interactive prompt assembly: stdin/argv merge and `@file` expansion.
2//!
3//! Ports the input half of `.references/pi/packages/coding-agent/src/modes/
4//! print-mode.ts` together with `cli/file-processor.ts`, `cli/initial-message.ts`,
5//! and the piped-stdin reader in `main.ts`. File argument expansion reuses the
6//! shared image pipeline in [`crate::core::tools::read`]
7//! (`detect_supported_image_mime_type` and `process_image_bytes`) and the
8//! macOS-aware path resolver in [`crate::core::tools::path_utils`], so print
9//! input and the `read` tool stay byte-for-byte consistent.
10//!
11//! `@@` is a literal escape: an argument the CLI parser already stripped one
12//! leading `@` from (`@@foo` -> file arg `@foo`) is treated as literal prompt
13//! text rather than a file reference, so users can pass prompts that start with
14//! `@`.
15
16use 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/// Errors produced while assembling the non-interactive prompt.
28#[derive(Debug, Error)]
29pub enum PrintInputError {
30    /// A referenced `@file` does not exist on disk.
31    #[error("File not found: {0}")]
32    FileNotFound(PathBuf),
33    /// A text `@file` exists but could not be read.
34    #[error("Could not read file {path}: {message}")]
35    FileNotReadable {
36        /// Absolute path of the unreadable file.
37        path: PathBuf,
38        /// Underlying read failure description.
39        message: String,
40    },
41    /// Path expansion failed (bad `file://` URL, no home directory, …).
42    #[error(transparent)]
43    PathResolve(#[from] PathResolveError),
44    /// Raw I/O failure not covered by the typed variants above.
45    #[error(transparent)]
46    Io(#[from] io::Error),
47}
48
49/// Text and image attachments produced by expanding `@file` arguments.
50#[derive(Clone, Debug, Default, PartialEq)]
51pub struct ProcessedFiles {
52    /// Concatenated `<file>` blocks and `@@` literal fragments.
53    pub text: String,
54    /// Inline image attachments ready for a user message.
55    pub images: Vec<ImageContent>,
56}
57
58/// Options for [`process_file_arguments`].
59#[derive(Clone, Copy, Debug)]
60pub struct ProcessFileOptions {
61    /// Whether to auto-resize images to the 2000×2000 / 4.5 MiB inline limits.
62    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/// The assembled initial prompt plus deferred follow-up messages.
74///
75/// Mirrors the TypeScript `buildInitialMessage` result: the first CLI message
76/// is consumed into [`initial_message`](Self::initial_message); the rest stay in
77/// [`remaining_messages`](Self::remaining_messages) for later prompts.
78#[derive(Clone, Debug, Default)]
79pub struct PromptSource {
80    /// Joined prompt for the first `session.prompt` call (`None` when empty).
81    pub initial_message: Option<String>,
82    /// Images attached to the initial prompt.
83    pub initial_images: Vec<ImageContent>,
84    /// Remaining CLI messages to send after the initial prompt.
85    pub remaining_messages: Vec<String>,
86}
87
88/// Combine piped stdin, `@file` text, and the first CLI message.
89///
90/// Join order matches `buildInitialMessage`: `stdin_content` (even an empty
91/// string when `Some`) first, then `file_text` (only when non-empty), then the
92/// first entry of `messages` (which is removed in place for later prompts).
93///
94/// # Errors
95///
96/// This function is infallible; the `Result` is reserved for future expansion
97/// and always returns [`Ok`].
98pub 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
135/// Expand `@file` arguments into text content and image attachments.
136///
137/// Each argument is resolved against `cwd` (with `~`, `file://`, Unicode-space,
138/// and macOS screenshot variants applied by the shared resolver). Missing files
139/// fail with [`PrintInputError::FileNotFound`]; empty files are skipped.
140/// Supported image bytes run through the shared resize pipeline and produce an
141/// [`ImageContent`] plus a `<file>` hint block; failed conversions emit the
142/// exact omission notice without an attachment. Text files are wrapped in
143/// `<file name="…">\n…\n</file>\n`.
144///
145/// A leading `@` in an argument (i.e. the user wrote `@@`) is a literal escape:
146/// the argument is appended verbatim to [`ProcessedFiles::text`] instead of
147/// being treated as a path.
148///
149/// # Errors
150///
151/// See [`PrintInputError`].
152pub 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        // `@@` literal escape: the CLI parser already stripped one `@`, so a
162        // leading `@` here means the user wrote `@@<text>` for a literal.
163        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            // Skip empty files, matching the TypeScript `stat().size === 0` branch.
190            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            // Text file: wrap content verbatim. Node `readFile` UTF-8 replaces
205            // invalid sequences with U+FFFD; `from_utf8_lossy` matches that.
206            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
239/// Read piped stdin into a trimmed string.
240///
241/// When `is_tty` is true the caller is an interactive terminal and nothing is
242/// read (`None`). Otherwise the full stream is read, trimmed of surrounding
243/// whitespace, and returned as `Some` (including an empty string when the input
244/// was whitespace-only). The trimmed-empty case maps to `None`, matching the
245/// TypeScript `data.trim() || undefined` reader in `main.ts`.
246///
247/// # Errors
248///
249/// Returns I/O failures from the underlying read.
250pub 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        // `@@literal` arrives as `@literal` after the CLI parser strips one `@`.
368        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        // A directory is not readable as a file via `fs::read`.
382        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}