Skip to main content

ferro_storage/
config.rs

1//! Configuration for the storage system.
2
3use crate::facade::DiskConfig;
4#[cfg(feature = "s3")]
5use crate::facade::DiskDriver;
6use std::collections::HashMap;
7use std::env;
8
9/// Configuration for the storage system.
10#[derive(Debug, Clone)]
11pub struct StorageConfig {
12    /// Default disk name.
13    pub default: String,
14    /// Disk configurations.
15    pub disks: HashMap<String, DiskConfig>,
16}
17
18impl Default for StorageConfig {
19    fn default() -> Self {
20        let mut disks = HashMap::new();
21        disks.insert("local".to_string(), DiskConfig::local("./storage"));
22
23        Self {
24            default: "local".to_string(),
25            disks,
26        }
27    }
28}
29
30impl StorageConfig {
31    /// Create a new storage config with a default disk.
32    pub fn new(default: impl Into<String>) -> Self {
33        Self {
34            default: default.into(),
35            disks: HashMap::new(),
36        }
37    }
38
39    /// Create configuration from environment variables.
40    ///
41    /// Reads the following environment variables:
42    /// - `FILESYSTEM_DISK`: Default disk name (default: "local")
43    /// - `FILESYSTEM_LOCAL_ROOT`: Root path for local disk (default: "./storage")
44    /// - `FILESYSTEM_LOCAL_URL`: Public URL for local files
45    /// - `FILESYSTEM_PUBLIC_ROOT`: Root path for public disk (default: "./storage/public")
46    /// - `FILESYSTEM_PUBLIC_URL`: Public URL for public files (default: "/storage")
47    ///
48    /// With `s3` feature:
49    /// - `AWS_ACCESS_KEY_ID`: S3 access key
50    /// - `AWS_SECRET_ACCESS_KEY`: S3 secret key
51    /// - `AWS_DEFAULT_REGION`: S3 region (default: "us-east-1")
52    /// - `AWS_BUCKET`: S3 bucket name
53    /// - `AWS_PUBLIC_URL`: Public base URL for generated file URLs (overrides `AWS_URL` for this purpose)
54    /// - `AWS_URL`: S3 API endpoint; also used as public URL base if `AWS_PUBLIC_URL` is not set
55    /// - `AWS_CDN_URL`: CDN base URL fronting the Spaces bucket (optional; used by `cdn_url()`)
56    ///
57    /// # Example
58    ///
59    /// ```rust,ignore
60    /// use ferro_storage::{StorageConfig, Storage};
61    ///
62    /// let config = StorageConfig::from_env();
63    /// let storage = Storage::with_storage_config(config);
64    /// ```
65    pub fn from_env() -> Self {
66        let default = env::var("FILESYSTEM_DISK").unwrap_or_else(|_| "local".to_string());
67        let mut disks = HashMap::new();
68
69        // Local disk
70        let local_root =
71            env::var("FILESYSTEM_LOCAL_ROOT").unwrap_or_else(|_| "./storage".to_string());
72        let mut local_config = DiskConfig::local(&local_root);
73        if let Ok(url) = env::var("FILESYSTEM_LOCAL_URL") {
74            local_config = local_config.with_url(url);
75        }
76        disks.insert("local".to_string(), local_config);
77
78        // Public disk (for publicly accessible files)
79        let public_root =
80            env::var("FILESYSTEM_PUBLIC_ROOT").unwrap_or_else(|_| "./storage/public".to_string());
81        let public_url =
82            env::var("FILESYSTEM_PUBLIC_URL").unwrap_or_else(|_| "/storage".to_string());
83        let public_config = DiskConfig::local(&public_root).with_url(public_url);
84        disks.insert("public".to_string(), public_config);
85
86        // S3 disk (if configured)
87        #[cfg(feature = "s3")]
88        if let Ok(bucket) = env::var("AWS_BUCKET") {
89            let region = env::var("AWS_DEFAULT_REGION").unwrap_or_else(|_| "us-east-1".to_string());
90            let mut s3_config = DiskConfig {
91                driver: DiskDriver::S3,
92                root: None,
93                url: None,
94                cdn_url: None,
95                bucket: Some(bucket.clone()),
96                region: Some(region),
97            };
98            // Resolve public file URL base (used by Storage::url() to build asset URLs).
99            // Priority: AWS_PUBLIC_URL → computed from AWS_URL+bucket → AWS_URL bare.
100            // Auto-compute handles providers like DigitalOcean Spaces and Cloudflare R2
101            // where the public URL is {bucket}.{endpoint_host} but the API endpoint is
102            // just {endpoint_host}.
103            let public_url = if let Ok(explicit) = env::var("AWS_PUBLIC_URL") {
104                Some(explicit)
105            } else if let Ok(api_url) = env::var("AWS_URL") {
106                let host = api_url
107                    .trim_start_matches("https://")
108                    .trim_start_matches("http://");
109                let scheme = if api_url.starts_with("https://") {
110                    "https"
111                } else {
112                    "http"
113                };
114                Some(format!("{scheme}://{bucket}.{host}"))
115            } else {
116                None
117            };
118            s3_config.url = public_url;
119            if let Ok(cdn) = env::var("AWS_CDN_URL") {
120                s3_config = s3_config.with_cdn_url(cdn);
121            }
122            disks.insert("s3".to_string(), s3_config);
123        }
124
125        Self { default, disks }
126    }
127
128    /// Add a disk configuration.
129    pub fn disk(mut self, name: impl Into<String>, config: DiskConfig) -> Self {
130        self.disks.insert(name.into(), config);
131        self
132    }
133
134    /// Set the default disk.
135    pub fn default_disk(mut self, name: impl Into<String>) -> Self {
136        self.default = name.into();
137        self
138    }
139
140    /// Get the default disk name.
141    pub fn get_default(&self) -> &str {
142        &self.default
143    }
144
145    /// Get a disk configuration by name.
146    pub fn get_disk(&self, name: &str) -> Option<&DiskConfig> {
147        self.disks.get(name)
148    }
149}
150
151#[cfg(test)]
152mod tests {
153    use super::*;
154
155    #[test]
156    fn test_storage_config_defaults() {
157        let config = StorageConfig::default();
158        assert_eq!(config.default, "local");
159        assert!(config.disks.contains_key("local"));
160    }
161
162    #[test]
163    fn test_storage_config_builder() {
164        let config = StorageConfig::new("s3")
165            .disk("local", DiskConfig::local("./storage"))
166            .disk("public", DiskConfig::local("./public").with_url("/files"));
167
168        assert_eq!(config.default, "s3");
169        assert!(config.disks.contains_key("local"));
170        assert!(config.disks.contains_key("public"));
171    }
172
173    #[test]
174    fn test_storage_config_from_env() {
175        // Test with default env (no env vars set)
176        let config = StorageConfig::from_env();
177        assert_eq!(config.default, "local");
178        assert!(config.disks.contains_key("local"));
179        assert!(config.disks.contains_key("public"));
180    }
181
182    #[cfg(feature = "s3")]
183    #[test]
184    fn from_env_cdn_url() {
185        std::env::set_var("AWS_BUCKET", "test-bucket");
186        std::env::set_var("AWS_CDN_URL", "https://cdn.test.example.com");
187        let config = StorageConfig::from_env();
188        let s3_disk = config.get_disk("s3").expect("s3 disk should be configured");
189        assert_eq!(
190            s3_disk.cdn_url,
191            Some("https://cdn.test.example.com".to_string())
192        );
193        std::env::remove_var("AWS_BUCKET");
194        std::env::remove_var("AWS_CDN_URL");
195    }
196}