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
79    #[test]
80    fn test_is_cloud_uri() {
81        assert!(is_cloud_uri("s3://bucket/key"));
82        assert!(is_cloud_uri("gs://bucket/obj"));
83        assert!(is_cloud_uri("az://container/blob"));
84        assert!(!is_cloud_uri("/local/path.tif"));
85        assert!(!is_cloud_uri("file:///local.tif"));
86        assert!(!is_cloud_uri("relative/path.tif"));
87    }
88
89    #[test]
90    fn test_error_for_cloud_write() {
91        let err = error_for_cloud_write("s3://my-bucket/output.tif");
92        let msg = err.to_string();
93        assert!(msg.contains("s3://my-bucket/output.tif"));
94        assert!(msg.contains("not yet supported"));
95    }
96
97    #[test]
98    fn test_open_datasource_file_path() {
99        let dir = std::env::temp_dir();
100        let path = dir.join("cloud_test_direct.bin");
101        std::fs::write(&path, b"test data").expect("write temp file");
102        let result = open_datasource(path.to_str().expect("valid path"));
103        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
104    }
105
106    #[test]
107    fn test_open_datasource_file_uri() {
108        let dir = std::env::temp_dir();
109        let path = dir.join("cloud_test_uri.bin");
110        std::fs::write(&path, b"test data").expect("write temp file");
111        let uri = format!("file://{}", path.display());
112        let result = open_datasource(&uri);
113        assert!(result.is_ok(), "expected Ok, got: {:?}", result.err());
114    }
115
116    #[test]
117    fn test_cloud_uri_classification_comprehensive() {
118        // All recognised cloud schemes
119        assert!(is_cloud_uri("s3://bucket/path/to/file.tif"));
120        assert!(is_cloud_uri("gs://my-gcs-bucket/dir/file.tif"));
121        assert!(is_cloud_uri("az://mycontainer/blob/path.tif"));
122
123        // Non-cloud URIs that must NOT be treated as cloud
124        assert!(!is_cloud_uri("file:///data/local.tif"));
125        assert!(!is_cloud_uri("/absolute/path.tif"));
126        assert!(!is_cloud_uri("relative/path.tif"));
127        assert!(!is_cloud_uri("http://example.com/file.tif"));
128        assert!(!is_cloud_uri("https://example.com/file.tif"));
129        assert!(!is_cloud_uri(""));
130    }
131}