Skip to main content

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}