Skip to main content

nautilus_persistence/common/
paths.rs

1// -------------------------------------------------------------------------------------------------
2//  Copyright (C) 2015-2026 Nautech Systems Pty Ltd. All rights reserved.
3//  https://nautechsystems.io
4//
5//  Licensed under the GNU Lesser General Public License Version 3.0 (the "License");
6//  You may not use this file except in compliance with the License.
7//  You may obtain a copy of the License at https://www.gnu.org/licenses/lgpl-3.0.en.html
8//
9//  Unless required by applicable law or agreed to in writing, software
10//  distributed under the License is distributed on an "AS IS" BASIS,
11//  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12//  See the License for the specific language governing permissions and
13//  limitations under the License.
14// -------------------------------------------------------------------------------------------------
15
16//! Cross-backend path and identifier operations.
17//!
18//! Holds primitives shared by catalog backends: separator normalization, local
19//! path to URI conversion, object-store path constructors, identifier
20//! sanitization, and persistence-owned type-to-prefix traits for
21//! streaming/session writers. Backend-specific catalog paths are mapped in
22//! `crate::catalog::types`.
23//!
24//! All path splitting in this module normalizes Windows `\` separators first,
25//! so parsing behaves identically on every platform. Call sites must use these
26//! helpers instead of splitting raw paths on `/`.
27
28use 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
36/// Persistence-owned static path prefix used by streaming/session writers.
37pub trait CatalogPathPrefix {
38    /// Returns the record family prefix.
39    fn path_prefix() -> &'static str;
40}
41
42/// Normalizes Windows `\` separators to `/`.
43///
44/// Object stores and URIs always use forward slashes, while Windows local paths
45/// use backslashes. Normalizing before splitting or joining keeps path parsing
46/// identical on every platform.
47#[must_use]
48pub fn normalize_path_separators(path: &str) -> String {
49    path.replace('\\', "/")
50}
51
52/// Extracts path components using platform-appropriate path parsing.
53#[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/// Converts a local `PathBuf` to an object store path string.
63#[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/// Joins a base path and components into an object-store path with forward slashes.
69///
70/// Object stores (S3, GCS, etc.) always expect forward slashes regardless of platform.
71#[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/// Converts an instrument ID to a URI-safe format by removing forward slashes and replacing
101/// carets with underscores. Some instrument IDs contain forward slashes (e.g., "BTC/USD") which
102/// are not suitable for use in file paths.
103#[must_use]
104pub fn urisafe_instrument_id(instrument_id: &str) -> String {
105    instrument_id.replace('/', "").replace('^', "_")
106}
107
108/// Normalizes a user-supplied identifier for use in directory paths.
109///
110/// Replaces `//` with `/` and filters out empty segments and `..` to prevent path traversal.
111/// Backslashes are normalized first so Windows separators cannot bypass the filter.
112#[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/// Extracts the identifier from a file path: typically the second-to-last path component.
123///
124/// For example, from `data/quotes/EURUSD/file.parquet`, extracts `EURUSD`.
125/// Both `/` and `\` separators are recognized so Windows paths resolve identically.
126#[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/// Makes an identifier safe for use in SQL table names.
136///
137/// Keeps ASCII alphanumerics and underscores; replaces everything else with `_`, then lowercases.
138#[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
152/// Normalizes a path to URI format for consistent object store usage.
153///
154/// If the path is already a URI (contains "://"), returns it as-is.
155/// Otherwise, converts local paths to file:// URIs with proper cross-platform handling.
156///
157/// Supported URI schemes:
158/// - `s3://` for AWS S3
159/// - `gs://` or `gcs://` for Google Cloud Storage
160/// - `az://` or `abfs://` for Azure Blob Storage
161/// - `http://` or `https://` for HTTP/WebDAV
162/// - `file://` for local files
163///
164/// # Cross-platform Path Handling
165///
166/// - Unix absolute paths: `/path/to/file` → `file:///path/to/file`
167/// - Windows drive paths: `C:\path\to\file` → `file:///C:/path/to/file`
168/// - Windows UNC paths: `\\server\share\file` → `file://server/share/file`
169/// - Relative paths: converted to absolute using current directory
170///
171/// # Errors
172///
173/// Returns an error if the path is relative and the current working directory cannot be
174/// resolved.
175pub fn normalize_path_to_uri(path: &str) -> anyhow::Result<String> {
176    if path.contains("://") {
177        // Already a URI - return as-is
178        Ok(path.to_string())
179    } else if is_absolute_path(path) {
180        Ok(path_to_file_uri(path))
181    } else {
182        // Relative path - make it absolute first
183        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
192/// Returns the local directory a streaming writer appends its Feather files to.
193///
194/// Streaming writers append to open local files, so remote storage URIs are rejected.
195///
196/// # Errors
197///
198/// Returns an error if `path` is not a local path or `file://` URI, or if a relative path cannot
199/// be resolved against the current directory.
200pub(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
209/// Creates the directory a local path or `file://` URI names, including missing parents.
210///
211/// Does nothing for a remote URI.
212///
213/// # Errors
214///
215/// Returns an error if a relative path cannot be resolved against the current directory, or if
216/// the directory cannot be created.
217pub 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/// Returns the folder a streaming writer groups `environment` runs under, below its writer path.
228#[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/// Returns the environment whose runs a streaming writer groups under `directory`.
238#[must_use]
239pub fn environment_from_directory(directory: &str) -> Option<Environment> {
240    Environment::iter().find(|environment| environment_directory(*environment) == directory)
241}
242
243/// Checks if a path is absolute on any supported platform.
244#[must_use]
245fn is_absolute_path(path: &str) -> bool {
246    path.starts_with('/') || path.starts_with("\\\\") || is_windows_drive_path(path)
247}
248
249/// Returns whether `path` is a Windows drive path such as `C:\data` or `C:/data`.
250#[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/// Returns a `file://` URI for a local catalog path.
258///
259/// A Windows drive path becomes `file:///C:/...`. UNC paths keep a direct
260/// `file://` join because [`file_uri_to_native_path`] does not restore the
261/// `\\server\share` prefix from `file://server/share`.
262#[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/// Converts an absolute path to a file:// URI with proper platform handling.
272#[must_use]
273pub(crate) fn path_to_file_uri(path: &str) -> String {
274    if path.starts_with('/') {
275        // Unix absolute path
276        format!("file://{path}")
277    } else if path.len() >= 3 && path.chars().nth(1) == Some(':') {
278        // Windows drive path - normalize separators and add proper prefix
279        let normalized = normalize_path_separators(path);
280        format!("file:///{normalized}")
281    } else if let Some(without_prefix) = path.strip_prefix("\\\\") {
282        // Windows UNC path \\server\share -> file://server/share
283        let normalized = normalize_path_separators(without_prefix);
284        format!("file://{normalized}")
285    } else {
286        // Fallback - treat as relative to root
287        format!("file://{path}")
288    }
289}
290
291/// Converts a file:// URI to a native path for the current platform.
292/// On Windows, `file:///C:/x/y` becomes `C:\x\y` so `LocalFileSystem` and `std::fs` work correctly.
293#[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    // Strip leading slash so "/C:/x/y" -> "C:/x/y", then use native separators
300    let without_leading = without_scheme.trim_start_matches('/');
301    without_leading.replace('/', "\\")
302}
303
304/// Converts a file:// URI to a path string for Unix (no-op; `object_store` accepts slash paths).
305#[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
310/// Returns the catalog type staged by a Feather session path like
311/// `backtest/{run_id}/{type}/{...}/file.feather`.
312///
313/// Custom data is staged under `data/custom/{type_name}/...`. Instrument files return the
314/// aggregate instrument family, because their class folder names no staged type.
315///
316/// # Errors
317///
318/// Returns an error if the path does not contain the `{environment}/{instance_id}/...`
319/// prefix or does not have a recognizable type segment.
320pub(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
355/// Returns the optional identifier (instrument id, bar type, ...) segment of a
356/// Feather session path, or `None` if the path encodes only a type with no
357/// identifier (e.g. catalog-level files).
358pub(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    // Instrument files sit in a class folder, which names no identifier
376    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        // Treating the remote URI as a local path would create it below the working directory
435        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        // Unix absolute paths
495        assert_eq!(
496            normalize_path_to_uri("/tmp/test").unwrap(),
497            "file:///tmp/test"
498        );
499
500        // Windows drive paths
501        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        // Windows UNC paths
515        assert_eq!(
516            normalize_path_to_uri("\\\\server\\share\\file").unwrap(),
517            "file://server/share/file"
518        );
519
520        // Already URIs - should remain unchanged
521        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        // Unix absolute paths
538        assert!(is_absolute_path("/tmp/test"));
539        assert!(is_absolute_path("/"));
540
541        // Windows drive paths
542        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        // Windows UNC paths
548        assert!(is_absolute_path("\\\\server\\share"));
549        assert!(is_absolute_path("\\\\localhost\\c$"));
550
551        // Relative paths
552        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        // Edge cases
558        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        // Unix absolute paths
584        assert_eq!(path_to_file_uri("/tmp/test"), "file:///tmp/test");
585        assert_eq!(path_to_file_uri("/"), "file:///");
586
587        // Windows drive paths
588        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        // Windows UNC paths
593        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        // Feather sessions are written under {environment}/{run_id}/{type}/{identifier?}/file.feather
626        // (no `data/` segment: that prefix only appears under the Delta catalog target
627        // layout, not under writer staging paths).
628        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}