honcho_ai/upload.rs
1//! File source abstraction for uploads.
2
3use std::path::{Path, PathBuf};
4use std::pin::Pin;
5
6use tokio::io::AsyncRead;
7
8/// A source for file data that will be uploaded.
9///
10/// Construct with [`FileSource::bytes`], [`FileSource::path`], or
11/// [`FileSource::stream`], or convert from [`PathBuf`]/[`std::path::Path`] via the
12/// `From` impls.
13///
14/// The API currently accepts `text/plain`, `application/pdf`, and
15/// `application/json`; other MIME types may be rejected by the server.
16///
17/// This enum is `#[non_exhaustive]`: new variants may be added in future
18/// versions without a breaking change, and each data-carrying variant is
19/// likewise `#[non_exhaustive]` so that new fields can be added to it
20/// non-breakingly. Out-of-crate code must construct values through the
21/// provided constructors ([`FileSource::bytes`], [`FileSource::path`],
22/// [`FileSource::stream`], or the `From` impls) rather than variant literals,
23/// and cannot exhaustively `match` on the variants or their fields.
24#[non_exhaustive]
25pub enum FileSource {
26 /// Raw bytes with explicit filename and content type.
27 #[non_exhaustive]
28 Bytes {
29 /// File name to send.
30 filename: String,
31 /// Raw file data.
32 bytes: Vec<u8>,
33 /// MIME content type.
34 content_type: String,
35 },
36 /// A filesystem path. Resolved at upload time.
37 #[non_exhaustive]
38 Path(PathBuf),
39 /// A streaming reader — fully buffered into memory before uploading.
40 #[non_exhaustive]
41 Stream {
42 /// File name to send.
43 filename: String,
44 /// Async reader producing the file data.
45 reader: Pin<Box<dyn AsyncRead + Send>>,
46 /// MIME content type.
47 content_type: String,
48 },
49}
50
51impl std::fmt::Debug for FileSource {
52 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
53 match self {
54 Self::Bytes {
55 filename,
56 bytes,
57 content_type,
58 } => f
59 .debug_struct("Bytes")
60 .field("filename", filename)
61 .field("bytes", &bytes.len())
62 .field("content_type", content_type)
63 .finish(),
64 Self::Path(p) => f.debug_tuple("Path").field(p).finish(),
65 Self::Stream {
66 filename,
67 content_type,
68 ..
69 } => f
70 .debug_struct("Stream")
71 .field("filename", filename)
72 .field("content_type", content_type)
73 .finish_non_exhaustive(),
74 }
75 }
76}
77
78impl FileSource {
79 /// Create a `Bytes` variant from explicit parts.
80 ///
81 /// `data` accepts anything convertible into `Vec<u8>` (e.g. `Vec<u8>`,
82 /// `&[u8]`, `&str`) and is stored as a `Vec<u8>`.
83 ///
84 /// The content type is validated when the upload is sent (during multipart
85 /// form construction), not here, to keep this constructor infallible.
86 ///
87 /// The API currently accepts `text/plain`, `application/pdf`, and
88 /// `application/json`; other MIME types may be rejected by the server.
89 pub fn bytes(
90 filename: impl Into<String>,
91 data: impl Into<Vec<u8>>,
92 content_type: impl Into<String>,
93 ) -> Self {
94 Self::Bytes {
95 filename: filename.into(),
96 bytes: data.into(),
97 content_type: content_type.into(),
98 }
99 }
100
101 /// Create a `Path` variant.
102 pub fn path(path: impl Into<PathBuf>) -> Self {
103 Self::Path(path.into())
104 }
105
106 /// Create a `Stream` variant from an [`AsyncRead`] source.
107 ///
108 /// The reader is fully consumed with [`tokio::io::AsyncReadExt::read_to_end`]
109 /// and buffered into memory before the upload begins. This is **not** true
110 /// streaming — the entire payload resides in memory during the request.
111 ///
112 /// For files on disk, prefer [`FileSource::path`].
113 ///
114 /// The API currently accepts `text/plain`, `application/pdf`, and
115 /// `application/json`; other MIME types may be rejected by the server.
116 ///
117 /// # Examples
118 ///
119 /// ```
120 /// use honcho_ai::FileSource;
121 ///
122 /// let cursor = std::io::Cursor::new(b"hello".to_vec());
123 /// let src = FileSource::stream("out.txt", cursor, "text/plain");
124 /// ```
125 pub fn stream(
126 filename: impl Into<String>,
127 reader: impl AsyncRead + Send + 'static,
128 content_type: impl Into<String>,
129 ) -> Self {
130 Self::Stream {
131 filename: filename.into(),
132 reader: Box::pin(reader),
133 content_type: content_type.into(),
134 }
135 }
136}
137
138impl From<PathBuf> for FileSource {
139 fn from(p: PathBuf) -> Self {
140 Self::Path(p)
141 }
142}
143
144impl From<&Path> for FileSource {
145 fn from(p: &Path) -> Self {
146 Self::Path(p.to_path_buf())
147 }
148}
149
150#[cfg(test)]
151mod tests {
152 use static_assertions::assert_impl_all;
153
154 use super::*;
155
156 // `FileSource` must stay `Send` so uploads can cross `.await` points and be
157 // driven from multi-threaded runtimes. Behavioural coverage of the upload
158 // path lives in `session.rs` tests, which exercise the production
159 // `Session::upload_file(...).send()` flow end-to-end.
160 assert_impl_all!(FileSource: Send);
161}