1use std::fmt::Write as _;
5use std::path::Path;
6
7use base64::Engine as _;
8use oino_ai::ImageContent;
9
10#[derive(Clone, Debug, Default, PartialEq)]
12pub struct FileContent {
13 pub text: String,
15 pub images: Vec<ImageContent>,
17}
18
19pub 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#[derive(Clone, Debug, Default, PartialEq)]
58pub struct InitialMessage {
59 pub text: Option<String>,
61 pub images: Vec<ImageContent>,
63}
64
65#[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}