ironflow_artifacts/
lib.rs1pub mod blob_store;
33pub mod error;
34pub mod name;
35
36#[cfg(feature = "artifact-local")]
37pub mod local;
38
39use std::path::Path;
40
41use bytes::Bytes;
42use futures_util::stream::{once, unfold};
43use tokio::fs::File;
44use tokio::io::AsyncReadExt;
45
46use crate::blob_store::ByteStream;
47use crate::error::ArtifactError;
48
49const READ_CHUNK_BYTES: usize = 64 * 1024;
51
52pub fn stream_from_bytes(bytes: impl Into<Bytes>) -> ByteStream {
67 let bytes = bytes.into();
68 Box::pin(once(async move { Ok(bytes) }))
69}
70
71pub fn stream_from_file(file: File) -> ByteStream {
87 Box::pin(unfold(Some(file), |state| async move {
88 let mut file = state?;
89 let mut buf = vec![0u8; READ_CHUNK_BYTES];
90 match file.read(&mut buf).await {
91 Ok(0) => None,
92 Ok(read) => {
93 buf.truncate(read);
94 Some((Ok(Bytes::from(buf)), Some(file)))
95 }
96 Err(err) => Some((Err(ArtifactError::from(err)), None)),
97 }
98 }))
99}
100
101pub async fn stream_from_path(path: impl AsRef<Path>) -> Result<ByteStream, ArtifactError> {
120 let path = path.as_ref();
121 let file = File::open(path).await.map_err(|err| match err.kind() {
122 std::io::ErrorKind::NotFound => ArtifactError::NotFound(path.display().to_string()),
123 _ => ArtifactError::from(err),
124 })?;
125
126 Ok(stream_from_file(file))
127}
128
129pub mod prelude {
131 pub use crate::blob_store::{BlobDigest, BlobFuture, BlobStore, ByteStream};
132 pub use crate::error::ArtifactError;
133 pub use crate::name::{
134 MAX_ARTIFACT_NAME_LEN, guess_content_type, storage_key, validate_artifact_name,
135 };
136 pub use crate::{stream_from_bytes, stream_from_file, stream_from_path};
137
138 #[cfg(feature = "artifact-local")]
139 pub use crate::local::{DEFAULT_MAX_ARTIFACT_BYTES, LocalBlobStore};
140}
141
142#[cfg(test)]
143mod tests {
144 use futures_util::TryStreamExt;
145
146 use super::*;
147
148 #[tokio::test]
149 async fn stream_from_bytes_yields_the_whole_buffer() {
150 let chunks: Vec<Bytes> = stream_from_bytes(b"hello".to_vec())
151 .try_collect()
152 .await
153 .expect("collect");
154
155 assert_eq!(chunks.concat(), b"hello");
156 }
157
158 #[tokio::test]
159 async fn stream_from_bytes_supports_an_empty_buffer() {
160 let chunks: Vec<Bytes> = stream_from_bytes(Vec::new())
161 .try_collect()
162 .await
163 .expect("collect");
164
165 assert!(chunks.concat().is_empty());
166 }
167
168 #[tokio::test]
169 async fn stream_from_path_reads_a_file_larger_than_one_chunk() {
170 let dir = tempfile::TempDir::new().expect("temp dir");
171 let path = dir.path().join("big.bin");
172 let payload: Vec<u8> = (0..200_000u32).map(|i| (i % 256) as u8).collect();
173 std::fs::write(&path, &payload).expect("write");
174
175 let chunks: Vec<Bytes> = stream_from_path(&path)
176 .await
177 .expect("open")
178 .try_collect()
179 .await
180 .expect("collect");
181
182 assert_eq!(chunks.concat(), payload);
183 assert!(chunks.len() > 1, "large file should stream in chunks");
184 }
185
186 #[tokio::test]
187 async fn stream_from_path_reports_a_missing_file_as_not_found() {
188 let result = stream_from_path("/nonexistent/ironflow/artifact").await;
189
190 assert!(matches!(result, Err(ArtifactError::NotFound(_))));
191 }
192}