Skip to main content

floe_core/config/
storage.rs

1use std::collections::HashMap;
2use std::path::{Path, PathBuf};
3
4use crate::config::{RootConfig, StorageDefinition};
5use crate::{ConfigError, FloeResult};
6
7#[derive(Debug, Clone)]
8pub struct ResolvedPath {
9    pub storage: String,
10    pub uri: String,
11    pub local_path: Option<PathBuf>,
12}
13
14pub struct StorageResolver {
15    config_dir: PathBuf,
16    default_name: String,
17    definitions: HashMap<String, StorageDefinition>,
18    has_config: bool,
19}
20
21impl StorageResolver {
22    pub fn new(config: &RootConfig, config_path: &Path) -> FloeResult<Self> {
23        let config_dir = config_path
24            .parent()
25            .unwrap_or_else(|| Path::new("."))
26            .to_path_buf();
27        if let Some(storages) = &config.storages {
28            let mut definitions = HashMap::new();
29            for definition in &storages.definitions {
30                if definitions
31                    .insert(definition.name.clone(), definition.clone())
32                    .is_some()
33                {
34                    return Err(Box::new(ConfigError(format!(
35                        "storages.definitions name={} is duplicated",
36                        definition.name
37                    ))));
38                }
39            }
40            let default_name = storages
41                .default
42                .clone()
43                .ok_or_else(|| Box::new(ConfigError("storages.default is required".to_string())))?;
44            if !definitions.contains_key(&default_name) {
45                return Err(Box::new(ConfigError(format!(
46                    "storages.default={} does not match any definition",
47                    default_name
48                ))));
49            }
50            Ok(Self {
51                config_dir,
52                default_name,
53                definitions,
54                has_config: true,
55            })
56        } else {
57            Ok(Self {
58                config_dir,
59                default_name: "local".to_string(),
60                definitions: HashMap::new(),
61                has_config: false,
62            })
63        }
64    }
65
66    pub fn resolve_path(
67        &self,
68        entity_name: &str,
69        field: &str,
70        storage_name: Option<&str>,
71        raw_path: &str,
72    ) -> FloeResult<ResolvedPath> {
73        let name = storage_name.unwrap_or(self.default_name.as_str());
74        if !self.has_config && name != "local" {
75            return Err(Box::new(ConfigError(format!(
76                "entity.name={} {field} references unknown storage {} (no storages block)",
77                entity_name, name
78            ))));
79        }
80
81        let definition = if self.has_config {
82            self.definitions.get(name).cloned().ok_or_else(|| {
83                Box::new(ConfigError(format!(
84                    "entity.name={} {field} references unknown storage {}",
85                    entity_name, name
86                )))
87            })?
88        } else {
89            StorageDefinition {
90                name: "local".to_string(),
91                fs_type: "local".to_string(),
92                bucket: None,
93                region: None,
94                prefix: None,
95            }
96        };
97
98        match definition.fs_type.as_str() {
99            "local" => {
100                let resolved = resolve_local_path(&self.config_dir, raw_path);
101                Ok(ResolvedPath {
102                    storage: name.to_string(),
103                    uri: local_uri(&resolved),
104                    local_path: Some(resolved),
105                })
106            }
107            "s3" => {
108                let uri = resolve_s3_uri(&definition, raw_path)?;
109                Ok(ResolvedPath {
110                    storage: name.to_string(),
111                    uri,
112                    local_path: None,
113                })
114            }
115            _ => Err(Box::new(ConfigError(format!(
116                "storage type {} is unsupported",
117                definition.fs_type
118            )))),
119        }
120    }
121
122    pub fn definition(&self, name: &str) -> Option<StorageDefinition> {
123        if self.has_config {
124            self.definitions.get(name).cloned()
125        } else if name == "local" {
126            Some(StorageDefinition {
127                name: "local".to_string(),
128                fs_type: "local".to_string(),
129                bucket: None,
130                region: None,
131                prefix: None,
132            })
133        } else {
134            None
135        }
136    }
137}
138
139pub fn resolve_local_path(config_dir: &Path, raw_path: &str) -> PathBuf {
140    let path = Path::new(raw_path);
141    if path.is_absolute() {
142        path.to_path_buf()
143    } else {
144        config_dir.join(path)
145    }
146}
147
148fn local_uri(path: &Path) -> String {
149    format!("local://{}", path.display())
150}
151
152fn resolve_s3_uri(definition: &StorageDefinition, raw_path: &str) -> FloeResult<String> {
153    let bucket = definition.bucket.as_ref().ok_or_else(|| {
154        Box::new(ConfigError(format!(
155            "storage {} requires bucket for type s3",
156            definition.name
157        )))
158    })?;
159    if let Some((bucket_in_path, key)) = parse_s3_uri(raw_path) {
160        if bucket_in_path != *bucket {
161            return Err(Box::new(ConfigError(format!(
162                "storage {} bucket mismatch: {}",
163                definition.name, bucket_in_path
164            ))));
165        }
166        return Ok(format_s3_uri(bucket, &key));
167    }
168
169    let key = join_s3_key(definition.prefix.as_deref().unwrap_or(""), raw_path);
170    Ok(format_s3_uri(bucket, &key))
171}
172
173fn parse_s3_uri(value: &str) -> Option<(String, String)> {
174    let stripped = value.strip_prefix("s3://")?;
175    let mut parts = stripped.splitn(2, '/');
176    let bucket = parts.next()?.to_string();
177    if bucket.is_empty() {
178        return None;
179    }
180    let key = parts.next().unwrap_or("").to_string();
181    Some((bucket, key))
182}
183
184fn join_s3_key(prefix: &str, raw_path: &str) -> String {
185    let prefix = prefix.trim_matches('/');
186    let trimmed = raw_path.trim_start_matches('/');
187    match (prefix.is_empty(), trimmed.is_empty()) {
188        (true, true) => String::new(),
189        (true, false) => trimmed.to_string(),
190        (false, true) => prefix.to_string(),
191        (false, false) => format!("{}/{}", prefix, trimmed),
192    }
193}
194
195fn format_s3_uri(bucket: &str, key: &str) -> String {
196    if key.is_empty() {
197        format!("s3://{}", bucket)
198    } else {
199        format!("s3://{}/{}", bucket, key)
200    }
201}