nautilus_persistence/common/
paths.rs1use std::{fs, path::PathBuf};
29
30use nautilus_common::enums::Environment;
31use nautilus_model::data::NautilusDataType;
32use strum::IntoEnumIterator;
33
34use crate::catalog::types::{CatalogDataType, catalog_data_type_from_path};
35
36pub trait CatalogPathPrefix {
38 fn path_prefix() -> &'static str;
40}
41
42#[must_use]
48pub fn normalize_path_separators(path: &str) -> String {
49 path.replace('\\', "/")
50}
51
52#[must_use]
54pub fn extract_path_components(path_str: &str) -> Vec<String> {
55 normalize_path_separators(path_str)
56 .split('/')
57 .filter(|s| !s.is_empty())
58 .map(ToString::to_string)
59 .collect()
60}
61
62#[must_use]
64pub fn local_to_object_store_path(local_path: &std::path::Path) -> String {
65 normalize_path_separators(&local_path.to_string_lossy())
66}
67
68#[must_use]
72pub fn make_object_store_path<I, S>(base_path: &str, components: I) -> String
73where
74 I: IntoIterator<Item = S>,
75 S: AsRef<str>,
76{
77 let mut parts = Vec::new();
78 let normalized_base = normalize_path_separators(base_path)
79 .trim_end_matches('/')
80 .to_string();
81
82 if !normalized_base.is_empty() {
83 parts.push(normalized_base);
84 }
85
86 for component in components {
87 let normalized_component = normalize_path_separators(component.as_ref())
88 .trim_start_matches('/')
89 .trim_end_matches('/')
90 .to_string();
91
92 if !normalized_component.is_empty() {
93 parts.push(normalized_component);
94 }
95 }
96
97 parts.join("/")
98}
99
100#[must_use]
104pub fn urisafe_instrument_id(instrument_id: &str) -> String {
105 instrument_id.replace('/', "").replace('^', "_")
106}
107
108#[must_use]
113pub fn safe_directory_identifier(identifier: &str) -> String {
114 let normalized = normalize_path_separators(identifier).replace("//", "/");
115 let segments: Vec<&str> = normalized
116 .split('/')
117 .filter(|s| !s.is_empty() && *s != "..")
118 .collect();
119 segments.join("/")
120}
121
122#[must_use]
127pub fn extract_identifier_from_path(file_path: &str) -> Option<&str> {
128 let parent = file_path.rfind(['/', '\\']).map(|idx| &file_path[..idx])?;
129 let identifier = parent
130 .rfind(['/', '\\'])
131 .map_or(parent, |idx| &parent[idx + 1..]);
132 (!identifier.is_empty()).then_some(identifier)
133}
134
135#[must_use]
139pub fn make_sql_safe_identifier(identifier: &str) -> String {
140 urisafe_instrument_id(identifier)
141 .chars()
142 .map(|c| {
143 if c.is_ascii_alphanumeric() {
144 c.to_ascii_lowercase()
145 } else {
146 '_'
147 }
148 })
149 .collect()
150}
151
152pub fn normalize_path_to_uri(path: &str) -> anyhow::Result<String> {
176 if path.contains("://") {
177 Ok(path.to_string())
179 } else if is_absolute_path(path) {
180 Ok(path_to_file_uri(path))
181 } else {
182 let current_dir = std::env::current_dir().map_err(|e| {
184 anyhow::anyhow!("Failed to resolve current directory for relative path '{path}': {e}")
185 })?;
186
187 let absolute_path = current_dir.join(path);
188 Ok(path_to_file_uri(&absolute_path.to_string_lossy()))
189 }
190}
191
192pub(crate) fn local_writer_directory(path: &str) -> anyhow::Result<PathBuf> {
201 let uri = normalize_path_to_uri(path)?;
202 anyhow::ensure!(
203 uri.starts_with("file://"),
204 "Streaming writers append to local files, writer path must be local, was {path}"
205 );
206 Ok(PathBuf::from(file_uri_to_native_path(&uri)))
207}
208
209pub fn create_local_directory(path: &str) -> anyhow::Result<()> {
218 let uri = normalize_path_to_uri(path)?;
219
220 if uri.starts_with("file://") {
221 fs::create_dir_all(file_uri_to_native_path(&uri))?;
222 }
223
224 Ok(())
225}
226
227#[must_use]
229pub const fn environment_directory(environment: Environment) -> &'static str {
230 match environment {
231 Environment::Backtest => "backtest",
232 Environment::Sandbox => "sandbox",
233 Environment::Live => "live",
234 }
235}
236
237#[must_use]
239pub fn environment_from_directory(directory: &str) -> Option<Environment> {
240 Environment::iter().find(|environment| environment_directory(*environment) == directory)
241}
242
243#[must_use]
245fn is_absolute_path(path: &str) -> bool {
246 path.starts_with('/') || path.starts_with("\\\\") || is_windows_drive_path(path)
247}
248
249#[must_use]
251fn is_windows_drive_path(path: &str) -> bool {
252 path.len() >= 3
253 && path.chars().nth(1) == Some(':')
254 && matches!(path.chars().nth(2), Some('\\' | '/'))
255}
256
257#[must_use]
263pub(crate) fn file_protocol_uri(path: &str) -> String {
264 if is_windows_drive_path(path) {
265 path_to_file_uri(path)
266 } else {
267 format!("file://{path}")
268 }
269}
270
271#[must_use]
273pub(crate) fn path_to_file_uri(path: &str) -> String {
274 if path.starts_with('/') {
275 format!("file://{path}")
277 } else if path.len() >= 3 && path.chars().nth(1) == Some(':') {
278 let normalized = normalize_path_separators(path);
280 format!("file:///{normalized}")
281 } else if let Some(without_prefix) = path.strip_prefix("\\\\") {
282 let normalized = normalize_path_separators(without_prefix);
284 format!("file://{normalized}")
285 } else {
286 format!("file://{path}")
288 }
289}
290
291#[cfg(windows)]
294pub(crate) fn file_uri_to_native_path(uri: &str) -> String {
295 let without_scheme = uri
296 .strip_prefix("file://")
297 .or_else(|| uri.strip_prefix("file:"))
298 .unwrap_or(uri);
299 let without_leading = without_scheme.trim_start_matches('/');
301 without_leading.replace('/', "\\")
302}
303
304#[cfg(not(windows))]
306pub(crate) fn file_uri_to_native_path(uri: &str) -> String {
307 uri.strip_prefix("file://").unwrap_or(uri).to_string()
308}
309
310pub(crate) fn catalog_data_type_from_session_feather_path(
321 path: &str,
322 environment: Environment,
323 instance_id: &str,
324) -> anyhow::Result<CatalogDataType> {
325 let components = extract_path_components(path);
326 let type_index = session_type_index(&components, environment, instance_id, path)?;
327 if has_custom_data_prefix(&components, type_index) {
328 let type_name = components.get(type_index + 2).ok_or_else(|| {
329 anyhow::anyhow!(
330 "Cannot infer custom data type from Feather session path '{path}' for {}/{instance_id}",
331 environment_directory(environment)
332 )
333 })?;
334
335 return Ok(CatalogDataType::from(NautilusDataType::Custom {
336 type_name: type_name.clone(),
337 }));
338 }
339
340 let type_segment = components[type_index].as_str();
341 let file_name = components.last().map_or(type_segment, String::as_str);
342
343 let type_name = if type_segment.ends_with(".feather") {
344 file_name
345 .strip_suffix(".feather")
346 .and_then(|stem| stem.rsplit_once('_').map(|(type_name, _)| type_name))
347 .unwrap_or(type_segment)
348 } else {
349 type_segment
350 };
351
352 catalog_data_type_from_path(type_name)
353}
354
355pub(crate) fn identifier_from_session_feather_path(
359 path: &str,
360 environment: Environment,
361 instance_id: &str,
362) -> Option<String> {
363 let components = extract_path_components(path);
364 let type_index = session_type_index(&components, environment, instance_id, path).ok()?;
365 if has_custom_data_prefix(&components, type_index) {
366 let identifier_start = type_index + 3;
367 let file_index = components.len().checked_sub(1)?;
368 if identifier_start >= file_index {
369 return None;
370 }
371
372 return Some(components[identifier_start].clone());
373 }
374
375 if components[type_index] == "instruments" {
377 return None;
378 }
379
380 let identifier = components.get(type_index + 1)?;
381 let file_name = components.last()?;
382
383 if identifier.ends_with(".feather") {
384 return None;
385 }
386
387 (identifier != file_name).then(|| identifier.clone())
388}
389
390fn session_type_index(
391 components: &[String],
392 environment: Environment,
393 instance_id: &str,
394 path: &str,
395) -> anyhow::Result<usize> {
396 let directory = environment_directory(environment);
397 components
398 .windows(2)
399 .position(|window| window[0] == directory && window[1] == instance_id)
400 .and_then(|environment_index| environment_index.checked_add(2))
401 .filter(|type_index| *type_index < components.len())
402 .ok_or_else(|| {
403 anyhow::anyhow!(
404 "Cannot infer data type from Feather session path '{path}' for {directory}/{instance_id}"
405 )
406 })
407}
408
409fn has_custom_data_prefix(components: &[String], type_index: usize) -> bool {
410 components.get(type_index).is_some_and(|c| c == "data")
411 && components
412 .get(type_index + 1)
413 .is_some_and(|c| c == "custom")
414}
415
416#[cfg(test)]
417mod tests {
418 use nautilus_core::UUID4;
419 use rstest::rstest;
420 use tempfile::TempDir;
421
422 use super::*;
423
424 #[rstest]
425 fn create_local_directory_creates_local_path_and_skips_remote_uri() {
426 let directory = TempDir::new().unwrap();
427 let local = directory.path().join("catalog").join("nested");
428 let bucket = format!("nautilus-{}", UUID4::new());
429
430 create_local_directory(&local.to_string_lossy()).unwrap();
431 create_local_directory(&format!("s3://{bucket}/catalog")).unwrap();
432
433 assert!(local.is_dir());
434 assert!(!PathBuf::from("s3:").join(&bucket).exists());
436 }
437
438 #[rstest]
439 fn normalize_path_separators_converts_backslashes() {
440 assert_eq!(
441 normalize_path_separators(r"C:\catalog\backtest\run-1"),
442 "C:/catalog/backtest/run-1",
443 );
444 assert_eq!(
445 normalize_path_separators("C:/catalog/backtest/run-1"),
446 "C:/catalog/backtest/run-1",
447 );
448 assert_eq!(
449 normalize_path_separators(r"\\server\share\live\run-2"),
450 "//server/share/live/run-2",
451 );
452 }
453
454 #[rstest]
455 fn extract_path_components_handles_platform_separators() {
456 assert_eq!(
457 extract_path_components(r"C:\catalog\backtest\run-1"),
458 vec!["C:", "catalog", "backtest", "run-1"],
459 );
460 assert_eq!(
461 extract_path_components("/catalog/backtest/run-1/"),
462 vec!["catalog", "backtest", "run-1"],
463 );
464 assert!(extract_path_components("").is_empty());
465 }
466
467 #[rstest]
468 fn extract_identifier_from_path_handles_platform_separators() {
469 assert_eq!(
470 extract_identifier_from_path("data/quotes/EURUSD/file.parquet"),
471 Some("EURUSD"),
472 );
473 assert_eq!(
474 extract_identifier_from_path(r"data\quotes\EURUSD\file.parquet"),
475 Some("EURUSD"),
476 );
477 assert_eq!(
478 extract_identifier_from_path(r"C:\data\quotes\EURUSD\file.parquet"),
479 Some("EURUSD"),
480 );
481 assert_eq!(extract_identifier_from_path("file.parquet"), None);
482 assert_eq!(extract_identifier_from_path(""), None);
483 }
484
485 #[rstest]
486 fn safe_directory_identifier_blocks_windows_traversal() {
487 assert_eq!(safe_directory_identifier(r"..\\..\\etc"), "etc");
488 assert_eq!(safe_directory_identifier("../../etc"), "etc");
489 assert_eq!(safe_directory_identifier("run-1"), "run-1");
490 }
491
492 #[rstest]
493 fn test_normalize_path_to_uri() {
494 assert_eq!(
496 normalize_path_to_uri("/tmp/test").unwrap(),
497 "file:///tmp/test"
498 );
499
500 assert_eq!(
502 normalize_path_to_uri("C:\\tmp\\test").unwrap(),
503 "file:///C:/tmp/test"
504 );
505 assert_eq!(
506 normalize_path_to_uri("C:/tmp/test").unwrap(),
507 "file:///C:/tmp/test"
508 );
509 assert_eq!(
510 normalize_path_to_uri("D:\\data\\file.txt").unwrap(),
511 "file:///D:/data/file.txt"
512 );
513
514 assert_eq!(
516 normalize_path_to_uri("\\\\server\\share\\file").unwrap(),
517 "file://server/share/file"
518 );
519
520 assert_eq!(
522 normalize_path_to_uri("s3://bucket/path").unwrap(),
523 "s3://bucket/path"
524 );
525 assert_eq!(
526 normalize_path_to_uri("file:///tmp/test").unwrap(),
527 "file:///tmp/test"
528 );
529 assert_eq!(
530 normalize_path_to_uri("https://example.com/path").unwrap(),
531 "https://example.com/path"
532 );
533 }
534
535 #[rstest]
536 fn test_is_absolute_path() {
537 assert!(is_absolute_path("/tmp/test"));
539 assert!(is_absolute_path("/"));
540
541 assert!(is_absolute_path("C:\\tmp\\test"));
543 assert!(is_absolute_path("C:/tmp/test"));
544 assert!(is_absolute_path("D:\\"));
545 assert!(is_absolute_path("Z:/"));
546
547 assert!(is_absolute_path("\\\\server\\share"));
549 assert!(is_absolute_path("\\\\localhost\\c$"));
550
551 assert!(!is_absolute_path("tmp/test"));
553 assert!(!is_absolute_path("./test"));
554 assert!(!is_absolute_path("../test"));
555 assert!(!is_absolute_path("test.txt"));
556
557 assert!(!is_absolute_path(""));
559 assert!(!is_absolute_path("C"));
560 assert!(!is_absolute_path("C:"));
561 assert!(!is_absolute_path("\\"));
562 }
563
564 #[rstest]
565 fn file_protocol_uri_normalizes_windows_drive_path() {
566 assert_eq!(
567 file_protocol_uri(r"C:\data\catalog"),
568 "file:///C:/data/catalog",
569 );
570 assert_eq!(
571 file_protocol_uri("C:/data/catalog"),
572 "file:///C:/data/catalog"
573 );
574 assert_eq!(file_protocol_uri("/tmp/cat"), "file:///tmp/cat");
575 assert_eq!(
576 file_protocol_uri(r"\\server\share\catalog"),
577 r"file://\\server\share\catalog",
578 );
579 }
580
581 #[rstest]
582 fn test_path_to_file_uri() {
583 assert_eq!(path_to_file_uri("/tmp/test"), "file:///tmp/test");
585 assert_eq!(path_to_file_uri("/"), "file:///");
586
587 assert_eq!(path_to_file_uri("C:\\tmp\\test"), "file:///C:/tmp/test");
589 assert_eq!(path_to_file_uri("C:/tmp/test"), "file:///C:/tmp/test");
590 assert_eq!(path_to_file_uri("D:\\"), "file:///D:/");
591
592 assert_eq!(
594 path_to_file_uri("\\\\server\\share\\file"),
595 "file://server/share/file"
596 );
597 assert_eq!(
598 path_to_file_uri("\\\\localhost\\c$\\test"),
599 "file://localhost/c$/test"
600 );
601 }
602
603 #[rstest]
604 #[case(Environment::Backtest, "backtest")]
605 #[case(Environment::Sandbox, "sandbox")]
606 #[case(Environment::Live, "live")]
607 fn environment_directory_round_trips(
608 #[case] environment: Environment,
609 #[case] directory: &str,
610 ) {
611 assert_eq!(environment_directory(environment), directory);
612 assert_eq!(environment_from_directory(directory), Some(environment));
613 }
614
615 #[rstest]
616 #[case("BACKTEST")]
617 #[case("data")]
618 #[case("")]
619 fn environment_from_directory_rejects_other_folders(#[case] directory: &str) {
620 assert_eq!(environment_from_directory(directory), None);
621 }
622
623 #[rstest]
624 fn session_feather_paths_recover_type_and_identifier() {
625 let path = "backtest/run-1/quotes/EURUSD.SIM/0001.feather";
629 assert_eq!(
630 catalog_data_type_from_session_feather_path(path, Environment::Backtest, "run-1")
631 .unwrap(),
632 CatalogDataType::from(NautilusDataType::QuoteTick),
633 );
634 assert_eq!(
635 identifier_from_session_feather_path(path, Environment::Backtest, "run-1").as_deref(),
636 Some("EURUSD.SIM"),
637 );
638 assert_eq!(
639 catalog_data_type_from_session_feather_path(
640 "backtest/run-1/quotes_1000-1.feather",
641 Environment::Backtest,
642 "run-1",
643 )
644 .unwrap(),
645 CatalogDataType::from(NautilusDataType::QuoteTick),
646 );
647
648 let custom = "backtest/run-1/data/custom/MyType/inst/0001.feather";
649 assert_eq!(
650 catalog_data_type_from_session_feather_path(custom, Environment::Backtest, "run-1")
651 .unwrap(),
652 CatalogDataType::from(NautilusDataType::Custom {
653 type_name: "MyType".to_string(),
654 }),
655 );
656 assert_eq!(
657 identifier_from_session_feather_path(custom, Environment::Backtest, "run-1").as_deref(),
658 Some("inst"),
659 );
660 }
661
662 #[rstest]
663 #[case::prefixed_custom(
664 "catalog/backtest/run-1/data/custom/MyType/inst/0001.feather",
665 "custom/MyType",
666 Some("inst")
667 )]
668 #[case::custom_without_identifier(
669 "backtest/run-1/data/custom/MyType/0001.feather",
670 "custom/MyType",
671 None
672 )]
673 #[case::identifier_named_custom(
674 "backtest/run-1/bars/custom/0001.feather",
675 "bars",
676 Some("custom")
677 )]
678 #[case::type_without_identifier("backtest/run-1/quotes/0001.feather", "quotes", None)]
679 #[case::instrument_class_folder(
680 "backtest/run-1/instruments/currency_pair/instruments_1.feather",
681 "instruments",
682 None
683 )]
684 fn session_feather_paths_recover_type_and_identifier_from_layouts(
685 #[case] path: &str,
686 #[case] expected_type: &str,
687 #[case] expected_identifier: Option<&str>,
688 ) {
689 assert_eq!(
690 catalog_data_type_from_session_feather_path(path, Environment::Backtest, "run-1")
691 .unwrap(),
692 catalog_data_type_from_path(expected_type).unwrap(),
693 );
694 assert_eq!(
695 identifier_from_session_feather_path(path, Environment::Backtest, "run-1").as_deref(),
696 expected_identifier,
697 );
698 }
699
700 #[rstest]
701 #[case::missing_type_segment(
702 "backtest/run-1",
703 "Cannot infer data type from Feather session path 'backtest/run-1' for backtest/run-1"
704 )]
705 #[case::other_instance(
706 "backtest/run-2/quotes/0001.feather",
707 "Cannot infer data type from Feather session path 'backtest/run-2/quotes/0001.feather' for backtest/run-1"
708 )]
709 #[case::missing_custom_type(
710 "backtest/run-1/data/custom",
711 "Cannot infer custom data type from Feather session path 'backtest/run-1/data/custom' for backtest/run-1"
712 )]
713 fn session_feather_paths_reject_unrecognized_layouts(
714 #[case] path: &str,
715 #[case] expected: &str,
716 ) {
717 let error =
718 catalog_data_type_from_session_feather_path(path, Environment::Backtest, "run-1")
719 .unwrap_err();
720
721 assert_eq!(error.to_string(), expected);
722 assert_eq!(
723 identifier_from_session_feather_path(path, Environment::Backtest, "run-1"),
724 None
725 );
726 }
727
728 #[rstest]
729 fn session_feather_paths_recover_type_and_identifier_with_backslashes() {
730 let path = r"backtest\run-1\quotes\EURUSD.SIM\0001.feather";
731 assert_eq!(
732 catalog_data_type_from_session_feather_path(path, Environment::Backtest, "run-1")
733 .unwrap(),
734 CatalogDataType::from(NautilusDataType::QuoteTick),
735 );
736 assert_eq!(
737 identifier_from_session_feather_path(path, Environment::Backtest, "run-1").as_deref(),
738 Some("EURUSD.SIM"),
739 );
740 }
741}