1use std::str::FromStr;
39
40use url::Url;
41
42use crate::error::{Error, Result};
43
44#[derive(Debug, Clone, PartialEq, Eq)]
50pub enum UCReference {
51 Volume {
53 catalog: String,
54 schema: String,
55 volume: String,
56 path: String,
60 },
61 Table {
63 catalog: String,
64 schema: String,
65 table: String,
66 },
67 Path(Url),
70}
71
72impl UCReference {
73 pub fn parse(input: &str) -> Result<Self> {
77 if let Some(rest) = input.strip_prefix("vol+dbfs:") {
81 return parse_volume_form(rest).map_err(|e| {
82 Error::invalid_argument(format!("invalid vol+dbfs URL `{input}`: {e}"))
83 });
84 }
85
86 let url = Url::parse(input)
87 .map_err(|e| Error::invalid_argument(format!("could not parse `{input}`: {e}")))?;
88
89 match url.scheme() {
90 "uc" => parse_uc(&url)
91 .map_err(|e| Error::invalid_argument(format!("invalid uc URL `{input}`: {e}"))),
92 "s3" | "s3a" | "gs" | "gcs" | "abfs" | "abfss" | "az" | "azure" | "adl" | "r2"
94 | "file" | "http" | "https" => Ok(UCReference::Path(url)),
95 other => Err(Error::invalid_argument(format!(
96 "unsupported URL scheme `{other}` (expected `uc`, `vol+dbfs`, or a cloud scheme like `s3`/`gs`/`abfss`)"
97 ))),
98 }
99 }
100
101 pub fn full_name(&self) -> Option<String> {
107 match self {
108 UCReference::Volume {
109 catalog,
110 schema,
111 volume,
112 ..
113 } => Some(format!("{catalog}.{schema}.{volume}")),
114 UCReference::Table {
115 catalog,
116 schema,
117 table,
118 } => Some(format!("{catalog}.{schema}.{table}")),
119 UCReference::Path(_) => None,
120 }
121 }
122}
123
124impl FromStr for UCReference {
125 type Err = Error;
126
127 fn from_str(s: &str) -> Result<Self> {
128 Self::parse(s)
129 }
130}
131
132fn parse_uc(url: &Url) -> std::result::Result<UCReference, String> {
133 if url.host_str().is_some_and(|h| !h.is_empty()) {
137 return Err(format!(
138 "`uc` URLs must use an empty authority (e.g. `uc:///Volumes/...`), got host `{}`",
139 url.host_str().unwrap()
140 ));
141 }
142 let path = url.path();
143 if !path.starts_with('/') {
144 return Err(format!("expected an absolute path, got `{path}`"));
145 }
146 let segments: Vec<&str> = path
147 .trim_start_matches('/')
148 .split('/')
149 .filter(|s| !s.is_empty())
150 .collect();
151 parse_securable_segments(&segments)
152}
153
154fn parse_securable_segments(segments: &[&str]) -> std::result::Result<UCReference, String> {
161 let kind = match segments.first() {
162 None => return Err("empty path; expected `/Volumes/...` or `/Tables/...`".to_string()),
163 Some(k) => *k,
164 };
165 let tail = &segments[1..];
166
167 if kind.eq_ignore_ascii_case("Volumes") {
168 return match tail {
169 [catalog, schema, volume, rest @ ..] => Ok(UCReference::Volume {
170 catalog: percent_decode(catalog)?,
171 schema: percent_decode(schema)?,
172 volume: percent_decode(volume)?,
173 path: rest.join("/"),
174 }),
175 _ => Err(
176 "`uc:///Volumes/<catalog>/<schema>/<volume>[/<path>]` requires all three names"
177 .to_string(),
178 ),
179 };
180 }
181
182 if kind.eq_ignore_ascii_case("Tables") {
183 return match tail {
184 [catalog, schema, table] => Ok(UCReference::Table {
185 catalog: percent_decode(catalog)?,
186 schema: percent_decode(schema)?,
187 table: percent_decode(table)?,
188 }),
189 _ => Err(
190 "`uc:///Tables/<catalog>/<schema>/<table>` requires exactly three name segments"
191 .to_string(),
192 ),
193 };
194 }
195
196 Err(format!(
197 "unknown securable kind `{kind}`; expected `Volumes` or `Tables` (case-insensitive)"
198 ))
199}
200
201fn parse_volume_form(rest: &str) -> std::result::Result<UCReference, String> {
206 let path = rest.trim_start_matches('/');
207 let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
208 match parse_securable_segments(&segments)? {
209 volume @ UCReference::Volume { .. } => Ok(volume),
210 _ => Err("expected `vol+dbfs:/Volumes/<catalog>/<schema>/<volume>[/<path>]`".to_string()),
211 }
212}
213
214fn percent_decode(s: &str) -> std::result::Result<String, String> {
215 percent_encoding::percent_decode_str(s)
218 .decode_utf8()
219 .map(|c| c.into_owned())
220 .map_err(|e| format!("invalid percent-encoding in `{s}`: {e}"))
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 #[test]
228 fn parses_volume_with_path() {
229 let r =
230 UCReference::parse("uc:///Volumes/main/default/landing/raw/2024/file.parquet").unwrap();
231 assert_eq!(
232 r,
233 UCReference::Volume {
234 catalog: "main".into(),
235 schema: "default".into(),
236 volume: "landing".into(),
237 path: "raw/2024/file.parquet".into(),
238 }
239 );
240 assert_eq!(r.full_name().as_deref(), Some("main.default.landing"));
241 }
242
243 #[test]
244 fn parses_volume_root() {
245 let r = UCReference::parse("uc:///Volumes/main/default/landing").unwrap();
246 assert!(matches!(&r, UCReference::Volume { path, .. } if path.is_empty()));
247 }
248
249 #[test]
250 fn parses_volume_root_trailing_slash() {
251 let r = UCReference::parse("uc:///Volumes/main/default/landing/").unwrap();
252 assert!(matches!(&r, UCReference::Volume { path, .. } if path.is_empty()));
253 }
254
255 #[test]
256 fn parses_table() {
257 let r = UCReference::parse("uc:///Tables/main/default/orders").unwrap();
258 assert_eq!(
259 r,
260 UCReference::Table {
261 catalog: "main".into(),
262 schema: "default".into(),
263 table: "orders".into(),
264 }
265 );
266 assert_eq!(r.full_name().as_deref(), Some("main.default.orders"));
267 }
268
269 #[test]
270 fn vol_dbfs_alias() {
271 let r = UCReference::parse("vol+dbfs:/Volumes/main/default/landing/sub/file.bin").unwrap();
272 assert_eq!(
273 r,
274 UCReference::Volume {
275 catalog: "main".into(),
276 schema: "default".into(),
277 volume: "landing".into(),
278 path: "sub/file.bin".into(),
279 }
280 );
281 }
282
283 #[test]
284 fn raw_cloud_urls_are_paths() {
285 for raw in [
286 "s3://bucket/prefix/key",
287 "s3a://bucket/prefix",
288 "gs://bucket/x",
289 "abfss://container@account.dfs.core.windows.net/path",
290 "az://account/container/blob",
291 "r2://bucket/key",
292 ] {
293 let r = UCReference::parse(raw).unwrap();
294 assert!(matches!(r, UCReference::Path(_)), "expected Path for {raw}");
295 assert!(r.full_name().is_none());
296 }
297 }
298
299 #[test]
300 fn rejects_volume_with_missing_segments() {
301 let err = UCReference::parse("uc:///Volumes/main/default").unwrap_err();
302 assert!(format!("{err}").contains("Volumes"));
303 }
304
305 #[test]
306 fn rejects_table_with_extra_segments() {
307 let err = UCReference::parse("uc:///Tables/main/default/orders/extra").unwrap_err();
308 assert!(format!("{err}").contains("Tables"));
309 }
310
311 #[test]
312 fn rejects_unknown_securable_kind() {
313 let err = UCReference::parse("uc:///Functions/main/default/fn").unwrap_err();
314 assert!(format!("{err}").contains("Functions"));
315 }
316
317 #[test]
318 fn rejects_unknown_scheme() {
319 let err = UCReference::parse("ftp://server/path").unwrap_err();
320 assert!(format!("{err}").contains("unsupported"));
321 }
322
323 #[test]
324 fn rejects_uc_with_authority() {
325 let err = UCReference::parse("uc://some-host/Volumes/main/default/v").unwrap_err();
326 assert!(format!("{err}").contains("empty authority"));
327 }
328
329 #[test]
330 fn kind_segment_is_case_insensitive() {
331 for url in [
332 "uc:///Volumes/main/default/landing/raw/file.parquet",
333 "uc:///volumes/main/default/landing/raw/file.parquet",
334 "uc:///VOLUMES/main/default/landing/raw/file.parquet",
335 "uc:///VoLuMeS/main/default/landing/raw/file.parquet",
336 ] {
337 let r = UCReference::parse(url).unwrap();
338 assert_eq!(
339 r,
340 UCReference::Volume {
341 catalog: "main".into(),
342 schema: "default".into(),
343 volume: "landing".into(),
344 path: "raw/file.parquet".into(),
345 },
346 "parsing {url}",
347 );
348 }
349
350 for url in [
351 "uc:///Tables/main/default/orders",
352 "uc:///tables/main/default/orders",
353 "uc:///TABLES/main/default/orders",
354 ] {
355 let r = UCReference::parse(url).unwrap();
356 assert!(matches!(r, UCReference::Table { .. }), "parsing {url}");
357 }
358 }
359
360 #[test]
361 fn vol_dbfs_alias_kind_is_case_insensitive() {
362 for url in [
363 "vol+dbfs:/Volumes/main/default/landing/file.bin",
364 "vol+dbfs:/volumes/main/default/landing/file.bin",
365 "vol+dbfs:/VOLUMES/main/default/landing/file.bin",
366 ] {
367 let r = UCReference::parse(url).unwrap();
368 assert!(matches!(r, UCReference::Volume { .. }), "parsing {url}");
369 }
370 }
371
372 #[test]
373 fn percent_encoded_names_are_decoded() {
374 let r = UCReference::parse("uc:///Volumes/main/default/my%5Fvolume/file").unwrap();
375 let UCReference::Volume { volume, .. } = r else {
376 panic!()
377 };
378 assert_eq!(volume, "my_volume");
379 }
380}