Skip to main content

oino/cli/
files.rs

1//! File processing and the initial message: `@file` arguments become text blocks and
2//! image attachments of the first message; piped stdin is prepended.
3
4use std::fmt::Write as _;
5use std::path::Path;
6
7use base64::Engine as _;
8use oino_ai::ImageContent;
9
10/// Text and images collected from `@file` arguments.
11#[derive(Clone, Debug, Default, PartialEq)]
12pub struct FileContent {
13    /// `<file name="…">…</file>` blocks.
14    pub text: String,
15    /// Image attachments.
16    pub images: Vec<ImageContent>,
17}
18
19/// Processes `@file` arguments; errors carry fixed text (the caller prints it and exits 1).
20///
21/// # Errors
22///
23/// `Error: File not found: {path}` and `Error: Could not read file {path}: {message}`.
24pub fn process_file_arguments(file_args: &[String], cwd: &Path) -> Result<FileContent, String> {
25    let mut content = FileContent::default();
26    let cwd_text = cwd.display().to_string();
27    for file_arg in file_args {
28        let absolute = oino_tools::path_utils::resolve_to_cwd(file_arg, &cwd_text)
29            .unwrap_or_else(|_| cwd.join(file_arg).display().to_string());
30        let path = Path::new(&absolute);
31        let Ok(metadata) = std::fs::metadata(path) else {
32            return Err(format!("Error: File not found: {absolute}"));
33        };
34        if metadata.len() == 0 {
35            continue;
36        }
37        let bytes = std::fs::read(path)
38            .map_err(|e| format!("Error: Could not read file {absolute}: {e}"))?;
39        let sniff = &bytes[..bytes.len().min(oino_tools::image::IMAGE_TYPE_SNIFF_BYTES)];
40        if let Some(mime_type) = oino_tools::image::detect_supported_image_mime_type(sniff) {
41            content.images.push(ImageContent {
42                data: base64::engine::general_purpose::STANDARD.encode(&bytes),
43                mime_type: mime_type.to_owned(),
44            });
45            let _ = writeln!(content.text, "<file name=\"{absolute}\"></file>");
46        } else {
47            let text = String::from_utf8(bytes)
48                .map_err(|e| format!("Error: Could not read file {absolute}: {e}"))?;
49            let text = text.strip_prefix('\u{feff}').unwrap_or(&text);
50            let _ = writeln!(content.text, "<file name=\"{absolute}\">\n{text}\n</file>");
51        }
52    }
53    Ok(content)
54}
55
56/// The first prompt of a non-interactive run.
57#[derive(Clone, Debug, Default, PartialEq)]
58pub struct InitialMessage {
59    /// Text (stdin + files + first message), `None` when all are absent.
60    pub text: Option<String>,
61    /// Image attachments.
62    pub images: Vec<ImageContent>,
63}
64
65/// Consumes the first positional message.
66#[must_use]
67pub fn build_initial_message(
68    messages: &mut Vec<String>,
69    files: FileContent,
70    stdin: Option<String>,
71) -> InitialMessage {
72    let mut parts = Vec::new();
73    if let Some(stdin) = stdin {
74        parts.push(stdin);
75    }
76    if !files.text.is_empty() {
77        parts.push(files.text);
78    }
79    if !messages.is_empty() {
80        parts.push(messages.remove(0));
81    }
82    InitialMessage {
83        text: (!parts.is_empty()).then(|| parts.concat()),
84        images: files.images,
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91
92    #[test]
93    fn files_become_tagged_text_and_images() {
94        let dir = tempfile::tempdir().unwrap();
95        std::fs::write(dir.path().join("a.md"), "\u{feff}hello").unwrap();
96        std::fs::write(dir.path().join("empty.txt"), "").unwrap();
97        std::fs::write(
98            dir.path().join("p.gif"),
99            b"GIF89a\x01\x00\x01\x00\x00\x00\x00;",
100        )
101        .unwrap();
102        let files = process_file_arguments(
103            &["a.md".into(), "empty.txt".into(), "p.gif".into()],
104            dir.path(),
105        )
106        .unwrap();
107        let a = dir.path().join("a.md").display().to_string();
108        let p = dir.path().join("p.gif").display().to_string();
109        assert_eq!(
110            files.text,
111            format!("<file name=\"{a}\">\nhello\n</file>\n<file name=\"{p}\"></file>\n")
112        );
113        assert_eq!(files.images[0].mime_type, "image/gif");
114        let missing = process_file_arguments(&["nope".into()], dir.path()).unwrap_err();
115        assert!(missing.starts_with("Error: File not found: "));
116    }
117
118    #[test]
119    fn initial_message_joins_stdin_files_and_first_message() {
120        let mut messages = vec!["first".to_owned(), "second".to_owned()];
121        let initial = build_initial_message(
122            &mut messages,
123            FileContent {
124                text: "<f/>\n".into(),
125                images: Vec::new(),
126            },
127            Some("piped".into()),
128        );
129        assert_eq!(initial.text.as_deref(), Some("piped<f/>\nfirst"));
130        assert_eq!(messages, vec!["second"]);
131        let mut none = Vec::new();
132        assert_eq!(
133            build_initial_message(&mut none, FileContent::default(), None).text,
134            None
135        );
136    }
137}