nautilus_persistence/common/
paths.rs1pub trait CatalogPathPrefix {
30 fn path_prefix() -> &'static str;
32}
33
34#[must_use]
40pub fn normalize_path_separators(path: &str) -> String {
41 path.replace('\\', "/")
42}
43
44#[must_use]
46pub fn extract_path_components(path_str: &str) -> Vec<String> {
47 let normalized = normalize_path_separators(path_str);
49 normalized
50 .split('/')
51 .filter(|s| !s.is_empty())
52 .map(ToString::to_string)
53 .collect()
54}
55
56#[must_use]
58pub fn local_to_object_store_path(local_path: &std::path::Path) -> String {
59 normalize_path_separators(&local_path.to_string_lossy())
60}
61
62#[must_use]
66pub fn make_object_store_path<I, S>(base_path: &str, components: I) -> String
67where
68 I: IntoIterator<Item = S>,
69 S: AsRef<str>,
70{
71 let mut parts = Vec::new();
72
73 if !base_path.is_empty() {
74 let normalized_base = normalize_path_separators(base_path)
75 .trim_end_matches('/')
76 .to_string();
77
78 if !normalized_base.is_empty() {
79 parts.push(normalized_base);
80 }
81 }
82
83 for component in components {
84 let normalized_component = normalize_path_separators(component.as_ref())
85 .trim_start_matches('/')
86 .trim_end_matches('/')
87 .to_string();
88
89 if !normalized_component.is_empty() {
90 parts.push(normalized_component);
91 }
92 }
93
94 parts.join("/")
95}
96
97#[must_use]
101pub fn urisafe_instrument_id(instrument_id: &str) -> String {
102 instrument_id.replace('/', "").replace('^', "_")
103}
104
105#[must_use]
110pub fn safe_directory_identifier(identifier: &str) -> String {
111 let normalized = normalize_path_separators(identifier).replace("//", "/");
112 let segments: Vec<&str> = normalized
113 .split('/')
114 .filter(|s| !s.is_empty() && *s != "..")
115 .collect();
116 segments.join("/")
117}
118
119#[must_use]
124pub fn extract_identifier_from_path(file_path: &str) -> Option<&str> {
125 let parent = file_path.rfind(['/', '\\']).map(|idx| &file_path[..idx])?;
126 let identifier = parent
127 .rfind(['/', '\\'])
128 .map_or(parent, |idx| &parent[idx + 1..]);
129 (!identifier.is_empty()).then_some(identifier)
130}
131
132#[must_use]
136pub fn make_sql_safe_identifier(identifier: &str) -> String {
137 urisafe_instrument_id(identifier)
138 .chars()
139 .map(|c| {
140 if c.is_ascii_alphanumeric() {
141 c.to_ascii_lowercase()
142 } else {
143 '_'
144 }
145 })
146 .collect()
147}
148
149pub fn normalize_path_to_uri(path: &str) -> anyhow::Result<String> {
173 if path.contains("://") {
174 Ok(path.to_string())
176 } else if is_absolute_path(path) {
177 Ok(path_to_file_uri(path))
178 } else {
179 let current_dir = std::env::current_dir().map_err(|e| {
181 anyhow::anyhow!("Failed to resolve current directory for relative path '{path}': {e}")
182 })?;
183
184 let absolute_path = current_dir.join(path);
185 Ok(path_to_file_uri(&absolute_path.to_string_lossy()))
186 }
187}
188
189#[must_use]
191fn is_absolute_path(path: &str) -> bool {
192 path.starts_with('/')
193 || path.starts_with("\\\\")
194 || (path.len() >= 3
195 && path.chars().nth(1) == Some(':')
196 && matches!(path.chars().nth(2), Some('\\' | '/')))
197}
198
199#[must_use]
201pub(crate) fn path_to_file_uri(path: &str) -> String {
202 if path.starts_with('/') {
203 format!("file://{path}")
205 } else if path.len() >= 3 && path.chars().nth(1) == Some(':') {
206 let normalized = normalize_path_separators(path);
208 format!("file:///{normalized}")
209 } else if let Some(without_prefix) = path.strip_prefix("\\\\") {
210 let normalized = normalize_path_separators(without_prefix);
212 format!("file://{normalized}")
213 } else {
214 format!("file://{path}")
216 }
217}
218
219#[cfg(windows)]
222pub(crate) fn file_uri_to_native_path(uri: &str) -> String {
223 let without_scheme = uri
224 .strip_prefix("file://")
225 .or_else(|| uri.strip_prefix("file:"))
226 .unwrap_or(uri);
227 let without_leading = without_scheme.trim_start_matches('/');
229 without_leading.replace('/', "\\")
230}
231
232#[cfg(not(windows))]
234pub(crate) fn file_uri_to_native_path(uri: &str) -> String {
235 uri.strip_prefix("file://").unwrap_or(uri).to_string()
236}
237
238pub(crate) fn type_name_from_session_feather_path(
249 path: &str,
250 kind: &str,
251 instance_id: &str,
252) -> anyhow::Result<String> {
253 let normalized = normalize_path_separators(path);
254 let components: Vec<&str> = normalized
255 .trim_matches('/')
256 .split('/')
257 .filter(|component| !component.is_empty())
258 .collect();
259 let type_index = session_type_index(&components, kind, instance_id, path)?;
260 if components.get(type_index) == Some(&"data")
261 && components.get(type_index + 1) == Some(&"custom")
262 {
263 let type_name = components.get(type_index + 2).ok_or_else(|| {
264 anyhow::anyhow!(
265 "Cannot infer custom data type from Feather session path '{path}' for {kind}/{instance_id}"
266 )
267 })?;
268 return Ok(format!("custom/{type_name}"));
269 }
270 let type_segment = components[type_index];
271 let file_name = components.last().copied().unwrap_or(type_segment);
272 let type_name = if type_segment.ends_with(".feather") {
273 file_name
274 .strip_suffix(".feather")
275 .and_then(|stem| stem.rsplit_once('_').map(|(type_name, _)| type_name))
276 .unwrap_or(type_segment)
277 } else {
278 type_segment
279 };
280 Ok(type_name.to_string())
281}
282
283pub(crate) fn identifier_from_session_feather_path(
287 path: &str,
288 kind: &str,
289 instance_id: &str,
290) -> Option<String> {
291 let normalized = normalize_path_separators(path);
292 let components: Vec<&str> = normalized
293 .trim_matches('/')
294 .split('/')
295 .filter(|component| !component.is_empty())
296 .collect();
297 let type_index = session_type_index(&components, kind, instance_id, path).ok()?;
298 if components.get(type_index) == Some(&"data")
299 && components.get(type_index + 1) == Some(&"custom")
300 {
301 let identifier_start = type_index + 3;
302 let file_index = components.len().checked_sub(1)?;
303 if identifier_start >= file_index {
304 return None;
305 }
306 return Some(components[identifier_start].to_string());
307 }
308 let identifier = components.get(type_index + 1)?;
309 let file_name = components.last()?;
310
311 if identifier.ends_with(".feather") {
312 return None;
313 }
314
315 (identifier != file_name).then(|| (*identifier).to_string())
316}
317
318fn session_type_index(
319 components: &[&str],
320 kind: &str,
321 instance_id: &str,
322 path: &str,
323) -> anyhow::Result<usize> {
324 components
325 .windows(2)
326 .position(|window| window[0] == kind && window[1] == instance_id)
327 .and_then(|kind_index| kind_index.checked_add(2))
328 .filter(|type_index| *type_index < components.len())
329 .ok_or_else(|| {
330 anyhow::anyhow!(
331 "Cannot infer data type from Feather session path '{path}' for {kind}/{instance_id}"
332 )
333 })
334}
335
336#[cfg(test)]
337mod tests {
338 use rstest::rstest;
339
340 use super::*;
341
342 #[rstest]
343 fn normalize_path_separators_converts_backslashes() {
344 assert_eq!(
345 normalize_path_separators(r"C:\catalog\backtest\run-1"),
346 "C:/catalog/backtest/run-1",
347 );
348 assert_eq!(
349 normalize_path_separators("C:/catalog/backtest/run-1"),
350 "C:/catalog/backtest/run-1",
351 );
352 assert_eq!(
353 normalize_path_separators(r"\\server\share\live\run-2"),
354 "//server/share/live/run-2",
355 );
356 }
357
358 #[rstest]
359 fn extract_path_components_handles_platform_separators() {
360 assert_eq!(
361 extract_path_components(r"C:\catalog\backtest\run-1"),
362 vec!["C:", "catalog", "backtest", "run-1"],
363 );
364 assert_eq!(
365 extract_path_components("/catalog/backtest/run-1/"),
366 vec!["catalog", "backtest", "run-1"],
367 );
368 assert!(extract_path_components("").is_empty());
369 }
370
371 #[rstest]
372 fn extract_identifier_from_path_handles_platform_separators() {
373 assert_eq!(
374 extract_identifier_from_path("data/quotes/EURUSD/file.parquet"),
375 Some("EURUSD"),
376 );
377 assert_eq!(
378 extract_identifier_from_path(r"data\quotes\EURUSD\file.parquet"),
379 Some("EURUSD"),
380 );
381 assert_eq!(
382 extract_identifier_from_path(r"C:\data\quotes\EURUSD\file.parquet"),
383 Some("EURUSD"),
384 );
385 assert_eq!(extract_identifier_from_path("file.parquet"), None);
386 assert_eq!(extract_identifier_from_path(""), None);
387 }
388
389 #[rstest]
390 fn safe_directory_identifier_blocks_windows_traversal() {
391 assert_eq!(safe_directory_identifier(r"..\\..\\etc"), "etc");
392 assert_eq!(safe_directory_identifier("../../etc"), "etc");
393 assert_eq!(safe_directory_identifier("run-1"), "run-1");
394 }
395
396 #[rstest]
397 fn test_normalize_path_to_uri() {
398 assert_eq!(
400 normalize_path_to_uri("/tmp/test").unwrap(),
401 "file:///tmp/test"
402 );
403
404 assert_eq!(
406 normalize_path_to_uri("C:\\tmp\\test").unwrap(),
407 "file:///C:/tmp/test"
408 );
409 assert_eq!(
410 normalize_path_to_uri("C:/tmp/test").unwrap(),
411 "file:///C:/tmp/test"
412 );
413 assert_eq!(
414 normalize_path_to_uri("D:\\data\\file.txt").unwrap(),
415 "file:///D:/data/file.txt"
416 );
417
418 assert_eq!(
420 normalize_path_to_uri("\\\\server\\share\\file").unwrap(),
421 "file://server/share/file"
422 );
423
424 assert_eq!(
426 normalize_path_to_uri("s3://bucket/path").unwrap(),
427 "s3://bucket/path"
428 );
429 assert_eq!(
430 normalize_path_to_uri("file:///tmp/test").unwrap(),
431 "file:///tmp/test"
432 );
433 assert_eq!(
434 normalize_path_to_uri("https://example.com/path").unwrap(),
435 "https://example.com/path"
436 );
437 }
438
439 #[rstest]
440 fn test_is_absolute_path() {
441 assert!(is_absolute_path("/tmp/test"));
443 assert!(is_absolute_path("/"));
444
445 assert!(is_absolute_path("C:\\tmp\\test"));
447 assert!(is_absolute_path("C:/tmp/test"));
448 assert!(is_absolute_path("D:\\"));
449 assert!(is_absolute_path("Z:/"));
450
451 assert!(is_absolute_path("\\\\server\\share"));
453 assert!(is_absolute_path("\\\\localhost\\c$"));
454
455 assert!(!is_absolute_path("tmp/test"));
457 assert!(!is_absolute_path("./test"));
458 assert!(!is_absolute_path("../test"));
459 assert!(!is_absolute_path("test.txt"));
460
461 assert!(!is_absolute_path(""));
463 assert!(!is_absolute_path("C"));
464 assert!(!is_absolute_path("C:"));
465 assert!(!is_absolute_path("\\"));
466 }
467
468 #[rstest]
469 fn test_path_to_file_uri() {
470 assert_eq!(path_to_file_uri("/tmp/test"), "file:///tmp/test");
472 assert_eq!(path_to_file_uri("/"), "file:///");
473
474 assert_eq!(path_to_file_uri("C:\\tmp\\test"), "file:///C:/tmp/test");
476 assert_eq!(path_to_file_uri("C:/tmp/test"), "file:///C:/tmp/test");
477 assert_eq!(path_to_file_uri("D:\\"), "file:///D:/");
478
479 assert_eq!(
481 path_to_file_uri("\\\\server\\share\\file"),
482 "file://server/share/file"
483 );
484 assert_eq!(
485 path_to_file_uri("\\\\localhost\\c$\\test"),
486 "file://localhost/c$/test"
487 );
488 }
489
490 #[rstest]
491 fn session_feather_paths_recover_type_and_identifier() {
492 let path = "backtest/run-1/quotes/EURUSD.SIM/0001.feather";
496 assert_eq!(
497 type_name_from_session_feather_path(path, "backtest", "run-1").unwrap(),
498 "quotes",
499 );
500 assert_eq!(
501 identifier_from_session_feather_path(path, "backtest", "run-1").as_deref(),
502 Some("EURUSD.SIM"),
503 );
504 assert_eq!(
505 type_name_from_session_feather_path(
506 "backtest/run-1/quotes_1000-1.feather",
507 "backtest",
508 "run-1",
509 )
510 .unwrap(),
511 "quotes",
512 );
513
514 let custom = "backtest/run-1/data/custom/MyType/inst/0001.feather";
515 assert_eq!(
516 type_name_from_session_feather_path(custom, "backtest", "run-1").unwrap(),
517 "custom/MyType",
518 );
519 assert_eq!(
520 identifier_from_session_feather_path(custom, "backtest", "run-1").as_deref(),
521 Some("inst"),
522 );
523 }
524
525 #[rstest]
526 fn session_feather_paths_recover_type_and_identifier_with_backslashes() {
527 let path = r"backtest\run-1\quotes\EURUSD.SIM\0001.feather";
528 assert_eq!(
529 type_name_from_session_feather_path(path, "backtest", "run-1").unwrap(),
530 "quotes",
531 );
532 assert_eq!(
533 identifier_from_session_feather_path(path, "backtest", "run-1").as_deref(),
534 Some("EURUSD.SIM"),
535 );
536 }
537}