Skip to main content

cloud_file/
lib.rs

1#![warn(missing_docs)]
2#![warn(clippy::pedantic)]
3#![allow(
4    clippy::missing_panics_doc, // LATER: add panics docs
5    clippy::missing_errors_doc, // LATER: add errors docs
6    clippy::similar_names,
7    clippy::cast_possible_truncation,
8    clippy::cast_possible_wrap,
9    clippy::cast_sign_loss,
10    clippy::cast_lossless
11)]
12#![doc = include_str!("../README.md")]
13//! ## Main Functions
14//!
15//! | Function | Description |
16//! | -------- | ----------- |
17//! | [`CloudFile::new`](struct.CloudFile.html#method.new) | Use a URL string to specify a cloud file for reading. |
18//! | [`CloudFile::new_with_options`](struct.CloudFile.html#method.new_with_options) | Use a URL string and string options to specify a cloud file for reading. |
19//!
20//! ## URLs
21//!
22//! | Cloud Service | Example |
23//! | ------------- | ------- |
24//! | HTTP          | `https://www.gutenberg.org/cache/epub/100/pg100.txt` |
25//! | local file    | `file:///M:/data%20files/small.bed` |
26//! | AWS S3        | `s3://bedreader/v1/toydata.5chrom.bed` |
27//!
28//! Note: For local files, use the [`abs_path_to_url_string`](fn.abs_path_to_url_string.html) function to properly encode into a URL.
29//!
30//! ## Options
31//!
32//! | Cloud Service | Details | Example |
33//! | -------- | ------- | ----------- |
34//! | HTTP | [`ClientConfigKey`](https://docs.rs/object_store/latest/object_store/enum.ClientConfigKey.html#variant.Timeout) | `[("timeout", "30s")]` |
35//! | local file | *none* | |
36//! | AWS S3 | [`AmazonS3ConfigKey`](https://docs.rs/object_store/latest/object_store/aws/enum.AmazonS3ConfigKey.html) | `[("aws_region", "us-west-2"), ("aws_access_key_id",` ...`), ("aws_secret_access_key",` ...`)]` |
37//! | Azure | [`AzureConfigKey`](https://docs.rs/object_store/latest/object_store/azure/enum.AzureConfigKey.html) |  |
38//! | Google | [`GoogleConfigKey`](https://docs.rs/object_store/latest/object_store/gcp/enum.GoogleConfigKey.html) |  |
39//!
40//!
41//! ## High-Level [`CloudFile`](struct.CloudFile.html) Methods
42//!
43//! | Method                        | Retrieves                                                                                                  |
44//! |-------------------------------|-------------------------------------------------------------------|
45//! | [`stream_chunks`](struct.CloudFile.html#method.stream_chunks)       | File contents as a stream of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) |
46//! | [`stream_line_chunks`](struct.CloudFile.html#method.stream_line_chunks) | File contents as a stream of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html), each containing one or more whole lines |
47//! | [`read_all`](struct.CloudFile.html#method.read_all)                | Whole file contents as an in-memory [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) |
48//! | [`read_range`](struct.CloudFile.html#method.read_range)            | [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range |
49//! | [`read_ranges`](struct.CloudFile.html#method.read_ranges)          | `Vec` of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from specified ranges |
50//! | [`read_range_and_file_size`](struct.CloudFile.html#method.read_range_and_file_size) | [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range & the file's size |
51//! | [`read_file_size`](struct.CloudFile.html#method.read_file_size)    | Size of the file                                     |
52//! | [`count_lines`](struct.CloudFile.html#method.count_lines)          | Number of lines in the file                          |
53//!
54//! Additional methods:
55//!
56//! | Method                        | Description                                                                                                  |
57//! |-------------------------------|-------------------------------------------------------------------|
58//! | [`clone`](struct.CloudFile.html#method.clone)                      | Clone the [`CloudFile`](struct.CloudFile.html) instance. Efficient by design. |
59//! | [`set_extension`](struct.CloudFile.html#method.set_extension)      | Change the [`CloudFile`](struct.CloudFile.html)'s file extension (in place).  |
60//!
61//! ## Low-Level [`CloudFile`](struct.CloudFile.html) Methods
62//!
63//! | Method | Description |
64//! | -------- | ----------- |
65//! | [`get`](struct.CloudFile.html#method.get) | Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get) crate's `get` method. |
66//! | [`get_opts`](struct.CloudFile.html#method.get_opts) | Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get_opts) crate's `get_opts` method. |
67//!
68//! ## Lowest-Level [`CloudFile`](struct.CloudFile.html) Methods
69//!
70//! You can call any method from the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html) crate. For example, here we
71//! use [`head`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#tymethod.head) to get the metadata for a file and the last_modified time.
72//!
73//! ```
74//! use cloud_file::CloudFile;
75//! use object_store::ObjectStoreExt;
76//!
77//! # Runtime::new().unwrap().block_on(async {
78//! let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
79//! let cloud_file = CloudFile::new(url)?;
80//! let meta = cloud_file.cloud_service.head(&cloud_file.store_path).await?;
81//! let last_modified = meta.last_modified;
82//! println!("last_modified: {}", last_modified);
83//! assert_eq!(meta.size, 303);
84//! # Ok::<(), CloudFileError>(())}).unwrap();
85//! # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
86//! ```
87
88#[cfg(not(target_pointer_width = "64"))]
89compile_error!("This code requires a 64-bit target architecture.");
90
91use bytes::Bytes;
92use core::fmt;
93use futures_util::stream::BoxStream;
94use futures_util::TryStreamExt;
95use object_store::delimited::newline_delimited_stream;
96use object_store::http::HttpBuilder;
97#[doc(no_inline)]
98pub use object_store::path::Path as StorePath;
99use object_store::{GetOptions, GetRange, GetResult, ObjectStore, ObjectStoreExt};
100use std::ops::{Deref, Range};
101use std::path::Path;
102use std::sync::Arc;
103use thiserror::Error;
104use url::Url;
105
106#[derive(Debug)]
107/// The main struct representing the location of a file in the cloud.
108///
109/// It is constructed with [`CloudFile::new`](struct.CloudFile.html#method.new). It is, by design, cheap to clone.
110///
111/// Internally, it stores two pieces of information: the file's cloud service and the path to the file on that service.
112///
113/// # Examples
114///
115/// ```
116/// use cloud_file::CloudFile;
117///
118/// # Runtime::new().unwrap().block_on(async {
119/// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
120/// let cloud_file = CloudFile::new(url)?;
121/// assert_eq!(cloud_file.read_file_size().await?, 303);
122/// # Ok::<(), CloudFileError>(())}).unwrap();
123/// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
124/// ```
125pub struct CloudFile {
126    /// A cloud service, for example, Http, AWS S3, Azure, the local file system, etc.
127    /// Under the covers, it is an `Arc`-wrapped [`DynObjectStore`](struct.DynObjectStore.html).
128    /// The `DynObjectStore`, in turn, holds an [`ObjectStore`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html) from the
129    /// powerful [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate.
130    pub cloud_service: Arc<DynObjectStore>,
131    /// A path to a file on the cloud service.
132    /// Under the covers, `StorePath` is an alias for a [`Path`](https://docs.rs/object_store/latest/object_store/path/struct.Path.html)
133    /// in the [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate.
134    pub store_path: StorePath,
135}
136
137impl Clone for CloudFile {
138    fn clone(&self) -> Self {
139        CloudFile {
140            cloud_service: self.cloud_service.clone(),
141            store_path: self.store_path.clone(),
142        }
143    }
144}
145
146/// An empty set of cloud options
147///
148/// # Example
149/// ```
150/// use cloud_file::{EMPTY_OPTIONS, CloudFile};
151///
152/// # Runtime::new().unwrap().block_on(async {
153/// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
154/// let cloud_file = CloudFile::new_with_options(url, EMPTY_OPTIONS)?;
155/// assert_eq!(cloud_file.read_file_size().await?, 303);
156/// # Ok::<(), CloudFileError>(())}).unwrap();
157/// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
158/// ```
159pub const EMPTY_OPTIONS: [(&str, String); 0] = [];
160
161impl CloudFile {
162    /// Create a new [`CloudFile`] from a URL string.
163    ///
164    /// # Example
165    /// ```
166    /// use cloud_file::CloudFile;
167    ///
168    /// # Runtime::new().unwrap().block_on(async {
169    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
170    /// let cloud_file = CloudFile::new(url)?;
171    /// assert_eq!(cloud_file.read_file_size().await?, 303);
172    /// # Ok::<(), CloudFileError>(())}).unwrap();
173    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
174    /// ```
175    pub fn new(location: impl AsRef<str>) -> Result<CloudFile, CloudFileError> {
176        let location = location.as_ref();
177        let url = Url::parse(location)
178            .map_err(|e| CloudFileError::CannotParseUrl(location.to_string(), e.to_string()))?;
179
180        let (object_store, store_path): (DynObjectStore, StorePath) =
181            parse_url_opts_work_around(&url, EMPTY_OPTIONS)?;
182        let cloud_file = CloudFile {
183            cloud_service: Arc::new(object_store),
184            store_path,
185        };
186        Ok(cloud_file)
187    }
188
189    /// Create a new [`CloudFile`] from an [`ObjectStore`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html)
190    /// and a [`object_store::path::Path`](https://docs.rs/object_store/latest/object_store/path/struct.Path.html).
191    ///
192    /// # Example
193    ///
194    /// ```
195    /// use cloud_file::CloudFile;
196    /// use object_store::{http::HttpBuilder, path::Path as StorePath, ClientOptions};
197    /// use std::time::Duration;
198    ///
199    /// # Runtime::new().unwrap().block_on(async {
200    /// let client_options = ClientOptions::new().with_timeout(Duration::from_secs(30));
201    /// let http = HttpBuilder::new()
202    ///     .with_url("https://raw.githubusercontent.com")
203    ///     .with_client_options(client_options)
204    ///     .build()?;
205    /// let store_path = StorePath::parse("fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed")?;
206    ///
207    /// let cloud_file = CloudFile::from_structs(http, store_path);
208    /// assert_eq!(cloud_file.read_file_size().await?, 303);
209    /// # Ok::<(), CloudFileError>(())}).unwrap();
210    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
211    /// ```
212
213    #[inline]
214    pub fn from_structs(store: impl ObjectStore, store_path: StorePath) -> Self {
215        CloudFile {
216            cloud_service: Arc::new(DynObjectStore(Box::new(store))),
217            store_path,
218        }
219    }
220
221    /// Create a new [`CloudFile`] from a URL string and options.
222    ///
223    /// # Example
224    /// ```
225    /// use cloud_file::CloudFile;
226    ///
227    /// # Runtime::new().unwrap().block_on(async {
228    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
229    /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
230    /// assert_eq!(cloud_file.read_file_size().await?, 303);
231    /// # Ok::<(), CloudFileError>(())}).unwrap();
232    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
233    /// ```
234    pub fn new_with_options<I, K, V>(
235        location: impl AsRef<str>,
236        options: I,
237    ) -> Result<CloudFile, CloudFileError>
238    where
239        I: IntoIterator<Item = (K, V)>,
240        K: AsRef<str>,
241        V: Into<String>,
242    {
243        let location = location.as_ref();
244        let url = Url::parse(location)
245            .map_err(|e| CloudFileError::CannotParseUrl(location.to_string(), e.to_string()))?;
246
247        let (object_store, store_path): (DynObjectStore, StorePath) =
248            parse_url_opts_work_around(&url, options)?;
249        let cloud_file = CloudFile {
250            cloud_service: Arc::new(object_store),
251            store_path,
252        };
253        Ok(cloud_file)
254    }
255
256    /// Count the lines in a file stored in the cloud.
257    ///
258    /// # Example
259    /// ```
260    /// use cloud_file::CloudFile;
261    ///
262    /// # Runtime::new().unwrap().block_on(async {
263    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.fam";
264    /// let cloud_file = CloudFile::new(url)?;
265    /// assert_eq!(cloud_file.count_lines().await?, 10);
266    /// # Ok::<(), CloudFileError>(())}).unwrap();
267    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
268    /// ```
269    pub async fn count_lines(&self) -> Result<usize, CloudFileError> {
270        let stream = self.stream_chunks().await?;
271
272        let newline_count = stream
273            .try_fold(0, |acc, bytes| async move {
274                let count = bytecount::count(&bytes, b'\n');
275                Ok(acc + count) // Accumulate the count
276            })
277            .await
278            .map_err(CloudFileError::ObjectStoreError)?;
279        Ok(newline_count)
280    }
281
282    /// Return the size of a file stored in the cloud.
283    ///
284    /// # Example
285    /// ```
286    /// use cloud_file::CloudFile;
287    ///
288    /// # Runtime::new().unwrap().block_on(async {
289    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
290    /// let cloud_file = CloudFile::new(url)?;
291    /// assert_eq!(cloud_file.read_file_size().await?, 303);
292    /// # Ok::<(), CloudFileError>(())}).unwrap();
293    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
294    /// ```
295    pub async fn read_file_size(&self) -> Result<u64, CloudFileError> {
296        let meta = self.cloud_service.head(&self.store_path).await?;
297        Ok(meta.size)
298    }
299
300    /// Return the [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range.
301    ///
302    /// # Example
303    /// ```
304    /// use cloud_file::CloudFile;
305    ///
306    /// # Runtime::new().unwrap().block_on(async {
307    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bim";
308    /// let cloud_file = CloudFile::new(url)?;
309    /// let bytes = cloud_file.read_range((0..10)).await?;
310    /// assert_eq!(bytes.as_ref(), b"1\t1:1:A:C\t");
311    /// # Ok::<(), CloudFileError>(())}).unwrap();
312    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
313    /// ```
314    pub async fn read_range(&self, range: Range<u64>) -> Result<Bytes, CloudFileError> {
315        Ok(self
316            .cloud_service
317            .get_range(&self.store_path, range)
318            .await?)
319    }
320
321    /// Return the `Vec` of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from specified ranges.
322    ///
323    /// # Example
324    /// ```
325    /// use cloud_file::CloudFile;
326    ///
327    /// # Runtime::new().unwrap().block_on(async {
328    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bim";
329    /// let cloud_file = CloudFile::new(url)?;
330    /// let bytes_vec = cloud_file.read_ranges(&[0..10, 1000..1010]).await?;
331    /// assert_eq!(bytes_vec.len(), 2);
332    /// assert_eq!(bytes_vec[0].as_ref(), b"1\t1:1:A:C\t");
333    /// assert_eq!(bytes_vec[1].as_ref(), b":A:C\t0.0\t4");
334    /// # Ok::<(), CloudFileError>(())}).unwrap();
335    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
336    /// ```
337    pub async fn read_ranges(&self, ranges: &[Range<u64>]) -> Result<Vec<Bytes>, CloudFileError> {
338        Ok(self
339            .cloud_service
340            .get_ranges(&self.store_path, ranges)
341            .await?)
342    }
343
344    /// Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get_opts) crate's `get_opts` method.
345    ///
346    /// You can, for example, in one call retrieve a range of bytes from the file and the file's metadata. The
347    /// result is a [`GetResult`](https://docs.rs/object_store/latest/object_store/struct.GetResult.html).
348    ///
349    /// # Example
350    ///
351    /// In one call, read the first three bytes of a genomic data file and get
352    /// the size of the file. Check that the file starts with the expected file signature.
353    /// ```
354    /// use cloud_file::CloudFile;
355    /// use object_store::{GetRange, GetOptions};
356    ///
357    /// # Runtime::new().unwrap().block_on(async {
358    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
359    /// let cloud_file = CloudFile::new(url)?;
360    /// let get_options = GetOptions {
361    ///     range: Some(GetRange::Bounded(0..3)),
362    ///     ..Default::default()
363    /// };
364    /// let get_result = cloud_file.get_opts(get_options).await?;
365    /// let size: u64 = get_result.meta.size;
366    /// let bytes = get_result
367    ///     .bytes()
368    ///     .await?;
369    /// assert_eq!(bytes.len(), 3);
370    /// assert_eq!(bytes[0], 0x6c);
371    /// assert_eq!(bytes[1], 0x1b);
372    /// assert_eq!(bytes[2], 0x01);
373    /// assert_eq!(size, 303);
374    /// # Ok::<(), CloudFileError>(())}).unwrap();
375    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
376    /// ```
377    pub async fn get_opts(&self, get_options: GetOptions) -> Result<GetResult, CloudFileError> {
378        Ok(self
379            .cloud_service
380            .get_opts(&self.store_path, get_options)
381            .await?)
382    }
383
384    /// Retrieve the [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html) from a specified range & the file's size.
385    ///
386    /// # Example
387    ///
388    /// In one call, read the first three bytes of a genomic data file and get
389    /// the size of the file. Check that the file starts with the expected file signature.
390    /// ```
391    /// use cloud_file::CloudFile;
392    ///
393    /// # Runtime::new().unwrap().block_on(async {
394    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
395    /// let cloud_file = CloudFile::new(url)?;
396    /// let (bytes, size) = cloud_file.read_range_and_file_size(0..3).await?;
397    /// assert_eq!(bytes.len(), 3);
398    /// assert_eq!(bytes[0], 0x6c);
399    /// assert_eq!(bytes[1], 0x1b);
400    /// assert_eq!(bytes[2], 0x01);
401    /// assert_eq!(size, 303);
402    /// # Ok::<(), CloudFileError>(())}).unwrap();
403    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
404    /// ```
405    pub async fn read_range_and_file_size(
406        &self,
407        range: Range<u64>,
408    ) -> Result<(Bytes, u64), CloudFileError> {
409        let get_options = GetOptions {
410            range: Some(GetRange::Bounded(range)),
411            ..Default::default()
412        };
413        let get_result = self
414            .cloud_service
415            .get_opts(&self.store_path, get_options)
416            .await?;
417        let size = get_result.meta.size;
418        let bytes = get_result
419            .bytes()
420            .await
421            .map_err(CloudFileError::ObjectStoreError)?;
422        Ok((bytes, size))
423    }
424
425    /// Call the [`object_store`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html#method.get) crate's `get` method.
426    ///
427    /// The result is a [`GetResult`](https://docs.rs/object_store/latest/object_store/struct.GetResult.html) which can,
428    /// for example, be converted into a stream of bytes.
429    ///
430    /// # Example
431    ///
432    /// Do a 'get', turn result into a stream, then scan all the bytes of the
433    /// file for the newline character.
434    ///
435    /// ```rust
436    /// use cloud_file::CloudFile;
437    /// use futures_util::StreamExt;  // Enables `.next()` on streams.
438    ///
439    /// # Runtime::new().unwrap().block_on(async {
440    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
441    /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
442    /// let mut stream = cloud_file.get().await?.into_stream();
443    /// let mut newline_count: usize = 0;
444    /// while let Some(bytes) = stream.next().await {
445    ///     let bytes = bytes?;
446    ///     newline_count += bytecount::count(&bytes, b'\n');
447    ///     }
448    /// assert_eq!(newline_count, 500);
449    /// # Ok::<(), CloudFileError>(())}).unwrap();
450    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
451    /// ```
452    pub async fn get(&self) -> Result<GetResult, CloudFileError> {
453        Ok(self.cloud_service.get(&self.store_path).await?)
454    }
455
456    /// Read the whole file into an in-memory [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html).
457    ///
458    /// # Example
459    ///
460    /// Read the whole file, then scan all the bytes of the
461    /// for the newline character.
462    ///
463    /// ```rust
464    /// use cloud_file::CloudFile;
465    ///
466    /// # Runtime::new().unwrap().block_on(async {
467    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
468    /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
469    /// let all = cloud_file.read_all().await?;
470    /// let newline_count = bytecount::count(&all, b'\n');
471    /// assert_eq!(newline_count, 500);
472    /// # Ok::<(), CloudFileError>(())}).unwrap();
473    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
474    /// ```
475    pub async fn read_all(&self) -> Result<Bytes, CloudFileError> {
476        let all = self
477            .cloud_service
478            .get(&self.store_path)
479            .await?
480            .bytes()
481            .await?;
482        Ok(all)
483    }
484
485    /// Retrieve the file's contents as a stream of
486    /// [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html).
487    ///
488    /// # Example
489    ///
490    /// Open the file as a stream of bytes, then scan all the bytes
491    /// for the newline character.
492    ///
493    /// ```rust
494    /// use cloud_file::CloudFile;
495    /// use futures::StreamExt; // Enables `.next()` on streams.
496    ///
497    /// # Runtime::new().unwrap().block_on(async {
498    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
499    /// let cloud_file = CloudFile::new_with_options(url, [("timeout", "30s")])?;
500    /// let mut chunks = cloud_file.stream_chunks().await?;
501    /// let mut newline_count: usize = 0;
502    /// while let Some(chunk) = chunks.next().await {
503    ///     let chunk = chunk?;
504    ///     newline_count += bytecount::count(&chunk, b'\n');
505    ///     }
506    /// assert_eq!(newline_count, 500);
507    /// # Ok::<(), CloudFileError>(())}).unwrap();
508    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
509    /// ```
510    pub async fn stream_chunks(
511        &self,
512    ) -> Result<BoxStream<'static, object_store::Result<Bytes>>, CloudFileError> {
513        let stream = self
514            .cloud_service
515            .get(&self.store_path)
516            .await?
517            .into_stream();
518        Ok(stream)
519    }
520
521    ///  Retrieve the file's contents as a stream of [`Bytes`](https://docs.rs/bytes/latest/bytes/struct.Bytes.html),
522    ///  each containing one or more whole lines.
523    ///
524    /// # Example
525    ///
526    /// Return the 12th line of a file.
527    ///
528    /// ```rust
529    /// use cloud_file::CloudFile;
530    /// use futures::StreamExt; // Enables `.next()` on streams.
531    /// use std::str::from_utf8;
532    ///
533    /// # Runtime::new().unwrap().block_on(async {
534    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
535    /// let goal_index = 12;
536    ///
537    /// let cloud_file = CloudFile::new(url)?;
538    /// let mut line_chunks = cloud_file.stream_line_chunks().await?;
539    /// let mut index_iter = 0..;
540    /// let mut goal_line = None;
541    /// 'outer_loop: while let Some(line_chunk) = line_chunks.next().await {
542    ///     let line_chunk = line_chunk?;
543    ///     let lines = from_utf8(&line_chunk)?.lines();
544    ///     for line in lines {
545    ///         let index = index_iter.next().unwrap(); // Safe because the iterator is infinite
546    ///         if index == goal_index {
547    ///             goal_line = Some(line.to_string());
548    ///             break 'outer_loop;
549    ///         }
550    ///     }
551    /// }
552    /// assert_eq!(goal_line, Some("per12 per12 0 0 2 -0.0382707".to_string()));
553    /// # Ok::<(), CloudFileError>(())}).unwrap();
554    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
555    /// ```
556    ///
557    pub async fn stream_line_chunks(
558        &self,
559    ) -> Result<BoxStream<'static, object_store::Result<Bytes>>, CloudFileError> {
560        let chunks = self.stream_chunks().await?;
561        let line_chunks = newline_delimited_stream(chunks);
562        Ok(Box::pin(line_chunks))
563    }
564
565    /// Change the [`CloudFile`]'s extension (in place).
566    ///
567    /// It removes the current extension, if any.
568    /// It appends the given extension, if any.
569    ///
570    /// The method is in-place rather than functional to make it consistent with
571    /// [`std::path::PathBuf::set_extension`](https://doc.rust-lang.org/stable/std/path/struct.PathBuf.html#method.set_extension).
572    ///
573    /// # Example
574    /// ```
575    /// use cloud_file::CloudFile;
576    ///
577    /// # Runtime::new().unwrap().block_on(async {
578    /// let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
579    /// let mut cloud_file = CloudFile::new(url)?;
580    /// assert_eq!(cloud_file.read_file_size().await?, 303);
581    /// cloud_file.set_extension("fam")?;
582    /// assert_eq!(cloud_file.read_file_size().await?, 130);
583    /// # Ok::<(), CloudFileError>(())}).unwrap();
584    /// # use {tokio::runtime::Runtime, cloud_file::CloudFileError};
585    /// ```
586    pub fn set_extension(&mut self, extension: &str) -> Result<(), CloudFileError> {
587        let mut path_str = self.store_path.to_string();
588
589        // Find the last dot in the object path
590        if let Some(dot_index) = path_str.rfind('.') {
591            // Remove the current extension
592            path_str.truncate(dot_index);
593        }
594
595        if !extension.is_empty() {
596            // Append the new extension
597            path_str.push('.');
598            path_str.push_str(extension);
599        }
600
601        // Parse the string back to StorePath
602        self.store_path = StorePath::parse(&path_str)?;
603        Ok(())
604    }
605}
606
607#[allow(clippy::match_bool)]
608fn parse_work_around(url: &Url) -> Result<(bool, StorePath), object_store::Error> {
609    let strip_bucket = || Some(url.path().strip_prefix('/')?.split_once('/')?.1);
610
611    let (scheme, path) = match (url.scheme(), url.host_str()) {
612        ("http", Some(_)) => (true, url.path()),
613        ("https", Some(host)) => {
614            if host.ends_with("dfs.core.windows.net")
615                || host.ends_with("blob.core.windows.net")
616                || host.ends_with("dfs.fabric.microsoft.com")
617                || host.ends_with("blob.fabric.microsoft.com")
618            {
619                (false, url.path())
620            } else if host.ends_with("amazonaws.com") {
621                match host.starts_with("s3") {
622                    true => (false, strip_bucket().unwrap_or_default()),
623                    false => (false, url.path()),
624                }
625            } else if host.ends_with("r2.cloudflarestorage.com") {
626                (false, strip_bucket().unwrap_or_default())
627            } else {
628                (true, url.path())
629            }
630        }
631        _ => (false, url.path()),
632    };
633
634    Ok((scheme, StorePath::from_url_path(path)?))
635}
636
637// LATER when https://github.com/apache/arrow-rs/issues/5310 gets fixed, can remove work around
638fn parse_url_opts_work_around<I, K, V>(
639    url: &Url,
640    options: I,
641) -> Result<(DynObjectStore, StorePath), object_store::Error>
642where
643    I: IntoIterator<Item = (K, V)>,
644    K: AsRef<str>,
645    V: Into<String>,
646{
647    let (is_http, path) = parse_work_around(url)?;
648    if is_http {
649        let url = &url[..url::Position::BeforePath];
650        let path = StorePath::parse(path)?;
651        let builder = options.into_iter().fold(
652            <HttpBuilder>::new().with_url(url),
653            |builder, (key, value)| match key.as_ref().parse() {
654                Ok(k) => builder.with_config(k, value),
655                Err(_) => builder,
656            },
657        );
658        let store = DynObjectStore::new(builder.build()?);
659        Ok((store, path))
660    } else {
661        let (store, path) = object_store::parse_url_opts(url, options)?;
662        Ok((DynObjectStore(store), path))
663    }
664}
665
666impl fmt::Display for CloudFile {
667    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
668        write!(f, "CloudFile: {:?}", self.store_path)
669    }
670}
671
672/// Wraps `Box<dyn ObjectStore>` for easier usage. An [`ObjectStore`](https://docs.rs/object_store/latest/object_store/trait.ObjectStore.html), from the
673/// powerful [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate, represents a cloud service.
674#[derive(Debug)]
675pub struct DynObjectStore(pub Box<dyn ObjectStore>);
676
677// Implement Deref to allow access to the inner `ObjectStore` methods
678impl Deref for DynObjectStore {
679    type Target = dyn ObjectStore;
680
681    fn deref(&self) -> &Self::Target {
682        &*self.0
683    }
684}
685
686impl DynObjectStore {
687    #[inline]
688    fn new(store: impl ObjectStore) -> Self {
689        DynObjectStore(Box::new(store) as Box<dyn ObjectStore>)
690    }
691}
692
693/// The error type for [`CloudFile`](struct.CloudFile.html) methods.
694#[derive(Error, Debug)]
695pub enum CloudFileError {
696    /// An error from [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate
697    #[error("Object store error: {0}")]
698    ObjectStoreError(#[from] object_store::Error),
699
700    /// An path-related error from [`object_store`](https://github.com/apache/arrow-rs/tree/master/object_store) crate
701    #[error("Object store path error: {0}")]
702    ObjectStorePathError(#[from] object_store::path::Error),
703
704    /// An error related to converting bytes into UTF-8
705    #[error("UTF-8 error: {0}")]
706    Utf8Error(#[from] std::str::Utf8Error),
707
708    /// An error related to parsing a URL string
709    #[error("Cannot parse URL: {0} {1}")]
710    CannotParseUrl(String, String),
711
712    /// An error related to creating a URL from a file path
713    #[error("Cannot create URL from this absolute file path: '{0}'")]
714    CannotCreateUrlFromFilePath(String),
715}
716
717#[tokio::test]
718async fn cloud_file_2() -> Result<(), CloudFileError> {
719    let cloud_file = CloudFile::new(
720        "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed",
721
722    )?;
723    assert_eq!(cloud_file.read_file_size().await?, 303);
724    Ok(())
725}
726
727#[tokio::test]
728async fn line_n() -> Result<(), CloudFileError> {
729    use futures_util::StreamExt;
730    use std::str::from_utf8; // Enables `.next()` on streams.
731
732    let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
733    let goal_index = 12;
734
735    let cloud_file = CloudFile::new(url)?;
736    let mut line_chunks = cloud_file.stream_line_chunks().await?;
737    let mut index_iter = 0..;
738    let mut goal_line = None;
739    'outer_loop: while let Some(line_chunk) = line_chunks.next().await {
740        let line_chunk = line_chunk?;
741        let lines = from_utf8(&line_chunk)?.lines();
742        for line in lines {
743            let index = index_iter.next().unwrap(); // safe because we know the iterator is infinite
744            if index == goal_index {
745                goal_line = Some(line.to_string());
746                break 'outer_loop;
747            }
748        }
749    }
750
751    assert_eq!(goal_line, Some("per12 per12 0 0 2 -0.0382707".to_string()));
752    Ok(())
753}
754
755#[tokio::test]
756async fn cloud_file_extension() -> Result<(), CloudFileError> {
757    let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
758    let mut cloud_file = CloudFile::new(url)?;
759    assert_eq!(cloud_file.read_file_size().await?, 303);
760    cloud_file.set_extension("fam")?;
761    assert_eq!(cloud_file.read_file_size().await?, 130);
762    Ok(())
763}
764
765// The AWS tests are skipped if credentials are not available.
766#[tokio::test]
767async fn s3_play_cloud() -> Result<(), CloudFileError> {
768    use rusoto_credential::{CredentialsError, ProfileProvider, ProvideAwsCredentials};
769    let credentials = if let Ok(provider) = ProfileProvider::new() {
770        provider.credentials().await
771    } else {
772        Err(CredentialsError::new("No credentials found"))
773    };
774
775    let Ok(credentials) = credentials else {
776        eprintln!("Skipping test because no AWS credentials found");
777        return Ok(());
778    };
779
780    let url = "s3://bedreader/v1/toydata.5chrom.bed";
781    let options = [
782        ("aws_region", "us-west-2"),
783        ("aws_access_key_id", credentials.aws_access_key_id()),
784        ("aws_secret_access_key", credentials.aws_secret_access_key()),
785    ];
786
787    let cloud_file = CloudFile::new_with_options(url, options)?;
788    assert_eq!(cloud_file.read_file_size().await?, 1_250_003);
789    Ok(())
790}
791
792/// Given a local file's absolute path, return a URL string to that file.
793///
794/// # Example
795/// ```
796/// use cloud_file::abs_path_to_url_string;
797///
798/// // Define a sample file_name and expected_url based on the target OS
799/// #[cfg(target_os = "windows")]
800/// let (file_name, expected_url) = (r"M:\data files\small.bed", "file:///M:/data%20files/small.bed");
801///
802/// #[cfg(not(target_os = "windows"))]
803/// let (file_name, expected_url) = (r"/data files/small.bed", "file:///data%20files/small.bed");
804///
805/// let url = abs_path_to_url_string(file_name)?;
806/// assert_eq!(url, expected_url);
807/// # use cloud_file::CloudFileError;
808/// # Ok::<(), CloudFileError>(())
809/// ```
810pub fn abs_path_to_url_string(path: impl AsRef<Path>) -> Result<String, CloudFileError> {
811    let path = path.as_ref();
812    let url = Url::from_file_path(path)
813        .map_err(|_e| {
814            CloudFileError::CannotCreateUrlFromFilePath(path.to_string_lossy().to_string())
815        })?
816        .to_string();
817    Ok(url)
818}
819
820#[test]
821fn readme_1() {
822    use futures_util::StreamExt; // Enables `.next()` on streams.
823    use tokio::runtime::Runtime;
824
825    Runtime::new()
826        .unwrap()
827        .block_on(async {
828            let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/toydata.5chrom.fam";
829            let cloud_file = CloudFile::new(url)?;
830            let mut chunks = cloud_file.stream_chunks().await?;
831            let mut newline_count: usize = 0;
832            while let Some(chunk) = chunks.next().await {
833                let chunk = chunk?;
834                newline_count += bytecount::count(&chunk, b'\n');
835            }
836            assert_eq!(newline_count, 500);
837            Ok::<(), CloudFileError>(())
838        })
839        .unwrap();
840}
841
842#[tokio::test]
843async fn check_file_signature() -> Result<(), CloudFileError> {
844    let url = "https://raw.githubusercontent.com/fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed";
845    let cloud_file = CloudFile::new(url)?;
846    let (bytes, size) = cloud_file.read_range_and_file_size(0..3).await?;
847
848    assert_eq!(bytes.len(), 3);
849    assert_eq!(bytes[0], 0x6c);
850    assert_eq!(bytes[1], 0x1b);
851    assert_eq!(bytes[2], 0x01);
852    assert_eq!(size, 303);
853    Ok(())
854}
855
856#[tokio::test]
857async fn from_structs_example() -> Result<(), CloudFileError> {
858    use object_store::{http::HttpBuilder, path::Path as StorePath, ClientOptions};
859    use std::time::Duration;
860
861    let client_options = ClientOptions::new().with_timeout(Duration::from_secs(30));
862    let http = HttpBuilder::new()
863        .with_url("https://raw.githubusercontent.com")
864        .with_client_options(client_options)
865        .build()?;
866    let store_path =
867        StorePath::parse("fastlmm/bed-sample-files/main/plink_sim_10s_100v_10pmiss.bed")?;
868
869    let cloud_file = CloudFile::from_structs(http, store_path);
870    assert_eq!(cloud_file.read_file_size().await?, 303);
871    Ok(())
872}
873
874#[tokio::test]
875async fn local_file() -> Result<(), CloudFileError> {
876    use std::env;
877
878    let apache_url =
879        abs_path_to_url_string(env::var("CARGO_MANIFEST_DIR").unwrap() + "/LICENSE-APACHE")?;
880    let cloud_file = CloudFile::new(&apache_url)?;
881    assert_eq!(cloud_file.count_lines().await?, 175);
882    Ok(())
883}