floe_core/config/
storage.rs1use 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}