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