Skip to main content

oxigeo_cli/util/
cloud.rs

1//! Cloud URI dispatch for reading from S3, GCS, Azure Blob, and local file paths.
2
3use anyhow::{Result, anyhow};
4use oxigeo_core::io::{DataSource, FileDataSource};
5use std::sync::OnceLock;
6
7static TOKIO_RUNTIME: OnceLock<tokio::runtime::Runtime> = OnceLock::new();
8
9/// Lazily initialises the shared Tokio runtime for cloud I/O.
10///
11/// Uses `OnceLock` so the runtime is created at most once across all calls.
12fn get_runtime() -> Result<&'static tokio::runtime::Runtime> {
13    if let Some(rt) = TOKIO_RUNTIME.get() {
14        return Ok(rt);
15    }
16    let rt = tokio::runtime::Runtime::new()
17        .map_err(|e| anyhow!("failed to create tokio runtime for cloud I/O: {}", e))?;
18    // `set` returns Err(rt) if another thread races and wins; in that case we
19    // discard our freshly-built runtime and use theirs.
20    let _ = TOKIO_RUNTIME.set(rt);
21    TOKIO_RUNTIME
22        .get()
23        .ok_or_else(|| anyhow!("tokio runtime unavailable after init"))
24}
25
26/// Returns true if the string looks like a cloud URI (s3://, gs://, az://).
27pub fn is_cloud_uri(uri: &str) -> bool {
28    uri.starts_with("s3://") || uri.starts_with("gs://") || uri.starts_with("az://")
29}
30
31/// Returns an informative error when the user tries to write to a cloud URI.
32pub fn error_for_cloud_write(uri: &str) -> anyhow::Error {
33    anyhow!("cloud write not yet supported: {uri}; please write locally then upload")
34}
35
36/// Opens a data source for the given URI or file path.
37///
38/// Supports:
39/// - Bare file paths: `/path/to/file.tif`
40/// - `file:///path/to/file.tif`
41/// - `s3://bucket/key`
42/// - `gs://bucket/object`
43/// - `az://container/blob`
44pub fn open_datasource(uri: &str) -> Result<Box<dyn DataSource>> {
45    if let Some(path) = uri.strip_prefix("file://") {
46        return Ok(Box::new(
47            FileDataSource::open(path).map_err(|e| anyhow!("{}", e))?,
48        ));
49    }
50
51    if is_cloud_uri(uri) {
52        let rt = get_runtime()?;
53        let ds = rt.block_on(open_cloud_datasource(uri))?;
54        return Ok(ds);
55    }
56
57    // Bare path
58    Ok(Box::new(
59        FileDataSource::open(uri).map_err(|e| anyhow!("{}", e))?,
60    ))
61}
62
63async fn open_cloud_datasource(uri: &str) -> Result<Box<dyn DataSource>> {
64    let (backend, bucket, key) = oxigeo_rs3gw::parse_url(uri).map_err(|e| anyhow!("{}", e))?;
65    let storage = backend
66        .create_storage()
67        .await
68        .map_err(|e| anyhow!("{}", e))?;
69    let ds = oxigeo_rs3gw::Rs3gwDataSource::new(storage, bucket, key)
70        .await
71        .map_err(|e| anyhow!("{}", e))?;
72    Ok(Box::new(ds))
73}
74
75#[cfg(test)]
76mod tests {
77    use super::*;
78    use std::sync::atomic::{AtomicU64, Ordering};
79
80    /// Per-test scratch fixture inside the system temp dir (house policy: no
81    /// hardcoded absolute paths).
82    ///
83    /// The leaf name embeds the process id and a monotonic counter, so no two
84    /// test binaries — nor two concurrent runs of this one — can ever land on
85    /// the same file.  Dropping the guard removes the fixture, so a panicking
86    /// test leaks nothing.
87    struct TempPath(std::path::PathBuf);
88
89    impl TempPath {
90        fn new(name: &str) -> Self {
91            static COUNTER: AtomicU64 = AtomicU64::new(0);
92            let seq = COUNTER.fetch_add(1, Ordering::Relaxed);
93            Self(std::env::temp_dir().join(format!(
94                "oxigeo_cli_cloud_{}_{seq}_{name}",
95                std::process::id()
96            )))
97        }
98    }
99
100    impl std::ops::Deref for TempPath {
101        type Target = std::path::Path;
102
103        fn deref(&self) -> &std::path::Path {
104            &self.0
105        }
106    }
107
108    impl AsRef<std::path::Path> for TempPath {
109        fn as_ref(&self) -> &std::path::Path {
110            &self.0
111        }
112    }
113
114    impl Drop for TempPath {
115        fn drop(&mut self) {
116            let _ = std::fs::remove_file(&self.0);
117        }
118    }
119
120    #[test]
121    fn test_is_cloud_uri() {
122        assert!(is_cloud_uri("s3://bucket/key"));
123        assert!(is_cloud_uri("gs://bucket/obj"));
124        assert!(is_cloud_uri("az://container/blob"));
125        assert!(!is_cloud_uri("/local/path.tif"));
126        assert!(!is_cloud_uri("file:///local.tif"));
127        assert!(!is_cloud_uri("relative/path.tif"));
128    }
129
130    #[test]
131    fn test_error_for_cloud_write() {
132        let err = error_for_cloud_write("s3://my-bucket/output.tif");
133        let msg = err.to_string();
134        assert!(msg.contains("s3://my-bucket/output.tif"));
135        assert!(msg.contains("not yet supported"));
136    }
137
138    #[test]
139    fn test_open_datasource_file_path() {
140        let path = TempPath::new("direct.bin");
141        std::fs::write(&path, b"test data").expect("write temp file");
142        let result = open_datasource(path.to_str().expect("valid path"));
143        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
144    }
145
146    #[test]
147    fn test_open_datasource_file_uri() {
148        let path = TempPath::new("uri.bin");
149        std::fs::write(&path, b"test data").expect("write temp file");
150        let uri = format!("file://{}", path.display());
151        let result = open_datasource(&uri);
152        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
153    }
154
155    #[test]
156    fn test_cloud_uri_classification_comprehensive() {
157        // All recognised cloud schemes
158        assert!(is_cloud_uri("s3://bucket/path/to/file.tif"));
159        assert!(is_cloud_uri("gs://my-gcs-bucket/dir/file.tif"));
160        assert!(is_cloud_uri("az://mycontainer/blob/path.tif"));
161
162        // Non-cloud URIs that must NOT be treated as cloud
163        assert!(!is_cloud_uri("file:///data/local.tif"));
164        assert!(!is_cloud_uri("/absolute/path.tif"));
165        assert!(!is_cloud_uri("relative/path.tif"));
166        assert!(!is_cloud_uri("http://example.com/file.tif"));
167        assert!(!is_cloud_uri("https://example.com/file.tif"));
168        assert!(!is_cloud_uri(""));
169    }
170}